Making encrypted Java traffic observable with eBPF
Nikolay SivkoCoroot's node agent uses eBPF to capture network traffic at the kernel level. It hooks into syscalls like read and write, reads the first bytes of each payload, and detects the protocol: HTTP, MySQL, PostgreSQL, Redis, Kafka, and others. This works for any language and any framework without touching application code.
For encrypted traffic, we attach eBPF uprobes to TLS library functions like SSL_write and SSL_read in OpenSSL, crypto/tls in Go, and rustls in Rust. The uprobes fire before encryption or after decryption, so we see the plaintext.
Java is different. And it has been a blind spot until now.
Why Java is special
Java's TLS implementation (JSSE) is not a native shared library. It's Java code that runs inside the JVM. There are no exported symbols like SSL_write that eBPF could attach to.
So when a Java app connects to MySQL or PostgreSQL over TLS, or makes HTTPS calls, eBPF tools cannot see the plaintext. All they see at the syscall level is encrypted data.
Our approach
We solved this by combining a lightweight Java agent with a tiny native library that serves as an eBPF uprobe target.
We dynamically load the agent into running JVMs using the attach API (the same mechanism profilers and debuggers use). The agent hooks SSLSocketImpl$AppOutputStream.write and SSLSocketImpl$AppInputStream.read, the internal JSSE classes where plaintext enters and leaves the TLS layer.
When the application does an SSL write, our hook copies the first 1KB of plaintext into a thread-local native buffer and calls a stub native function.
We copy to native memory because the pointer is stored and read later when the underlying write() syscall fires. By that time our JNI call has already returned, and Java's GC could have moved the original byte array.
We considered using GetPrimitiveArrayCritical to pin the array in place and avoid the copy, but it blocks all garbage collectors while held, which is worse for the application than a small memcpy.
For reads, we do the same after JSSE decrypts the data.
The native stub function does nothing at runtime:
int coroot_java_tls_write_enter(const char *buf, int len) {
asm volatile("" ::: "memory");
return len;
}
The asm volatile barrier prevents the compiler from optimizing it away. We attach eBPF uprobes to this function, so every call is captured with the buffer pointer and the payload size. From there, the data goes into our existing protocol detection pipeline, and HTTP, MySQL, PostgreSQL, Redis, Kafka and other protocols are parsed automatically.
The file descriptor (which connection the data belongs to) is discovered without any Java reflection. When JSSE writes, the sequence is always: encrypt, then write(fd, ciphertext) syscall. Our eBPF code stores the plaintext pointer when the stub is called, then the syscall that follows on the same thread provides the file descriptor. This is the same ssl_pending mechanism we use for OpenSSL.
The native library is compiled with -nostdlib, so it has no dependencies and works in any container.
The nice thing about this design is that there is no transport between the JVM and the node agent. No unix sockets, no shared memory, no protocols to maintain. The Java agent just calls a native function, and eBPF picks up the data through uprobes and existing syscall tracepoints.
Safety
Our agent modifies the bytecode of two JVM internal classes to insert our hooks. That sounds scary, but all we add is a single method call before each SSL write and after each SSL read. The original code stays exactly the same. Every inserted call is wrapped in a try-catch that catches Throwable, so if our code fails for any reason, the error is silently ignored and the original SSL operation runs as if we were never there.
We use ASM for the bytecode transformation. ByteBuddy would make the code shorter, but the agent JAR would grow from 130KB to over 8MB. Since we deploy the JAR into every container with a running JVM, keeping it small matters.
Benchmark
We compared three scenarios on the same workload:
- No instrumentation (baseline)
- eBPF with our Java TLS agent
- OpenTelemetry Java agent with traces exported to a collector
We included the OpenTelemetry comparison because it is the most common alternative for Java observability without code changes. The OTel agent auto-instruments HTTP clients, JDBC, and other libraries by rewriting bytecode at class load time.
The test uses two machines to avoid resource contention:
Machine 1 (8 vCPU): Java HTTP proxy making HTTPS calls + coroot-node-agent
Machine 2 (8 vCPU): Go HTTPS server (5ms delay, ~1KB response) + wrk2 load generator

Each scenario ran for 15 minutes at 1,000 requests per second.

The baseline (no instrumentation) uses about 370m CPU cores. With our eBPF agent, CPU increases to about 426m, a 15% increase. The eBPF agent delivers the same throughput as the baseline.
With the OpenTelemetry Java agent, CPU goes up to 511m, a 38% increase, and the application could only sustain about 800 of the 1,000 target requests per second, a 20% throughput drop.

Limitations
JVM compatibility. We support HotSpot-based JVMs: OpenJDK, Oracle JDK, Amazon Corretto, Azul Zulu, Eclipse Temurin. OpenJ9 and GraalVM native images are detected and skipped.
SSLSocket only. We instrument SSLSocket (blocking I/O), which covers JDBC drivers, HttpsURLConnection, and most traditional Java HTTP clients. SSLEngine (used by Netty and async HTTP clients) is not yet supported.
Dynamic agent loading. On Java 21+ the JVM prints a warning about dynamic agent loading being deprecated. JVMs with -XX:+DisableAttachMechanism or -XX:-EnableDynamicAgentLoading are detected and skipped.
Disabled by default
This feature must be explicitly enabled:
coroot-node-agent --enable-java-tls
Or with an environment variable:
ENABLE_JAVA_TLS=true
If you use the Coroot Operator on Kubernetes, add it to the Coroot CR:
apiVersion: coroot.com/v1
kind: Coroot
metadata:
name: coroot
spec:
nodeAgent:
env:
- name: ENABLE_JAVA_TLS
value: "true"
Loading an agent into a running JVM without the user asking for it is not something we want to do by default. The agent is safe, but we think this should be the user's choice.
What you get
With this feature enabled, Coroot automatically detects and parses protocols inside encrypted Java connections: HTTP, MySQL, PostgreSQL, Redis, Kafka, and everything else we support. No code changes, no SDKs, no sidecars. Enable the flag and encrypted Java traffic becomes visible.
Coroot is open source. If you find this useful, give us a star on GitHub.
You can try Coroot Community Edition for free, or start a free trial of Coroot Enterprise to enable AI-powered features.