RASP 快速入门
RASP 生成
前置知识:基础篇 - Java Agent 详解
仿照搭建 Java Agent 示例环境的过程,只需要将 Hook 的类改变为关键漏洞触发的类,即可实现一个简单的 RASP 。
Java 不允许在默认包中定义代理类。因此,建议为您的代理类添加包声明。
下面是我的简单 rasp 代码示例:
Agent:
实现了 premain 和 agentmain 两种方法,覆盖了 jvm 启动与 动态 attach 两种方式。
1 | package com.miaoji; |
Transformer:
Hook 了 java.lang.Runtime 类,并检查所有 exec 重载方法的参数是否包含 calc 字符串,匹配到则抛出异常。
需要注意的是,在匹配类名的时候,要将全类名的 .
换成 /
,即 java/lang/Runtime 。这是因为在 JVM 的内部表示中,类名采用斜杠(/
)分隔。
这种设计主要源于历史原因。JVM 的类文件格式采用了类似 Unix 文件系统的路径结构,其中斜杠用于表示层级关系。由于 Java 类文件最初是直接映射到文件系统结构的,使用斜杠作为分隔符更符合文件路径的表示方式。
1 | package com.miaoji; |
resources\META-INF\MANIFEST.MF :
1 | Manifest-Version: 1.0 |
如此,一个简单的 RASP 示例项目就完成了。
但是,如果想用 mvn package 直接打包,建议用 pom.xml 的配置代替 MANIFEST.MF ,在 pom.xml 中添加如下内容:
1 | <build> |
xml 格式,一样的效果。
随后直接 mvn package 打包即可。
RASP 使用
要在新项目中使打包的 Java RASP Agent 生效,可以通过以下两种主要方式进行集成:
方法一:在启动时通过 -javaagent
参数加载
这是最常见且推荐的方式,适用于大多数 Java 应用程序。缺点:需要重启。
在启动目标 Java 应用程序时,使用 -javaagent
参数指定 Agent JAR 文件的位置:
1 | java -javaagent:/path/to/SimpleRaspAgent.jar -jar your-application.jar |
请确保将 /path/to/SimpleRaspAgent.jar
替换为您实际的 Agent JAR 文件路径,将 your-application.jar
替换为您的应用程序 JAR 文件路径。
注意事项:
-javaagent
参数必须在-jar
参数之前指定。- 确保您的 Agent JAR 文件中包含
MANIFEST.MF
文件,并正确设置了Agent-Class
和Premain-Class
属性。或者在 pox.xml 中正确添加了这些标签。 - 如果您的 Agent 使用了
agentmain
方法进行动态加载,确保目标 JVM 支持动态附加(如 HotSpot JVM)。
idea 中也可以直接在运行配置中添加虚拟机选项(VM options):

如果没有 虚拟机选项(VM options),点击右边修改选项(Modify options),选择添加虚拟机选项(Add VM options):

下面是测试代码与运行结果:
1 | package com.miaoji; |

方法二:动态附加 Agent(无需重启 JVM)
如果您无法在启动时使用 -javaagent
参数,或者需要在运行时动态加载 Agent,可以使用 Java Attach API。
用下面的代码去实现:
1 | package com.miaoji; |
首先我们要获取 JVM 进程的 pid ,有多种方式,这里用全类名来查找(一般来说 vmd.displayName() 包含类名或 jar 名)。你也可以在要被 attach 的项目中用 ProcessHandle.current().pid() 来让目标程序在启动日志里打印自己的 pid ,但那样需要修改项目代码。
Windows 上,Attach API 的 id()
与任务管理器看到的 PID 是一致的。
其中用到的 com.sun.tools.attach.VirtualMachine 类需要 tools.jar 依赖,IDEA 默认不会导入,我们需要手动导入:

只要你能获取到 pid ,你可以在任何地方运行上述代码来动态 attach 。
测试代码:
1 | package com.miaoji; |
先运行测试代码,再运行 attach 代码,最后输入 unlock:

以上,我们用 Javassist 实现了一个简单的 RASP 示例,Hook 了 java.lang.Runtime 的 exec 方法,并拦截了 calc 字符串。这种示例仅展示 Rasp 的简单原理,如果想要绕过,还是很简单的,比如用 @ 占位:
1 | cmd /c "set x=c@lc & echo %x:@=a% | cmd" |

那拦截办法,加更多的黑名单?或者有没有办法从更底层拦截呢?显然,实际情况下的 RASP 会比这个更复杂。
OpenRasp
由百度 OAESE 智能终端安全生态联盟开源的一款 RASP ,作为我们了解市面上真正应用的 RASP 的窗口。
下载链接:https://github.com/baidu/openrasp
rasp
刚来不太了解目录结构,定位 Premain-Class ,在 agent/java/boot/pom.xml 文件中找到了相关配置:

