Reading HTTPS Traffic with eBPF Uprobes: How I Monitor AI Agents Through the Kernel
TL;DR: eBPF uprobes on
SSL_writeandSSL_readlet you read HTTPS traffic in plaintext without modifying the application or breaking TLS. I built a monitor in Go that traces everything an AI agent does: API calls, shell commands, file access, network connections. The key insight: Node.js bundles its own OpenSSL, so system libssl uprobes miss it entirely. You need to probe the binary directly.
I run an AI-powered Telegram bot on a VM. It talks to various LLM APIs, spawns shell commands, reads and writes files. Standard agentic AI stuff. But I had no idea what it was actually doing between receiving a message and sending a reply. What APIs was it calling? What commands was it running? What files was it touching?
I wanted to see everything. Not through logs, through the kernel.
Why Syscall Tracing Is Not Enough
The naive approach: attach eBPF tracepoints to write and read syscalls and look for HTTP patterns.
SEC("tracepoint/syscalls/sys_enter_write")
int trace_write(struct trace_event_raw_sys_enter *ctx) {
void *buf = PT_REGS_PARM2(ctx);
int len = PT_REGS_PARM3(ctx);
if (!is_ai_api_traffic(payload, len))
return 0;
bpf_ringbuf_submit(event, 0);
}
This works for plain HTTP. But every AI API uses HTTPS. At the syscall level, write and read only see encrypted TLS ciphertext. You get random bytes, not JSON.
SSL Uprobes: Reading HTTPS in Plaintext
The key insight is where in the stack you attach your probe:
The trick is to go one level up in the stack. Instead of hooking syscalls, attach uprobes to the SSL library functions before encryption and after decryption.
SEC("uprobe/ssl_write")
int uprobe_ssl_write_entry(struct pt_regs *ctx) {
// SSL_write(ssl, buf, num)
// buf contains the PLAINTEXT before OpenSSL encrypts it
void *buf = PT_REGS_PARM2(ctx);
int len = PT_REGS_PARM3(ctx);
return submit_ssl_event(buf, len, SSL_WRITE_EVENT);
}
SEC("uretprobe/ssl_read")
int uretprobe_ssl_read_exit(struct pt_regs *ctx) {
// SSL_read just returned
// The buffer now contains DECRYPTED plaintext
return submit_ssl_event(buf, ret, SSL_READ_EVENT);
}
The monitor attaches these uprobes to SSL_write and SSL_read in the system’s libssl.so. Any process using OpenSSL (Python, curl, Ruby, PHP) is now visible in plaintext. No certificates to install, no proxy to configure, no application changes.
How to Find Your libssl
The uprobe needs the exact path to the shared library:
# Find the system libssl
find /usr/lib -name 'libssl.so*' -type f 2>/dev/null
# Typically: /usr/lib/x86_64-linux-gnu/libssl.so.3
# Verify SSL_write symbol exists
nm -D /usr/lib/x86_64-linux-gnu/libssl.so.3 | grep SSL_write
# Expected: T SSL_write
On the Go side, cilium/ebpf loads the BPF program and attaches the uprobes. The Go code parses the captured plaintext, extracts the HTTP host, request body, and matches against ~35 known AI API domains (OpenAI, Anthropic, Google, AWS Bedrock, Mistral, Groq, and more).
The Node.js Gotcha: Static OpenSSL
This is where I got stuck for hours. My bot runs on Node.js, and the uprobes on system libssl.so caught nothing. No traffic at all.
The reason: Node.js bundles its own OpenSSL statically. It does not link against the system’s libssl.so. Your uprobes on /usr/lib/libssl.so.3 will never fire for Node processes.
The fix: find the Node binary and attach uprobes directly to its embedded SSL symbols.
# Find the SSL symbols inside the node binary itself
which node
# /usr/bin/node
nm -D /usr/bin/node | grep SSL_write
# If nm shows nothing, try:
readelf -Ws /usr/bin/node | grep SSL_write
# Expected: SSL_write_ex or SSL_write
In Go:
if nodePath, err := exec.LookPath("node"); err == nil {
attachSSLUprobes(nodePath, "node")
}
Now the monitor sees Node.js HTTPS traffic. The same approach works for any runtime that bundles its own SSL (Deno, some Go binaries linked with CGO, Rust with vendored OpenSSL).
What This Does Not Catch
Not everything uses OpenSSL:
- Go’s native
crypto/tls: Go does not use OpenSSL at all (unless CGO-linked). You would need to probe Go’s internal TLS functions, which have no stable ABI. - BoringSSL: Used by Chrome and some Google tools. Different symbol names (
bssl::SSL_write). - musl-linked binaries: Alpine containers use musl libc and often bundle their own SSL. Probe the binary, not the system lib.
- Java/JVM: Uses its own TLS implementation (JSSE). Requires different probes entirely.
For my use case (monitoring a Node.js AI bot and Python scripts calling AI APIs), OpenSSL uprobes cover everything.
Tracking What the AI Does After the API Call
Seeing the API calls was just the start. The interesting part is what happens after, when the AI decides to run commands, read files, or make network connections.
The eBPF program maintains an LRU map of “AI process IDs.” Any process that makes an AI API call gets tracked. When that process forks a child, the child inherits the tracking:
SEC("tracepoint/sched/sched_process_fork")
int trace_fork(struct trace_event_raw_sched_process_fork *ctx) {
u32 ai_ancestor = get_ai_ancestor(parent_pid);
if (ai_ancestor != 0) {
bpf_map_update_elem(&ai_pids, &child_pid, &ai_ancestor, BPF_ANY);
}
}
With ancestry tracking in place, the monitor also hooks:
sys_enter_execve: what commands are being spawnedsys_enter_openat: what files are being accessed (read or write)sys_enter_connect: what network connections are being made
Every event carries the ai_ancestor_pid, so you can trace it back to the original API call. AI sends a request, gets a response saying “run ip neigh show”, and you see the full chain: API call, response, exec, file reads, network connections.
A Web UI That Makes Sense of It
Raw events in a terminal get old fast. I embedded a web UI into the Go binary using go:embed:
//go:embed static/*
var staticFiles embed.FS
The frontend connects via WebSocket for live streaming and renders events in a tree view. AI API calls are root nodes, child events nest underneath:
▼ 19:35:20 [ai] node PID:1141 api.anthropic.com - claude-sonnet-4-5 3 exec, 2 file, 1 net
│ 19:35:21 [ai-resp] ai-response Assistant: "Here's what I found..."
│ 19:35:21 [exec] sh PID:2306 /bin/sh -c ip neigh show
│ 19:35:21 [exec] sh PID:2307 /bin/sh -c cat /etc/hosts
│ 19:35:21 [file] node PID:1141 WRITE+CREATE: /tmp/result.json
│ 19:35:22 [net] node PID:1141 CONNECT: 149.154.167.220:443
File events are filtered to only show writes and sensitive path access (SSH keys, .env files, credentials). The hundreds of boring READ /proc/... events are tracked in the badge count but hidden from the tree.
The Build
The eBPF toolchain (clang, llvm, libbpf-dev, kernel headers) is annoying to set up. I run the build in Docker so the host stays clean:
FROM golang:1.22-bookworm AS builder
RUN apt-get update && apt-get install -y clang llvm libbpf-dev linux-headers-generic
COPY . .
RUN go generate ./... # Compiles eBPF C to bytecode via bpf2go
RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o ai-cli-monitor .
go generate invokes bpf2go from cilium/ebpf, which compiles the C code into eBPF bytecode and generates Go bindings. The final binary is ~7 MB, fully static, web UI baked in. Copy it to a server, run with sudo, done.
sudo ./ai-cli-monitor --web :8080
This was a weekend vibe-coding experiment built iteratively with Claude, not a polished open-source project. The eBPF probes and Go event pipeline came together quickly, but I spent most of the time on the edge cases: finding the right SSL symbols per runtime, sizing the ring buffer, and filtering the noise from hundreds of /proc reads per second.
Performance Overhead
The uprobes add negligible latency. Each probe fires only on SSL_write/SSL_read calls (not every syscall), and the kernel-side filter drops non-AI traffic immediately via the ring buffer. In practice, I measured no detectable difference in API response times with the monitor running. The ring buffer is sized at 256 KB by default. For bursty AI agents that stream responses, increase it:
sudo ./ai-cli-monitor --web :8080 --ring-buffer-size 1048576 # 1 MB
If events get dropped (visible in the UI as a gap counter), increase the buffer. Too large wastes locked kernel memory.
What I Learned
SSL uprobes are the key to modern eBPF monitoring. Without them, you are blind to 99% of interesting traffic. The trick is knowing which SSL library each runtime uses: system libssl, Node.js bundled OpenSSL, or something else entirely.
AI agents are chatty. A single user message to my bot triggers dozens of API calls, file reads, command executions, and network connections. Without a tree view grouping them by the originating AI request, it is impossible to follow what happened.
The kernel sees everything. No amount of application-level logging gives you the same visibility as eBPF. If a process makes a syscall, you see it. If an AI agent decides to read your SSH keys or connect to an unexpected IP, you know immediately.
The code is a single Go module with an embedded BPF program. No frameworks, no dependencies beyond cilium/ebpf and the standard library. Sometimes the simplest stack is the right one.
Interested in CI/CD supply chain security? I built a related tool that monitors secrets access in CI pipelines using eBPF.
The views and opinions expressed here are my own and do not reflect those of my employer.