Skip to main content
NJannasch.Dev

Reading HTTPS Traffic with eBPF Uprobes: How I Monitor AI Agents Through the Kernel

· 8 min read
eBPFSecurityGoAIObservability

TL;DR: eBPF uprobes on SSL_write and SSL_read let 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:

Application Code HTTP request with JSON body readable data OpenSSL (libssl.so) SSL_write() encrypts / SSL_read() decrypts uprobe encrypted bytes Socket Layer write() / read() syscalls syscall Linux Kernel TCP/IP network stack uprobe hooks here: reads data before encryption syscall hooks here: only sees encrypted bytes

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 spawned
  • sys_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.