这是以 xml 格式配置的,不过看别人的分析文章是配置在 MANIFEST.MF 里,想来时代变迁,xml 配置因为适配 maven 打包而变得更流行了。
其中指示了主要的 agent 类为 com.baidu.openrasp.Agent ,直接关注其 premain 与 agentmain 方法:

均调用了 init 方法,继续追踪:

其中 JarFileHelper.addJarToBootstrap 首先获取 agent 的 Jar 文件路径,然后调用 inst.appendToBootstrapClassLoaderSearch
。
在 Java Agent 里,Instrumentation.appendToBootstrapClassLoaderSearch(JarFile jar)
的核心作用,就是把指定 JAR 加入到 JVM 最顶层的 bootstrap 类加载器的“兜底搜索列表”, 其等价于运行期的 -Xbootclasspath/a
。
/a 与 /p 的区别:
-Xbootclasspath/p
把你的 JAR 或目录“放到”引导类加载器(bootstrap class loader)的最前面,优先级甚至高于 rt.jar
;
-Xbootclasspath/a
把它们追加到最后,只有当 JDK 自己和其它 boot JAR 都没命中时才会去找。
/p 能覆盖或“热补丁”JDK 核心类,/a 只能让 boot loader 能看到你的新类而不会覆盖旧实现。

getLocalJarPath 其关键在于 Agent.class.getProtectionDomain().getCodeSource().getLocation()
会在运行时返回 Agent
这个类究竟是从哪儿加载进 JVM 的——通常是一个指向 agent.jar
的 URL
(例如 file:/…/agent.jar
或 jar:file:/…/agent.jar!/
),于是就获取了 Jar 路径:

上面这种方式在 agent 中挺常见的,即
Class.getProtectionDomain().getCodeSource().getLocation()
获取 Jar 路径inst.appendToBootstrapClassLoaderSearch(new JarFile(localJarPath))
将 Jar 放入 bootstrap 类加载器中,以确保能搜索到类。
于是,JarFileHelper.addJarToBootstrap 我们就看完了,接下来看 ModuleLoader.load :

看注释,它用来加载 RASP 模块,其要求三个参数:mode、action 和 inst 。在前面阶段,premain 、agentmain 传入的 mode 分别为 “normal” 和 “attach” ,premain 传入的 action 为 “install” ,agentmain 的 action 需要在运行时手动传入:

ModuleLoader.load 仅对 action 为 “install” 与 “uninstall” 的调用进行处理。当为 “install” 时初始化一个 ModuleLoader 对象:

其中,当 mode 为 “normal” 时额外调用一个 setStartupOptionForJboss() 方法,是为了扩展其在 Jboss7 下的兼容性。对于其他 mode ,直接初始化一个 ModuleContainer 对象,并将 ENGINE_JAR 作为参数传入,我们可以看到 ENGINE_JAR 指代 rasp-engine.jar :

来看看 ModuleContainer 的构造,其读取 JAR 中的自定义清单,确认「模块名 + 入口类」,然后把 JAR 动态塞进系统/自定义类加载器的搜索路径,再反射加载并实例化入口类:

