
TLS visibility is not one universal probe: each runtime introduces a new boundary that needs its own adapter.
In the prior article on The SSL/TLS Trap, we had elaborated on the problems that we faced while extracting telemetry data from systems in which network traffic is encrypted in transit. We primarily focused on OpenSSL and specifically the SSL_read and SSL_write hook and how we use uprobes to attach and extract plaintext information from them.
However, that approach leaves out numerous other cases which do not satisfy the criteria in one way or another. Let’s establish the cases that we want to cover in the form of ladder where each rung represents a novel problem, starting with OpenSSL at rung-0.

The TLS compatibility ladder: each runtime moves the plaintext boundary, or the path used to discover and attribute it.
One thing to note here, the ladder doesn’t cover all the cases, just the ones we’ve engineered so far.
We start with a brief recap to establish the baseline. A lot of programming frameworks use dynamically linked OpenSSL libraries to encrypt / decrypt the data that is sent over the wire. This category of applications are the easiest to trace because of multiple reasons:
SSL_read, SSL_write, SSL_read_ex, SSL_write_ex)This approach covers a lot of cases and is the simplest to understand and implement. Now let’s try something a bit more complicated.
Astral’s high performance python package and project manager uv installs managed CPython distributions from python-build-standalone. Some observed distributions expose OpenSSL symbols inside the CPython executable. There is no dynamically attached libssl.so to latch on to. As a result, the original way of discovery using the file open → /proc/<pid>/maps doesn’t work here. Hence we end up with uv HTTPS traffic being shown as encrypted. This is primarily the case for applications acting as AI-agents ( which often use the high-performance uv framework ).
Even though the original way didn’t work, this problem isn’t that much of a stretch to solve, and we’ve alluded to the solution before. We change the discovery path. Instead of waiting on file-open events on libssl.so files, we look for all TLS capable processes on exec. This is done independent of agent activity hooks. Then we inspect the executable-local ELF symbols, not the libssl.so files. And for each such file we attach the OpenSSL uprobes at a PID level with associated lifecycle management. The flow looks somewhat like this:
target = /proc/<pid>/exe
symbols = ELF .symtab + .dynsym
if any(SSL_read, SSL_read_ex)
and any(SSL_write, SSL_write_ex):
classify target as embedded-OpenSSL candidate
attach read and write probes with PID scope
else:
do not classify it as supported embedded OpenSSLWe also hardened our original implementation by adding a (tgid, SSL*) -> fd cache to fix our PID-level attribution. Note: we didn’t solve for RustTLS yet, just the uv part which is implemented in Rust. We’ll see this pattern more often where we prefer specificity over general solutions.

Shared and executable-local OpenSSL converge on the same probes; discovery starts from a different file.
Like the previous case, Node also embeds OpenSSL, so the PID-scoped uprobes attach fine. The problem arises at the very next step after attachment, the attribution. We have unencrypted packet data, but no idea who this data belongs to. Node’s TLSWrap gives OpenSSL two custom in-memory NodeBIO buffers rather than socket BIOs. The BIO therefore does not directly identify the network fd. Reading the BIO→num field gives us no network fd ( it’s often 0 or garbage ). As a result, the plaintext read from SSL_read/SSL_write can’t be tied to the live connection.
Now this boils down to finding the correct fd without the struct field giving it directly. The solution we finalized can be considered “best effort”. We determine the most correct option using a series of fallbacks:
READ_SSL_BIO, rbio/wbio num; switched across OpenSSL 1.1 / 3.0 / 3.2 / 3.5)pid_tgid. Recency threshold is 1-second by default.The safety guards ( like the recency threshold ) are in place to ensure correct attribution, which is preferred over always attribution. We even narrow down the list by allowing it to record an fd only for tgids with an active OpenSSL probe and only from known-live connection state. We also clear the map on connection close. This solution is validated against official Node 20/22/24 Linux, HTTP/1.1 through core https, fetch/undici, and tested OpenAI SDK paths. This doesn’t cover Electron, Bun, custom stripped Node, native HTTP/2, HTTP/3/QUIC, alternate TLS stacks.

