
Fifteen years building engineering platforms, currently focused on advanced AI infrastructure at yeet. I love turning the deeply complex topics into something everyone can understand. I relate deeply with the core yeet philosophy that you can just build things.
Last updated: October 2026
Quick answer. You do not decrypt anything. You read the buffer on either side of the encryption, inside the process, by attaching uprobes to the TLS library's own functions. For OpenSSL that is
SSL_writebefore encryption andSSL_readafter decryption, and you need four probes rather than two, becauseSSL_readonly knows how many bytes it produced when it returns. The trap that wastes the most time: every Python since 3.10 callsSSL_write_exandSSL_read_exinstead, so a probe on the legacy names attaches cleanly, reports no error, and shows you nothing. Hook both spellings. Go and rustls do not expose these symbols at all, for different reasons.
I build eBPF tooling at yeet, which means I spend a lot of time on the gap between a probe that attached and a probe that is working. Those two states look identical from the terminal, and TLS tracing is where they diverge most often, because the usual symptom of getting it wrong is silence rather than an error.
You attach to the function that handles the plaintext, which means you never touch the key or the ciphertext at all. The framing of the question is the thing that misleads people: there is no decryption step to perform, only a choice of where to stand relative to one that the library is already performing for you.
An application using TLS calls into a library with plaintext and gets ciphertext written to a socket. On the way back, ciphertext arrives from the socket and the library hands plaintext to the application. Both of those handoffs happen at a function boundary inside the process, and a uprobe can read the buffer at that boundary. The encryption is irrelevant because you are upstream of it on the write path and downstream of it on the read path.
This is why the private key never enters the picture, and why the technique survives TLS 1.3 and perfect forward secrecy, both of which defeat the older approach of capturing ciphertext now and decrypting it later with a stored key. It is also why it is strictly a local technique. You need to be on the machine, attached to that process, while the traffic is actually flowing, and there is no equivalent of handing someone a capture file to analyse afterwards.
The tradeoff is that you have bound yourself to one specific library's API. A packet capture works on anything that speaks TCP; this works on anything that calls SSL_write, which is a much smaller and more version-sensitive set.
SSL_read or SSL_write?SSL_write and SSL_read, plus a return probe on the read, because the two directions hand you their data at different moments in the call. The asymmetry is not an implementation quirk you can work around; it follows from what each function knows at the point it is entered.
On the write path the plaintext is in the buffer when the function is called, so an entry probe has everything it needs: the pointer and the length are both arguments. On the read path, the entry probe has a pointer to a buffer the library has not filled yet, and the length argument is the size of the buffer rather than the number of bytes read. The byte count arrives as the return value, so you need a return probe to know how much of that buffer is real.
The working set is therefore an entry probe on SSL_write, an entry probe on SSL_read that stashes the buffer pointer keyed by thread id, and a return probe on SSL_read that reads the count and pairs it back with the stashed pointer. That shape shows up in every implementation doing the job properly, including sqlsnoop, which attaches exactly these three programs in order to read SQL statements from inside TLS connections:
SEC("uprobe/SSL_write")
SEC("uprobe/SSL_read")
SEC("uretprobe/SSL_read")
Skipping the return probe is the most common way to end up with plausible-looking garbage, because the entry buffer contains whatever was there before the read.
SSL_read uprobe not firing for a Python process?Because CPython does not call SSL_read at all. Since version 3.10 it calls SSL_read_ex, and a probe on the legacy name attaches to a symbol that exists in the library but is never invoked by that process. Both halves of that sentence matter: the symbol being present is why the attach succeeds, and the call never happening is why nothing arrives.
This is the single most expensive failure in TLS tracing because nothing announces it. The symbol is present in libssl, the uprobe attaches successfully, the tool starts, and the output stays empty while the application is visibly making HTTPS requests. Every other failure mode at least gives you an error.
The change landed in CPython in April 2021 via bpo-42854 and shipped in 3.10, so it covers every Python version in support today:
| Python | Calls in Modules/_ssl.c |
|---|---|
| 3.7, 3.8, 3.9 | SSL_read, SSL_write |
| 3.10 and later | SSL_read_ex, SSL_write_ex |
The _ex variants differ in signature as well as name. They return 1 or 0 for success rather than a byte count, and write the actual count through a size_t * out-parameter, so a return probe has to read the count from that pointer rather than from the return register. Treating SSL_read_ex as if it returned a length produces a byte count of 1, which looks like a working probe emitting single-byte reads.
The practical rule is to attach to all four names and accept that two of them will be unused on any given process. Checking which symbols a binary actually exports is worth doing first:
nm -D /usr/lib/x86_64-linux-gnu/libssl.so.3 | grep -E ' (SSL_read|SSL_write)(_ex)?$'
One line gets you a liveness check, and it is worth running before writing anything more complicated, because it answers the question that invalidates everything else: is this process calling the library I think it is calling. Most time lost to TLS tracing is lost before this check, not after it.
bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_write {
printf("%s pid=%d len=%d\n", comm, pid, arg2);
}'
For the plaintext itself, the write path is the easy direction, because the buffer is fully populated by the time the function is entered and the length is sitting in a register. A single entry probe is the whole implementation:
bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_write {
printf("%s\n", str(arg1, 64));
}'
The read path needs the two-probe pairing described above, stashing the buffer pointer on entry keyed by thread id and reading it back on return once the byte count is known. The predicate guards against a return firing for a thread whose entry you missed:
bpftrace -e '
uprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_read { @buf[tid] = arg1; }
uretprobe:/lib/x86_64-linux-gnu/libssl.so.3:SSL_read /@buf[tid]/ {
if (retval > 0) { printf("%s\n", str(@buf[tid], 64)); }
delete(@buf[tid]);
}'
Two things about this are worth knowing before you trust the output. str() stops at the first NUL byte, so binary protocols get truncated at the first zero rather than at the length you asked for. And the length is a compile-time constant with a hard ceiling, so you are sampling the first N bytes of each buffer rather than capturing a stream.
SSL_write output missing events under load?Because printf is asynchronous and the buffer between the kernel and bpftrace is finite, so a busy process produces events faster than userspace drains them. Nothing in the output marks where the losses happened, which is what makes this failure mode worth understanding before you rely on a capture.
The formatting happens in user space after the data crosses a perf ring buffer, and when that buffer fills, events are dropped rather than queued. On a quiet process you never notice. On anything serving real traffic, the output is a sample with no marker telling you which events went missing, which is the dangerous part: a gap looks exactly like a period of no activity.
Three responses, in the order worth trying:
pid or comm drops events in the kernel before they ever reach the buffer, which is the only fix that scales.BPFTRACE_PERF_RB_PAGES defaults to 64 pages per CPU, and raising it buys headroom at the cost of memory. It moves the threshold rather than removing it.Treat bpftrace here as the instrument for confirming a hypothesis, not for capturing a session. The moment you want every message rather than a sample, the one-liner has stopped being the right tool.
SSL_read?For two different reasons, and conflating them leads to trying fixes that cannot work. One is an absence of the thing you want to attach to, and the other is a correlation problem, so the first is a dead end and the second is merely expensive.
Go does not use OpenSSL. The standard library implements TLS in pure Go in crypto/tls, so there is no libssl in the process and no SSL_read symbol to attach to. The functions handling plaintext are Go methods, and reaching them means attaching to those symbols instead, which brings its own problems: Go's calling convention passes arguments in registers in a way that has changed across releases, goroutines move between OS threads so thread-keyed state is unreliable, and return probes on Go functions are unsafe enough that most implementations avoid uretprobe entirely and hook the return sites directly.
rustls is harder rather than impossible. A Rust binary using rustls has no C ABI to attach to, and symbol mangling is unstable across compiler versions, so you cannot match on an exact name. Worse, rustls separates plaintext handling from socket I/O, so the syscall and the plaintext do not happen at the same moment and correlating them requires stashing the file descriptor on recvfrom and attaching it when the application later reads. It is tractable, and Coroot has implemented it, but it is a project rather than a one-liner.
The honest summary is that OpenSSL-linked processes are easy, Go and Rust are specialist work, and a tool claiming universal TLS visibility is either doing something considerably more involved or quietly covering only the first case. Plaintext connections from Go and Rust are read normally, which is worth remembering before concluding you need any of this.
Yes, with one detail that accounts for most of the confusion: the path to libssl has to be the one the target process sees, not the one on the host. Everything else about the mechanism is unchanged inside a container, which is why this single point absorbs nearly all of the trouble people hit.
A uprobe attaches to an inode in a file, so the question is which file. A container with its own base image has its own libssl, at its own inode, and attaching to the host's copy silently traces a library nothing in the container is using. The fix is to resolve the library inside the target's mount namespace, usually through /proc/<pid>/root/..., which gives you the path as that process sees it.
Two consequences follow. A statically linked binary has no separate libssl at all, so the symbols live in the executable and that is what you attach to. And one attach covers one library file, so a host running five containers from three different base images needs the targets resolved per container rather than once.
The probe itself does not care about the namespace, which is why this works at all: the kernel-side mechanism is the same, and only the question of which file you name changes.
Point a tool that already knows the full symbol set at the process, rather than assembling the four probes yourself and discovering the _ex problem the slow way:
curl -fsSL https://yeet.cx | sh # install yeet, once
yeet run gh:yeet-src/httpscope # HTTP and HTTPS, decrypted at the library boundary
For SQL inside TLS, where the useful output is statements rather than requests, the same mechanism is wrapped differently:
yeet run gh:yeet-src/sqlsnoop -- --tls-binary libssl.so # dynamically-linked clients
yeet run gh:yeet-src/sqlsnoop -- --tls-binary "$(command -v node)" # a runtime bundling its own TLS
The second form matters more than it looks. A runtime that bundles its own OpenSSL, which includes most Node builds and plenty of Python distributions, carries the symbols inside its own executable rather than in a shared libssl, so the target you name is the interpreter binary rather than a library path. Naming the system libssl for one of these attaches to a file the process never loads, which lands you back in the silent-failure case.
One ordering rule applies to every approach here: a uprobe only fires for processes that start after it attaches, so a client already running when you attach stays invisible until it reconnects. Start the capture before the workload.
Reading TLS plaintext without keys is a question of where you stand rather than what you decrypt: upstream of encryption on the write path, downstream of decryption on the read path, both inside the process. The mechanism is four probes on OpenSSL, and the thing that decides whether you see anything is whether you hooked the names your runtime calls, which for every supported Python means the _ex variants. When the output is empty, check the symbol before checking the probe.
Yes, and this is its main advantage over capturing ciphertext. Because the plaintext is read at the library boundary inside the process, the cipher suite and key exchange are irrelevant, so ephemeral keys defeat nothing.
Yes, in practice. Loading BPF programs and attaching uprobes requires CAP_BPF and CAP_PERFMON (or root on older kernels), and reading another process's memory requires the privileges to trace it.
Run nm -D /path/to/libssl.so.3 | grep -E ' (SSL_read|SSL_write)(_ex)?$'. A stripped or statically linked build may export nothing, in which case the symbols live in the executable rather than a shared library.
SSL_read and SSL_read_ex?SSL_read returns the number of bytes read. SSL_read_ex returns 1 or 0 for success and writes the count through a size_t * out-parameter, so a return probe must read the count from that pointer.
Not reliably. str() truncates at the first NUL byte and at a fixed length, and printf output drops events when the perf ring buffer fills. It is a good instrument for confirming a probe fires, not for capturing a session.
Filter inside the probe with a predicate on pid or comm, so events are discarded in the kernel before reaching the buffer. Raising BPFTRACE_PERF_RB_PAGES above its default of 64 pages per CPU moves the threshold, and aggregating in a map avoids per-event writes entirely.
Yes, if it links OpenSSL statically: the symbols live in the executable, so you attach to that path rather than to a shared libssl. If the binary is stripped, you need the symbol offsets, which is considerably harder.
Not with a simple uprobe. rustls has no C ABI, symbol mangling is unstable across compiler versions, and plaintext handling is decoupled from socket I/O, so correlating the two requires stashing state across the syscall.
Resolve it through the process's own mount namespace with /proc/<pid>/root/, or read /proc/<pid>/maps to see which library file it actually mapped. The host path of the same name is usually a different inode.
Not immediately. A uprobe only fires for processes that start after it attaches, so an existing client is invisible on the TLS path until it reconnects. Start the capture first.
SSL_read_ex/SSL_write_ex that shipped in Python 3.10sqlsnoop, whose BPF side shows the entry and return probe pairing for SSL_readhttpscope, the one-command HTTP and HTTPS capturestr(), async printf and BPFTRACE_PERF_RB_PAGES