1 读取并校验 JAR 清单
1 | JarFile jarFile = new JarFile(originFile); |
-
JarFile
+ Manifest
用来解析META-INF/MANIFEST.MF
(或者说 pom.xml 中的配置项);这里约定了两条私有属性:Rasp-Module-Name
和Rasp-Module-Class
,只有两者都非空才算合法模块。 - 读完立即
jarFile.close()
,防止文件句柄泄漏。
2 判断运行时环境:系统加载器是不是 URLClassLoader
2.1 传统 JDK 8- 及以下 —— URLClassLoader
分支
1 | if (ClassLoader.getSystemClassLoader() instanceof URLClassLoader) { |
- 核心点:反射调用受保护的
addURL(URL)
方法——这是在运行时把新 JAR 塞进URLClassLoader
的经典“黑科技”。 - 既往 JVM 把 系统加载器(AppClassLoader) 也实现成
URLClassLoader
,所以这种方式在 JDK 8- 时代可行。 - 代码既把 JAR 加进 专用的
moduleClassLoader
(给模块自身用),也加进 系统加载器(给其它类直接引用)。
2.2 JDK 9+ 或自定义加载器 —— appendToClassPathForInstrumentation
分支
1 | else if (ModuleLoader.isCustomClassloader()) { |
- 从 Java 9 开始,系统加载器不再继承
URLClassLoader
,addURL
不复存在;JVM 改为保留一条私有的 fallback 钩子——appendToClassPathForInstrumentation(String)
,专门给 Java Agent/Instrumentation 在运行期追加 classpath 用。 - 反射调用该方法即可把 JAR 挂进系统加载器的搜索列表(效果与启动参数
-Xbootclasspath/a
类似)。 ModuleLoader.isCustomClassloader()
用来探知当前 JVM 是否启用了 OpenRASP 自己的特殊加载器;若返回 true,就走这一分支。
3 加载并实例化模块入口类
1 | moduleClass = moduleClassLoader.loadClass(moduleEnterClassName); |
- 反射加载清单里声明的 模块入口类,并强转为 OpenRASP 统一定义的
Module
接口/父类。 - 这样每个模块都能实现自己的逻辑,同时被主程序管理。
这段代码完成以后,会将 Rasp-Module-Class 指示的类实例化并封装进 module 属性中,后续 engineContainer.start 也就是调用 module 属性的 start 方法

所以,这一部分 agent 指向了一个新的 Jar 包 rasp-engine.jar ,以及其配置文件,其中必包含两个项 Rasp-Module-Name 和 Rasp-Module-Class ,并且 Rasp-Module-Class 指向的类会被实例化,其 start 会被调用。
rasp-engine
下载的项目里面并不直接有 rasp-engine.jar 这个文件,但我们可以找到其对应的代码在 agent/java/engine 目录下。其 pom.xml :

既然这里又指示了 com.baidu.openrasp.EngineBoot 这个类,顺藤摸瓜找到这个类,在其 Start 方法发现了一个大 logo :

缺失了 com.baidu.openrasp.v8 包,导致 com.baidu.openrasp.v8.Loader 找不到,在这里下载:
https://github.com/baidu-security/openrasp-v8/tree/4e1398d9e3de81a581c8f465b117abb8399a014d
不过 Loader.load() 在这里是加载了一个名为 openrasp_v8_java 的库,实质是一段本地(Native)代码,暂不关注。
loadConfig() 完成对日志(log4j、云控、Syslog)的配置,不重要。
Agent.readVersion() 从 /META-INF/MANIFEST.MF 中读取配置信息,但是项目并没有 MANIFEST.MF 文件,同样是写在 pom.xml 文件中,其在 maven 打包时会将相关信息写入 jar 的 MANIFEST.MF 文件,使得代码可以读取到这些信息:

JS.Initialize() 完成了 V8 的初始化还有一些设置,仍然不是很清楚:

跟进 V8.Initialize() ,最终调用的是一个 native 方法:

纯初始化。
后面的 V8.SetLogger 和 V8.SetStackGetter 设置两个属性 V8.logger 和 V8.stackGetter ,暂时没有看到用法。
Transformer 配置
回到 EngineBoot 继续往后看,initTransformer(inst) 很关键,它果然是用来配置转换器的:


这里添加的转换器是 CustomClassTransformer 自身,那么真正的 hook 代码就藏在其 transform 当中了:

这里维护了一个要被 hook 的类的列表 hooks ,通过 addHook 可以向其中添加值。
对于 hooks 列表中的所有类,调用 hook.transformClass 去修改(通过参数 classfileBuffer 拿到类的字节码)。

hookMethod 有 99 个实现,针对不同的执行点指定了不同的拦截方案:

JNDI Hook
找一个比较熟悉的,看看 JNDIHook 中的实现:


相当于把 com.baidu.openrasp.hook.JNDIHook.checkJNDILookup($1); 插入 lookup 方法开头。
这个 checkJNDILookup 又干了什么呢?





追到 checkParam 这里有 7 个实现:

V8AttackChecker
到底由哪一个 Checker 来处理 JNDI Hook 产生的检测请求,其实早就写死在 CheckParameter.Type 这个枚举里,最开始 checkJNDILookup 传入的第一个参数是 CheckParameter.Type.JNDI ,故而选择 V8AttackChecker 。

V8AttackChecker 的 checkParam 方法:

JS.Check:
1、将数据发送到 V8.Check 检查,并获取返回结果:

2、如果返回结果为空,将其对应的 hashData 加入到缓存 Config.commonLRUCache 中:

这个 hashData 是来自于请求参数转化成的 json 数据,就是这串 json 数据的 hash 值(或者当 json 长度小于某个值时,直接使用 json 数据本身):

3、将返回结果以 json 格式提取出来:

V8.Check 实际调用 native 方法 Java_com_baidu_openrasp_v8_V8_Check ,找到这个方法,在 com_baidu_openrasp_v8_V8.cc 文件中:

这个方法实际上依然是一个中间层,它调用 isolate->Check 检查,然后处理返回值。
对于 isolate->Check ,全局搜索 Isolate::Check ,找到方法定义在 isolate.cc 文件中:

真正的调用 js 方法的地方:

那我又要问了,它怎么知道要调哪个方法呢?这里面没有看到任何跟 js 文件或是方法有关的位置信息。
果真如此吗?下面来回答一下:Isolate::Check 何以调用检测脚本?
js 插件注册
js 插件文件在 com.baidu.openrasp.plugin.js.JS#UpdatePlugin() 中被加载,并早在 JS.Initialize() 中就被调用过。其中指示了加载 js 文件的路径,为:<rasp_root>/plugins 。


最后调用 UpdatePlugin(scripts) 处理 js 脚本内容:
这里调用 V8.CreateSnapshot 创建 v8 快照,V8.CreateSnapshot 是一个 native 方法,我们可以找到对应的方法名为 Java_com_baidu_openrasp_v8_V8_CreateSnapshot :

新建一个 Snapshot 对象,这个对象将 js 插件列表封装进去了。
在 Snapshot 的构造函数 Snapshot::Snapshot 中,运行了其内建脚本 gen_builtins 、配置脚本 config 、插件脚本 plugin_list :

其中调用 isolate->ExecScript 执行 js 脚本,可以找到其实现 Isolate::ExecScript ,在 isolate.cc 文件中:

在 Isolate::ExecScript 中,代码被编译为 HeapObject(Script / SharedFunctionInfo / JSFunction),进入 Isolate 的 JS 堆。
后续于是 Isolate::Check 可以找到这些方法并执行(这里不再解释,如果熟悉 C++ ,可以更快理解)。
RASP 黑名单
最后来看看真正定义 RASP 拦截的黑名单在哪里。
<rasp_root>/plugins,应该指的是 openrasp-master\plugins 即根目录下的 plugins 目录,其中有一个 plugins/official/plugin.js 文件,表示官方插件。
找到 plugins/official/plugin.js 中针对 Jndi 的处理部分,或者说,针对 Java 的处理部分,主要在 validate_stack_java 方法中:

根据栈上匹配到的类返回不同的信息。
有意思的是,它对 ysoserial.Pwner 、org.su18 、net.rebeyond.behinder 这种包名也做了匹配,并返回信息说明使用了工具:

OpenRasp 绕过
这里只提出比较浅显的意见,我也只是纸上谈兵,同时结合了 Y4tacker 等前辈的一些观点。
OpenRasp 对各种漏洞类型都做了防护,防护措施同中存异,所以我们谈绕过,也应该分开的、逐个的来谈。
1、黑名单绕过
最容易想到的,实际情况下也是最难做到的,找出黑名单之外的类进行利用,常见于反序列化绕过:

这个名单看着就不太全的样子。
2、正则绕过
譬如命令执行这一块,最低级别的防护:

对于 cat 命令就只拦截了 cat /etc/passwd 操作,cat 其他文件那是睁一只眼闭一只眼。或者就像 Y4tacker 师傅说的,cat 函数支持同时读多个文件,像这样操作:cat /abc/def /etc/passwd 。
3、业务逻辑绕过
举个例子,xxe 这块对其他协议直接拉黑,但对于 file:// 协议就只是记录日志:

为什么呢?可能有些正常业务它要用这个 file:// 协议。所以这就引出一个关键问题:RASP 在很底层,如果限制太多,可能正常程序都跑不起来。在兼容性要求下,RASP 就不能拦得太死。
在前面分析代码的时候,其实也遇到了这么一个地方:服务器的 cpu 使用率超过 90% ,以及云控注册成功之前,禁用全部 hook 点:

这也是为业务让路的一种体现,Y4tacker 师傅专门为这个点写了示例程序,就不再赘述。
当前,前面提到的命令执行,除了正则过滤,还有一种模式是全部禁用:

为什么要设置两种模式呢?低级别的防护用于兼容正常需要执行命令的系统,所以这也是一种体现。
4、覆盖文件
覆盖文件分为两种,一种是覆盖 js 插件文件,因为黑名单就写在插件文件里。并且我们可以看到 OpenRasp 中对于上传 html/js 文件是采取直接忽略的方式:

还有一种思路就是直接覆盖 Rasp 的 jar 文件,Rasp 本质上还是 agent ,一般以 jar 文件形式生效,如果能够覆盖,那么可以直接禁掉 Rasp 。OpenRasp 中并没有对上传 jar 文件有拦截。但是可能需要服务器重启才能生效,且 Windows 下可能会因为文件正在运行,进程占用无法覆盖。
总结
以上几种方式,我认为利用第三种方式是最可能的,就像先前说的,RASP 处在很底层,真正要布置到实际环境中一定要考虑对程序的兼容性,不能影响程序自身的逻辑,为此,要么降低黑名单强度,要么为程序设计定制化的 RASP ,如此便削弱了其防护能力。
记得某人曾说,许多安全问题的产生都是由于要为业务让路,在这里我们也能看到同样的体现。
参考文章
https://www.cnblogs.com/lccsetsun/p/14000936.html
https://y4tacker.github.io/2022/05/28/year/2022/5/OpenRasp%E5%88%86%E6%9E%90