FFM API 非终止字符串导致的堆外越界读取复现
漏洞分析
把逻辑字符串和相邻字段放在同一块堆外内存中,让 native 读取稳定越过逻辑片段边界,同时避免依赖随机崩溃或未初始化内存。
Java 的 `MemorySegment` 带有长度,C 的 `char *` 没有长度。通过 JDK 21 FFM API 调用 libc 字符串函数时,如果把一个没有 `0x00` 结尾的堆外片段传给 `strlen`、`strcmp`、`strstr` 或按 `%s` 消费字符串的 native 接口,C 代码不会读取 `MemorySegment.byteSize()`,只会从传入地址开始向后扫描,直到遇到第一个空字节。 问题边界 ---- 这里的“越界读取”指越过 Java 代码声明的逻辑片段边界,而不是一定越过底层分配器返回的整块内存。 比如: - `block` 是 64 字节堆外内存。 - `logical = block.asSlice(0, 8)` 只有 8 字节。 - `strlen(logical)` 从 `logical.address()` 开始读取。 - C 函数不会知道 `logical.byteSize()` 是 8。 只要第 8 字节之后不是 `0x00`,`strlen` 就会继续读取后面的字段。后面的字段如果是 token、路径、用户名、临时密钥、协议头或错误上下文,就可能被带入日志、比较、哈希、回调返回值或异常消息。 环境 -- ```php java -version javac -version uname -m ```  JDK 21 中 FFM API 是 preview API,编译和运行都要加 `--enable-preview`。调用 native linker 时还需要允许 native access: ```php java --enable-preview --enable-native-access=ALL-UNNAMED FfmCStringLab ``` `--enable-native-access=ALL-UNNAMED` 只是在权限层面允许未命名模块调用 native 接口,不会检查 C 字符串是否正确终止,也不会替 native 函数传递 Java 片段长度。 ffm-cstring-lab --------------- ```php mkdir -p ffm-cstring-lab cd ffm-cstring-lab ``` FfmCStringLab.java ```php import java.lang.foreign.Arena; import java.lang.foreign.FunctionDescriptor; import java.lang.foreign.Linker; import java.lang.foreign.MemorySegment; import java.lang.invoke.MethodHandle; import java.nio.charset.StandardCharsets; import static java.lang.foreign.ValueLayout.ADDRESS; import static java.lang.foreign.ValueLayout.JAVA_BYTE; import static java.lang.foreign.ValueLayout.JAVA_LONG; public class FfmCStringLab { static void putAscii(MemorySegment segment, long offset, String value) { byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); for (int i = 0; i < bytes.length; i++) { segment.set(JAVA_BYTE, offset + i, bytes[i]); } } static String ascii(MemorySegment segment, long offset, long length) { StringBuilder sb = new StringBuilder(); for (long i = 0; i < length; i++) { int b = Byte.toUnsignedInt(segment.get(JAVA_BYTE, offset + i)); if (b == 0) { sb.append("\\0"); } else if (b >= 0x20 && b <= 0x7e) { sb.append((char) b); } else { sb.append('.'); } } return sb.toString(); } static void dump(MemorySegment segment, long offset, long length) { System.out.println("offset hex ascii note"); for (long i = 0; i < length; i++) { int b = Byte.toUnsignedInt(segment.get(JAVA_BYTE, offset + i)); String ch = b == 0 ? "\\0" : Character.toString((char) b); String note = i < 8 ? "logical-string" : (i < 24 ? "adjacent-field" : "terminator"); System.out.printf("%02d %02x %-5s %s%n", i, b, ch, note); } } static void proveJavaBoundary(MemorySegment logical) { System.out.printf("logical[7]=0x%02x%n", Byte.toUnsignedInt(logical.get(JAVA_BYTE, 7))); try { logical.get(JAVA_BYTE, 8); System.out.println("logical[8]=unexpected-readable"); } catch (IndexOutOfBoundsException ex) { System.out.println("logical[8]=IndexOutOfBoundsException"); } } public static void main(String[] args) throws Throwable { Linker linker = Linker.nativeLinker(); MethodHandle strlen = linker.downcallHandle( linker.defaultLookup().find("strlen").orElseThrow(), FunctionDescriptor.of(JAVA_LONG, ADDRESS) ); MethodHandle strnlen = linker.downcallHandle( linker.defaultLookup().find("strnlen").orElseThrow(), FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG) ); System.out.println("java.version=" + Runtime.version()); try (Arena arena = Arena.ofConfined()) { MemorySegment block = arena.allocate(64, 1); block.fill((byte) 0x00); putAscii(block, 0, "REQ-0001"); putAscii(block, 8, "SESSION=KALI2026"); block.set(JAVA_BYTE, 24, (byte) 0x00); MemorySegment logical = block.asSlice(0, 8); System.out.printf("block.address=0x%x%n", block.address()); System.out.printf("logical.address=0x%x%n", logical.address()); System.out.println("logical.byteSize=" + logical.byteSize()); dump(block, 0, 25); System.out.println("-- java-boundary --"); proveJavaBoundary(logical); System.out.println("-- native-c-string --"); long nativeLength = (long) strlen.invokeExact(logical); System.out.println("strlen(logical)=" + nativeLength); System.out.println("strlen-visible-bytes=" + ascii(block, 0, nativeLength)); System.out.println("-- bounded-native-call --"); long boundedLength = (long) strnlen.invokeExact(logical, logical.byteSize()); System.out.println("strnlen(logical, logical.byteSize)=" + boundedLength); System.out.println("-- safe-c-string --"); MemorySegment safe = arena.allocateUtf8String("REQ-0001"); long safeLength = (long) strlen.invokeExact(safe); System.out.println("safe.byteSize=" + safe.byteSize()); System.out.println("strlen(safe)=" + safeLength); System.out.println("safe.raw=" + ascii(safe, 0, safe.byteSize())); } } } ``` 代码中没有使用 JNI,也没有编译 C 文件。`Linker.nativeLinker()` 从当前进程可见的 native symbol 中查找 `strlen` 和 `strnlen`,再通过 `FunctionDescriptor` 描述参数与返回值。`strlen` 的 C 原型可理解为: ```php size_t strlen(const char *s); ``` FFM 描述为: ```php FunctionDescriptor.of(JAVA_LONG, ADDRESS) ``` 编译与运行 ----- 编译命令: ```php javac --enable-preview --release 21 -Xlint:preview FfmCStringLab.java ```  > 出现 preview API 警告是正常现象,原因是 JDK 21 的 FFM API 仍处于 preview 状态。只要没有 `error`,即可继续运行。 > > 运行 ```php java --enable-preview --enable-native-access=ALL-UNNAMED FfmCStringLab ```  `logical.byteSize=8` 说明 Java 侧传给 native 的视图只有 8 字节。  `logical[8]=IndexOutOfBoundsException` 说明 Java API 自己访问第 9 个字节时会触发边界检查。 > 说明 `MemorySegment` 的 Java 视图边界是存在的。 但是 `strlen(logical)=24` 表示 libc 从 `logical.address()` 开始读到了第 24 字节。 `strlen-visible-bytes=REQ-0001SESSION=KALI2026` 表示 native 侧实际消费的内容已经包含了第 8 到第 23 字节的相邻字段。 > 这两个结果放在一起,说明:Java 边界存在,但没有传递给 C 字符串函数。 `strlen` 不会调用 Java 的 `MemorySegment.get`,也不会触发 Java 的越界异常。它拿到的是地址,扫描规则是 C 字符串规则。 实验内存布局如下:  ```php offset 0..7:Java 逻辑片段 logical,长度 8 offset 8..23:相邻字段 SESSION=KALI2026,不属于 logical offset 24:C 字符串终止符 \0 ``` `REQ-0001` 长度正好是 8 字节。代码没有在 offset 8 写入 `0x00`,而是在 offset 8 开始写入了相邻字段。`logical` 只覆盖 offset 0 到 7: ```php MemorySegment logical = block.asSlice(0, 8); ``` Java 侧读取 offset 8 会失败: ```php logical.get(JAVA_BYTE, 8); ``` 失败原因是 `logical` 的合法索引只有 0 到 7。native 侧却不是通过 `logical.get` 读取,而是通过裸地址读取: ```php strlen.invokeExact(logical); ``` FFM 在 downcall 时把 `logical` 转换为地址参数。由于 `strlen` 的函数签名没有长度参数,native 侧无法知道 offset 8 是 Java 逻辑边界,只会继续读 offset 8、9、10,直到 offset 24 的 `0x00`。 为什么 `asSlice` 不能保护 C 字符串函数 -------------------------- `asSlice(0, 8)` 创建的是 Java 视图。这个视图影响 Java 代码通过 FFM API 进行的访问,例如 `get`、`set`、`copyFrom`、`asByteBuffer`。它不能改变 libc 中 `strlen` 的实现。 `strlen` 的行为可以简化理解为: ```php size_t n = 0; while (s[n] != '\0') { n++; } return n; ``` 这个循环没有接收 `MemorySegment.byteSize()`,也没有办法向 JVM 查询 `s` 对应哪个 Java 对象。FFM 的 `ADDRESS` 参数不是带长度的指针。它只满足 ABI 层面的地址传递。 因此,下列写法在 Java 侧边界含义不同,但进入 `strlen` 后都会变成一个 `char *` 地址: ```php block block.asSlice(0, 8) ``` 如果 native 函数按 `\0` 终止读取,边界就由内存内容中的第一个零字节决定,而不是由 Java 片段长度决定。 需要单独区分 `MemorySegment.ofArray(byteArray)`。它创建的是堆内存 segment,不是 native segment。在 JDK 21 中直接把这种 heap segment 传给 `ADDRESS` 参数会被拒绝。 `allocateArray` 引入的同类问题 ----------------------- 真实 binding 中更容易出现的写法是把 Java 字符串转为字节数组,再直接分配到堆外: ```php byte[] bytes = input.getBytes(StandardCharsets.UTF_8); MemorySegment name = arena.allocateArray(JAVA_BYTE, bytes); nativeOpen.invokeExact(name); ``` `allocateArray(JAVA_BYTE, bytes)` 只复制 `bytes.length` 个字节,不会自动追加 `0x00`。如果 `nativeOpen` 的 C 原型是: ```php int native_open(const char *name); ``` 那么 Java 侧传的是“有长度的字节序列”,C 侧消费的是“以 `\0` 结尾的字符串”。这不是类型系统能自动发现的问题,因为 FFM 描述通常只写成: ```php FunctionDescriptor.of(JAVA_INT, ADDRESS) ``` 只看 Java 代码时,`name` 似乎是一个长度明确的 `MemorySegment`。进入 native 函数后,长度信息丢失,`name` 后方的堆外字节就可能成为字符串的一部分。 结构化堆外内存中也会出现同样的问题: ```php MemorySegment record = arena.allocate(128, 1); MemorySegment user = record.asSlice(0, 32); MemorySegment token = record.asSlice(32, 32); ``` 如果 `user` 被当作 `const char *` 传入 native 函数,而 `user` 内部没有 `0x00`,后面的 `token` 字段就处于读取路径上。这里不需要利用堆布局随机性,因为两个字段本来就在同一块连续内存里。 用 `strnlen` 收敛读取范围 ------------------ Linux/glibc 提供 `strnlen`: ```php size_t strnlen(const char *s, size_t maxlen); ``` FFM 描述为: ```php MethodHandle strnlen = linker.downcallHandle( linker.defaultLookup().find("strnlen").orElseThrow(), FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG) ); ``` 调用时把 Java 片段长度显式传给 native: ```php long boundedLength = (long) strnlen.invokeExact(logical, logical.byteSize()); ``` 输出:  ```php strnlen(logical, logical.byteSize)=8 ``` 这个结果表示 native 读取没有超过 8 字节,但它不等价于“字符串合格”。在前 8 字节内没有遇到 `0x00` 时,`strnlen` 返回上限值 8。安全封装应把“返回值等于上限”视为“片段内部没有终止符”,然后拒绝把它当作完整 C 字符串继续传递。 可用的 Java 校验函数: ```php static long nulOffset(MemorySegment segment) { for (long i = 0; i < segment.byteSize(); i++) { if (segment.get(JAVA_BYTE, i) == 0) { return i; } } return -1; } static void requireNulTerminated(MemorySegment segment) { if (nulOffset(segment) < 0) { throw new IllegalArgumentException("segment has no NUL terminator"); } } ``` 这个检查发生在 Java 侧,仍受 `MemorySegment` 边界保护,不会越过 `segment.byteSize()`。 正确构造 C 字符串 ---------- 如果 native 原型无法修改,参数必须是 `const char *`,Java binding 应保证传入的片段以 `0x00` 结尾。JDK 21 的 `SegmentAllocator` 提供了 `allocateUtf8String`: ```php try (Arena arena = Arena.ofConfined()) { MemorySegment cName = arena.allocateUtf8String("REQ-0001"); nativeOpen.invokeExact(cName); } ``` 输出:  `safe.byteSize=9` 是因为 8 字节内容后面还有 1 字节终止符。`strlen(safe)=8` 是 C 字符串长度,不包含终止符。 如果数据来源是 `byte[]`,可以显式分配 `length + 1`,并拒绝内嵌 NUL: ```php static MemorySegment cStringFromBytes(Arena arena, byte[] bytes) { MemorySegment out = arena.allocate(bytes.length + 1L, 1); for (int i = 0; i < bytes.length; i++) { if (bytes[i] == 0) { throw new IllegalArgumentException("embedded NUL is not accepted"); } out.set(JAVA_BYTE, i, bytes[i]); } out.set(JAVA_BYTE, bytes.length, (byte) 0); return out; } ``` 拒绝内嵌 NUL 的原因是 C 字符串会在第一个 `0x00` 处停止。若 Java 侧认为完整值是 `admin\0guest`,C 侧看到的可能只是 `admin`,从而引入路径截断、名称截断或校验绕过。 更稳妥的 native 设计是不要单独接收 `const char *`,而是接收地址和长度: ```php int native_open_bytes(const char *name, size_t name_len); ``` 对应 FFM 描述: ```php FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_LONG) ``` 调用时传入: ```php nativeOpenBytes.invokeExact(name, name.byteSize()); ``` 这种接口不依赖数据中是否存在 `0x00`,也不会把边界交给 libc 字符串扫描逻辑。 Binding 审查位置 ------------ 审查 FFM binding 时,不应只搜索 `strlen`。关键是看每个 `ADDRESS` 参数背后的 C 原型。如果 C 原型里出现 `char *`、`const char *`、结构体内嵌指针或回调参数,Java 封装必须明确三件事: - 字符串是否需要 `0x00` 结尾。 - 长度是否显式传入 native。 - 传入的片段生命周期是否覆盖 native 使用期。 这些 Java API 需要重点看调用上下文: ```php arena.allocateArray(JAVA_BYTE, bytes) segment.asSlice(offset, len) FunctionDescriptor.of(..., ADDRESS) ``` 它们不会自动把字节片段变成合格 C 字符串。只要后续 native 函数按 `char *` 消费,就需要额外处理终止符或长度。 `Arena.ofConfined()` 只管理生命周期和线程访问,不会限制 `strlen` 的扫描范围。`asSlice` 只创建 Java 访问视图,不会让 libc 知道片段长度。`--enable-native-access` 只打开 native 调用权限,不会做字符串格式校验。 收束 -- JDK 21 FFM API 提供的是低层 native 互操作能力,不会自动修正 C ABI 的原始语义。`MemorySegment.byteSize()` 对 Java 访问有效,对 `const char *` 参数无效。把无终止符片段传给 C 字符串函数时,读取边界由内存中的第一个 `0x00` 决定,而不是由 Java 片段长度决定。 可靠修正只有两类:能改 native 接口时传地址加长度;不能改 native 接口时,Java binding 必须构造或验证以 `0x00` 结尾的 C 字符串。对 FFM binding 来说,类型写对还不够,必须让 Java 的边界语义和 C 的消费语义一致。
发表于 2026-07-17 09:35:51
阅读 ( 5299 )
分类:
漏洞分析
1 推荐
收藏
0 条评论
Dracarys
6 篇文章
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
温馨提示
您当前没有「奇安信攻防社区」的账号,注册后可获取更多的使用权限。
×
举报此文章
垃圾广告信息:
广告、推广、测试等内容
违规内容:
色情、暴力、血腥、敏感信息等内容
不友善内容:
人身攻击、挑衅辱骂、恶意行为
其他原因:
请补充说明
举报原因:
×
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!