WEBKT

攻克 JVM 盲区:如何利用 eBPF 追踪 Java 进程的 SSL/TLS 加密流量?

1 0 0 0

在云原生可观测性领域,eBPF(Extended Berkeley Packet Filter)凭借无侵入、高性能的优势,已经成为获取 L4/L7 网络流量的利器。然而,当面对 SSL/TLS 加密流量时,eBPF 在内核态捕获到的只是乱码般的密文。

对于 C/C++、Go 或 Rust 编写的程序,我们通常可以通过 uprobe 挂载到 OpenSSL、BoringSSL 或 Go 的 crypto/tls 库上,在明文未加密前(或解密后)将其拦截。

但在 Java(JVM) 领域,情况变得异常复杂。Java 拥有自己的类安全沙箱、JIT 编译器和垃圾回收机制(GC),它的网络库(JSSE)运行在 JVM 内部。我们无法直接用标准 eBPF uprobe 去寻找动态 JIT 编译后的 Java 方法内存地址

本文将深入探讨 eBPF 追踪 Java 进程 SSL/TLS 流量的两种主流方案:针对 Netty Native 绑定的原生 Uprobe 方案,以及针对纯 Java JSSE 的“Java Agent + JNI 锚点 + eBPF”混合方案


核心痛点:为什么 JVM 是 eBPF 的硬骨头?

在非 JVM 环境中,追踪 TLS 极具规律性:

[ 用户态应用 ] -> 调用 SSL_write(明文) -> [ libssl.so (OpenSSL) ] -> 加密 -> syscall write(密文) -> [ 内核态 ]

eBPF 只需要在 libssl.soSSL_readSSL_write 函数入口处挂载 uprobe/uretprobe,就能不着痕迹地偷走明文。

但在 Java 的世界里,默认的 SSL/TLS 是由 JSSE (Java Secure Socket Extension) 实现的,流程如下:

[ Java 业务代码 ] 
       ↓ (调用)
[ javax.net.ssl.SSLSocket / SSLEngine ] (纯 Java 字节码,动态编译为机器码)
       ↓ (在 JVM 堆内存中完成加密)
[ java.io.SocketOutputStream ]
       ↓ (通过 JNI 调用 JVM 内部 C++ 代码)
[ libjvm.so ] -> syscall write(密文) -> [ 内核态 ]

这里有两个致命问题:

  1. 符号表缺失:JVM 的 JIT 编译器在运行时把 Java 字节码编译为机器码,这些方法在 ELF 磁盘文件中根本没有静态符号表,eBPF uprobe 无法直接定位 SSLEngine.wrap() 的内存地址。
  2. 内存布局多变:Java 对象的内存布局受 JVM 版本、GC 垃圾回收(对象地址会移动)的影响,在内核态通过 eBPF 解析 JVM 堆中的 byte[] 极其不稳定且容易导致系统崩溃。

方案一:降维打击 —— 针对 Netty 架构的 Native Uprobe

幸运的是,在现代 Java 微服务架构(如 Spring Boot WebFlux、gRPC-Java、Dubbo)中,为了追求极致的 I/O 性能,开发者通常会配置 Netty 及其 Native 传输/SSL 适配器:netty-tcnative

netty-tcnative 本质上是一个桥接器,它通过 JNI 调用系统中的 BoringSSLOpenSSL

在这种场景下,Java 进程的 SSL 行为实际上退化成了 Native C/C++ 的行为。

实现原理

当 Java 应用配置了 netty-tcnative 时,加密动作是在 C 层的动态链接库(例如 libnetty_tcnative_osx_x86_64.dylib 或特定的 .so 文件)中进行的。其核心调用依然是 SSL_readSSL_write

落地步骤

  1. 定位 .so 动态链接库
    在 Java 进程运行时,通过查看 /proc/<PID>/maps 找到 Netty 加载的 native 库路径:

    cat /proc/$(pgrep -f java)/maps | grep libnetty_tcnative
    

    通常会输出类似 /tmp/libnetty_tcnative_xxx.so 的路径。

  2. 编写 eBPF 探测程序
    我们可以使用 BCC 或 bpftrace 编写一个简单的 uprobe 脚本,直接挂载到该 .soSSL_write 符号上。

    下面是一个使用 bpftrace 快速验证的示例:

    /* trace_netty_ssl.bt */
    uprobe:/tmp/libnetty_tcnative_xxx.so:SSL_write {
        $buf = arg1; // SSL_write 的第二个参数是 buffer 指针
        $len = arg2; // 第三个参数是长度
        printf("Java Netty SSL_write PID %d: %s\n", pid, str($buf, $len));
    }
    

局限性:此方案仅适用于显式启用了 Netty Native OpenSSL 的 Java 应用。如果应用使用的是 JDK 默认的 sun.security.ssl(如传统的 Tomcat 阻塞 I/O 模式),此方案彻底失效。


方案二:降维打击 —— Java Agent + JNI 锚点 + eBPF(普适方案)

为了通配所有的 Java TLS 流量(包括 JDK 原生的 JSSE),业界(如 DeepFlow、Pixie 等开源项目)引入了一种**“纯 native 与 JVM 联手”**的混合架构。

既然 eBPF 在内核态进不去 JVM,那我们就派一个“卧底”深入 JVM 内部,把明文数据抛给内核态的 eBPF。这个卧底就是 Java Agent

核心设计:JNI 锚点技术 (JNI Anchor)

如果 Java Agent 拿到明文后,直接通过网络或本地 Socket 发送出去,会有巨大的性能开销(多次上下文切换与数据拷贝)。

