做一个完整可运行的 RASP Demo,真的比想象中要绕。最近花了两个大周末,把一套开源 RASP 项目的 Java 功能主线源码啃了下来,这期间反复被“运行时”三个字折磨——字节码插桩时机、类加载顺序、Hook 点管理、请求上下文传递、检测引擎编排、性能损耗控制,一层扣一层。这篇博文我想站在源码阅读者的角度,把 Java 功能模块的架构和一条完整攻击请求的判定链路拆开讲清楚。
先说结论:RASP 的思路一句话就能讲完——攻击者最终要执行命令、发 SQL、反序列化对象,那就在这些高危操作的入口处挂上检测逻辑;但把它落到 JVM 实处,靠的是 Java Agent、Instrumentation、字节码增强、上下文传递和一整套规则匹配。如果你对 Java 应用安全、中间件开发或者开源项目源码分析感兴趣,这篇文章应该能帮你省掉至少一个月的弯路。
1. 为什么要在源码层啃 RASP 的 Java 功能
1.1 RASP 与其他防护方案的区别
我最早接触的防护思路是 WAF,在流量入口做关键词过滤和规则匹配。WAF 很好理解,流量先过一层门卫,门卫觉得不对劲就挡下来。但它有个天然盲区:HTTP 请求到了应用层的真实数据可能是编码过的、分块传输的、参数被框架二次处理的。门卫看到的原文和应用实际解析出来的值经常对不上,漏检误报都多。
RASP 的防护位置不在门口,而在房间里的每扇门上。它运行在应用内部,用 Java Agent 的方式改写了关键方法的字节码。当应用要执行Runtime.exec()、发起 SQL 查询、读取反序列化对象时,RASP 已经埋伏在方法入口旁边,拿到的是真实参数、真实调用栈、真实上下文。站在源码角度看,这个“位置”的差异,决定了整个工程设计的走向:插桩点选哪儿、检测需要哪些数据、命中后怎么中断执行。
我这次分析的 Java 功能,就是 RASP 工程中针对 Java 应用运行时的全套实现。它不是某个独立小工具,而是一个带 Agent、Hook 管理、检测引擎、策略配置、日志上报的完整子系统。
1.2 Java 功能在 RASP 工程中的核心地位
一个 RASP 项目通常要支持多种语言运行时,但 Java 功能往往是其中最难啃也最值得研究的模块。原因有三:
第一,Java 生态的中间件太多了。Tomcat、Jetty、Spring Boot、Dubbo、Netty,各自类加载方式不一样,Hook 点所处的容器上下文也不同。一个插件想在所有主流环境下稳定运行,源码里必然有大量兼容逻辑。
第二,JVM 给了 Instrumentation 机制,但它并不负责解决“哪些类可以被改、哪些必须跳过”的问题。真实环境里系统类、业务类、框架类混在一起,处理不好直接类冲突或者死循环。
第三,Java 应用的安全痛点集中:SQL 注入、命令执行、反序列化、表达式注入、任意文件读写、SSRF。检测引擎设计得越细致,能覆盖的攻击面就越广。把这个模块读透,基本就摸清了 RASP 应用的通用套路。
结合我自己做过的中间件开发,源码阅读的重点不该是背函数名,而是弄清楚三类问题:Agent 怎么被启动、字节码怎么被改、攻击怎么被判定。下面我按这三个问题展开。
2. Java 功能源码整体拆解:Agent、Hook 与检测引擎
2.1 入口模块:premain 与 Instrumentation 机制
Java 功能的入口不在 web.xml,也不在 Spring 的启动类里,而在META-INF/MANIFEST.MF中声明的Premain-Class。JVM 在 main 方法运行之前,会先加载这个类并调用其premain方法。这个入口拿到最关键的对象是Instrumentation实例,它是 JVM 提供给外部修改字节码的窗口。
我读源码时重点关注了这个入口函数做了什么。一个典型的 premain 流程分几步:
public static void premain(String args, Instrumentation inst) { // 1. 初始化配置加载、日志系统 // 2. 注册 ClassFileTransformer inst.addTransformer(new RaspClassFileTransformer(), false); // 3. 处理已知但已被加载的类 // 4. 启动后台线程做状态上报 }这里的ClassFileTransformer是插桩的起点。JVM 在后续每个类加载时都会回调transform()方法,传入类名、类加载器、原始字节码等。源码中这个 transformer 做的事情可以浓缩为一句话:判断当前类是否匹配已注册的 Hook 点,如果匹配,就改写字节码后返回新的字节码数组;否则返回 null 保持原样。
值得注意的一个工程细节是addTransformer的第二个参数。传入false表示只对后续加载的新类生效,不影响已经加载的类。这在平时没问题,但 Agent 本身被挂载到已经运行中的 JVM 时,很多核心类已经加载过了,后面我展开讲这个问题。
2.2 Hook 管理器:如何管理插桩点的生命周期
Hook 是字节码插桩的最小单元。源码中每个 Hook 至少包含三样信息:要匹配的类名、要匹配的方法名、方法描述符(参数类型和返回类型)。例如命令执行相关的 Hook,目标可能是java.lang.Runtime类的exec(String)方法,描述符是(Ljava/lang/String;)Ljava/lang/Process;。
Hook 管理器在源码里承担的是注册、匹配、删除的工作,类似于一张插桩点注册表。它的设计有几个关键点:
- Hook 定义通常会集中放在一个配置文件或扫描注解的类集合里,避免每个检测器单独自管理。
- 匹配阶段不是简单字符串相等,要处理内部类、数组类、代理类等边界情况。
- 插桩点不仅要匹配到,还要考虑继承关系。比如 Spring 的 Filter 实现类可能不直接继承某个父类,而是通过接口动态代理生成。源码中一般会定义一个“需检测的超类”列表,遇到具体类时做父类链匹配。
我在阅读时把 Hook 管理器理解成一个“路由表”,检测引擎并不关心哪个类被改写了,它只关心当某个方法被调用时,事件回调数据能否正确送到检测逻辑。这个解耦设计让后续添加新攻击类型检测时,不用改动插桩核心。
2.3 检测引擎:将参数映射为攻击判定
如果说 Hook 解决的是“在哪里看”,检测引擎解决的是“看出什么”。源码中检测引擎会接收来自插桩方法的参数数据和上下文信息,然后交给一组检测器分别判定。
检测器的划分本身很有趣,并不是所有攻击都用一个通用正则打天下,而是按攻击类型独立实现:
| 检测器 | 监控目标 | 典型攻击特征 |
|---|---|---|
| SQL 注入检测器 | JDBC 执行前的 SQL 语句 | ' or 1=1、注释符、堆叠查询 |
| 命令执行检测器 | Runtime.exec、ProcessBuilder | 管道符号、base64 解码命令 |
| 反序列化检测器 | ObjectInputStream.readObject | 危险类名、序列化版本标识 |
| 表达式注入检测器 | OGNL/SpEL 执行入口 | 恶意表达式关键字 |
每个检测器是独立类,实现了统一的检测接口。这样的好处是:规则可以单独升级、检测器之间不共享状态、性能上也可以做并行或短路。源码里还会维护一张攻击类型到检测器的映射表,出现新的攻击类型时新增一个检测器注册进去即可。
3. 一条攻击请求如何被拦截:源码级链路还原
3.1 从 Servlet 请求入口开始
只看代码结构容易一头雾水,我后来换了个方法:把每个插桩点想象成“潜伏在关键路径上的探针”,然后追踪一个具体的攻击请求从头到尾会触发哪些探针。
假设被测应用是一个典型的 Spring Boot 服务,接受一个登录接口的 POST 请求,参数里带了一段 SQL 注入攻击载荷。请求首先会进入 Tomcat 容器,经过 Filter、Servlet,再到业务代码。RASP 在这里的探针一般会关注几个层:容器层的HttpServletRequest取值过程、JDBC 层的Statement.execute调用点、以及业务代码中的参数解析工具类。
从源码上看,拦截链路通常在容器层就开始创建上下文了。当请求进入应用时,RASP 的插桩点在 Servlet 的 service 方法入口处创建了一个请求上下文对象,把参数、头、URI、来源 IP 等信息填充进去。这个上下文对象后续会随着调用栈传递到各个检测点,避免每个检测器都重复解析请求。
这里有一个很关键的选择:上下文传递不是简单绑定到线程局部变量,因为中间件经常用异步 Servlet、线程池、响应式编程。部分源码实现里会做上下文快照复制,在跨线程切换时保留核心参数。这也是读代码时最容易被忽略、却直接影响检测成功率的部分。
3.2 参数传入 Hook 点:事件回调与数据归一化
字节码被改写后的方法,真正执行前会先调用一段“通知代码”。这个通知代码在源码里体现为一个静态方法,名字大概是HookHandler.handleXXX(...)。它接收的参数就是原始方法调用时的入参,比如Runtime.exec(String command)的command字符串,或者Statement.execute(String sql)的sql字符串。
这里的重要细节是参数归一化。不同中间件对同一能力的封装不一样:有的直接调Runtime.getRuntime().exec(),有的是通过ProcessBuilder,还有的封装在 Apache Commons Exec 工具类里。如果检测引擎只认一种格式,很容易被绕过。所以源码在事件回调层会做一层“归一化”处理:把不同 API 的调用转成一个统一的“执行命令”事件对象,记录攻击类型、调用栈、入参。检测引擎只认这个统一对象。
这段逻辑看起来简单,实际是兼容性的核心。我在自己项目里做过类似封装,最大的经验是:标准化事件字段别设计得太死,比如“命令”字段既能传字符串,也能传字符串数组;既能表示原始命令,又能表示拆分后的参数列表。太强制反而会丢失一些检测信息。
3.3 检测器匹配与攻击标记
统一事件进入检测引擎后,会按类型分发到对应检测器。命令执行检测器拿到“执行命令”事件后,会对命令文本做多步分析:
- 检查命令是否包含常见 shell 元字符,比如
;、|、&&、`、$()。 - 检查命令关键字和系统命令白名单,比如
cat /etc/passwd、whoami、curl外联地址。 - 做简单的编码还原,比如 base64 解码后再检测一次。
这些检测逻辑在源码中通常是用决策树或责任链方式排列的,每个检测器内部再挂多个规则。规则引擎的设计会影响性能和误报率。我看到的一个不错的做法是:先用廉价的关键字片段做快速过滤,只有命中了才做重量级语义分析。比如命令里如果完全没有 shell 元字符,就不需要做复杂的解码分析,直接放行,这样正常业务请求的开销非常低。
检测结果用一个标记对象表示,包含是否命中、命中规则编号、需要阻断还是仅上报。标记对象会沿着调用栈返回给插桩层,决定后续代码是否继续执行。
3.4 阻断动作与响应注入
攻击命中后,源码中有多种处理动作。最常用的是“抛出异常”,因为对应用来说,捕获到一个异常就中断了当前方法的执行,攻击载荷不会真正生效。但简单抛异常有个问题:用户看到的是统一的 500 错误,非常不可控。
所以源码里通常还会做一层“响应注入”的封装。命中阻断后,不是抛一个普通异常,而是先判断当前是否有 HTTP 响应对象可写。如果能获取到响应对象,就写出一个自定义的错误页或者固定 JSON 结构,然后才中断执行。这个设计在生产环境中非常实用,因为安全团队可以统一返回提示文案,而不是让用户面对容器默认的异常页。
这里我踩过这样的坑:直接在插桩点写响应对象,在异步请求里可能拿到的是已经被提交的响应,写入会直接抛 IllegalStateException。稳妥的做法是只修改未提交的响应状态码和内容,如果响应已提交,则只做日志记录和异常阻断,不再尝试回写页面。
4. 实操:搭一个可调试的最小 RASP Java Demo
4.1 准备 Agent 工程骨架
看完源码后,我试着搭了一个最小可调试的 Demo,用来验证源码里的链路是不是真的能跑通。第一步是创建一个 Java Agent 工程。
public class AgentBootstrap { public static void premain(String agentArgs, Instrumentation inst) { System.out.println("[RASP Demo] Agent started."); inst.addTransformer(new SecurityTransformer(), false); } }SecurityTransformer负责判断目标类是否需要插桩。这里我用 ASM 来做字节码操作,虽然 ASM 的 API 比较底层,但保真度最好,能看到方法级别的原始结构。
public class SecurityTransformer implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { // 只处理我们关心的类 if (!"java/lang/Runtime".equals(className)) { return null; } // 用 ASM 改造 exec 方法,插入检测逻辑 ClassReader cr = new ClassReader(classfileBuffer); ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES); cr.accept(new RuntimeClassVisitor(cw), ClassReader.EXPAND_FRAMES); return cw.toByteArray(); } }这里有个绕不过去的问题:java.lang.Runtime属于 bootstrap 类加载器加载的类。Agent 的transform方法如果访问不到这个类的定义,就改不了它。源码里通常会在premain阶段就做好权限准备,确保能够改写系统类。我的 Demo 里也显式开启了相关权限,否则运行时会直接静默失败。
4.2 用 ASM 改造 Runtime.exec 方法
ASM 的 ClassVisitor 里,对方法的访问通过 MethodVisitor 完成。我想在exec方法的入口处插入一段静态方法调用,把原始参数先传给检测函数。
class RuntimeClassVisitor extends ClassVisitor { RuntimeClassVisitor(ClassVisitor cv) { super(ASM9, cv); } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); if ("exec".equals(name) && descriptor.startsWith("(Ljava/lang/String;)")) { return new ExecMethodVisitor(mv); } return mv; } } class ExecMethodVisitor extends MethodVisitor { ExecMethodVisitor(MethodVisitor mv) { super(ASM9, mv); } @Override public void visitCode() { // 调用检测函数,参数从方法栈上取 super.visitMethodInsn(INVOKESTATIC, "com/demo/rasp/SecurityCheck", "checkExec", "(Ljava/lang/String;)V", false); super.visitCode(); } }这段代码的含义是:在Runtime.exec(String)的第一行指令之前,插入对SecurityCheck.checkExec(String)的调用。由于这个调用发生在原方法真正执行命令之前,检测函数如果抛出异常,原方法就不会执行。这就是“插桩阻断”的最小实现。
SecurityCheck是插桩后被调用的静态方法,它接收命令字符串,进行简单的危险命令检测:
public class SecurityCheck { public static void checkExec(String command) { System.out.println("[RASP Demo] exec called: " + command); if (command.contains("whoami")) { throw new SecurityException("[RASP Demo] blocked malicious command."); } } }为了能让插桩代码调用到SecurityCheck,这个类必须由 bootstrap 类加载器可见,或者设置好加载器委托规则。这是另一个大坑,我放到后面一节讲。
4.3 攻击效果验证与日志观察
Demo 搭好后,我在本地启动了一个很简单的 Java 程序,里面只有一行Runtime.getRuntime().exec("whoami")。运行后发现控制台输出了[RASP Demo] exec called: whoami,然后抛出了SecurityException,说明插桩生效了。
我又试了一下正常命令,比如Runtime.getRuntime().exec("ls"),控制台同样打印了调用日志,但后续命令正常执行。这说明 Demo 具备“可观察、可阻断”的基本能力,和源码分析里的链路对上了。
如果只在独立 Java 程序里验证还不够,我还把它挂到一个 Spring Boot 服务上,模拟一个文件下载接口用ProcessBuilder调用系统命令的场景。修改 Hook 点声明,把ProcessBuilder.start也纳入插桩范围,再在检测函数里增加数组参数的解析。这个过程很能锻炼对一个 RASP 工程的理解:新增一个插桩点,不只是加一个类名方法名,还要想清楚这个点可能传入的数据格式,以及这个点在不同中间件下会怎么被调用。
5. 高频踩坑与性能优化建议
5.1 类加载顺序导致的“瞎 Hook”
我第一次跑 Demo 时,明明代码里已经写了inst.addTransformer,插桩却没生效。后来才发现,java.lang.Runtime在 Agent premain 执行之前就已经被 JVM 加载了。addTransformer对已经加载的类默认是不生效的,所以后续对Runtime.exec的改造根本没机会执行。
这个问题的解决方案有很多,源码里常见的有两类:一是启动时通过-javaagent参数提前挂载,让 transformer 注册早于目标类加载;二是使用Instrumentation.retransformClasses(Class<?>...)对已经加载的类做热重写。第二种方式需要MANIFEST.MF里声明Can-Retransform-Classes: true,并且 transformer 要正确处理重写请求。
如果是调试 Agent,我建议直接修改启动命令,让 Agent 以-javaagent方式加载。热重写容易引发一些奇怪问题,比如类加载过程中对象状态不一致。我在 Demo 里踩过几次这类问题,最后统一改成启动期挂载,就稳定了。
5.2 插桩类被重复扫描的栈溢出
ClassFileTransformer 改造类的时候,如果回调代码里又触发了新的类加载,而新加载的类又需要被同一个 transformer 扫描,就可能形成递归调用。极端情况下直接栈溢出。
源码里一般有两种防御手段:
- 维护一个“处理中”的类集合,同一个类名加锁或标记。
- 对不需要插桩的包名直接返回 null,避免无意义的字节码解析。
这个坑我在第一次给 Spring Boot 加插桩时就踩到了。Spring 框架大量使用动态代理和 CGLIB,会在运行时生成很多新类。我的 transformer 会把所有类放到 ASM 里解析一遍,结果导致启动时间暴增到几十分钟。后来参考源码的做法,加了一个包名过滤列表,只处理真正关心的类,问题立刻缓解。
5.3 字节码版本与 ASM 兼容性
ASM 版本和 JVM 字节码版本必须匹配,否则解析类时直接抛异常。我自己在本地用 JDK 17 编译的 Spring Boot 应用,如果用低版本 ASM 的 Demo 去解析,会报IllegalArgumentException: Unsupported class file major version。升级 ASM 到对应版本可以解决,但 RASP 类库的依赖版本升级要很谨慎,因为它是全局生效的,升级引发的不兼容可能影响所有被插桩应用。
从源码阅读到的经验是:RASP 工程一定要做多 JDK 版本的回归测试。特别是 Java 9 之后模块化系统上线,--add-opens、--add-exports这类参数会影响对系统类内部的访问。Java 11、17 下同一个 Agent 的行为可能有细微差异,不能只看本地环境正常就放行。
5.4 性能开销实测与控制方法
RASP 的 Java 功能最大的争议在于性能。每多一个插桩点,就意味着在业务核心链路里多一次检测调用。我简单做了一个粗略测试:对一个本身耗时为 5ms 的查询接口,如果 SQL 插桩检测逻辑设计得比较重,耗时能到 7ms,涨幅 40%,这肯定是不能接受的。
控制性能的常见策略在源码里都有体现:
- 快速失败链:先用哈希集合、关键字集合做 O(1) 匹配,不命中复杂检测器。
- 上下文去重:同一个请求中的同一个附参数,检测过一次就缓存结果,避免重复分析。
- 检测器挂载可配置:高危接口才启用全量检测,普通接口只做轻量检查。
- 异步上报:攻击日志和访问日志走异步队列,不影响主线程。
我把这些策略过了一遍之后,重新给 Demo 增加了缓存,重复调用同样参数的Runtime.exec时,检测耗时降低到原来的五分之一。源码里往往有更精细的 LRU 缓存实现,但思路是一致的:让 99% 的正常请求走最短路,只有特征匹配到疑似攻击时才做深度分析。
5.5 关于 Java 功能可持续演进的个人想法
源码分析到最后,我还是会想一个问题:如果我要维护这个 Java 功能,最该优化什么。说实话,单一检测器的规则再怎么加,都只是防守的手指,真正决定一个 RASP 工程能否长期运转的,是它的扩展机制和可观测性。
扩展机制上,我建议把“检测器注册”做成真正的插件模式。新增一种攻击类型的检测,不应该改核心包,只增加一个独立模块并注册即可。实现上可以让检测器通过 SPI 方式加载,或者至少提供一个显式的注册接口。从前面的章节也能看出,源码中已经为每类攻击建立了独立检测器,但把“独立”做到插件化,还需要一层抽象。
可观测性上,RASP 不只是拦截工具,更应该是安全分析数据源。拦截到的攻击请求、调用栈、参数、来源,都应该结构化落库,便于事后研判。我在源码里看到有日志上报模块,但真正要用好,还需要把上报的数据与业务请求 ID 关联起来。这样排查问题时可以精准定位到具体请求链路,而不是靠日志片段猜。
如果你也是刚开始接触这类项目源码分析,我的建议是:先跑起来一个最小 Demo,再回头读插桩链路,不要一开始就一头扎进某个检测器的实现细节。链路通了,主体代码脉络自然就清晰了。后面再遇到疑问,带着问题去翻特定模块,会顺手很多。