Monitoring Claude Code HTTPS Traffic with eBPF
Overview
This article introduces how to use eBPF technology to monitor HTTPS traffic from Claude Code processes. By hooking OpenSSL library's SSL_read and SSL_write functions, we can capture plaintext data at the TLS encryption/decryption boundary, enabling monitoring of HTTPS communication content.
Technical Principles
Why Hook SSL Functions?
HTTPS communication is encrypted with TLS, so direct packet capture only shows ciphertext. However, at the application layer, data is in plaintext before entering the SSL library for encryption (sending) and after being decrypted by the SSL library (receiving):
Application
│
├─ SSL_write(plaintext) ──→ Encrypt ──→ Network (ciphertext)
│
└─ SSL_read(plaintext) ←── Decrypt ←── Network (ciphertext)By setting uprobes on SSL_write and SSL_read functions, we can capture this plaintext data.
SSL Function Prototypes
// Send data (before encryption)
int SSL_write(SSL *ssl, const void *buf, int num);
// Receive data (after decryption)
int SSL_read(SSL *ssl, void *buf, int num);Implementation Details
1. Data Structure Definition
Define the event structure shared between kernel and user space in common.h:
#define MAX_DATA_SIZE 4096
struct ssl_event {
__u32 pid; // Process ID
__u32 data_len; // Data length
__u8 is_read; // 0=write, 1=read
char comm[16]; // Process name
char data[MAX_DATA_SIZE]; // Plaintext data
};2. BPF Map Definitions
Use RingBuffer to efficiently transfer events from kernel to user space:
// RingBuffer Map - for passing SSL events
struct {
__uint(type, BPF_MAP_TYPE_RINGBUF);
__uint(max_entries, 256 * 1024); // 256KB
} events SEC(".maps");
// PID filter configuration map
struct {
__uint(type, BPF_MAP_TYPE_ARRAY);
__uint(max_entries, 1);
__type(key, u32);
__type(value, u32);
} target_pid_map SEC(".maps");
// SSL_read argument temporary storage (needed at return time)
struct ssl_read_args {
void *buf;
size_t num;
};
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__uint(max_entries, 1024);
__type(key, u64); // pid_tgid
__type(value, struct ssl_read_args);
} ssl_read_args_map SEC(".maps");3. SSL_write Hook Implementation
The SSL_write implementation is relatively simple because all needed data can be obtained at function entry:
SEC("uprobe/SSL_write")
int BPF_UPROBE(ssl_write_hook, void *ssl, const void *buf, size_t num) {
// Filter invalid data
if (num <= 0 || num > MAX_DATA_SIZE) {
return 0;
}
// PID filter check
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 current_pid = pid_tgid >> 32;
u32 key = 0;
u32 *target_pid = bpf_map_lookup_elem(&target_pid_map, &key);
if (target_pid && *target_pid != 0) {
if (current_pid != *target_pid) {
return 0;
}
}
// Allocate event memory
struct ssl_event *event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event) {
return 0;
}
// Fill event data
event->pid = current_pid;
event->data_len = num;
event->is_read = 0; // 0 = write
bpf_get_current_comm(&event->comm, sizeof(event->comm));
// Copy plaintext data from user space
if (bpf_probe_read_user(event->data, num, buf) != 0) {
bpf_ringbuf_discard(event, 0);
return 0;
}
bpf_ringbuf_submit(event, 0);
return 0;
}4. SSL_read Hook Implementation
SSL_read requires two probes working together: entry probe saves arguments, return probe captures actual data.
Entry Probe - Save Arguments:
SEC("uprobe/SSL_read")
int BPF_UPROBE(ssl_read_entry, void *ssl, void *buf, size_t num) {
u64 pid_tgid = bpf_get_current_pid_tgid();
struct ssl_read_args args = {
.buf = buf,
.num = num,
};
// Save arguments for return probe
bpf_map_update_elem(&ssl_read_args_map, &pid_tgid, &args, BPF_ANY);
return 0;
}Return Probe - Capture Data:
SEC("uretprobe/SSL_read")
int BPF_URETPROBE(ssl_read_exit, int ret) {
u64 pid_tgid = bpf_get_current_pid_tgid();
u32 current_pid = pid_tgid >> 32;
// PID filter check
u32 key = 0;
u32 *target_pid = bpf_map_lookup_elem(&target_pid_map, &key);
if (target_pid && *target_pid != 0) {
if (current_pid != *target_pid) {
goto cleanup;
}
}
// Lookup saved arguments
struct ssl_read_args *args = bpf_map_lookup_elem(&ssl_read_args_map, &pid_tgid);
if (!args) {
return 0;
}
// Check return value (actual bytes read)
if (ret <= 0 || ret > MAX_DATA_SIZE) {
goto cleanup;
}
// Ensure valid range (satisfy BPF verifier)
u32 data_len = ret & (MAX_DATA_SIZE - 1);
if (data_len == 0 || data_len > MAX_DATA_SIZE) {
goto cleanup;
}
// Allocate and fill event
struct ssl_event *event = bpf_ringbuf_reserve(&events, sizeof(*event), 0);
if (!event) {
goto cleanup;
}
event->pid = current_pid;
event->data_len = data_len;
event->is_read = 1; // 1 = read
bpf_get_current_comm(&event->comm, sizeof(event->comm));
if (bpf_probe_read_user(event->data, data_len, args->buf) != 0) {
bpf_ringbuf_discard(event, 0);
goto cleanup;
}
bpf_ringbuf_submit(event, 0);
cleanup:
bpf_map_delete_elem(&ssl_read_args_map, &pid_tgid);
return 0;
}5. User Space Program
Attach uprobe to Target Library:
static int attach_ssl_probes(struct claude_trace_bpf *skel,
const char *lib_path,
const char *lib_name) {
LIBBPF_OPTS(bpf_uprobe_opts, opts);
// SSL_write probe
opts.func_name = "SSL_write";
opts.retprobe = false;
struct bpf_link *link_write = bpf_program__attach_uprobe_opts(
skel->progs.ssl_write_hook, target_pid, lib_path, 0, &opts);
if (!link_write) {
return -1;
}
// SSL_read entry probe
opts.func_name = "SSL_read";
opts.retprobe = false;
struct bpf_link *link_read_entry = bpf_program__attach_uprobe_opts(
skel->progs.ssl_read_entry, target_pid, lib_path, 0, &opts);
// SSL_read return probe
opts.retprobe = true;
struct bpf_link *link_read_exit = bpf_program__attach_uprobe_opts(
skel->progs.ssl_read_exit, target_pid, lib_path, 0, &opts);
return 0;
}Event Handling Callback:
static int handle_event(void *ctx, void *data, size_t data_sz) {
const struct ssl_event *e = data;
char timestamp[32];
get_timestamp(timestamp, sizeof(timestamp));
printf("[%s] %s | PID:%u | data_length:%u\n",
timestamp,
e->is_read ? "SSL_read " : "SSL_write",
e->pid, e->data_len);
// Print data content...
return 0;
}Key Technical Points
1. Why Does SSL_read Need Two Probes?
- Entry Probe: At this point, the memory pointed to by
bufparameter hasn't been filled with data yet - Return Probe: At this point,
bufcontains the decrypted data, but entry parameters cannot be accessed directly
Therefore, a Map is needed to save the buf pointer at entry and read it at return.
2. BPF Verifier Data Range Check
u32 data_len = ret & (MAX_DATA_SIZE - 1);This bit operation technique helps the BPF verifier confirm that the data length is within a valid range.
3. Finding SSL Library Location
For Node.js (used by Claude Code), OpenSSL is usually statically linked in the Node.js binary:
static char* find_node_binary() {
static char *paths[] = {
"/opt/node-v22.20.0/bin/node",
"/usr/bin/node",
"/usr/local/bin/node",
NULL
};
// ...
}Usage
# Monitor HTTPS traffic for specified PID
sudo ./claude_trace -p <PID>
# Monitor SSL events only (disable others)
sudo ./claude_trace -p <PID> -E -B
# Output to JSON file
sudo ./claude_trace -p <PID> -o output.jsonOutput Example
[14:30:01] SSL_write | PID:12345 | data_length:256
[SSE] event: message_start
type: message_start
[14:30:02] SSL_read | PID:12345 | data_length:1024
[SSE] event: content_block_delta
type: content_block_delta
delta.type: text_delta
delta.text: "Hello, world!"Notes
- Requires Root Privileges: uprobe needs CAP_BPF capability
- Performance Impact: May have slight performance overhead in high-traffic scenarios
- Data Truncation: Maximum 4KB per capture, larger packets will be truncated
- Library Compatibility: Only supports applications using OpenSSL