sni-spoof is a small Go port of a DPI research tool. The useful Go details are raw packet access, TCP checksum construction, handshake tracking, and careful relay startup.

sni-spoof: reading a Go TCP forwarder that works below TLS


sni-spoof is a Go port of an SNI-spoofing research tool. It is dual-use code and should only be used in environments where you have permission to test network behaviour. The value for Go readers is in the low-level networking: raw sockets, BPF, checksums, sequence numbers, and a TCP forwarder that waits for a packet-level condition before relaying application data.

This is not typical web-service Go. That is why it is worth reading carefully.

The normal path is still a TCP forwarder

The user-facing server listens locally and dials a configured upstream:

ln, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.ListenHost, cfg.ListenPort))

Each accepted client gets a server connection through a net.Dialer bound to the selected local IP:

d := net.Dialer{LocalAddr: &net.TCPAddr{IP: localIP}}
server, err := d.Dial("tcp", fmt.Sprintf("%s:%d", cfg.ConnectIP, cfg.ConnectPort))

After the packet trick has completed, the relay is ordinary Go:

go func() { io.Copy(server, client); done <- struct{}{} }()
go func() { io.Copy(client, server); done <- struct{}{} }()

The unusual part is what happens before those io.Copy calls start.

Raw packet access is platform-specific

Linux uses AF_PACKET raw sockets. macOS uses BPF devices under /dev/bpf*. The project keeps those behind matching functions:

func openRaw() error
func recvFrame(buf []byte) (int, error)
func sendFrame(frame []byte) error

That is the right shape for platform code in Go. The rest of the program can sniff and inject frames without caring whether the implementation is Linux raw sockets or Darwin BPF.

Build tags and platform files are a clean way to keep syscall-heavy code contained.

The fake ClientHello is built by hand

The program carries a TLS ClientHello template and patches the SNI field:

func buildClientHello(sni string) []byte {
	// copy template, patch lengths and hostname bytes
}

This is byte-slice programming, so the helper functions are intentionally small:

func be16(b []byte, v uint16) []byte {
	b[0] = byte(v >> 8)
	b[1] = byte(v)
	return b
}

When Go code gets this close to a protocol, clarity matters more than cleverness. Small helpers for endian writes and header lengths are easier to audit than a pile of numeric offsets inside one function.

Checksums are explicit

The code computes IPv4 and TCP checksums itself:

func ipChecksum(iph []byte) uint16 { return fold(sum16(iph)) }

func tcpChecksum(iph, tcpAndPayload []byte) uint16 {
	// pseudo-header + TCP header + payload
}

If you usually live in net/http, this is a useful reminder of what the kernel normally hides. Once you inject packets yourself, checksums, header lengths, sequence numbers, and pseudo-headers are your responsibility.

Handshake tracking controls relay startup

The sniffer tracks ports in a shared map:

type portState struct {
	isn       uint32
	ready     chan struct{}
	confirmed bool
}

The flow is roughly:

  • record the outbound SYN initial sequence number
  • watch for the third handshake ACK
  • inject a crafted frame with an older sequence number
  • wait for an ACK proving the upstream still expects the real byte stream
  • unblock the TCP relay

That last step is important. The forwarder does not start moving client bytes immediately. It waits until the packet-level condition says the upstream ignored the injected frame.

The code uses a timeout around that wait. If confirmation does not arrive, the connection is closed instead of hanging indefinitely.

What to take from sni-spoof

The useful Go lessons are platform isolation for raw packet access, explicit checksum construction, careful state around TCP sequence numbers, and keeping the normal stream relay separate from packet injection.

Most Go developers should not need raw sockets often. When you do, this repository is a compact example of how quickly you leave the comfortable parts of the standard library. Small functions, obvious state, and strict timeouts matter more at this layer.