TailVNC bundles a WireGuard peer and RFB server into one Go binary. Here's what makes the Go engineering interesting.

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 device
  • golang.org/x/sys/windows removes 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