为了实现零拷贝或极低开销的传输,我们可以在 Native 侧设计一个 没有任何业务逻辑的“空壳”动态链接库(JNI Library),里面只包含一个空函数。我们称之为 JNI 锚点(JNI Anchor)

1. 架构流转图

[ Java 业务 ] 
     ↓ (TLS 发送)
[ SSLEngine.wrap() ] (被 Java Agent 拦截)
     ↓ 
[ 提取明文 Byte Buffer ]
     ↓ (调用)
[ ebpf_tracker.probe(buffer, len) ] (JNI 虚设方法)
     │ ─────────────────────────────────┐
     ▼                                  ▼ 
[ libebpf_anchor.so ]            [ eBPF 运行时 (内核态) ]
  └─ 符号: Java_ebpf_tracker_probe()  ▲
       └─ (没有任何操作,直接 return) ────┼─ [挂载 uprobe 强行截获参数]

2. 步骤一:编写 JNI 锚点 C 代码

编写一个名为 libebpf_anchor.c 的源文件,定义一个虚设的方法,接受数据指针和长度。

#include <jni.h>

// 这是一个空函数,它的存在只是为了给 eBPF 提供一个稳定的 ELF 符号表和调用上下文
JNIEXPORT void JNICALL Java_com_ebpf_agent_Tracker_probe
  (JNIEnv *env, jobject obj, jbyteArray payload, jint len) {
    // 保持空实现,不消耗任何 CPU
    return;
}

编译为动态链接库:

gcc -shared -fPIC -I${JAVA_HOME}/include -I${JAVA_HOME}/include/linux libebpf_anchor.c -o libebpf_anchor.so

3. 步骤二:编写 Java Agent 进行字节码插桩

利用 ByteBuddy 或 ASM 框架,拦截 javax.net.ssl.SSLEnginewrap(加密前)和 unwrap(解密后)方法。

package com.ebpf.agent;

public class SslTransformer {
    // 加载我们编译好的 JNI 锚点库
    static {
        System.load("/path/to/libebpf_anchor.so");
    }

    // 声明 native 方法,作为 eBPF 的触发锚点
    public static native void probe(byte[] data, int len);

    // 在 SSLEngine.wrap/unwrap 执行结束时注入此拦截器
    public static void onSslEngineActive(java.nio.ByteBuffer src) {
        if (src != null && src.hasRemaining()) {
            int len = src.remaining();
            byte[] data = new byte[len];
            int pos = src.position();
            src.get(data);
            src.position(pos); // 恢复 position 指针

            // 调用 native 方法,引发 CPU 执行流走到 libebpf_anchor.so
            probe(data, len);
        }
    }
}

4. 步骤三:编写 eBPF 程序挂载到 JNI 锚点上

现在,只要 Java 发生 SSL 加密或解密,就会调用 Java_com_ebpf_agent_Tracker_probe。我们只需要让 eBPF 在内核中静静等待这个符号被触发。

编写 eBPF (C) 代码:

#include <uapi/linux/ptrace.h>

// 对应 Java_com_ebpf_agent_Tracker_probe 的 JNI 签名
// JNIEnv* (arg0), jobject (arg1), jbyteArray (arg2), jint (arg3)
SEC("uprobe/Java_com_ebpf_agent_Tracker_probe")
int probe_java_tls(struct pt_regs *ctx) {
    // 获取长度参数
    int len = (int)PT_REGS_PARM4(ctx); 
    if (len <= 0) return 0;

    // 获取 byte array 的地址(注意:Java 数组在 JNI 传递时可能需要特殊处理偏移量)
    void *array_ptr = (void *)PT_REGS_PARM3(ctx);
    
    // 从数组中读取真实数据的指针位置并提交到 Perf Ring Buffer / Maps
    // ...
    return 0;
}

该方案的闪光点

  • 零侵入业务:业务开发人员无需修改一行代码,通过 -javaagent 启动即可。
  • 极高性能:Java Agent 只是把指针和数据丢给了空 C 函数,不发生任何高开销的 I/O 操作;数据在内存中的复制和提取完全在 eBPF 环形缓冲区中以零拷贝思想完成。
  • 100% 覆盖率:无论 JVM 底层使用 JSSE 还是第三方安全包,只要拦截了 SSLEngine 接口,所有的加解密流量无处遁形。

方案对比与生产环境选择

维度 方案一:Netty Uprobe 方案二:Java Agent 混合体
技术侵入性 完全无侵入(无需重启 Java 应用) 半无侵入(需要通过 Agent 重启应用)
普适性 较窄(仅限 Netty + Native SSL) 极广(通杀所有 Java TLS 实现)
维护成本 极低 中等(需维护 Java 字节码兼容性)
性能损耗 几乎为零 极轻微(受 JNI 边界转换影响)
安全合规 内核态安全审计,进程无法感知 进程内注入,需注意沙箱越权风险

在实际生产落地中:

  • 如果你们的架构已经全面收敛于 Spring Cloud / gRPC 且明确开启了 Netty Epoll/BoringSSL 运载,推荐直接使用 方案一。这代表了未来零侵入观测的终极演进方向。
  • 如果历史包袱重,Tomcat、Jetty、Weblogic 等各类服务器并存,方案二 是保证监控指标不遗漏的唯一工业级解法。目前如 DeepFlow 的 Java 流量采集组件正是此方案的坚定实践者。
网路探针官 eBPFJavaTLS加密

评论点评