TailVNC embeds WireGuard and VNC in a single Go binary — here is how
TailVNC is a Windows persistence tool that creates a Tailscale-based VNC connection, bypasses Session 0 isolation, and ships everything as a single binary. Yes, it’s a red team tool. No, I’m not here to debate that. What I’m here for is the Go engineering, which is genuinely impressive: a full WireGuard peer and an RFB (Remote Framebuffer) server crammed into one executable, zero external dependencies.
The Go patterns involved are worth pulling apart regardless of how you feel about the tool’s purpose. Userspace networking, Windows syscalls without cgo, tight binary composition. Let’s get into it.
Userspace WireGuard with wireguard-go
TailVNC doesn’t shell out to a WireGuard client. It embeds one using wireguard-go, the official Go implementation of WireGuard’s protocol.
The package that makes this possible is golang.zx2c4.com/wireguard/tun/netstack. It creates a WireGuard tunnel entirely in userspace — no kernel module, no TUN device, no admin-level network adapter. The tunnel runs inside a gVisor network stack (netstack), so all networking happens in-process. Your Go binary is the network stack.
Here’s a simplified version of how you’d spin up a userspace WireGuard peer in Go:
package main
import (
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
"log"
"net/netip"
)
func main() {
// Create a userspace TUN backed by netstack
localAddr := netip.MustParseAddr("100.64.0.2")
tun, tnet, err := netstack.CreateNetTUN(
[]netip.Addr{localAddr},
[]netip.Addr{netip.MustParseAddr("8.8.8.8")}, // DNS
1420, // MTU
)
if err != nil {
log.Fatal(err)
}
// Create the WireGuard device
dev := device.NewDevice(tun, conn.NewDefaultBind(), device.NewLogger(device.LogLevelVerbose, "[wg] "))
// Apply WireGuard config (IPC format)
err = dev.IPC(
"private_key=<base64-encoded-key>\n" +
"public_key=<peer-public-key>\n" +
"endpoint=<peer-ip>:51820\n" +
"allowed_ip=0.0.0.0/0\n")
if err != nil {
log.Fatal(err)
}
err = dev.Up()
if err != nil {
log.Fatal(err)
}
// Now use tnet to dial or listen — all traffic goes through WireGuard
conn, err := tnet.DialContextTCPAddrPort(
/* context */, netip.AddrPortFrom(netip.MustParseAddr("100.64.0.1"), 5900))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
// conn is a regular net.Conn — use it for VNC, HTTP, anything
_ = conn
}
The tnet object returned by CreateNetTUN gives you a net.Dial-like interface. Any TCP or UDP connection through it gets encrypted and routed through the WireGuard tunnel. No OS-level network changes. No firewall rules. The whole stack lives in your Go process’s memory.
This is how TailVNC stays invisible. No new network adapter appears in Windows, no WireGuard service running, nothing for endpoint monitoring to latch onto.
The RFB server: VNC in pure Go
VNC uses the Remote Framebuffer (RFB) protocol. TailVNC implements an RFB server that captures the Windows desktop and streams it over the WireGuard tunnel.
Implementing a protocol server in Go follows a pattern you’ve probably seen before: accept a net.Conn, handle the handshake, then loop reading and writing frames. Here’s a stripped-down skeleton of what an RFB server looks like:
package rfb
import (
"encoding/binary"
"io"
"net"
)
// ServerInit sends the initial server message to the VNC client.
type ServerInit struct {
Width uint16
Height uint16
PixelFormat PixelFormat
NameLength uint32
Name []byte
}
type PixelFormat struct {
BitsPerPixel uint8
Depth uint8
BigEndian uint8
TrueColor uint8
RedMax uint16
GreenMax uint16
BlueMax uint16
RedShift uint8
GreenShift uint8
BlueShift uint8
_ [3]byte // padding
}
func HandleConnection(conn net.Conn) error {
// RFB handshake: send protocol version
_, err := conn.Write([]byte("RFB 003.008\n"))
if err != nil {
return err
}
// Read client's protocol version
buf := make([]byte, 12)
_, err = io.ReadFull(conn, buf)
if err != nil {
return err
}
// Security handshake (simplified — no auth)
// Send number of security types, then type 1 (None)
_, err = conn.Write([]byte{1, 1})
if err != nil {
return err
}
// Read client's security choice
var secType uint8
err = binary.Read(conn, binary.BigEndian, &secType)
if err != nil {
return err
}
// Send SecurityResult (0 = OK)
err = binary.Write(conn, binary.BigEndian, uint32(0))
if err != nil {
return err
}
// Read ClientInit
var sharedFlag uint8
err = binary.Read(conn, binary.BigEndian, &sharedFlag)
if err != nil {
return err
}
// Send ServerInit with framebuffer dimensions
init := ServerInit{
Width: 1920,
Height: 1080,
PixelFormat: PixelFormat{
BitsPerPixel: 32,
Depth: 24,
TrueColor: 1,
RedMax: 255,
GreenMax: 255,
BlueMax: 255,
RedShift: 16,
GreenShift: 8,
BlueShift: 0,
},
}
init.Name = []byte("TailVNC")
init.NameLength = uint32(len(init.Name))
// Write struct fields, then the name
err = binary.Write(conn, binary.BigEndian, init.Width)
// ... write remaining fields ...
// Enter the main loop: read client messages, send framebuffer updates
return mainLoop(conn)
}
RFB is a binary protocol, and Go’s encoding/binary package handles it cleanly. Each message type has a fixed header, so you read with binary.Read into a struct and dispatch based on the message type byte. Nothing fancy, but it works.
TailVNC captures the actual Windows desktop by calling Win32 APIs through Go’s syscall package (and golang.org/x/sys/windows). If you’ve worked with cgo-free Windows syscalls in Go, you know the drill: windows.NewLazyDLL and NewProc to load functions at runtime.
Session 0 isolation bypass with Go syscalls
This is where things get specifically Windows-weird. Services run in Session 0, which is walled off from user desktops (Session 1+). TailVNC needs the user’s actual desktop from a service context. Solving this requires direct Win32 API calls from Go.
The approach: use CreateProcessAsUser to launch a helper process in the user’s session.
package session
import (
"fmt"
"unsafe"
"golang.org/x/sys/windows"
)
var (
modWtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
procWTSGetActiveConsole = modWtsapi32.NewProc("WTSGetActiveConsoleSessionId")
modAdvapi32 = windows.NewLazySystemDLL("advapi32.dll")
)
func GetActiveSessionID() (uint32, error) {
r, _, _ := procWTSGetActiveConsole.Call()
sessionID := uint32(r)
if sessionID == 0xFFFFFFFF {
return 0, fmt.Errorf("no active console session")
}
return sessionID, nil
}
func LaunchInUserSession(executable string) error {
sessionID, err := GetActiveSessionID()
if err != nil {
return err
}
// Get the user token for the active session
var userToken windows.Token
err = windows.WTSQueryUserToken(sessionID, &userToken)
if err != nil {
return fmt.Errorf("WTSQueryUserToken: %w", err)
}
defer userToken.Close()
// Duplicate the token
var dupToken windows.Token
err = windows.DuplicateTokenEx(
userToken,
windows.MAXIMUM_ALLOWED,
nil,
windows.SecurityIdentification,
windows.TokenPrimary,
&dupToken,
)
if err != nil {
return fmt.Errorf("DuplicateTokenEx: %w", err)
}
defer dupToken.Close()
// Create process in the user's session
si := new(windows.StartupInfo)
si.Cb = uint32(unsafe.Sizeof(*si))
si.Desktop = windows.StringToUTF16Ptr("winsta0\\default")
var pi windows.ProcessInformation
err = windows.CreateProcessAsUser(
dupToken,
nil,
windows.StringToUTF16Ptr(executable),
nil, nil, false,
windows.CREATE_NEW_CONSOLE,
nil, nil, si, &pi,
)
if err != nil {
return fmt.Errorf("CreateProcessAsUser: %w", err)
}
windows.CloseHandle(pi.Thread)
windows.CloseHandle(pi.Process)
return nil
}
A few things worth calling out:
windows.NewLazySystemDLL loads DLLs lazily. The DLL isn’t loaded until the first Call(). For a single-binary tool, this matters because you don’t want hard dependencies that blow up at load time.
unsafe.Sizeof sets the Cb field in StartupInfo. Classic Windows API pattern: the struct’s first field tells Windows how big it is, so versioning across Windows releases still works.
windows.StringToUTF16Ptr bridges Go’s UTF-8 strings to Windows’s UTF-16 world. The helper from golang.org/x/sys/windows handles the conversion and null-termination.
I find this kind of low-level Windows work in Go surprisingly pleasant compared to C. The golang.org/x/sys/windows package wraps most common Win32 functions, and for everything else, NewLazyDLL/NewProc gives you a clean calling convention without touching cgo.
Why a single binary matters (and how Go enables it)
Go compiles to a statically linked binary by default. No runtime to install, no shared libraries to think about. For TailVNC, this is non-negotiable: the binary needs to run on arbitrary Windows machines with zero setup.
Three things come together here:
wireguard-go’s netstack mode removes the need for a kernel TUN devicegolang.org/x/sys/windowsremoves the need for cgo and a C compiler- Go’s static compilation bundles everything into one file
The result: WireGuard peer, VNC server, session bypass logic, all in a single .exe. No DLLs to drop alongside it, no install scripts.
If you’ve worked with Go’s build constraints and cross-compilation, you know the GOOS=windows GOARCH=amd64 dance. TailVNC takes full advantage of this.
Concurrency: goroutines tying it together
A tool like this juggles several things at once:
- The WireGuard device reading/writing encrypted packets
- The RFB server streaming framebuffer updates
- Desktop capture grabbing screenshots on a timer
- Input events from the VNC client being injected into the Windows session
Go’s goroutine model makes this almost boring to implement. Each concern gets its own goroutine. They communicate through channels or shared state behind a sync.Mutex. No thread pool configuration, no async/await ceremony.
Here’s a typical pattern for this kind of project:
func run(ctx context.Context, tnet *netstack.Net) error {
listener, err := tnet.ListenTCP(&net.TCPAddr{Port: 5900})
if err != nil {
return err
}
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
return err
}
}
go handleVNCClient(ctx, conn)
}
}
func handleVNCClient(ctx context.Context, conn net.Conn) {
defer conn.Close()
// Framebuffer capture goroutine
frames := make(chan []byte, 2)
go captureLoop(ctx, frames)
// Main RFB protocol loop
for {
select {
case <-ctx.Done():
return
case frame := <-frames:
// Send framebuffer update to VNC client
sendFramebufferUpdate(conn, frame)
}
}
}
Straightforward Go concurrency. The context.Context flows through everything for clean cancellation. The frame channel decouples capture speed from network send speed. If you want to dig deeper into context propagation, there’s a ByteSizeGo post on context in Go worth reading.
What Go developers can take from this
You’re probably never going to build a VNC persistence tool. Fair enough. But TailVNC demonstrates several techniques that transfer directly to other problems:
Userspace networking with netstack: Running a full TCP/IP stack inside your Go process is useful far beyond offensive tooling. Testing, proxying, air-gapped environments where you can’t modify the host network.
Win32 API access without cgo: The golang.org/x/sys/windows package and the NewLazyDLL pattern let you call any Windows API from pure Go. I wish more people knew about this. No C compiler dependency means your CI pipeline stays simple.
Binary protocol implementation: encoding/binary plus net.Conn handles most binary protocols. RFB, DNS, custom wire formats, whatever. The pattern is the same every time, and it’s one of those things Go gets right without trying too hard.
**Static compilation as