DEV Community

云微
云微

Posted on

Reverse Engineering Claude Code's SSL Traffic with eBPF

Ever wondered what your AI coding agent is actually sending over the network? As part of our work on AgentSight — an eBPF-powered observability tool that monitors AI agents at system boundaries without modifying their code — we needed to intercept Claude Code's encrypted API traffic to understand its communication patterns. AgentSight can capture both what an AI agent intends to do (by intercepting LLM API traffic) and what it actually does (by monitoring kernel events), then correlate the two into a complete causal chain. To make this work, we first need to crack open the agent's encrypted network traffic. Sounds straightforward, right? Just hook into Node.js's OpenSSL with sslsniff, like we did with older versions of Claude Code. Back then, Claude Code ran on Node.js with dynamically linked libssl.so, and standard eBPF-based SSL hooking worked out of the box.

But everything changed. The new Claude Code is a 213 MB stripped Bun binary with BoringSSL statically linked and all symbols removed. No libssl.so, no SSL_write symbol to hook — standard tools are completely blind to it. So we did what any self-respecting systems researcher would do: we asked Claude Code to reverse engineer itself. Yes, the very AI agent we were trying to monitor became our reverse engineering assistant, analyzing its own binary, searching for byte patterns, and helping us locate the stripped SSL functions inside itself.

This post chronicles that complete journey: from discovering why standard tools fail, to matching byte patterns against Bun's open-source profile builds, to finally capturing every single API call — including the full /v1/messages conversation stream with prompts, responses, and SSE events. Along the way, we chased a fascinating red herring about Bun's "dual TLS paths" that turned out to be a simple timing issue. The techniques here apply to any stripped binary with statically-linked SSL, not just Claude Code.

AgentSight is open source at https://github.com/agent-sight/agentsight, and the arxiv paper describing the full system is available at https://arxiv.org/abs/2508.02736.

Executive Summary

Claude Code CLI is a Bun v1.3.9-canary application with BoringSSL
statically linked and symbols stripped. We successfully modified sslsniff to
auto-detect BoringSSL functions via byte-pattern matching and can now capture
ALL SSL/TLS traffic including the conversation API (/v1/messages).

What works

Traffic Type Endpoint HTTP Client Captured?
Heartbeat GET /api/hello axios/1.8.4 YES
Telemetry POST /api/event_logging/batch axios/1.8.4 YES
Datadog logs POST /api/v2/logs axios/1.8.4 YES
Conversation API POST /v1/messages?beta=true Bun native fetch YES

All traffic flows through a single HTTP Client thread via BoringSSL
SSL_write/SSL_read. The initial hypothesis that Bun's native fetch()
used a separate TLS path was incorrect — it uses the same BoringSSL functions
through uSockets. Earlier test captures missed /v1/messages due to timing
(no new messages were submitted during the capture window).

Captured data includes

  • Request: Full HTTP headers (Authorization, anthropic-beta, etc.) and JSON body (model, messages array, system prompt)
  • Response: Complete SSE streaming events (message_start, content_block_delta with text/tool_use, message_stop)
  • Protocol: HTTP/1.1 over TLS (not HTTP/2)

Phase 1: Initial Reconnaissance

Goal

Determine if the existing sslsniff can monitor Claude out of the box.

Commands & Results

# Check if claude is running
$ ps aux | grep claude
yunwei37  847282 14.8  0.6 75318520 838308 pts/7 Rl+ claude

# Try standard sslsniff with comm filter
$ sudo timeout 10 ./bpf/sslsniff -c claude
# Result: NO OUTPUT — nothing captured in 10 seconds

# Check sslsniff help for relevant options
$ sudo ./bpf/sslsniff -h
# Lists: -p PID, -c COMMAND, --binary-path PATH, etc.
Enter fullscreen mode Exit fullscreen mode

Result: Standard sslsniff captures nothing from claude processes.

Root Cause Investigation

# Check claude binary type
$ file ~/.local/share/claude/versions/2.1.39
ELF 64-bit LSB executable, x86-64, dynamically linked

# Check dynamic library dependencies
$ ldd ~/.local/share/claude/versions/2.1.39
    libc.so.6
    libpthread.so.0
    libdl.so.2
    libm.so.6
    libstdc++.so.6.0.33
    libgcc_s.so.1
# NOTE: NO libssl.so or libcrypto.so!

# Check loaded libraries at runtime
$ sudo cat /proc/847282/maps | grep "\.so" | awk '{print $NF}' | sort -u
    /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
    /usr/lib/x86_64-linux-gnu/libc.so.6
    /usr/lib/x86_64-linux-gnu/libdl.so.2
    /usr/lib/x86_64-linux-gnu/libgcc_s.so.1
    /usr/lib/x86_64-linux-gnu/libm.so.6
    /usr/lib/x86_64-linux-gnu/libnss_mdns4_minimal.so.2
    /usr/lib/x86_64-linux-gnu/libpthread.so.0
    /usr/lib/x86_64-linux-gnu/libstdc++.so.6.0.33
# CONFIRMED: no SSL library loaded at runtime either
Enter fullscreen mode Exit fullscreen mode

Conclusion: Claude uses a statically-linked SSL library embedded in the binary.


Phase 2: Understanding Why Standard Hooking Fails

Attempt: Use --binary-path to hook SSL in the Claude binary directly

