How to Implement Linux eBPF Uprobes for Dynamic User-Space TLS Interception

Observing and debugging encrypted network traffic in production environments is exceptionally difficult. Traditional methods involve deploying Man-in-the-Middle (MitM) proxies, replacing certificates, or configuring applications to dump TLS master secrets via the SSLKEYLOGFILE environment variable. These approaches require application restarts, breaking client trust, or extensive reconfiguration.

eBPF (Extended Berkeley Packet Filter) offers a revolutionary alternative. By using uprobes (User-Space Probes), we can dynamically instrument the encryption and decryption functions of cryptographic libraries (like OpenSSL’s libssl.so) while the application is actively running. This allows us to intercept plaintext HTTP data immediately before encryption and immediately after decryption, entirely bypassing the TLS tunnel without breaking cryptographic trust.

Understanding eBPF uprobes and OpenSSL

While kprobes hook into kernel functions, uprobes hook into user-space applications and shared libraries. When an application dynamically links to OpenSSL, it uses specific functions to read and write encrypted data, primarily:

  • SSL_read() – Reads decrypted data from the TLS buffer.
  • SSL_write() – Writes plaintext data into the TLS buffer to be encrypted.

By attaching an eBPF uprobe to the entry point of these functions, and an eBPF uretprobe (user-space return probe) to their exit points, we can read the memory buffers containing the plaintext data, completely transparent to the application and the network.

Prerequisites for eBPF User-Space Tracing

To compile and deploy eBPF uprobes, you require the BCC (BPF Compiler Collection) toolchain. On a Debian/Ubuntu system, install the necessary packages:

sudo apt update
sudo apt install bpfcc-tools linux-headers-$(uname -r) python3-bpfcc

You must also locate the exact path to the OpenSSL shared library on your system, as uprobes attach directly to the binary file. Usually, this is located at /lib/x86_64-linux-gnu/libssl.so.3 or similar.

ldconfig -p | grep libssl

Step 1: Writing the eBPF uprobe Program

We will construct a Python script utilizing BCC. The embedded C code defines the eBPF program, while the Python wrapper handles attaching the probes and parsing the output.

Create a file named tls_intercept.py:

#!/usr/bin/env python3
from bcc import BPF
import argparse

# Define the C eBPF program
bpf_program = """
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>

// Maximum buffer size to read
#define MAX_DATA_SIZE 256

// Data structure to pass to user-space
struct data_t {
    u32 pid;
    u32 uid;
    char comm[TASK_COMM_LEN];
    char buf[MAX_DATA_SIZE];
    int len;
    int is_write;
};

BPF_PERF_OUTPUT(events);
BPF_HASH(active_reads, u64, const char *);

// Hook SSL_write entry (plaintext is available in arguments)
int probe_SSL_write_enter(struct pt_regs *ctx, void *ssl, const void *buf, int num) {
    struct data_t data = {};
    
    data.pid = bpf_get_current_pid_tgid() >> 32;
    data.uid = bpf_get_current_uid_gid();
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    
    data.len = num;
    data.is_write = 1;
    
    // Copy the plaintext buffer safely
    bpf_probe_read_user(&data.buf, sizeof(data.buf), buf);
    
    events.perf_submit(ctx, &data, sizeof(data));
    return 0;
}

// Hook SSL_read entry (save the buffer pointer)
int probe_SSL_read_enter(struct pt_regs *ctx, void *ssl, void *buf, int num) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    active_reads.update(&pid_tgid, &buf);
    return 0;
}

// Hook SSL_read return (plaintext is now populated in the buffer)
int probe_SSL_read_exit(struct pt_regs *ctx) {
    u64 pid_tgid = bpf_get_current_pid_tgid();
    const char **bufp = active_reads.lookup(&pid_tgid);
    
    if (bufp == 0) return 0; // Missed the entry
    
    int ret = PT_REGS_RC(ctx);
    if (ret <= 0) {
        active_reads.delete(&pid_tgid);
        return 0;
    }
    
    struct data_t data = {};
    data.pid = pid_tgid >> 32;
    bpf_get_current_comm(&data.comm, sizeof(data.comm));
    data.len = ret;
    data.is_write = 0;
    
    bpf_probe_read_user(&data.buf, sizeof(data.buf), *bufp);
    events.perf_submit(ctx, &data, sizeof(data));
    
    active_reads.delete(&pid_tgid);
    return 0;
}
"""

# Initialise BPF
b = BPF(text=bpf_program)

# Define the path to libssl (adjust according to your OS)
libssl_path = "/lib/x86_64-linux-gnu/libssl.so.3"

# Attach uprobes
b.attach_uprobe(name=libssl_path, sym="SSL_write", fn_name="probe_SSL_write_enter")
b.attach_uprobe(name=libssl_path, sym="SSL_read", fn_name="probe_SSL_read_enter")
b.attach_uretprobe(name=libssl_path, sym="SSL_read", fn_name="probe_SSL_read_exit")

print("Successfully attached uprobes to libssl. Intercepting TLS traffic... (Press Ctrl+C to exit)")

# Process output
def print_event(cpu, data, size):
    event = b["events"].event(data)
    direction = "WRITE (Outbound)" if event.is_write else "READ (Inbound)"
    print(f"\\n--- {direction} | PID: {event.pid} | Comm: {event.comm.decode('utf-8', 'replace')} ---")
    try:
        print(event.buf.decode('utf-8', 'replace').strip())
    except Exception:
        print("[Binary Data]")

b["events"].open_perf_buffer(print_event)

while True:
    try:
        b.perf_buffer_poll()
    except KeyboardInterrupt:
        exit()

Step 2: Executing the eBPF Interception

Because eBPF interacts with the kernel’s tracing subsystem and memory, running the script requires root privileges (specifically CAP_SYS_PTRACE and CAP_SYS_ADMIN).

sudo python3 tls_intercept.py

Leave the script running. Open a new terminal session on the same server and execute a command that performs an HTTPS request using the system’s OpenSSL library, such as curl.

curl https://example.com

Step 3: Analyzing the Output

Switch back to your tracing terminal. You will see the complete, unencrypted HTTP request and response traversing the terminal in real-time.

--- WRITE (Outbound) | PID: 45912 | Comm: curl ---
GET / HTTP/2
Host: example.com
user-agent: curl/7.81.0
accept: */*

--- READ (Inbound) | PID: 45912 | Comm: curl ---
HTTP/2 200 
content-type: text/html; charset=UTF-8
server: ECS (nyb/1D2E)
...

The eBPF program captured the plaintext HTTP headers and body before OpenSSL encrypted them and sent them over the TCP socket, and it captured the response after OpenSSL decrypted it.

Conclusion

Linux eBPF uprobes provide an unparalleled capability for dynamic application tracing. By hooking directly into cryptographic libraries, DevOps and Security teams can inspect encrypted payloads, debug microservice API calls, and hunt for malware Command and Control (C2) beacons in zero-trust environments without the catastrophic overhead of TLS inspection proxies.

Get the best tech tips delivered straight to your inbox.

Join thousands of readers mastering Apple, Google, Microsoft, and Linux.