Node.js fd attribution stops at the first trustworthy match and refuses to guess when every guard fails.
Go natively doesn’t use OpenSSL for its TLS operations. It uses it’s own implementation crypto/tls. While the fundamentals underneath are the same, there are language-level nuances baked into it which can vary. The first place to look will be at the functions handling the unencrypted packet data. So we look at adding hooks to the functions of interest like:crypto/tls.(*Conn).Read and crypto/tls.(*Conn).writeRecordLocked
However, the primary challenge here is to actually find the functions to hook to. We first try to find the ELF static/dynamic symbols using known offsets from the virtual file addresses. Then we convert those virtual addresses to file-backed addresses. This works if the Golang binary isn’t stripped using -ldflags="-w -s". For such cases we fallback to parsing the symbol table from .gopclntab using .gosymtab. Then we convert them to explicit file-backed offsets and follow the same process as for non-stripped binaries.
Another language specific case we had to handle was related to return probes. Go TLS functions can return through multiple machine-level RET instructions. But traditional uretprobes rewrite the return address, which can conflict with Go’s movable goroutine stacks. Hence we locate each return offset and attach a regular uprobe there. An entry probe saves the required connection state, keyed by goroutine; the return-offset probe reads the result and joins it with that saved state. This avoids relying on uretprobe argument recovery, which is unreliable after the function has overwritten its input registers.
Post this, the only task remains is the connection tracking part. Here the connections are keyed by goroutine pointer, as most Go programs multiplex goroutines across threads. There are some challenges related to ABI compatibility across versions. However, most modern Golang versions usually contain the .gopclntab in the build, so we have ways to find the offsets.

Go TLS reads use regular uprobes at every decoded return, joining entry state by goroutine pointer.
Now that we’ve reached the “top” of the ladder, we find ourselves in real deep waters. Whatever cases we’ve handled before involved finding a function which deals with plaintext data and attaching a hook to it. Java lacks a stable exported native function such as SSL_read that eBPF can uprobe. JSSE (sun.security.ssl.*) lives entirely inside the JVM, so eBPF at the syscall boundary only ever sees encrypted text.
The core idea comes from Coroot’s Java TLS instrumentation with eBPF, which we adapted. What we share with the original article:
asm volatile("" ::: "memory") no-op marker stub attached by uprobe These are the cases where we extended beyond that:

Aurva retains the Java-agent-to-native-marker bridge and extends it across SSLEngine, container attach, version handling, and explicit correlation boundaries.
The implementation targets HotSpot-family JVMs and JSSE. OpenJ9 is outside our supported HotSpot attach/instrumentation path, and GraalVM native images have no JVM to instrument. BouncyCastle and Conscrypt can bypass sun.security.ssl.* and are not captured by these hooks. Connection attribution here assumes that the marker and the socket syscall execute on the same OS thread.
Each new breakthrough in the SSL tracing landscape is achieved by careful debugging and diagnosing the cases where it fails. We usually have a checklist of items to look at when things go wrong ( getting encrypted data instead of plaintext ). Following these steps in order usually leads to identifying the gap and implementing the next enhancement:
hostPid: true )
A missing request is not always a failed probe: validate the boundary, identity, attribution, and reconstruction in order.
The key takeaway from all this is that for TLS compatibility, a specific solution works better than shoehorning a generic one. We’ve seen how we progressed our solution from the baseline uprobe OpenSSL level to the highly customized Java agent instrumentation. Since exceptions outnumber the norm, this architecture is more dependent on adapters than a universal probe.
The next steps for achieving full compatibility falls into the following focus areas:
SSLEngine operation and its network syscall occur on different threads.Longer term, each adapter should report its own performance and failures so that it can be addressed instead of silently capturing encrypted data.
USA
AURVA INC. 1241 Cortez Drive, Sunnyvale, CA, USA - 94086
India
Aurva, 4th Floor, 2316, 16th Cross, 27th Main Road, HSR Layout, Bengaluru – 560102, Karnataka, India
Platform
Solutions
Resources
Resource Library
Company
USA
AURVA INC. 1241 Cortez Drive, Sunnyvale, CA, USA - 94086
India
Aurva, 4th Floor, 2316, 16th Cross, 27th Main Road, HSR Layout, Bengaluru – 560102, Karnataka, India
Platform
Solutions
Resources
Resource Library
Company
USA
AURVA INC. 1241 Cortez Drive, Sunnyvale, CA, USA - 94086
India
Aurva, 4th Floor, 2316, 16th Cross, 27th Main Road, HSR Layout, Bengaluru – 560102, Karnataka, India
Platform
Solutions
Resources
Resource Library
Company