Skip to content

feat(sslsnoop): capture Go crypto/tls plaintext via uprobe - #13

Open
dvrkn wants to merge 5 commits into
mainfrom
feat/go-crypto-tls-uprobe
Open

dvrkn wants to merge 5 commits into
mainfrom
feat/go-crypto-tls-uprobe

Conversation

@dvrkn

@dvrkn dvrkn commented Jul 7, 2026 •

Copy link
Copy Markdown
Owner

Capture Go crypto/tls request plaintext via uprobe

What & why

ebfw's HTTPS L7 visibility came from an SSL_write uprobe, which only hooks OpenSSL's dynamically-linked libssl. Go statically links crypto/tls and never calls SSL_write, so every native Go net/http client was invisible — no host, path, or headers. This PR closes that gap by attaching a second uprobe to Go's TLS write path, so we recover the same L7 detail before encryption, with no proxy and no TLS MITM.

How it works

  • BPF (bpf/sslsnoop.bpf.c) — a new go_tls_write uprobe on crypto/tls.(Conn).Write, feeding the same ringbuf as ssl_write. Go uses the register-based ABIInternal (Go ≥ 1.17), not the C ABI, so BPF_UPROBE's PT_REGS_PARM read the wrong registers — go_arg() reads them by hand per arch (amd64 rax/rbx/rcx/rdi, arm64 user_pt_regs.regs[i]). For (*Conn).Write(b []byte) the args are (c, b.ptr, b.len, b.cap); the *Conn pointer becomes the per-connection key, so the existing HTTP/1.x + HTTP/2/HPACK tracker is reused unchanged. Entry-only probe (a uretprobe would corrupt Go's movable stacks).
  • Userspace (internal/sslsnoop) — a second discovery loop scans every /proc//exe, checks the ELF symbol table for the Go TLS symbol, and attaches per unique inode. Dedup is by exe inode (not mount namespace — every process has a distinct executable); it skips the agent's own binary (ebfw links crypto/tls via client-go).
  • New ebfw_go_uprobe_attached metric.

It's generic, not just the test fixture

Discovery is fully generic — any Go binary on the node that links crypto/tls is picked up. In testing, the agent captured the host's real tailscaled process alongside the fixture:

attached crypto/tls.(*Conn).Write uprobe via /proc/3196611/exe ...
HTTPS [pid=3196611 tailscaled] POST log.tailscale.com/c/tailnode.log.tailscale.io/107854...
Host: log.tailscale.com
Content-Length: 624
...

Limitations (documented)

  • Non-stripped Go only — the symbol must be in .symtab; -ldflags "-s -w" removes it (no offset fallback yet).
  • Go ≥ 1.17 (register ABI).
  • Long-running processes — a one-shot process can exit before the ~1s discovery scan sees it.
  • Still uncovered: Java, rustls, statically-linked OpenSSL.

Testing

  • New test/e2e.sh case builds a native Go net/http client from a fixture (test/fixtures/gotls-client/ — its own module + Dockerfile, built in a container so no host Go toolchain is assumed), runs it on the host, and asserts ebfw recovers its host, path, and a custom header. Self-skips without Docker. The client waits just past one discovery interval so the probe attaches, then sends a single request — deterministic proof.
  • Verified RESULT: ALL PASS on arm64 (kernel 6.17); the BPF object cross-compiles clean for both __TARGET_ARCH_x86 and __TARGET_ARCH_arm64, and CI exercises the amd64 register path.

Docs

README, ROADMAP, CLAUDE.md, and docs/{comparison,configuration,tests}.md updated to drop the "OpenSSL-only" claim and describe the two-uprobe model + its caveats.

dvrkn added 5 commits July 8, 2026 00:21
Go statically links crypto/tls and never calls OpenSSL's SSL_write, so its
HTTPS requests were invisible to the existing uprobe. Add a second uprobe on
crypto/tls.(*Conn).Write feeding the same ringbuf, so host/path/headers are
recovered before encryption for any net/http client.

- bpf: go_tls_write reads Go's register-based ABIInternal (amd64 ax/bx/cx/di,
  arm64 user_pt_regs.regs[i]) by hand, since BPF_UPROBE's PT_REGS_PARM* assume
  the C ABI. The *Conn pointer is the per-connection key, reusing the existing
  HTTP/1.x + HTTP/2/HPACK tracker. Entry-only (uretprobes corrupt Go stacks).
- sslsnoop: discover Go binaries by scanning /proc/<pid>/exe, checking the ELF
  symbol table, and attaching per unique inode. Dedup by exe inode (not mount
  namespace — every process has a distinct executable); skip the agent's own
  binary (ebfw links crypto/tls via client-go).
- e2e: build a native Go net/http client inside a Go container (no host Go
  toolchain assumed) and assert ebfw recovers its host, path, and a custom
  header. Self-skips without Docker.
- metrics: add ebfw_go_uprobe_attached. Docs updated to drop the OpenSSL-only
  claim (Java/rustls/stripped-Go still uncovered).
Move the inline heredoc client into test/fixtures/gotls-client (main.go +
go.mod) and build it via its own Dockerfile with `docker build --target bin
--output`, mirroring the repo's binary-export pattern. e2e no longer inlines Go
source or an ad-hoc `docker run go build`.
Remove EBFW_GOTLS_CLIENT; the Go crypto/tls client is always built from its
fixture Dockerfile, and the checks self-skip only when Docker is absent.
The userspace <asm/ptrace.h> struct pt_regs names x86_64 registers with the `r`
prefix; the bare ax/bx/cx/di names exist only in the kernel/vmlinux definition
we don't include, so the amd64 BPF compile failed with "no member named 'ax'".
Validated by cross-compiling the object for both __TARGET_ARCH_x86 and
__TARGET_ARCH_arm64.
The loop only existed to dodge the uprobe-attach race. Instead, the client
sleeps just past one discovery interval so the probe attaches while it is alive,
then sends a single request — one capture is enough to prove host/path/header
extraction, and it's deterministic.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant