TLS Visibility Beyond OpenSSL

Akash Mandal

Akash Mandal

TLS Visibility Beyond OpenSSL

TLS visibility is not one universal probe: each runtime introduces a new boundary that needs its own adapter.

TL;DR

  • OpenSSL uprobes work as a baseline when an application exposes recognizable native functions and a reliable path from the TLS object to its network connection.
  • That model changes across runtimes: uv-managed CPython can place OpenSSL symbols inside the executable, Node.js separates OpenSSL from the socket through in-memory BIOs, Go implements TLS in its standard library, and JSSE has no exported native plaintext function.
  • Production TLS visibility therefore requires runtime-specific adapters for discovery, plaintext capture, connection attribution, and protocol reconstruction.

Why “TLS visibility” is not one mechanism

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.

Rung 0: OpenSSL shared libraries (the baseline)

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:

  • They use standard openssl functions to encrypt / decrypt data ( SSL_read, SSL_write, SSL_read_ex, SSL_write_ex)
  • Most applications perform these operations on the same thread so it’s easy to stitch function calls to a common connection.
  • Even though different OpenSSL versions come with different base-structs, it’s simpler to cover as a union of fields.
  • Since the functions already exist it’s easy to attach uprobe to them either manually by name or based on file events.

This approach covers a lot of cases and is the simplest to understand and implement. Now let’s try something a bit more complicated.

Rung 1: uv-managed Python: OpenSSL is inside the executable

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 OpenSSL

We 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.

Rung 2: Node.js: memory BIOs and misleading fd correlation

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:

  • Reuse a previously validated (tgid, SSL*) → fd association. This is the map we added before as an optimization, now acts as the most useful fallback. Most of the time the search ends here.
  • Try the supported OpenSSL BIO layouts. Sometimes the fd is present but it’s hidden at a different layer of abstraction, and varies with version. ( we check the READ_SSL_BIO, rbio/wbio num; switched across OpenSSL 1.1 / 3.0 / 3.2 / 3.5)
  • 1-second same-thread fallback. This is the most non-deterministic step of the three. We check for a recent fd last observed on the same thread. This is based on a map we maintain keyed by 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.

Rung 3: Go: There is no OpenSSL

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.

Rung 4: The JVM: injecting a plaintext boundary

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 byte-code instrumentation of JSSE
  • a thread-local native buffer
  • a asm volatile("" ::: "memory") no-op marker stub attached by uprobe
  • same-thread write→syscall fd correlation reusing the OpenSSL logic

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.

Debugging a real gap in SSL Tracing

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:

  • Was correct TLS implementation identified? ( OpenSSL, GoTLS, etc. )
  • Were usable symbols/runtime hooks found? ( ELF file, or symbol table )
  • Are host and namespace PIDs aligned? ( usually relevant for Kubernetes based deployments, hostPid: true )
  • Did probe attachment succeed completely? ( probe attachment failures can be transient or systemic, needs attention either way )
  • Can plaintext be tied to correct fd/connection? ( if not, we find novel ways to fix attribution )
  • Does concurrency model invalidate thread-based correlation? ( special cases like goroutine multiplexing )
  • Is alternate TLS provider active? ( uv-Python for example )
  • Is capture working but message reconstruction failing? ( this is mostly related to protocol inference and message parsing logic, not specifically tied to TLS capture )

A missing request is not always a failed probe: validate the boundary, identity, attribution, and reconstruction in order.

Final Thoughts

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:

  • Additional native TLS implementations, including rustls and BoringSSL-based runtimes.
  • Node-native HTTP/2 and custom embeddings where same-thread fd proximity has not been established.
  • Java correlation mechanisms that remain correct when an 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.

aicpa-logoiso-logo

© 2025 Aurva. All rights reserved.Terms of ServicePrivacy Policy

twitterlinkeding
TLS Visibility Beyond OpenSSL | Aurva