$ sudo ./bpf/sslsniff -c claude \
    --binary-path ~/.local/share/claude/versions/2.1.39
# ERROR:
# libbpf: elf: 'SSL_write' is 0 in symtab for '...2.1.39':
#   try using shared library path instead
# no program attached for probe_SSL_rw_enter: No such file or directory
Enter fullscreen mode Exit fullscreen mode

Why it fails: sslsniff uses bpf_program__attach_uprobe_opts with
.func_name = "SSL_write", which searches the binary's symbol table. Since
all SSL symbols are stripped, the lookup returns offset 0 and libbpf rejects it.

Verify: Are SSL symbols really stripped?

# Check static symbol table
$ readelf -s ~/.local/share/claude/versions/2.1.39 | grep -i "ssl_write\|ssl_read"
# (no output)

# Check dynamic symbol table
$ readelf --dyn-syms ~/.local/share/claude/versions/2.1.39 | grep -i ssl
# (no output)

# Use gdb to search
$ sudo gdb -batch -p 847282 -ex "info functions SSL"
# All functions matching regular expression "SSL":
# (no results)
Enter fullscreen mode Exit fullscreen mode

Confirmed: All SSL/BoringSSL symbols are completely stripped from the binary.


Phase 3: Identifying the Runtime

Discovering Bun

# Search for runtime identification strings
$ strings ~/.local/share/claude/versions/2.1.39 | grep "node_module_register"
node_module_register
# → Uses Node.js API

$ strings ~/.local/share/claude/versions/2.1.39 | grep "bun-vfs"
/bun-vfs$$/node_modules/crypto/index.js
# → Uses Bun virtual filesystem!

$ strings ~/.local/share/claude/versions/2.1.39 | grep "Bun v"
Bun v1.3.9-canary.51+d5628db23 (Linux x64 baseline)
# → Exact Bun version identified
Enter fullscreen mode Exit fullscreen mode

Discovering BoringSSL

$ strings ~/.local/share/claude/versions/2.1.39 | grep "boring"
BoringSSLError
openssl_is_boringssl
../../../vendor/boringssl/ssl/ssl_buffer.cc
../../../vendor/boringssl/ssl/ssl_lib.cc
../../../vendor/boringssl/crypto/fipsmodule/bn/...
# (many more BoringSSL source paths)
Enter fullscreen mode Exit fullscreen mode

Exported symbols confirm Bun

$ readelf -s ~/.local/share/claude/versions/2.1.39 | grep "BUN_1.2" | grep "FUNC" | wc -l
556
# 556 exported functions with BUN_1.2 version tag
# Includes: uv_write, uv_read_start, napi_*, v8::* etc.
Enter fullscreen mode Exit fullscreen mode

Binary Properties Summary

Property Value
Binary path ~/.local/share/claude/versions/2.1.39
Symlink target /home/yunwei37/.local/share/claude/versions/2.1.39
File size 222,867,057 bytes (~213 MB)
Runtime Bun v1.3.9-canary.51+d5628db23
Build variant Linux x64 baseline
SSL library BoringSSL (statically linked, fully stripped)
Exported symbols 556 functions (BUN_1.2), 981 dynamic symbols total
USDT probes 3 (libstdcxx: catch, throw, rethrow — not useful for SSL)

Phase 4: Finding BoringSSL Function Offsets

Strategy: Cross-reference with Bun profile build

Since Bun is open-source, release builds include a "profile" variant with
debug symbols. The function code is identical between profile and stripped
builds; only symbols differ.

Step 1: Download the matching profile build

# List available assets for bun v1.3.9
$ gh release view bun-v1.3.9 --repo oven-sh/bun --json assets \
    --jq '.assets[].name' | grep linux-x64
bun-linux-x64-baseline-profile.zip   # <-- This one (Claude uses "baseline")
bun-linux-x64-baseline.zip
bun-linux-x64-profile.zip
bun-linux-x64.zip

# Download the baseline profile build
$ gh release download bun-v1.3.9 --repo oven-sh/bun \
    --pattern "bun-linux-x64-baseline-profile.zip"
$ unzip bun-linux-x64-baseline-profile.zip
Enter fullscreen mode Exit fullscreen mode

Step 2: Extract SSL function symbols from the profile build

$ readelf -s bun-profile | grep -E " SSL_write$| SSL_read$| SSL_do_handshake$"
 97928: 0000000005f3fe00   379 FUNC    LOCAL  HIDDEN    16 SSL_write
 97985: 0000000005f3ea70  1506 FUNC    LOCAL  HIDDEN    16 SSL_do_handshake
 97988: 0000000005f3f160   247 FUNC    LOCAL  HIDDEN    16 SSL_read
Enter fullscreen mode Exit fullscreen mode

Step 3: Calculate file offsets from virtual addresses

The profile build's ELF program headers:

LOAD  offset=0x000000  VA=0x200000   size=0x28dfef0  (R)    ← data
LOAD  offset=0x28dff00 VA=0x2ae0f00  size=0x38c80f0  (R E)  ← code
Enter fullscreen mode Exit fullscreen mode

Conversion formula: file_offset = (VA - seg_VA) + seg_file_offset

# Profile build function file offsets:
SSL_write:        VA=0x5f3fe00 → FileOff=0x5d3ee00
SSL_read:         VA=0x5f3f160 → FileOff=0x5d3e160
SSL_do_handshake: VA=0x5f3ea70 → FileOff=0x5d3da70
Enter fullscreen mode Exit fullscreen mode

Step 4: Extract function byte prologues

$ xxd -s 0x5d3ee00 -l 48 bun-profile  # SSL_write
05d3ee00: 5548 89e5 4157 4156 4155 4154 5348 83ec
05d3ee10: 1841 89d7 4989 f648 89fb 488b 4730 c780

$ xxd -s 0x5d3e160 -l 48 bun-profile  # SSL_read
05d3e160: 5548 89e5 4157 4156 5350 4883 bf98 0000
05d3e170: 0000 742b baf3 5a31 00bf 1000 0000 be42

$ xxd -s 0x5d3da70 -l 48 bun-profile  # SSL_do_handshake
05d3da70: 5548 89e5 4157 4156 4155 4154 5348 83ec
05d3da80: 2849 89fc 488b 4730 c780 c400 0000 0000
Enter fullscreen mode Exit fullscreen mode

Step 5: Search for patterns in the Claude binary

# Python script using mmap to search for byte patterns
# SSL_write pattern (26 bytes):
#   55 48 89 e5 41 57 41 56 41 55 41 54 53 48 83 ec
#   18 41 89 d7 49 89 f6 48 89 fb
#
# Result: 13 matches (many false positives from common prologues)
# Last match at 0x5c39b20 — closest to SSL_read match

# SSL_read pattern (19 bytes):
#   55 48 89 e5 41 57 41 56 53 50 48 83 bf 98 00 00
#   00 00 74
#
# Result: 1 match at 0x5c38e80 ← unique!

# SSL_do_handshake pattern (24 bytes):
#   55 48 89 e5 41 57 41 56 41 55 41 54 53 48 83 ec
#   28 49 89 fc 48 8b 47 30
#
# Result: 1 match at 0x5c38790 ← unique!
Enter fullscreen mode Exit fullscreen mode

Step 6: Validate with relative distances

=== Profile build relative distances ===
read - handshake = 0x6F0 (1776 bytes)
write - read     = 0xCA0 (3232 bytes)

=== Claude binary relative distances ===
read - handshake = 0x6F0 (1776 bytes)  ← IDENTICAL
write - read     = 0xCA0 (3232 bytes)  ← IDENTICAL

*** PERFECT MATCH ***
Enter fullscreen mode Exit fullscreen mode

Step 7: Verify byte-level match

# Compare 128 bytes of SSL_write between profile and Claude
$ xxd -s 0x5d3ee00 -l 128 bun-profile
$ xxd -s 0x5c39b20 -l 128 claude-binary

# Result: nearly identical! Only address references differ
# (expected, since binaries are linked at different addresses)
Enter fullscreen mode Exit fullscreen mode

Final verified offsets in Claude binary

SSL_do_handshake: 0x5c38790
SSL_read:         0x5c38e80
SSL_write:        0x5c39b20
Enter fullscreen mode Exit fullscreen mode

Phase 5: Implementing Byte-Pattern Detection

Changes to sslsniff.c

Added three components:

1. Offset-based uprobe macros

#define __ATTACH_UPROBE_OFFSET(skel, binary_path, offset, prog_name, is_retprobe) \
    do {                                                                          \
      LIBBPF_OPTS(bpf_uprobe_opts, uprobe_opts, .retprobe = is_retprobe);         \
      skel->links.prog_name = bpf_program__attach_uprobe_opts(                    \
          skel->progs.prog_name, env.pid, binary_path, offset, &uprobe_opts);     \
    } while (false)
Enter fullscreen mode Exit fullscreen mode

Key difference from symbol-based macro: func_name is omitted (NULL), and
the offset parameter is passed directly as the file offset.

2. find_boringssl_offsets() function

  • Opens the target binary with mmap()
  • Searches for three unique byte patterns (SSL_read → SSL_do_handshake → SSL_write)
  • Validates matches using known relative distances (0x6F0 and 0xCA0)
  • Falls back to broader search if distances don't match

3. Two-stage --binary-path handler

// First: try symbol-based attachment
struct bpf_link *test_link = bpf_program__attach_uprobe_opts(..., "SSL_write", ...);
if (test_link) {
    // Standard path: symbols available
    attach_openssl(obj, env.extra_lib);
} else {
    // Fallback: pattern detection for stripped binaries
    struct boringssl_offsets offsets = find_boringssl_offsets(env.extra_lib);
    if (offsets.found) {
        attach_openssl_by_offset(obj, env.extra_lib, &offsets);
    }
}
Enter fullscreen mode Exit fullscreen mode

Build & Test

$ make -C bpf
# Compiles successfully with no errors

$ cd collector && cargo build --release
# Builds successfully

$ cd bpf && make test
# 24/24 C tests passed

$ cd collector && cargo test
# 89/89 Rust tests passed
Enter fullscreen mode Exit fullscreen mode

Phase 6: Testing — Partial Success

Verbose output confirms BoringSSL detection

$ sudo ./bpf/sslsniff --binary-path ~/.local/share/claude/versions/2.1.39 --verbose
# stderr output:
Attaching to binary: /home/yunwei37/.local/share/claude/versions/2.1.39
Symbols not found, trying BoringSSL pattern detection...
BoringSSL detected in /home/yunwei37/.local/share/claude/versions/2.1.39:
  SSL_do_handshake offset: 0x5c38790
  SSL_read offset:         0x5c38e80
  SSL_write offset:        0x5c39b20
BoringSSL detected! Attaching by offset...
Enter fullscreen mode Exit fullscreen mode

First capture: telemetry traffic

$ sudo ./bpf/sslsniff --binary-path ~/.local/share/claude/versions/2.1.39
Enter fullscreen mode Exit fullscreen mode

Captured 3 events within seconds:

{"function":"WRITE/SEND","comm":"HTTP Client","pid":847282,
 "data":"GET /api/hello HTTP/1.1\r\nHost: api.anthropic.com\r\nUser-Agent: axios/1.8.4\r\n\r\n"}

{"function":"READ/RECV","comm":"HTTP Client","pid":847282,
 "data":"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n..."}

{"function":"READ/RECV","comm":"HTTP Client","pid":847282,
 "data":"0\r\n\r\n"}
Enter fullscreen mode Exit fullscreen mode

Extended capture (8 seconds): more traffic including event logging

Captured POST to /api/event_logging/batch with full telemetry data including:

  • Session IDs, device IDs
  • Event types: tengu_permission_request_option_selected, tengu_unary_event, etc.
  • Model information: claude-opus-4-6
  • Growthbook experiment events
  • Cost threshold events

Failure: --comm claude filter doesn't work

$ sudo ./bpf/sslsniff -c claude --binary-path ~/.local/share/claude/versions/2.1.39
# NO OUTPUT for 20 seconds
Enter fullscreen mode Exit fullscreen mode

Root cause: SSL traffic comes from thread named HTTP Client, not claude.
The eBPF bpf_get_current_comm() returns the thread name, not the process
name. Bun's HTTP client runs in a separate thread.

Fix: Omit the -c filter when monitoring Claude, or the collector should
not pass --comm for the SSL runner when using --binary-path.


Phase 7: The Two-Path TLS Problem

Discovery: No /v1/messages API calls captured

After 60 seconds of monitoring during active conversation:

$ sudo ./bpf/sslsniff --binary-path ~/.local/share/claude/versions/2.1.39 \
    2>/dev/null > /tmp/capture.log
$ sleep 60
# Result: 92 lines captured

# Check endpoints:
$ python3 -c "..." /tmp/capture.log
# Unique endpoints seen:
#   GET /api/hello
#   POST /api/event_logging/batch
#   POST /api/v2/logs
#
# Messages API check:
$ grep -c "messages\|v1/message" /tmp/capture.log
# 0  ← NONE!
Enter fullscreen mode Exit fullscreen mode

The actual conversation API (/v1/messages) was never captured.

Thread analysis reveals the cause

$ for tid in $(ls /proc/847282/task/); do
    comm=$(cat /proc/847282/task/$tid/comm)
    echo "TID=$tid comm=$comm"
  done

TID=847282 comm=claude
TID=847284 comm=claude
TID=847285-847291 comm=HeapHelper (7 threads)
TID=847297 comm=HTTP Client        # ← axios telemetry uses this
TID=847298-847333 comm=Bun Pool 0-6
TID=847323 comm=File Watcher
TID=890093+ comm=JITWorker
Enter fullscreen mode Exit fullscreen mode

All captured traffic came from TID=847297 (HTTP Client thread) via the
axios/1.8.4 HTTP client library. The main conversation API uses Bun's
native fetch() which goes through a completely different TLS path.

Root cause: Bun's dual TLS architecture

Bun has two independent TLS implementations:

  1. Node.js compatibility path (used by axios, node:https):

    • Goes through standard BoringSSL SSL_write/SSL_read
    • Runs on the HTTP Client thread
    • Our hooks capture this path ✓
  2. Bun native path (used by fetch(), Bun's HTTP client):

    • Uses uSockets library with custom BIO callbacks
    • Does NOT call SSL_write/SSL_read
    • Uses ssl_on_data callback for received data
    • Runs on the main claude thread
    • Our hooks do NOT capture this path ✗

uSockets function analysis

From the Bun profile build:

$ readelf -W -s bun-profile | grep -E "ssl_on_|BIO_s_custom|us_internal_ssl"

48809: 040f6cc0    18 FUNC LOCAL  BIO_s_custom_create
48810: 040f6cf0    74 FUNC LOCAL  BIO_s_custom_write     # encrypted data out
48811: 040f6d40   101 FUNC LOCAL  BIO_s_custom_read      # encrypted data in
48812: 040f6ce0    14 FUNC LOCAL  BIO_s_custom_ctrl
48825: 040f77b0   732 FUNC LOCAL  ssl_on_data            # plaintext data received
48826: 040f7a90   164 FUNC LOCAL  ssl_on_writable         # writable notification
48831: 040f6db0   417 FUNC LOCAL  ssl_on_open             # connection opened
48824: 040f7690   136 FUNC LOCAL  ssl_on_close            # connection closed
Enter fullscreen mode Exit fullscreen mode

uSockets functions located in Claude binary

Using 32-byte pattern matching with 64-byte verification:

# Search results (verified with 64-byte similarity check):
ssl_on_data:          claude offset=0x3dde620 (64/64 bytes match)
ssl_on_writable:      claude offset=0x3dde900 (63/64 bytes match)
ssl_on_open:          claude offset=0x3dddc20 (64/64 bytes match)
ssl_on_close:         claude offset=0x3dde500 (60/64 bytes match)
BIO_s_custom_write:   claude offset=0x3dddb60 (64/64 bytes match)
BIO_s_custom_read:    claude offset=0x3dddbb0 (63/64 bytes match)
Enter fullscreen mode Exit fullscreen mode

Important: Initial pattern search for ssl_on_data returned a false
positive at offset 0x27d8050 (only 25% byte match). The prologue
55 48 89 e5 41 57 41 56 41 55 41 54 53 48 83 ec is extremely common
across all functions. Using 32+ byte patterns with 64-byte validation
eliminated the false positive.

Failed attempt: bpftrace verification

$ sudo bpftrace -e '
uprobe:/home/yunwei37/.local/share/claude/versions/2.1.39:0x3dde620 { ... }
' -c "sleep 10"
# ERROR: Could not resolve address: ...0x3dde620
Enter fullscreen mode Exit fullscreen mode

bpftrace was unable to resolve the offset. This may be a bpftrace limitation
with large stripped ELF executables. The libbpf-based sslsniff approach
(using bpf_program__attach_uprobe_opts directly) works correctly for the
BoringSSL hooks, suggesting the same approach would work for uSockets hooks.


Current Status & Next Steps

Current status

Component Status
BoringSSL pattern detection ✅ Working
sslsniff offset-based attachment ✅ Working
Telemetry/heartbeat capture ✅ Working
/v1/messages (prompt) capture ✅ Working (see Phase 9)
Collector integration ✅ --binary-path passed through

How to capture Claude traffic

# Start monitoring BEFORE submitting a message to Claude:
sudo ./bpf/sslsniff --binary-path ~/.local/share/claude/versions/2.1.39

# The --comm filter does NOT work because traffic comes from "HTTP Client" thread
# Do NOT use: sudo ./bpf/sslsniff -c claude --binary-path ...
Enter fullscreen mode Exit fullscreen mode

All traffic (telemetry + conversation API) flows through the HTTP Client
thread via BoringSSL. The capture must be running when a new message is
submitted to capture the POST /v1/messages request and SSE response stream.

Remaining work

  1. Collector --comm filter fix: FIXED — collector/src/main.rs
    now skips passing --comm to sslsniff when --binary-path is specified.
    The process runner still receives --comm for process monitoring.

  2. HTTP response decompression: Responses may use br (Brotli) or gzip
    encoding. The collector's HTTP decompressor should handle these.

  3. Large message reassembly: Conversation context can be very large (the
    full message history is sent with each request). The 512KB MAX_BUF_SIZE
    in sslsniff.h may truncate large requests. Multiple SSL_write calls may
    need to be reassembled.

Files modified

File Changes
bpf/sslsniff.c Added find_boringssl_offsets(), attach_openssl_by_offset(), offset-based uprobe macros, two-stage --binary-path handler
collector/src/main.rs Fixed --comm filter: skip passing -c to sslsniff when --binary-path is specified (SSL traffic uses "HTTP Client" thread name, not process name)
docs/claude-code-analysis.md This document

Appendix: Claude Binary Architecture

ELF Program Headers

Type    Offset     VirtAddr           FileSiz    MemSiz     Flg
LOAD    0x000000   0x0000000000200000 0x27d6908  0x27d6908  R     ← rodata
LOAD    0x27d6c00  0x0000000002ae7c00 0x38cc010  0x38cc010  R E   ← code
LOAD    0x60a2c10  0x00000000063b4c10 0x08fd88   0x1753e0   RW    ← data/bss
Entry point: 0x2ae7c00
Enter fullscreen mode Exit fullscreen mode

Thread Model (full)

Thread Name Count Purpose
claude 2 Main thread + event loop
HeapHelper 7 Garbage collection assistance
HTTP Client 1 axios HTTP client (telemetry, heartbeat)
Bun Pool 0-6 7 Bun thread pool (async I/O)
File Watcher 1 File system monitoring
JITWorker 2-3 Just-in-time compilation

Network Endpoints Observed

Host Endpoint Method Purpose Client
api.anthropic.com /api/hello GET Heartbeat axios
api.anthropic.com /api/event_logging/batch POST Telemetry axios
api.anthropic.com /v1/messages POST Conversation Bun fetch
http-intake.logs.us5.datadoghq.com /api/v2/logs POST Datadog logging axios

Multi-Process Architecture

Claude Code runs multiple processes:

$ pgrep -a claude
847282 claude   # Main session (19-21 threads, active conversation)
890428 claude   # Sub-process (20 threads, also sends telemetry)
269932 claude   # Other user's session
Enter fullscreen mode Exit fullscreen mode

Both PID 847282 and 890428 emit telemetry traffic via their respective
HTTP Client threads. The sub-process (890428) appears to handle some
independent tasks.

Key HTTP Headers

Authorization: Bearer sk-ant-oat01-...
Content-Type: application/json
User-Agent: claude-code/2.1.39
anthropic-beta: oauth-2025-04-20
x-service-name: claude-code
Enter fullscreen mode Exit fullscreen mode

Telemetry payloads include: session_id, device_id, model name, event types
(permission requests, accept/submit events, cost thresholds, Growthbook
experiments), platform info (linux, node v24.3.0, is_running_with_bun: true).


Phase 8: Deep Investigation of the Native Fetch TLS Path

Goal

Determine exactly how Bun's native fetch() handles TLS encryption for the
/v1/messages API, since BoringSSL SSL_read/SSL_write hooks only capture
the axios telemetry path.

Step 1: Verify ssl_on_data is NOT the native fetch path

We confirmed via disassembly that ssl_on_data in the Claude binary calls
SSL_read at exactly our hooked offset (0x5c38e80):

# Profile build: ssl_on_data calls SSL_read at offset +0xA1
40f7851: e8 0a 79 e4 01    call 5f3f160 <SSL_read>

# Claude binary: same relative call at ssl_on_data + 0xA1
03dde6c1: e8 ba a7 e5 01    → target = 0x5c38e80 ✓ (matches our hook)
Enter fullscreen mode Exit fullscreen mode

But when traced with ftrace uprobes, ssl_on_data only fires from the
HTTP Client thread (axios), never from the main thread:

$ sudo bash -c 'echo "p:my_ssl_on_data /path/to/claude:0x3dde620" > \
    /sys/kernel/tracing/uprobe_events'
$ sudo bash -c 'echo 1 > /sys/kernel/tracing/events/uprobes/my_ssl_on_data/enable'
$ sleep 3

# Result: ONLY HTTP Client thread
HTTP Client-847297  [016] my_ssl_on_data: (0x40ef620)
HTTP Client-847297  [016] my_ssl_on_data: (0x40ef620)
HTTP Client-890449  [017] my_ssl_on_data: (0x40ef620)
Enter fullscreen mode Exit fullscreen mode

Step 2: Confirm SSL_read is only called from HTTP Client

Registered a uprobe directly on SSL_read (offset 0x5c38e80) and traced for
10 seconds during active conversation:

$ sudo bash -c 'echo "p:ssl_read_trace /path/to/claude:0x5c38e80" > \
    /sys/kernel/tracing/uprobe_events'
# ... enable and wait 10 seconds ...

# Result: 18 calls, ALL from HTTP Client
     18 HTTP Client
Enter fullscreen mode Exit fullscreen mode

No SSL_read calls from the main claude thread, Bun Pool threads, or
any other thread.

Step 3: Check for multiple copies of SSL_read

Searched the entire Claude binary for the SSL_read byte-pattern prologue:

pattern = bytes([0x55, 0x48, 0x89, 0xe5, 0x41, 0x57, 0x41, 0x56,
                 0x53, 0x50, 0x48, 0x83, 0xbf, 0x98, 0x00, 0x00,
                 0x00, 0x00, 0x74])

# Result: Only 1 match at offset 0x5c38e80
# There is NO second copy of SSL_read in the binary.
Enter fullscreen mode Exit fullscreen mode

Step 4: Find ALL callers of SSL_read and SSL_write

Scanned the entire code section of the Bun profile build for e8 (CALL)
instructions targeting SSL_read (VA 0x5f3f160) and SSL_write (VA 0x5f3fe00):

=== Functions calling SSL_read (2 call sites) ===
1. VA=0x369a330 → SSLWrapper.handleTraffic  (ssl_wrapper.zig:491)
2. VA=0x40f7851 → ssl_on_data              (uSockets callback)

=== Functions calling SSL_write (4 call sites) ===
1. VA=0x34fa553 → http.ProxyTunnel.write              (ProxyTunnel.zig:351)
2. VA=0x35c9308 → deps.uws.UpgradedDuplex.encodeAndWrite (UpgradedDuplex.zig:356)
3. VA=0x3a42208 → http.websocket_client.WebSocketProxyTunnel.write
4. VA=0x40fde0e → us_socket_write                      (uSockets C library)
Enter fullscreen mode Exit fullscreen mode

Step 5: Verify SSLWrapper.handleTraffic is NOT called

Located SSLWrapper.handleTraffic in the Claude binary at file offset
0x3390c30 (pattern-matched from profile build). Verified it also calls
SSL_read at the correct offset. Then traced it:

$ sudo bash -c 'echo "p:ssl_wrapper_trace /path/to/claude:0x3390c30" > \
    /sys/kernel/tracing/uprobe_events'
# ... enabled for 10 seconds ...

# Result: 0 calls. SSLWrapper.handleTraffic is NEVER invoked.
Enter fullscreen mode Exit fullscreen mode

Step 6: Verify BIO_s_custom_write/read are NOT used by native fetch

Traced the uSockets custom BIO functions:

# BIO_s_custom_write at offset 0x3dddb60
# BIO_s_custom_read at offset 0x3dddbb0

# Result after 5 seconds:
=== BIO_s_custom_write calls ===
      7 HTTP Client
=== BIO_s_custom_read calls ===
      5 HTTP Client
Enter fullscreen mode Exit fullscreen mode

All BIO custom callbacks also only fire from HTTP Client thread.

Step 7: Investigate kernel TLS (kTLS)

Checked if kTLS was the mechanism bypassing SSL_read/SSL_write:

$ lsmod | grep tls
tls    155648  26    # Module loaded!

$ cat /proc/net/tls_stat
TlsCurrTxSw    0
TlsCurrRxSw    0
TlsTxSw        0
TlsRxSw        0
# All counters are 0 → kTLS is NOT actively used
Enter fullscreen mode Exit fullscreen mode

kTLS ruled out. Module is loaded but no connections use it.

Step 8: Trace network syscalls from Claude

Main thread (TID=959023) syscalls

The main Claude thread only performs:

  • read(fd=6, count=8) — eventfd (event loop notification)
  • read(fd=7, count=8) — timerfd (timer events)
  • write(fd=13, ...) — /dev/pts/7 (terminal output)
  • write(fd=6, count=8) — eventfd (signaling event loop)
  • Occasional write(fd=18, count=0x6c7) — socket to api.anthropic.com
FD 6  → anon_inode:[eventfd]     (event loop)
FD 7  → anon_inode:[timerfd]     (timer)
FD 8  → anon_inode:[timerfd]     (timer)
FD 13 → /dev/pts/7               (terminal)
FD 14 → anon_inode:[eventpoll]   (epoll)
FD 17 → socket (api.anthropic.com:443)
FD 18 → socket (api.anthropic.com:443)
FD 19 → socket (api.anthropic.com:443)
FD 25 → socket (api.anthropic.com:443)
Enter fullscreen mode Exit fullscreen mode

Key finding: The main thread does NOT read from network sockets. It only
writes to them occasionally. It spends most time on epoll_pwait2(fd=4)
processing events and writing to the terminal.

Cross-thread analysis

Traced ALL write-related syscalls (write, writev, sendmsg, sendto)
across ALL system processes for 5 seconds. Filtered for Claude threads:

No Claude thread writes to socket FDs during normal response streaming.
The main thread writes ONLY to terminal (fd=13) and eventfd (fd=6).

Step 9: Check for io_uring

# Traced io_uring_enter and io_uring events for 3 seconds
# Result: NO io_uring events from Claude or any process
Enter fullscreen mode Exit fullscreen mode

io_uring ruled out. Bun uses epoll_pwait2 as its event loop.

Step 10: Investigate Bun's socket.write() source code

Examined Bun's source code (v1.3.9) to understand the TLS write path:

// From src/deps/uws/socket.zig
pub fn write(this: ThisSocket, data: []const u8) i32 {
    return switch (this.socket) {
        .upgradedDuplex => |socket| socket.encodeAndWrite(data),
        .connected => |socket| socket.write(is_ssl, data),
        .connecting, .detached => 0,
    };
}
Enter fullscreen mode Exit fullscreen mode

For .connected SSL sockets, socket.write(is_ssl=true, data) delegates to
the C-level us_socket_write → SSL_write. For .upgradedDuplex,
encodeAndWrite also calls SSL_write.

The Bun HTTP client uses BoringSSL through uSockets, imported as:

const BoringSSL = bun.BoringSSL.c;
Enter fullscreen mode Exit fullscreen mode

Analysis: Why don't we capture /v1/messages traffic?

Despite exhaustive investigation, the BoringSSL SSL_read/SSL_write
functions are only called from the HTTP Client thread. All evidence
points to a timing/observation issue rather than a different TLS library:

  1. All SSL functions (SSL_read, SSL_write, BIO_s_custom_write/read, ssl_on_data) fire exclusively from the "HTTP Client" thread
  2. Bun's source code confirms BoringSSL is used for all TLS, through the uSockets library
  3. Only ONE copy of SSL_read exists in the binary
  4. kTLS and io_uring are not used
  5. The native fetch socket write path (.connected → us_socket_write → SSL_write) is the same code path as axios

Hypothesis: Connection lifecycle timing

The most likely explanation is that all TLS traffic flows through the
HTTP Client thread
, including the /v1/messages API calls. The reason
we haven't captured /v1/messages in test captures is:

  1. The API request (POST /v1/messages) is sent at the start of each turn — before our monitoring captures begin
  2. The streaming SSE response is received quickly over the existing HTTP/2 connection
  3. By the time we start tracing, the response data has already been received and buffered
  4. The main thread is simply writing buffered response to the terminal

To verify this, a capture must span the exact moment the user submits
a new message to Claude, triggering a fresh /v1/messages POST request.

Process Architecture (Updated)

PID 959023 (claude -c --dangerously-skip-permissions)
├── TID 959023  claude          ← Main thread: JS execution, terminal I/O, epoll
├── TID 959024  claude          ← Secondary event loop
├── TID 959025-959031  HeapHelper (7) ← GC threads
├── TID 959035  HTTP Client     ← ALL SSL/TLS traffic (axios + native fetch?)
├── TID 959036-959625  Bun Pool 0-11 (12) ← Worker threads
├── TID 959061  File Watcher    ← FS monitoring
├── TID 994361+ JITWorker (3)   ← JIT compilation
└── TID 997844  t Helper Thread ← Unknown

Also running:
PID 890428 (claude) — background process, 18 threads, 3 API connections
PID 302782, 313517 — Node.js-based Claude processes (no API connections)
PID 269932, 845341, 894340 — Other sessions
Enter fullscreen mode Exit fullscreen mode

Summary of all BoringSSL functions traced

Function Offset (Claude) Called from Purpose
SSL_read 0x5c38e80 HTTP Client only Read decrypted data
SSL_write 0x5c39b20 HTTP Client only Write data for encryption
SSL_do_handshake 0x5c38790 (not traced) TLS handshake
ssl_on_data 0x3dde620 HTTP Client only uSockets data callback
ssl_on_writable 0x3dde900 (not traced) uSockets writable callback
BIO_s_custom_write 0x3dddb60 HTTP Client only Custom BIO write
BIO_s_custom_read 0x3dddbb0 HTTP Client only Custom BIO read
SSLWrapper.handleTraffic 0x3390c30 NEVER called Zig SSL wrapper (unused?)

Next steps

  1. Long-running capture spanning message submission: Run sslsniff continuously while a user submits a new message to Claude, to capture the initial POST /v1/messages request and streaming response
  2. HTTP/2 frame decoding: The /v1/messages traffic may use HTTP/2 over a persistent connection. Captured data would be binary HTTP/2 frames, not plaintext HTTP/1.1. Need HTTP/2 frame parser to extract the actual request/response data
  3. Alternative: Hook at the Zig/JS layer: Instead of hooking at the BoringSSL level, hook higher-level Bun functions that handle HTTP request/response data before/after TLS encryption

Phase 9: Breakthrough — Full /v1/messages Capture

Goal

Resolve the mystery of why /v1/messages traffic was never captured despite
all evidence pointing to BoringSSL as the only TLS implementation.

Key Insight: Timing Was the Issue

The Phase 8 investigation conclusively proved that all TLS traffic flows
through the HTTP Client thread via BoringSSL SSL_write/SSL_read. The reason
previous captures missed /v1/messages was simple: no new messages were
submitted during the capture window
.

The /v1/messages POST request is sent at the start of each conversation turn.
If sslsniff starts after the request has already been sent, and the SSE
streaming response completes before the next capture check, the traffic is missed.

Experiment: Self-referential capture

Since this analysis is being performed by a Claude Code instance (PID 959023),
the act of making tool calls generates /v1/messages API traffic. By starting
sslsniff before making more tool calls, we can capture our own API traffic.

# Start sslsniff in background
$ sudo ./bpf/sslsniff --binary-path ~/.local/share/claude/versions/2.1.39 \
    2>/tmp/sslsniff_stderr.log > /tmp/sslsniff_capture.log &

# sslsniff attaches successfully:
# "BoringSSL detected! Attaching by offset..."

# Then continue making tool calls (which generates API traffic)
# After ~30 seconds, analyze the capture:
Enter fullscreen mode Exit fullscreen mode

Results: Complete capture of ALL traffic

Total events captured: 3,088 lines
├── HTTP Client(pid=959023): 2,985 events  (this session)
└── HTTP Client(pid=996386): 103 events    (other Claude process)

Functions:
├── READ/RECV: 3,043 events (SSE streaming responses)
└── WRITE/SEND: 45 events (requests + telemetry)

HTTP Endpoints captured:
├── POST /v1/messages?beta=true: 12 requests  ← CONVERSATION API!
├── POST /api/event_logging/batch: 5 requests
├── POST /api/v2/logs: 2 requests
└── GET /api/hello: 2 requests

SSE response events: 2,072 (content_block_delta, message_start, etc.)
Enter fullscreen mode Exit fullscreen mode

Captured request details

Full HTTP/1.1 request with all headers and JSON body:

POST /v1/messages?beta=true HTTP/1.1
Accept: application/json
Authorization: Bearer sk-ant-oat01-...
Content-Type: application/json
User-Agent: claude-cli/2.1.39 (external, cli)
X-Stainless-Arch: x64
X-Stainless-Lang: js
X-Stainless-OS: Linux
X-Stainless-Package-Version: 0.73.0
X-Stainless-Runtime: node
X-Stainless-Runtime-Version: v24.3.0
X-Stainless-Timeout: 600
anthropic-beta: oauth-2025-04-20,interleaved-thinking-2025-05-14,...
anthropic-version: 2023-06-01
x-app: cli
Host: api.anthropic.com
Accept-Encoding: gzip, deflate, br, zstd

{"model":"claude-haiku-4-5-20251001","messages":[...full conversation...],...}
Enter fullscreen mode Exit fullscreen mode

Captured SSE streaming response

event: message_start
data: {"type":"message_start","message":{"model":"claude-haiku-4-5-20251001",
  "id":"msg_01LUTkpAXp558VX17nfxzG3x","usage":{"input_tokens":390,...}}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text",...}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta",
  "text":"..."}}
Enter fullscreen mode Exit fullscreen mode

Why Phase 7 / Phase 8 were misleading

The original Phase 7 hypothesis was that Bun uses two separate TLS paths:

  1. axios → BoringSSL SSL_write/SSL_read (captured)
  2. fetch() → uSockets with different TLS path (not captured)

This was incorrect. Both paths use the same BoringSSL functions through
the same HTTP Client thread. The confusion arose because:

  1. Timing: Previous test captures ran for 60 seconds but during that window no new /v1/messages POST requests were made. The response streaming had already completed before sslsniff started.
  2. Thread naming: The HTTP Client thread handles ALL HTTP traffic, not just axios. Bun's native fetch also dispatches through this thread.
  3. uSockets: The ssl_on_data / us_socket_write functions are intermediate layers that ultimately call SSL_read/SSL_write, which our hooks capture.

Architecture correction

BEFORE (incorrect hypothesis):
  axios    → BoringSSL SSL_write/SSL_read → HTTP Client thread  (captured ✓)
  fetch()  → uSockets custom TLS path    → Main thread          (not captured ✗)

AFTER (correct architecture):
  axios    → Node.js http → BoringSSL SSL_write/SSL_read → HTTP Client  (captured ✓)
  fetch()  → uSockets     → BoringSSL SSL_write/SSL_read → HTTP Client  (captured ✓)
Enter fullscreen mode Exit fullscreen mode

Both code paths converge at the same BoringSSL functions on the HTTP Client
thread. No additional hooks are needed.

Top comments (0)