From 5be943ca5627f8f48863a72889d523253dda1fb3 Mon Sep 17 00:00:00 2001 From: dvrkn <4789422+dvrkn@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:21:19 +0300 Subject: [PATCH 1/5] feat(sslsnoop): capture Go crypto/tls plaintext via uprobe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//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). --- README.md | 15 ++-- ROADMAP.md | 3 +- bpf/sslsnoop.bpf.c | 93 ++++++++++++++++++----- docs/comparison.md | 14 ++-- docs/configuration.md | 6 +- docs/tests.md | 5 +- internal/metrics/metrics.go | 7 ++ internal/sslsnoop/sslsnoop.go | 136 +++++++++++++++++++++++++++++++++- test/e2e.sh | 98 +++++++++++++++++++++++- 9 files changed, 340 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index c9fe35a..6b88fe9 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,11 @@ also sees HTTP paths and headers and lets policy match on method and path - **One `cgroup_skb/egress` program** at the node's root cgroup v2 sees egress from **every pod on the node** — DNS, TLS ClientHello SNI, plaintext HTTP, and new TCP connections — no per-pod sidecar. -- **An `SSL_write` uprobe** reads HTTPS request plaintext **before** encryption, - recovering paths the packet layer can't see. Auto-discovered per container's - libssl, live (no sampling). +- **TLS uprobes** read HTTPS request plaintext **before** encryption, recovering + paths and headers the packet layer can't see. Two probes cover the common cases: + OpenSSL's `SSL_write` (auto-discovered per container's libssl) and Go's + statically-linked `crypto/tls.(*Conn).Write` (auto-discovered per Go binary). + Live, no sampling. - **Attributed per pod** in-kernel via the originating cgroup id, enriched to `namespace/name` by a node-scoped Pods informer. The same maps carry policy verdicts back to the kernel for enforcement. @@ -110,9 +112,10 @@ DaemonSet on any node (any CNI, or no Kubernetes at all), watch egress in `log` mode, and pull it back out with zero effect on connectivity — it never touches the dataplane. -The headline difference: ebfw reads **HTTPS request paths and headers from an -`SSL_write` uprobe — before encryption, with no proxy and no TLS MITM.** Cilium -needs a terminating Envoy and injected certs to see the same thing. +The headline difference: ebfw reads **HTTPS request paths and headers from TLS +uprobes (OpenSSL `SSL_write` + Go `crypto/tls`) — before encryption, with no proxy +and no TLS MITM.** Cilium needs a terminating Envoy and injected certs to see the +same thing. ebfw is the lightweight egress firewall + per-pod L7 *visibility* layer; Cilium is the full networking platform (and does L7 *enforcement* today, which ebfw doesn't diff --git a/ROADMAP.md b/ROADMAP.md index d77e243..4e08309 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,5 +34,6 @@ lockdown, log mode, and deferred-dimension negative checks — see - Pinned maps + an external map-programming controller (the per-node agent programs its own maps for now). - TLS/HTTP multi-segment reassembly, TLS 1.3 ECH, cgroup-v1 fallback, - request-body inspection, uprobe coverage beyond OpenSSL-dynamic, multi-kernel CI, + request-body inspection, uprobe coverage beyond OpenSSL-dynamic + Go crypto/tls + (Java, rustls, OpenSSL-static, stripped Go), multi-kernel CI, HA, and scale tests. diff --git a/bpf/sslsnoop.bpf.c b/bpf/sslsnoop.bpf.c index 2f569fc..97f3566 100644 --- a/bpf/sslsnoop.bpf.c +++ b/bpf/sslsnoop.bpf.c @@ -1,17 +1,27 @@ // SPDX-License-Identifier: GPL-2.0 // -// sslsnoop (uprobe PoC). +// sslsnoop (uprobe). // -// Attaches a uprobe to OpenSSL's SSL_write to capture the *plaintext* of TLS -// traffic before it is encrypted. This is the only non-MITM way to see HTTPS -// request paths (which are encrypted on the wire and therefore invisible to the -// packet-level egress monitor). +// Captures the *plaintext* of TLS traffic before it is encrypted — the only +// non-MITM way to see HTTPS request paths (encrypted on the wire, so invisible +// to the packet-level egress monitor). Two entry points feed one ring buffer: // -// int SSL_write(SSL *ssl, const void *buf, int num); -// arg1 = SSL* arg2 = buf (plaintext) arg3 = num (length) +// 1. OpenSSL's dynamically-linked SSL_write (curl, nginx, most C/Python/…): +// int SSL_write(SSL *ssl, const void *buf, int num); +// arg1 = SSL* arg2 = buf (plaintext) arg3 = num (length) // -// The probe copies up to MAX_DATA bytes of the buffer to userspace via a ring -// buffer; HTTP parsing happens in Go. +// 2. Go's statically-linked crypto/tls.(*Conn).Write (any net/http client): +// func (c *Conn) Write(b []byte) (int, error) +// Go does NOT call SSL_write, so OpenSSL's uprobe never sees it. We attach +// directly to the Go symbol instead and read Go's register-based ABIInternal +// (Go >= 1.17): integer/pointer args go in a fixed register sequence, not the +// C ABI, so we cannot use BPF_UPROBE's PT_REGS_PARM* — we read the registers +// by hand per arch. For (*Conn).Write the sequence is (c, b.ptr, b.len, b.cap): +// amd64: RAX, RBX, RCX, RDI arm64: R0, R1, R2, R3 +// The *Conn pointer stands in for SSL* as the per-connection key. +// +// Both paths copy up to MAX_DATA bytes of the buffer to userspace via the ring +// buffer; HTTP parsing (HTTP/1.x and HTTP/2 + HPACK) happens in Go. #include #include @@ -47,12 +57,12 @@ struct { __uint(max_entries, 1 << 20); // 1 MiB } ssl_events SEC(".maps"); -// BPF_UPROBE (libbpf >= 1.2, provided by the trixie build image) extracts the -// function arguments from pt_regs. -SEC("uprobe/SSL_write") -int BPF_UPROBE(ssl_write, void *ssl, const void *buf, int num) +// submit_plaintext reserves an event, tags it with the calling task's identity, +// copies up to MAX_DATA bytes of buf, and submits. `key` is the per-connection +// identifier (SSL* for OpenSSL, *Conn for Go). Shared by both uprobes. +static __always_inline int submit_plaintext(__u64 key, const void *buf, __u64 num) { - if (num <= 0 || buf == 0) + if (num == 0 || buf == 0) return 0; struct ssl_event *e = bpf_ringbuf_reserve(&ssl_events, sizeof(*e), 0); @@ -60,17 +70,17 @@ int BPF_UPROBE(ssl_write, void *ssl, const void *buf, int num) return 0; e->pid = bpf_get_current_pid_tgid() >> 32; - e->ssl = (__u64)(unsigned long)ssl; + e->ssl = key; // cgroup v2 id of the calling task, captured here (in process context) so // attribution doesn't race the process exiting — short-lived TLS clients // (curl, etc.) are often gone before userspace could read /proc. e->cgroup_id = bpf_get_current_cgroup_id(); bpf_get_current_comm(&e->comm, sizeof(e->comm)); - // Same verifier-friendly clamp as the egress program: __u64 + clamp + + // Verifier-friendly clamp as in the egress program: __u64 + clamp + // barrier + non-zero guard, so the bound lands on the register passed as // the read length. - __u64 n = (__u32)num; + __u64 n = num; if (n > MAX_DATA) n = MAX_DATA; barrier_var(n); @@ -84,3 +94,52 @@ int BPF_UPROBE(ssl_write, void *ssl, const void *buf, int num) bpf_ringbuf_submit(e, 0); return 0; } + +// BPF_UPROBE (libbpf >= 1.2, provided by the trixie build image) extracts the +// function arguments from pt_regs following the C ABI. +SEC("uprobe/SSL_write") +int BPF_UPROBE(ssl_write, void *ssl, const void *buf, int num) +{ + if (num <= 0) + return 0; + return submit_plaintext((__u64)(unsigned long)ssl, buf, (__u32)num); +} + +// Go's ABIInternal (Go >= 1.17) passes integer/pointer args in a fixed register +// sequence, NOT the C ABI, so BPF_UPROBE's PT_REGS_PARM* would read the wrong +// registers. Read them by hand per arch. go_arg(ctx, i) returns the i-th +// integer-class argument register in ABIInternal order. +#if defined(__TARGET_ARCH_x86) +static __always_inline __u64 go_arg(struct pt_regs *ctx, int i) +{ + switch (i) { + case 0: return ctx->ax; // RAX + case 1: return ctx->bx; // RBX + case 2: return ctx->cx; // RCX + case 3: return ctx->di; // RDI + } + return 0; +} +#elif defined(__TARGET_ARCH_arm64) +static __always_inline __u64 go_arg(struct pt_regs *ctx, int i) +{ + // arm64 UAPI exposes the register file as struct user_pt_regs (regs[0..30]); + // ABIInternal integer args start at R0. + return ((const struct user_pt_regs *)ctx)->regs[i & 31]; +} +#else +static __always_inline __u64 go_arg(struct pt_regs *ctx, int i) { return 0; } +#endif + +// crypto/tls.(*Conn).Write(b []byte): args in ABIInternal order are +// (c=*Conn, b.ptr, b.len, b.cap). We key on c and copy b.ptr[0:b.len]. +SEC("uprobe/go_tls_write") +int go_tls_write(struct pt_regs *ctx) +{ + __u64 conn = go_arg(ctx, 0); + const void *buf = (const void *)go_arg(ctx, 1); + __s64 len = (__s64)go_arg(ctx, 2); + if (len <= 0) + return 0; + return submit_plaintext(conn, buf, (__u64)len); +} diff --git a/docs/comparison.md b/docs/comparison.md index 226cd22..e13acf5 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -34,10 +34,12 @@ egress-firewall slice of Cilium" (`toFQDNs`, L7 HTTP policy, Hubble flow visibil 1. **No proxy in the data path for L7 visibility.** This is the big one. To see HTTP method / path / headers on **HTTPS**, Cilium has to terminate TLS through Envoy — a man-in-the-middle with injected certs sitting in every flow. ebfw recovers the - same plaintext request line by hooking `SSL_write` *before* encryption: no latency - tax, no cert management, no proxy to operate. - *Caveat:* the uprobe is OpenSSL-dynamic only — it misses statically-linked TLS - (Go `crypto/tls`, Java, rustls). Envoy termination catches all of those. + same plaintext request line by hooking the TLS write path *before* encryption: no + latency tax, no cert management, no proxy to operate. Two uprobes cover the common + cases — OpenSSL's dynamic `SSL_write` (curl, nginx, most C/Python/…) and Go's + statically-linked `crypto/tls.(*Conn).Write` (any `net/http` client). + *Caveat:* still misses other statically-linked TLS (Java, rustls, OpenSSL-static, + stripped Go binaries). Envoy termination catches all of those. 2. **Additive, not a commitment.** Adopting Cilium means adopting (or CNI-chaining into) a network dataplane — a cluster-wide, hard-to-reverse decision. ebfw is a @@ -64,8 +66,8 @@ Stated plainly, because the honest comparison matters: gRPC) today. ebfw evaluates those dimensions for log + metrics but **does not yet drop on them** — that needs the terminating proxy + TLS MITM we've deliberately avoided. It's on the [roadmap](https://github.com/dvrkn/ebfw/blob/main/ROADMAP.md). -- **Universal TLS coverage.** Envoy termination sees every TLS library; our uprobe - sees OpenSSL-dynamic only. +- **Universal TLS coverage.** Envoy termination sees every TLS library; our uprobes + cover OpenSSL-dynamic + Go `crypto/tls` (not Java, rustls, or stripped/static). - **Breadth & maturity.** Ingress policy, encryption, load balancing, multi-cluster, identity-based scaling, HA, multi-kernel CI, scale tests, CNCF-graduated. ebfw is early: IPv6 enforcement is incomplete, there's no map pinning yet, and it's diff --git a/docs/configuration.md b/docs/configuration.md index 2bb8801..0d62e4d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -151,8 +151,10 @@ in the event lines, not in metric labels. and IPv6, but an IPv6 packet whose next-header is not TCP/UDP directly (a hop-by-hop, routing, fragment, or destination-options header) is skipped rather than walked. These are rare on normal egress. -- **HTTPS paths need OpenSSL-dynamic.** Statically-linked TLS has no `libssl.so` - to hook — notably Go (`crypto/tls`, often stripped), Java, rustls. +- **HTTPS paths need OpenSSL-dynamic or Go crypto/tls.** Two uprobes cover these: + OpenSSL's `SSL_write` (via `libssl.so`) and Go's `crypto/tls.(*Conn).Write` (via + the Go binary's symbol table). Still uncovered: Java, rustls, statically-linked + OpenSSL, and *stripped* Go binaries (`-ldflags "-s -w"` removes the symbol). - **Single segment** — DNS/TLS/HTTP are parsed from the first packet/segment only; larger ClientHellos/requests are truncated. Reassembly is future work. - **Request bodies are a stub** (`EBFW_INSPECT_BODY` does nothing yet). diff --git a/docs/tests.md b/docs/tests.md index 12007d9..f080958 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -24,7 +24,10 @@ Linux host, eBPF, root. Covers visibility — DNS, TLS SNI, HTTP/1.x + HTTP/2 request paths, headers — plus internal-traffic filtering, IPv4 + IPv6 packet decoding, JSON output, and enforcement: a CIDR deny drops the connection, a domain deny blocks via DNS→IP learning, and `log` mode annotates the verdict without -dropping. +dropping. It also builds a native Go `net/http` client (statically-linked +`crypto/tls`, invisible to the OpenSSL uprobe) and asserts ebfw recovers its host, +path, and a custom header via the `crypto/tls.(*Conn).Write` uprobe — self-skipping +where no Go toolchain is present. ## Kubernetes e2e — `test/crd.sh` diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 989cc4e..3b3c0df 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -39,6 +39,13 @@ var ( Help: "Number of libssl SSL_write uprobes currently attached.", }) + // GoUprobesAttached is the number of Go crypto/tls (*Conn).Write uprobes + // attached — one per unique Go binary that links crypto/tls statically. + GoUprobesAttached = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "ebfw_go_uprobe_attached", + Help: "Number of Go crypto/tls (*Conn).Write uprobes currently attached.", + }) + // EnforcementDecisionsTotal counts policy verdicts applied to connection-level // events, by action (allow/deny/modify) and mode (log/enforce). EnforcementDecisionsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ diff --git a/internal/sslsnoop/sslsnoop.go b/internal/sslsnoop/sslsnoop.go index e503e64..78beadb 100644 --- a/internal/sslsnoop/sslsnoop.go +++ b/internal/sslsnoop/sslsnoop.go @@ -11,6 +11,7 @@ package sslsnoop import ( "bytes" "context" + "debug/elf" "encoding/binary" "errors" "fmt" @@ -72,7 +73,8 @@ func Run(ctx context.Context, cfg *config.Config, filter *config.Filter, resolve }() go discoverLoop(ctx, objs.SslWrite) - log.Printf("ebfw sslsnoop: auto-discovering libssl across the node (live capture)") + go discoverGoLoop(ctx, objs.GoTlsWrite) + log.Printf("ebfw sslsnoop: auto-discovering libssl + Go crypto/tls across the node (live capture)") t := newTracker(filter, cfg.Inspect, resolver, sink) for { @@ -229,6 +231,138 @@ func discoverLoop(ctx context.Context, prog *ebpf.Program) { } } +// goTLSWriteSym is the Go symbol we attach to for statically-linked TLS. It is +// present in the .symtab of any non-stripped Go binary that imports crypto/tls +// (the default `go build` keeps the symbol table). +const goTLSWriteSym = "crypto/tls.(*Conn).Write" + +// discoverGoLoop attaches the go_tls_write uprobe to the crypto/tls.(*Conn).Write +// symbol of every unique Go binary on the node, rescanning on the same cadence +// as the libssl loop to pick up new containers. Symbol presence is immutable per +// inode, so each binary's ELF is parsed at most once. +func discoverGoLoop(ctx context.Context, prog *ebpf.Program) { + attached := map[string]link.Link{} + hasSym := map[string]bool{} // inode -> ELF parsed, symbol present? + failed := map[string]bool{} // inode -> attach error already logged + defer func() { + for _, l := range attached { + l.Close() + } + }() + tick := time.NewTicker(discoverInterval) + defer tick.Stop() + for { + for key, path := range discoverExecutables() { + if _, ok := attached[key]; ok { + continue + } + present, ok := hasSym[key] + if !ok { + // First sighting of this binary: parse its symbol table. A parse + // error (e.g. the pid exited, path now stale) is left uncached so + // the next tick retries via a different live pid. + p, err := hasGoTLSSymbol(path) + if err != nil { + continue + } + hasSym[key] = p + present = p + } + if !present { + continue // not a Go crypto/tls binary + } + ex, err := link.OpenExecutable(path) + if err != nil { + if !failed[key] { + log.Printf("ebfw sslsnoop: open %s: %v", path, err) + failed[key] = true + } + continue + } + up, err := ex.Uprobe(goTLSWriteSym, prog, nil) + if err != nil { + if !failed[key] { + log.Printf("ebfw sslsnoop: go uprobe %s: %v", path, err) + failed[key] = true + } + continue + } + delete(failed, key) + attached[key] = up + metrics.GoUprobesAttached.Inc() + log.Printf("ebfw sslsnoop: attached %s uprobe via %s [inode %s]", goTLSWriteSym, path, key) + } + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} + +// discoverExecutables returns "dev:inode" -> /proc//exe for every unique +// process image on the node, deduped by inode (each physical binary attached +// once). Unlike a shared library — one file per mount namespace — every process +// has its own executable, so we must stat every pid's exe rather than dedup by +// mount namespace. The /proc//exe magic symlink is openable for ELF parsing +// and uprobe attachment across namespaces. +func discoverExecutables() map[string]string { + out := map[string]string{} + // Exclude the agent's own binary: ebfw links crypto/tls (via client-go), so + // it carries goTLSWriteSym; attaching to ourselves would just capture the + // agent's own API-server traffic. + var self syscall.Stat_t + haveSelf := syscall.Stat("/proc/self/exe", &self) == nil + + procs, err := os.ReadDir("/proc") + if err != nil { + return out + } + for _, p := range procs { + pid, err := strconv.Atoi(p.Name()) + if err != nil { + continue + } + exe := fmt.Sprintf("/proc/%d/exe", pid) + var st syscall.Stat_t + if err := syscall.Stat(exe, &st); err != nil { + continue + } + if haveSelf && st.Dev == self.Dev && st.Ino == self.Ino { + continue + } + key := fmt.Sprintf("%d:%d", st.Dev, st.Ino) + if _, ok := out[key]; !ok { + out[key] = exe + } + } + return out +} + +// hasGoTLSSymbol reports whether the ELF at path defines goTLSWriteSym in its +// symbol table (i.e. it is a non-stripped Go binary linking crypto/tls). A +// missing symbol table is not an error — it just means "not a match". +func hasGoTLSSymbol(path string) (bool, error) { + f, err := elf.Open(path) + if err != nil { + return false, err + } + defer f.Close() + syms, err := f.Symbols() + if err != nil { + if errors.Is(err, elf.ErrNoSymbols) { + return false, nil + } + return false, err + } + for i := range syms { + if syms[i].Name == goTLSWriteSym { + return true, nil + } + } + return false, nil +} + // discoverLibssl finds the libssl shared object in every container's root // filesystem via /proc//root and returns "dev:inode" -> path. Dedup by // mount namespace keeps it to one scan per container; dedup by inode means each diff --git a/test/e2e.sh b/test/e2e.sh index 9c56937..7e6704f 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -24,11 +24,12 @@ cfg="$(mktemp)" metrics="$(mktemp)" jlog="$(mktemp)" cleanup() { - kill "${AGENT:-}" "${JAGENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null - wait "${AGENT:-}" "${JAGENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null - rm -f "$log" "$cfg" "$metrics" "$jlog" \ + kill "${AGENT:-}" "${JAGENT:-}" "${GAGENT:-}" "${GCLIENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null + wait "${AGENT:-}" "${JAGENT:-}" "${GAGENT:-}" "${GCLIENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null + rm -f "$log" "$cfg" "$metrics" "$jlog" "${glog:-}" \ "${epol:-}" "${elog:-}" "${emetrics:-}" "${dpol:-}" "${dlog:-}" "${dmetrics:-}" \ "${lpol:-}" "${llog:-}" + rm -rf "${gotmp:-}" } trap cleanup EXIT @@ -127,6 +128,97 @@ if command -v python3 >/dev/null 2>&1; then fi fi +# ---- Go native crypto/tls capture (statically-linked TLS) ---- +# curl links OpenSSL's libssl, which the SSL_write uprobe hooks directly. A Go +# net/http client instead links crypto/tls *statically* into the binary and +# never calls SSL_write, so its request is invisible to the OpenSSL uprobe — it +# is captured ONLY via the crypto/tls.(*Conn).Write uprobe. A Go request showing +# up with its host, path, and header is therefore decisive proof that we recover +# L7 detail from a statically-linked-TLS binary before encryption. +# +# The client is compiled inside a Go container (EBFW_GO_IMAGE, default +# golang:1.26-trixie) so the test never assumes a host Go toolchain — only Docker, +# which CI and the dev box already have. The resulting static binary runs on the +# host under the agent. Set EBFW_GOTLS_CLIENT to a prebuilt binary to skip the +# container build; otherwise, with no Docker and no prebuilt client, the checks +# self-skip. +echo "# ---- Go crypto/tls capture ----" +GOTLS_PATH="/e2e/go-tls-path" +GOTLS_HDR_NAME="X-Ebfw-Gotls" +GOTLS_HDR_VAL="e2e-$$" +GO_IMAGE="${EBFW_GO_IMAGE:-golang:1.26-trixie}" +gobin="${EBFW_GOTLS_CLIENT:-}" +gotmp="" +if [ -z "$gobin" ] && command -v docker >/dev/null 2>&1; then + gotmp="$(mktemp -d)" + cat > "$gotmp/main.go" <<'GOEOF' +// Native Go HTTPS client. crypto/tls is linked statically, so its requests are +// invisible to the OpenSSL SSL_write uprobe and only captured via the Go +// crypto/tls.(*Conn).Write uprobe. HTTP/1.1 is forced so the captured plaintext +// is a deterministic "GET ". +// +// It loops, issuing a request every second, because uprobe discovery scans /proc +// on a ~1s cadence: a one-shot process can exit before it is ever seen. Looping +// guarantees requests keep firing after the uprobe attaches to this binary. +package main + +import ( + "crypto/tls" + "io" + "net/http" + "os" + "time" +) + +func main() { + url, name, val := os.Args[1], os.Args[2], os.Args[3] + tr := &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}} + client := &http.Client{Transport: tr} + deadline := time.Now().Add(12 * time.Second) + for time.Now().Before(deadline) { + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set(name, val) + if resp, err := client.Do(req); err == nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + time.Sleep(time.Second) + } +} +GOEOF + echo "# building Go client in $GO_IMAGE" + if docker run --rm -v "$gotmp":/w -w /w "$GO_IMAGE" \ + sh -c 'go mod init e2egotls >/dev/null 2>&1; CGO_ENABLED=0 go build -o client .' >/dev/null 2>&1 \ + && [ -x "$gotmp/client" ]; then + gobin="$gotmp/client" + else + echo "# Go client build failed (image pull or compile); go-tls checks will skip" + fi +fi + +if [ -n "$gobin" ] && [ -x "$gobin" ]; then + glog="$(mktemp)" + EBFW_CONFIG="$cfg" EBFW_INSPECT_PATHS=true EBFW_INSPECT_HEADERS=true EBFW_METRICS_ADDR= \ + "$BIN" > "$glog" 2>&1 & + GAGENT=$! + sleep 3 # allow cgroup attach + # Run the looping client in the background; discovery attaches to it within a + # scan or two, and its later requests are captured. + "$gobin" "https://${SHOWN}${GOTLS_PATH}" "$GOTLS_HDR_NAME" "$GOTLS_HDR_VAL" & + GCLIENT=$! + sleep 7 + kill "$GCLIENT" 2>/dev/null; wait "$GCLIENT" 2>/dev/null + kill "$GAGENT" 2>/dev/null; wait "$GAGENT" 2>/dev/null; GAGENT="" + echo "# ---- go-tls output ----"; cat "$glog"; echo "# -------------------------" + present_in "$glog" "go crypto/tls: uprobe attached" "attached crypto/tls.*Write uprobe" + present_in "$glog" "go crypto/tls: host+path pre-encrypt" "HTTPS .* GET ${SHOWN}${GOTLS_PATH}" + present_in "$glog" "go crypto/tls: header pre-encrypt" "${GOTLS_HDR_NAME}: ${GOTLS_HDR_VAL}" +else + skip "go crypto/tls: uprobe attached — no Docker / prebuilt client" + skip "go crypto/tls: host+path pre-encrypt — no Docker / prebuilt client" + skip "go crypto/tls: header pre-encrypt — no Docker / prebuilt client" +fi + # ---- enforcement: deny a CIDR; the connection must be dropped ---- # Cloudflare's 1.1.1.0/24 is reliably reachable, so a timeout is meaningful, and # we additionally assert the agent logged the deny + bumped the drop metric (so From def05ac4a2061ae41f8833a1cf4ad8abcd9d479f Mon Sep 17 00:00:00 2001 From: dvrkn <4789422+dvrkn@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:27:06 +0300 Subject: [PATCH 2/5] test(e2e): extract Go crypto/tls client to a fixture + Dockerfile 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`. --- test/e2e.sh | 57 +++++---------------------- test/fixtures/gotls-client/Dockerfile | 19 +++++++++ test/fixtures/gotls-client/go.mod | 3 ++ test/fixtures/gotls-client/main.go | 36 +++++++++++++++++ 4 files changed, 68 insertions(+), 47 deletions(-) create mode 100644 test/fixtures/gotls-client/Dockerfile create mode 100644 test/fixtures/gotls-client/go.mod create mode 100644 test/fixtures/gotls-client/main.go diff --git a/test/e2e.sh b/test/e2e.sh index 7e6704f..ee802cf 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -136,61 +136,24 @@ fi # up with its host, path, and header is therefore decisive proof that we recover # L7 detail from a statically-linked-TLS binary before encryption. # -# The client is compiled inside a Go container (EBFW_GO_IMAGE, default -# golang:1.26-trixie) so the test never assumes a host Go toolchain — only Docker, -# which CI and the dev box already have. The resulting static binary runs on the -# host under the agent. Set EBFW_GOTLS_CLIENT to a prebuilt binary to skip the -# container build; otherwise, with no Docker and no prebuilt client, the checks -# self-skip. +# The client lives in test/fixtures/gotls-client and is compiled via its own +# Dockerfile, so the test never assumes a host Go toolchain — only Docker, which +# CI and the dev box already have. The resulting static binary runs on the host +# under the agent. Set EBFW_GOTLS_CLIENT to a prebuilt binary to skip the build; +# with no Docker and no prebuilt client, the checks self-skip. echo "# ---- Go crypto/tls capture ----" GOTLS_PATH="/e2e/go-tls-path" GOTLS_HDR_NAME="X-Ebfw-Gotls" GOTLS_HDR_VAL="e2e-$$" -GO_IMAGE="${EBFW_GO_IMAGE:-golang:1.26-trixie}" +GOTLS_DIR="$(dirname "$0")/fixtures/gotls-client" gobin="${EBFW_GOTLS_CLIENT:-}" gotmp="" if [ -z "$gobin" ] && command -v docker >/dev/null 2>&1; then gotmp="$(mktemp -d)" - cat > "$gotmp/main.go" <<'GOEOF' -// Native Go HTTPS client. crypto/tls is linked statically, so its requests are -// invisible to the OpenSSL SSL_write uprobe and only captured via the Go -// crypto/tls.(*Conn).Write uprobe. HTTP/1.1 is forced so the captured plaintext -// is a deterministic "GET ". -// -// It loops, issuing a request every second, because uprobe discovery scans /proc -// on a ~1s cadence: a one-shot process can exit before it is ever seen. Looping -// guarantees requests keep firing after the uprobe attaches to this binary. -package main - -import ( - "crypto/tls" - "io" - "net/http" - "os" - "time" -) - -func main() { - url, name, val := os.Args[1], os.Args[2], os.Args[3] - tr := &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}} - client := &http.Client{Transport: tr} - deadline := time.Now().Add(12 * time.Second) - for time.Now().Before(deadline) { - req, _ := http.NewRequest(http.MethodGet, url, nil) - req.Header.Set(name, val) - if resp, err := client.Do(req); err == nil { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - } - time.Sleep(time.Second) - } -} -GOEOF - echo "# building Go client in $GO_IMAGE" - if docker run --rm -v "$gotmp":/w -w /w "$GO_IMAGE" \ - sh -c 'go mod init e2egotls >/dev/null 2>&1; CGO_ENABLED=0 go build -o client .' >/dev/null 2>&1 \ - && [ -x "$gotmp/client" ]; then - gobin="$gotmp/client" + echo "# building Go client from $GOTLS_DIR" + if docker build --target bin --output "type=local,dest=$gotmp" "$GOTLS_DIR" >/dev/null 2>&1 \ + && [ -x "$gotmp/gotls-client" ]; then + gobin="$gotmp/gotls-client" else echo "# Go client build failed (image pull or compile); go-tls checks will skip" fi diff --git a/test/fixtures/gotls-client/Dockerfile b/test/fixtures/gotls-client/Dockerfile new file mode 100644 index 0000000..0f9692e --- /dev/null +++ b/test/fixtures/gotls-client/Dockerfile @@ -0,0 +1,19 @@ +# syntax=docker/dockerfile:1 +# +# Builds the e2e Go crypto/tls test client. Kept as a fixture (not inlined in +# test/e2e.sh) so it compiles like any Go program and so the host needs no Go +# toolchain — only Docker, which CI and the dev box already have. +# +# docker build --target bin --output type=local,dest=out test/fixtures/gotls-client +# -> out/gotls-client +# +# Default `go build` keeps the symbol table (no -s/-w), so ebfw's +# crypto/tls.(*Conn).Write uprobe can resolve the symbol and attach. +FROM golang:1.26-trixie AS build +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 go build -o /out/gotls-client . + +# Export just the static binary for host runs / e2e. +FROM scratch AS bin +COPY --from=build /out/gotls-client /gotls-client diff --git a/test/fixtures/gotls-client/go.mod b/test/fixtures/gotls-client/go.mod new file mode 100644 index 0000000..2bd5e75 --- /dev/null +++ b/test/fixtures/gotls-client/go.mod @@ -0,0 +1,3 @@ +module e2egotls + +go 1.26 diff --git a/test/fixtures/gotls-client/main.go b/test/fixtures/gotls-client/main.go new file mode 100644 index 0000000..01a60ca --- /dev/null +++ b/test/fixtures/gotls-client/main.go @@ -0,0 +1,36 @@ +// Command gotls-client is the e2e fixture that exercises ebfw's Go crypto/tls +// uprobe. crypto/tls is linked statically into the binary, so its requests never +// call OpenSSL's SSL_write and are invisible to that uprobe — they are captured +// ONLY via the crypto/tls.(*Conn).Write uprobe. HTTP/1.1 is forced so the +// captured plaintext is a deterministic "GET ". +// +// It loops, issuing a request every second, because uprobe discovery scans /proc +// on a ~1s cadence: a one-shot process can exit before it is ever seen. Looping +// guarantees requests keep firing after the uprobe attaches to this binary. +// +// Usage: gotls-client +package main + +import ( + "crypto/tls" + "io" + "net/http" + "os" + "time" +) + +func main() { + url, name, val := os.Args[1], os.Args[2], os.Args[3] + tr := &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}} + client := &http.Client{Transport: tr} + deadline := time.Now().Add(12 * time.Second) + for time.Now().Before(deadline) { + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set(name, val) + if resp, err := client.Do(req); err == nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + time.Sleep(time.Second) + } +} From 4fe6424f45633b31b909d29dde0f5c2fb309a986 Mon Sep 17 00:00:00 2001 From: dvrkn <4789422+dvrkn@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:28:52 +0300 Subject: [PATCH 3/5] test(e2e): drop the prebuilt-binary escape hatch, docker-only 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. --- test/e2e.sh | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/test/e2e.sh b/test/e2e.sh index ee802cf..b7fac04 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -139,16 +139,15 @@ fi # The client lives in test/fixtures/gotls-client and is compiled via its own # Dockerfile, so the test never assumes a host Go toolchain — only Docker, which # CI and the dev box already have. The resulting static binary runs on the host -# under the agent. Set EBFW_GOTLS_CLIENT to a prebuilt binary to skip the build; -# with no Docker and no prebuilt client, the checks self-skip. +# under the agent. With no Docker (or a build failure) the checks self-skip. echo "# ---- Go crypto/tls capture ----" GOTLS_PATH="/e2e/go-tls-path" GOTLS_HDR_NAME="X-Ebfw-Gotls" GOTLS_HDR_VAL="e2e-$$" GOTLS_DIR="$(dirname "$0")/fixtures/gotls-client" -gobin="${EBFW_GOTLS_CLIENT:-}" +gobin="" gotmp="" -if [ -z "$gobin" ] && command -v docker >/dev/null 2>&1; then +if command -v docker >/dev/null 2>&1; then gotmp="$(mktemp -d)" echo "# building Go client from $GOTLS_DIR" if docker build --target bin --output "type=local,dest=$gotmp" "$GOTLS_DIR" >/dev/null 2>&1 \ @@ -177,9 +176,9 @@ if [ -n "$gobin" ] && [ -x "$gobin" ]; then present_in "$glog" "go crypto/tls: host+path pre-encrypt" "HTTPS .* GET ${SHOWN}${GOTLS_PATH}" present_in "$glog" "go crypto/tls: header pre-encrypt" "${GOTLS_HDR_NAME}: ${GOTLS_HDR_VAL}" else - skip "go crypto/tls: uprobe attached — no Docker / prebuilt client" - skip "go crypto/tls: host+path pre-encrypt — no Docker / prebuilt client" - skip "go crypto/tls: header pre-encrypt — no Docker / prebuilt client" + skip "go crypto/tls: uprobe attached — no Docker" + skip "go crypto/tls: host+path pre-encrypt — no Docker" + skip "go crypto/tls: header pre-encrypt — no Docker" fi # ---- enforcement: deny a CIDR; the connection must be dropped ---- From 88a0f1fce152076bb22d56f876fc178854d9c5d2 Mon Sep 17 00:00:00 2001 From: dvrkn <4789422+dvrkn@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:35:57 +0300 Subject: [PATCH 4/5] fix(sslsnoop): use rax/rbx/rcx/rdi for Go args on amd64 The userspace 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. --- bpf/sslsnoop.bpf.c | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bpf/sslsnoop.bpf.c b/bpf/sslsnoop.bpf.c index 97f3566..bcd03d0 100644 --- a/bpf/sslsnoop.bpf.c +++ b/bpf/sslsnoop.bpf.c @@ -112,11 +112,15 @@ int BPF_UPROBE(ssl_write, void *ssl, const void *buf, int num) #if defined(__TARGET_ARCH_x86) static __always_inline __u64 go_arg(struct pt_regs *ctx, int i) { + // Userspace names the x86_64 registers with the `r` prefix + // (rax/rbx/…); the bare ax/bx/… names exist only in the kernel/vmlinux + // struct pt_regs, which we don't include. This matches BPF_UPROBE's own + // PT_REGS_PARM* under the UAPI header. switch (i) { - case 0: return ctx->ax; // RAX - case 1: return ctx->bx; // RBX - case 2: return ctx->cx; // RCX - case 3: return ctx->di; // RDI + case 0: return ctx->rax; // RAX + case 1: return ctx->rbx; // RBX + case 2: return ctx->rcx; // RCX + case 3: return ctx->rdi; // RDI } return 0; } From 5506df0b259bdd39837284d49d71f114dc1fb75e Mon Sep 17 00:00:00 2001 From: dvrkn <4789422+dvrkn@users.noreply.github.com> Date: Wed, 8 Jul 2026 00:40:37 +0300 Subject: [PATCH 5/5] test(e2e): single Go request instead of a 12s loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/e2e.sh | 14 ++++++------- test/fixtures/gotls-client/main.go | 32 ++++++++++++++++++------------ 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/test/e2e.sh b/test/e2e.sh index b7fac04..f54c0b1 100755 --- a/test/e2e.sh +++ b/test/e2e.sh @@ -24,8 +24,8 @@ cfg="$(mktemp)" metrics="$(mktemp)" jlog="$(mktemp)" cleanup() { - kill "${AGENT:-}" "${JAGENT:-}" "${GAGENT:-}" "${GCLIENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null - wait "${AGENT:-}" "${JAGENT:-}" "${GAGENT:-}" "${GCLIENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null + kill "${AGENT:-}" "${JAGENT:-}" "${GAGENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null + wait "${AGENT:-}" "${JAGENT:-}" "${GAGENT:-}" "${EAGENT:-}" "${DAGENT:-}" "${LAGENT:-}" 2>/dev/null rm -f "$log" "$cfg" "$metrics" "$jlog" "${glog:-}" \ "${epol:-}" "${elog:-}" "${emetrics:-}" "${dpol:-}" "${dlog:-}" "${dmetrics:-}" \ "${lpol:-}" "${llog:-}" @@ -164,12 +164,10 @@ if [ -n "$gobin" ] && [ -x "$gobin" ]; then "$BIN" > "$glog" 2>&1 & GAGENT=$! sleep 3 # allow cgroup attach - # Run the looping client in the background; discovery attaches to it within a - # scan or two, and its later requests are captured. - "$gobin" "https://${SHOWN}${GOTLS_PATH}" "$GOTLS_HDR_NAME" "$GOTLS_HDR_VAL" & - GCLIENT=$! - sleep 7 - kill "$GCLIENT" 2>/dev/null; wait "$GCLIENT" 2>/dev/null + # The client waits internally (> the ~1s discovery interval) so the uprobe + # attaches to it before it sends, then issues a single request and exits. + "$gobin" "https://${SHOWN}${GOTLS_PATH}" "$GOTLS_HDR_NAME" "$GOTLS_HDR_VAL" || true + sleep 2 # let the captured event drain kill "$GAGENT" 2>/dev/null; wait "$GAGENT" 2>/dev/null; GAGENT="" echo "# ---- go-tls output ----"; cat "$glog"; echo "# -------------------------" present_in "$glog" "go crypto/tls: uprobe attached" "attached crypto/tls.*Write uprobe" diff --git a/test/fixtures/gotls-client/main.go b/test/fixtures/gotls-client/main.go index 01a60ca..ceca389 100644 --- a/test/fixtures/gotls-client/main.go +++ b/test/fixtures/gotls-client/main.go @@ -4,33 +4,39 @@ // ONLY via the crypto/tls.(*Conn).Write uprobe. HTTP/1.1 is forced so the // captured plaintext is a deterministic "GET ". // -// It loops, issuing a request every second, because uprobe discovery scans /proc -// on a ~1s cadence: a one-shot process can exit before it is ever seen. Looping -// guarantees requests keep firing after the uprobe attaches to this binary. +// It sleeps briefly before sending so uprobe discovery — which scans /proc on a +// ~1s cadence — can attach to this binary while the process is alive; a client +// that fired immediately could exit before it was ever seen. After the wait it +// issues a single request: that one capture proves host/path/header extraction. // // Usage: gotls-client package main import ( "crypto/tls" + "fmt" "io" "net/http" "os" "time" ) +// attachWait must exceed the agent's ~1s uprobe-discovery interval by a margin so +// the probe is guaranteed live before the single request goes out. +const attachWait = 4 * time.Second + func main() { url, name, val := os.Args[1], os.Args[2], os.Args[3] + time.Sleep(attachWait) + tr := &http.Transport{TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{}} - client := &http.Client{Transport: tr} - deadline := time.Now().Add(12 * time.Second) - for time.Now().Before(deadline) { - req, _ := http.NewRequest(http.MethodGet, url, nil) - req.Header.Set(name, val) - if resp, err := client.Do(req); err == nil { - io.Copy(io.Discard, resp.Body) - resp.Body.Close() - } - time.Sleep(time.Second) + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set(name, val) + resp, err := (&http.Client{Transport: tr}).Do(req) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } + io.Copy(io.Discard, resp.Body) + resp.Body.Close() }