资讯动态

OpenRASP源码解析:Java Agent与字节码插桩实现运行时防护

发布时间:2026/10/16 14:04:23 来源:尧图企业网站定制
前段时间我把OpenRasp的Java端源码从头到尾梳理了一遍。说实话这类RASPRuntime Application Self-Protection项目的代码量不算大但信息密度极高如果只盯着某个类看很容易在字节码和类加载器的迷宫里转不出来。它的核心动作归纳起来就三件事用Java Agent把自身塞进目标JVM靠字节码插桩在危险函数上埋钩子再把运行时上下文交给检测插件判断是放行还是阻断。这种思路和传统WAF完全不同——安全检测不再发生在网络入口而是直接落在应用自己的执行流里。你会立刻发现它能看到的东西是WAF怎么都拿不到的真实的对象值、完整的调用堆栈、以及用户请求在整个会话里的上下文。这篇文章我会把这套机制的源码逻辑从Agent启动到插桩再到检测决策完整拆开最后给一个能跑起来的最小Demo适合正在做Java安全、想理解RASP原理或者准备在公司内部做自研防护的同学照着复现。1. OpenRasp是什么它到底解决什么问题1.1 从WAF到RASP防护视角的根本变化在聊OpenRasp源码之前得先把RASP和WAF这件事掰扯清楚。WAF的思路是大楼门口的保安所有访客经过时查证件、翻包但它看不到访客进入大楼之后做的事情——请求到了应用层之后到底触发了哪些函数、参数被加工程了什么样子WAF是盲的。而且现代应用普遍走HTTPS、各种网关、内网链WAF看到的流量往往经过多次解包和转译跟应用实际收到的参数内容已经对不上了。RASP的思路则是给应用装上免疫系统。代码里每一个危险函数——执行系统命令、发起数据库查询、读取文件、解析表达式——都可能变成被监控的关卡。任何请求参数要想触发这些函数就必须经过插桩点拦不拦、记不记完全由应用进程内的决策模块说了算。OpenRasp正是基于这个思路做的一套可运行的参考实现。它不需要改变网络拓扑不需要代理流量只需要在启动时挂上Agent就能获得应用内部的完整信息被调用的方法、参数值、调用栈、当前请求上下文。这种视角差异决定了产品能力上限。拿SQL注入来讲WAF看的是请求文本里有没有“或”表达式很容易被编码变体和参数位置变化绕过去。而RASP在数据库驱动真正执行SQL的前一刻拿的是即将被执行的那条拼好的完整语句做过什么编码、拼了多少次都能看到判断依据完全不同。这也是为什么我会把OpenRasp源码分析当作学习现代应用安全的一个很好的切入口。1.2 源码模块的五个层次OpenRasp的Java端如果按职责拆分大致能分成五个层次这个分层结构也是很多开源RASP的通用骨架。第一层是Agent引导层负责拿到JVM的Instrumentation对象注册字节码转换器加载基础配置。这段代码位于整个项目的最前端静态启动时由premain进入动态挂载时由agentmain进入。第二层是字节码增强层这是源码里最考验功力的部分。它要决定哪些类需要被转换、哪些方法需要被插入指令、如何避免重复插桩和循环加载通常基于开源字节码库实现在类加载过程中做精确方法匹配。第三层是Hook适配层可以理解成“安全参数模型”的封装层。插桩点在被拦截方法上埋下的回调会把当前实例、方法参数、返回值、调用栈等原始信息汇总成统一模型。这里面的一个难点在于方法重载种类很多比如JDBC的execute方法有一种参数签名、两种参数签名、三种参数签名的版本Hook适配层要能把所有相关入口都收口到同一个安全模型。第四层是检测与决策层也就是多个安全插件按责任链组合的地方。SQL注入检测插件、命令执行检测插件、文件操作检测插件、表达式注入检测插件同时挂载按优先级依次检查。插件返回的结果汇总成最终决策是可放行、仅记录还是立即阻断。第五层是管理配置与事件上报层负责读取策略配置、过滤规则白名单、控制防护开关并把攻击事件格式化输出。把这五个层次放在脑子里再去看源码就不会乱。很多文章一上来就分析某个类怎么匹配类名其实是把第三层和第四层的概念杂糅在一起了。我建议从第五层倒着看先明白一个攻击事件最终长什么样再回推它需要哪些信息再去理解插桩为什么要那样做。1.3 一个请求从进入到阻断的完整路径找一个典型攻击场景走一遍全流程会非常直观。假设攻击者发了一个携带SQL注入载荷的请求通过某Web框架的入口访问/api/user?name1 or 11这个请求会被RASP在多个环节盯住。Web框架入口的Hook先触发创建一个请求上下文对象把请求URI、查询参数、请求头、客户端IP记入其中。这相当于给请求发了一张“病例卡”此后无论在哪个插桩点被拦都能查这张卡。到了业务代码里拼装SQL的环节SQL通过JDBC驱动准备执行数据库驱动类中负责执行查询的方法被插桩Hook把拼好的SQL字符串抓出来连同当前调用栈一起提交给检测插件。检测插件拿到参数、调用栈、上下文后在SQL文本中发现了典型的注入特征并且发现可疑子串跟请求参数有对应关系于是判定命中。决策模块按策略执行阻断抛出一个自定义运行时异常业务线程的执行流被中断框架层把这个异常转换成拒绝访问响应。整个事件连同调用栈被异步记录平台后端收到一条攻击详情。这条链路看起来不复杂但每个环节都有隐藏的工程问题后面几章会逐层拆解。先把这条路径记住接下来的源码分析都会围绕它展开。2. Agent启动机制一段代码是怎么“进”到JVM里的2.1 premain静态加载与agentmain动态挂载Java Agent的标准入口有两个。一个是在JVM启动命令里通过-javaagent指定JAR包JVM在main方法执行之前会先调用JAR清单文件里Premain-Class指定的类的premain方法。另一个做法是运行时利用Attach机制把JAR包挂载到已经运行的JVM里入口是Agent-Class指定的类的agentmain方法。OpenRasp的Java端保留了这两种接入方式。配置上和大多数Agent一致需要特别注意JAR包内META-INF/MANIFEST.MF里那几个属性的拼写Manifest-Version: 1.0 Premain-Class: com.demoproj.agent.MyAgent Agent-Class: com.demoproj.agent.MyAgent Can-Redefine-Classes: true Can-Retransform-Classes: trueCan-Retransform-Classes: true很容易被漏掉。如果没有这个属性即便代码里调用retransformClasses成功注册了Transformer也只是对新加载的类生效已经存在于JVM里的类不会被转换。对线上动态挂载来说这条缺失意味着功能直接失效而且不会有任何明显报错属于典型的“静默失败”。我当时第一次写Agent时就吃过这个亏日志怎么打都没有输出查了半天才发现manifest少了一行。2.2 ClassFileTransformer与类转换时机premain里最关键的一步就是拿到Instrumentation实例然后调用addTransformer注册一个ClassFileTransformer。JVM在类加载流程中读取到类的原始字节码之后、定义类之前会把控制权交给Transformer链上的每个处理器。处理器可以对类文件的字节数组做修改返回一个新的字节数组或者返回null放弃本次转换。源码里围绕这个类要处理的问题很多最直观的是“类加载顺序”。Transformer本身只负责转换别人它自己不参与转换。但转换后的字节码里插入了对RASP辅助类的静态方法调用这就有个依赖关系如果辅助类还没有被加载而应用某个类已经加载到一半字节码验证器去解析那些新插入的符号引用时就会触发新的加载流程严重时可能导致循环加载错误或者ClassCircularityError。工程上的应对办法是让Agent在注册Transformer之前先主动加载一批基础辅助类把字节码符号依赖提前准备好。这个顺序对性能的影响也很直接。Transformer会被每个类的加载动作触发不管是不是自己的目标类。如果每个类都要用ASM解析一遍再判断是否模特征类加载的总开销会被无限放大。所以大多数RASP实现第一步都是基于类名做快速字符串匹配先把绝大部分不相关的JDK内部类、工具类、框架底层类直接过滤掉再去走ASM解析流程。这个“先粗筛、再精算”的思路是控制启动损耗的关键。2.3 两种方式的适用场景和典型坑静态premain的好处是介入时机最早所有类在还没进入JVM之前就处于被监控状态不需要考虑“已经加载的类怎么办”的问题。缺点是必须改启动命令和重启应用这在生产环境里往往要等发布窗口。动态agentmain的好处是无需重启适合在应急场景下快速拖一个应用进去观察。但坑也更多。第一动态挂载后之前已经加载的类不会自动被转换代码必须显式挑选目标类逐个调用retransformClasses或redefineClasses去重放。第二动态挂载需要在目标进程里有权限并且不同JDK版本的Attach实现细节有差异。第三挂载过程中的任何异常如果没被兜住会直接污染目标应用进程。从源码设计上看动态挂载的主要作用并不是替代静态模式而是给已有实例补课。最稳妥的做法是新发的应用全部用静态premain存量应用通过agentmain补挂然后在下一次发布时把javaagent参数固化到启动脚本里。3. 字节码插桩与Hook点工程化3.1 为什么RASP几乎都选ASM这套字节码路线Java里调整字节码有两条主流路线一条是运行时生成动态代理或者使用反射拦截另一条是直接操作字节码。RASP的场景里目标方法大多数是JDK底层和框架底层方法不是接口方法用动态代理根本接不进去。所以要绕开使用ASM或类似ByteBuddy之类的字节码库在类加载阶段直接改写字节码指令。很多RASP产品选择ASM是因为它体积小、性能开销低、可以非常精确地在一条方法里控制要改哪个指令位置不引入一套庞大的代理框架。缺点也明显ASM是访问者模型代码写起来反直觉可读性差一旦出错极难排查。所以分析和阅读源码时的重点不是逐行记API而是抓住“方法进入时插入什么、方法退出时插入什么”这两个问题。ASM操作一个类的基本姿势是这样的用ClassReader读入原始字节码把ClassWriter接在后面中间挂一层ClassVisitor。在visitMethod方法里拦截需要修改的目标方法返回一个定制过的MethodVisitor在visitCode里往方法头插入调用逻辑在visitInsn里检查到返回指令时插入方法退出调用。3.2 方法内织入的基本示例这是我在学习过程中验证过的最小插桩样例去掉业务逻辑后可以直接跑ClassReader cr new ClassReader(originalBytes); ClassWriter cw new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); ClassVisitor cv new ClassVisitor(Opcodes.ASM9, cw) { Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv super.visitMethod(access, name, desc, signature, exceptions); if (doService.equals(name)) { mv new MethodVisitor(Opcodes.ASM9, mv) { Override public void visitCode() { super.visitCode(); mv.visitMethodInsn(Opcodes.INVOKESTATIC, com/demoproj/agent/AgentCallback, onRequest, ()V, false); } }; } return mv; } }; cr.accept(cv, ClassReader.EXPAND_FRAMES); byte[] result cw.toByteArray();这段代码的作用是当地址表示请求分发容器的类的doService方法被加载时在方法体最前面插入一行静态方法调用。每次这个方法被真正执行AgentCallback.onRequest就会触发。代码里两个细节值得说。ClassWriter.COMPUTE_MAXS告诉ASM自动计算栈帧和局部变量表的最大值可以大大减少手工算栈的负担但EXPAND_FRAMES会引入额外的成本只应在必要时使用。onRequest是无状态的没有参数这是为了先跑通插桩链路。生产中真正复杂的插桩要把局部变量、参数、接收者对象都压栈传给回调函数那就要精确控制指令位置的栈深度稍有不慎就生成非法字节码。3.3 Hook点管理怎么描述一个“要拦截的方法”源码里对Hook点的描述往往不是代码里写死的if (name.equals(...))而是维护了一套结构化的匹配规则。规则至少要包含四要素全限定类名、方法名、方法描述符、加载器范围。方法描述符是JVM对方法签名的文本形式比如(Ljava/lang/String;)V表示接收一个字符串参数且无返回值。同一类名相同方法会有多个重载描述符就是用来精确区分它们的。写规则的时候要格外留意类名格式。ASM里传递的类名和反射Class.getName()返回的格式不一样org.apache.catalina.core.ApplicationFilterChain变成org/apache/catalina/core/ApplicationFilterChain斜杠不能写错这是新人最容易忽略的差异。此外不同版本的框架中同一个逻辑层的方法名和描述符可能不同。比如某个中间件的请求处理方法在新老两个版本里一个叫doService一个叫execute。工程上要么维护多套规则要么在匹配时做一次兼容映射源码里一般会看到同一安全语义对应多条拦截规则的情况这不是冗余是适配不同版本必需的工作量。3.4 三个典型插桩工程错误第一个错误是重复插桩。一个类可能被同一个Transformer处理两次尤其在做retransform时字节码已经被改过一次再次转换如果没有标记注入逻辑会叠加方法越来越臃肿最终导致逻辑异常。通用解决办法是在目标类里通过ASM插入一个静态布尔标志字段转换前先检查标志发现已经改过就直接返回原始字节码或者不再继续修改。这个标志本身也要承担“避免应用启动时反复做无效ASM解析”的作用。第二个错误是辅助类的类加载器边界。应用里有很多类是由自定义类加载器加载的应用代码可见的类自定义类加载器未必可见。如果被插桩的类引用了辅助类辅助类又不可见运行时就会抛NoClassDefFoundError。经验做法是让辅助类尽量少依赖外部库让Agent自身以系统类加载器可见的方式预加载这些辅助类使它们对所有被插桩类都可见。第三个错误是transformer内部异常没有兜底。一个类的转换代码抛出异常JVM不会自动跳过然后继续它会把这个异常抛到类加载流程里引发一连串初始化失败。所以transform回调的最外层必须catch所有Throwable任何解析失败都返回null让类保持原始字节码加载。用“宁可漏一个类也不崩一头应用”的保守策略处理插桩过程是RASP能够上线的前提。3.5 动态挂载之后如何处理已加载的类前面提到动态挂载时不处理存量类就不生效但真正触发重转换时又会遇到新的约束。redefineClasses和retransformClasses都要求新字节码与旧字节码做差异对比时不能增删方法、不能改变方法签名、不能改变类的继承关系。也就是说动态挂载时的字节码增强面要比静态时更保守。做过一次全量retransform的现场如果恰好Transformer有微小的逻辑变动导致方法结构不一致JVM会直接拒绝再定义这期间被转换的类状态会异常。在实际执行中一般不会对JVM里所有类做retransform那样消耗太大而且大量无关类也可能因为ASM解析而变慢。正确做法是根据Hook规则生成一个明确的“已加载目标类清单”对这个清单里的类逐个执行retransform并且给每个类的转换做超时和异常隔离一个类转换失败至少不能拖垮其他类。4. 参数采集与攻击上下文管理4.1 插桩后到底能拿到什么参数插桩点能拿到的东西越丰富检测插件的判断就越可靠。一个设计良好的Hook回调应当把下面几类信息都传出来当前实例对象、本次调用的参数数组、返回值如果有、调用栈快照、以及一个全局唯一的事件编号。其中参数值是最核心的检测依据。为了兼容多种方法签名RASP通常会把参数统一包装成一个对象数组再传给回调函数。比如SQL执行相关的Hook要把语句字符串从原始参数里找出来文件操作的Hook要把文件路径字符串找出来表达式解析的Hook要把表达式字符串找出来。参数提取逻辑如果写得不严谨容易把索引搞错用到别的参数上去产生毫无关联的误报。调用栈快照也很重要它能帮分析人员确认攻击链路到底经过了多少层业务代码。如果一次SQL注入事件只看到数据库驱动里的调用栈没有上层业务信息很难定位问题入口。所以Hook回调里通常会记录一份线程调用栈但要注意不能整个打成字符串日志而应该只保留必要的堆栈元素控制内存占用。4.2 请求上下文从入口到数据库的贯穿只有零零散散的参数点还不够真正让检测“聪明”起来的是请求上下文贯穿机制。RASP会在Servlet入口或框架入口创建上下文将当前请求的方法、URI、查询字符串、Header、客户端IP存进去然后将上下文绑定到当前线程。后续所有插桩点触发时都能从同一个上下文里取信息从而知道“我正在处理的这个请求来自哪个URL带了什么参数”。上下文按线程绑定的做法在Web场景下很自然因为一个请求在一个线程里执行。但实际工程中有两个例外一是线程池复用请求结束后如果不清理上下文下一个请求会读到上一个请求的残留脏数据二是异步Servlet和队列任务业务逻辑会跑到其他线程ThreadLocal就串不过去了很多越权误报就是这么产生的。源码在上下文管理上一般都会提供主动清理和传递开关至少要确保同步Web请求的主流链路上不会串数据。有了上下文检测插件的判断就能从“字符串里有没有危险特征”升级成“这个危险特征是否来自用户输入”。同样一段SQL语句业务代码自己拼出来是正常功能用户参数直接拼进去就是攻击两者在字符串特征上可能一模一样靠上下文才能区分。4.3 必须覆盖的高危入口清单一个Java应用里值得Hook的点非常多但最优先覆盖的通常是这六类命令执行入口比如ProcessBuilder的start方法、Runtime.exec的各种重载SQL执行入口比如JDBC的Statement.executeQuery、Statement.executeUpdate表达式执行入口比如SpelExpression和OGNL相关类的解析方法文件操作入口比如文件输出流、Files工具类的路径参数读取方法反序列化入口比如ObjectInputStream.readObject以及框架自身用于路由分发、动态代理调用的通用入口。每一类入口的Hook方式差别很大。SQL执行点天然适合在数据库驱动层拦截因为SQL已经拼完最接近真实执行语义反序列化入口则要同时关注readObject方法本身还要关注它往下走的深链里是否解析到危险类。搭建这类Hook清单本身就是一个持续迭代的过程不能期望一次到位先覆盖最常被利用的SQL和命令执行再逐步铺开文件、表达式、反序列化这些点。5. 检测引擎与防护插件的决策机制5.1 责任链多个插件有序协作检测引擎的骨架通常是责任链模式。Hook回调拿到统一的安全参数模型后不会直接在一大段if-else代码里判断而是把事件沿着一条按优先级排序的插件链依次传递。每一个插件只关心自己领域的安全问题SQL插件只处理SQL相关事件命令执行插件只处理命令参数相关事件。每个插件内部的结构也很相似。初始化阶段加载自己的规则配置检查阶段接收参数模型如果事件类型不匹配就直接放行交给下一个插件一旦命中危险规则就抛出结果并中断链路。这种设计带来的好处是插件之间完全解耦新增一个安全插件不需要改核心链路只需要插进链里并声明优先级。OpenRasp源码中插件数量虽然多但逐个看下来结构高度一致这也是我推荐初学者从这套插件机制出发去阅读的原因。5.2 匹配策略危险特征与上下文结合检测插件拿到参数后做的判断不是单一的字符串包含匹配。以SQL注入插件为例它会先判断参数值类型不是字符串就跳过再看上下文里是否有“当前请求确实涉及数据库操作”的标志最后才把SQL文本送入特征匹配逻辑。这个三层结构能有效降低正常SQL语句被误判的概率。特征匹配本身也有多种策略。黑名单正则匹配“注释符拼接”“恒真恒假”“堆查询分隔符”等常见注入技巧是最直接的但它无法穷举所有变体。更稳健的做法是把特征匹配结果和参数溯源结果做交叉验证若SQL里出现的可疑片段能对应到请求参数值的一部分命中置信度就很高。流程上只有交叉验证通过后才会把事件升级为攻击否则除非人工配置了强拦截规则一般只记录不阻断。这是我们日常运营中控制误报的核心经验。5.3 响应动作放行、拦截、仅记录每个插件检查完最终会把结果汇总成决策。三种主要动作是允许放行、仅记录、拦截阻断。放行就是什么都不做让业务继续执行仅记录是把攻击详情写到日志或上报平台但不打断业务拦截阻断则直接抛出异常中止当前执行流。拦截阻断的具体实现其实很朴素插桩回调方法里抛出一个自定义RuntimeException。由于插桩点已经嵌在被拦截方法首部异常就会顺着原始调用栈一路向上传播业务方法在真正执行危险动作之前就被终止。上层框架一般会把这个未捕获异常转换成错误响应或者交给全局异常处理器尽管不同框架呈现给用户的状态码不一样但业务代码不会继续跑下去。在策略上我比较推荐“先记录后拦截”的过渡方案。新接入RASP的先全部设成仅记录模式让插件和各Hook点跑上两三天收集真实业务在正常场景下产生的事件再根据误报情况逐步把高危规则切成阻断模式。上来就全局拦截大概率会把线上正常功能打破届时再紧急回滚就相当被动了。6. 性能开销与工程避坑实录6.1 插桩带来的三类性能损耗第一类损耗发生在类加载阶段。每个被匹配到的类在执行ASM解析和重写时都会额外消耗CPU类加载速度明显变慢。对一个大型应用来说如果插桩规则覆盖面太宽启动时间可能从几十秒拖到几分钟这是RASP上线后最常见的性能抱怨来源。第二类损耗发生在方法执行阶段。每个插桩点在方法入口和出口都多了一次静态方法的调用方法本身调用的频率越高额外开销越大。如果这个插桩点还做了上下文拼接、调用栈快照等相对重的操作可能直接让被监控热点方法成为性能瓶颈。第三类损耗更隐蔽是JIT编译器层面的。插桩会改变热方法内部的字节码结构导致原本已经被JIT精准优化的代码路径发生改变一些优化比如逃逸分析、方法内联可能失效。用压测去看往往不是某个接口变慢几毫秒而是整体TPS出现明显下降排查方向容易跑偏。6.2 控制插桩面的几个手段控制漏出消耗的核心是“减少无关类被打扰”。规则配置应该以白名单为主只把真正关心的类和对应方法放进插桩列表。类名不能匹配通过就立即停止应该再做方法和描述符的精确匹配避免同类其他重载方法被误插桩。对那些高频调用但安全性影响不大的插桩点可以只做轻量记录不进检测引擎。比如框架分发类的方法主要目的是保存上下文做一些内存写入就行没必要一进来就走完整条责任链。事件上报也要与请求主流程隔离用异步队列而不是在每个请求里同步打印日志。还应该给自身加监控。收集每个插桩点的调用次数、平均耗时、异常次数并定期上报。这些指标能让负责人知道当前防护系统本身占了多少资源哪几个Hook点需要优化。没有指标就把Agent部署到生产环境等于闭着眼睛开车。6.3 线上问题排查速查表我把实际使用中遇到的常见问题整理成了一份排查清单方便对照。现象最可能原因排查方法无任何检测日志插桩未生效检查manifest的Premain-Class与Can-Retransform属性用-verbose:class观察Agent类是否加载启动明显变慢Transformer覆盖面过宽统计transform调用次数开启匹配过程的日志定位高耗时规则动态挂载后不拦截存量类未retransform确认代码针对目标类调用了retransformClasses而非只addTransformer类加载报ClassCircularityError插桩引用辅助类但辅助类未预加载在注册Transformer前主动加载全部辅助类保持辅助类零外部依赖异步线程出现串数据误报ThreadLocal上下文未清理检查请求结束路径是否执行清除异步场景是否做了上下文传递业务线程被莫名阻断高危规则在仅记录模式下仍触发阻断复查策略配置层级确认是否全局开关漏了日志模式6.4 一个容易被忽视的点并发修改与锁插桩回调跑在业务线程里与业务代码共享全部上下文所以回调里的任何公共数据都要小心并发。最常见的坑是在上下文里放了一个HashSet或Map多个插桩点同时写入如果没有做并发控制轻则丢数据重则死循环。有一种比较隐蔽的性能下降就来自这里检测模块为了保护上下文使用了粗粒度的同步锁结果每个SQL请求都要抢锁并发一高线程全部挤在锁上。正确做法是上下文使用线程私有对象公共规则只读需要修改的计数用原子类尽量避免在关键路径上使用重锁。7. 自己动手做一个最小可运行的Agent插桩Demo7.1 工程结构与准备看懂源码之后自己动手写一个几十行的最小Agent是建立手感最快的方式。目标很简单往一个普通Web应用的请求处理类里插入一个无参数静态调用请求进来时打印一行日志。工程结构只需要两个Java文件和一个资源目录。一个文件是Agent入口类在premain里注册Transformer另一个是回调类包含被插入调用的静态方法最后在src/main/resources/META-INF下放一个MANIFEST.MF。用Maven打包时要注意manifest配置不能丢失最简单的方式是在pom.xml的maven-jar-plugin里显式配置。7.2 最小Transformer与回调实现下面给出一套可编译运行的代码骨架。首先是Agent入口package com.demoproj.agent; public final class MyAgent { public static void premain(String args, Instrumentation inst) { inst.addTransformer(new MyTransformer(), false); System.out.println([DemoAgent] premain loaded); } }然后是Transformer这里用ASM库依赖坐标可以自己加核心逻辑是把某个类的doService方法头部插入一个对AgentCallback.requestEnter()的调用package com.demoproj.agent; public class MyTransformer implements ClassFileTransformer { Override public byte[] transform(Module module, ClassLoader loader, String className, Class? classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { if (!com/demoproj/web/EntryServlet.equals(className)) { return null; } try { ClassReader cr new ClassReader(classfileBuffer); ClassWriter cw new ClassWriter(cr, ClassWriter.COMPUTE_MAXS); ClassVisitor cv new ClassVisitor(Opcodes.ASM9, cw) { Override public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv super.visitMethod(access, name, desc, signature, exceptions); if (service.equals(name) (Ljavax/servlet/http/HttpServletRequest;Ljavax/servlet/http/HttpServletResponse;)V.equals(desc)) { mv new MethodVisitor(Opcodes.ASM9, mv) { Override public void visitCode() { super.visitCode(); mv.visitMethodInsn(Opcodes.INVOKESTATIC, com/demoproj/agent/AgentCallback, requestEnter, ()V, false); } }; } return mv; } }; cr.accept(cv, ClassReader.EXPAND_FRAMES); return cw.toByteArray(); } catch (Throwable t) { return null; } } }回调类的代码就非常简单这里只打印一条日志package com.demoproj.agent; public final class AgentCallback { public static void requestEnter() { System.out.println([DemoAgent] request enter: Thread.currentThread().getName()); } }把这套代码编译成JAR后启动Web应用时加上参数-javaagent:/path/to/demo-agent.jar。应用起来之后发一个普通请求到EntryServlet控制台会立刻出现一行[DemoAgent] request enter: ...。到这里就意味着整个插桩链路已经打通了。接下来在这两个文件的基础上继续扩展就能逐步实现参数捕获、上下文管理和规则判断也就是前面几章描述的东西。7.3 从Demo到可用RASP的距离Demo跑通时人会有一种“这就完了”的错觉但真正把它变成一个可用RASP差距主要在异常处理、性能采集和规则工程上。上面这段代码如果遇到目标类不存在就直接返回null可一旦目标类存在而ASM生成的字节码无法通过验证器异常就暴露给JVM了所以生产代码必须把transform内所有异常的问题扩展到每个类的加载过程。回调函数也是一样当前它什么都不做只用println打印正式实现里它很快就会成为高频代码路径必须经过充分压测和性能基准测试确保它不会把业务接口拖垮。从Demo到生产环境的另一个分水岭是规则库。代码框架搭好之后防护能力完全取决于有几个高质量插件、多少条经过了误报测试的规则。与其追求规则数量不如先把SQL注入和命令执行这两条最核心的链路打磨到极致。这两条链路跑稳了RASP防护框架的骨架就有了后续每加一类入口都只是增量工作。写在最后的实际操作心得按我自己从源码阅读到动手复现的过程来看最值得推荐的学习路径不是先啃完所有字节码细节而是先跑通这几十行最小Demo再回去对照源码理解框架为什么这样做。框架设计里的很多选择比如为什么要做类名粗筛、为什么要预加载辅助类、为什么插件要挂成责任链只有在自己踩过启动变慢、重复插桩、误报夹击这些坑之后才能真正体会到。还有一个容易被忽略的小技巧在调试插桩逻辑时不要直接用生产框架当做实验对象先用一个能复现的最小Servlet容器跑打印出JVM实际加载的类名和字节码调试效率会高很多。RASP这类项目上手门槛确实比普通业务代码高但一旦跨过Agent、Transformer、ASM这几道坎你得到的就不是某一条规则而是一整套可以延续到更多语言的运行时防护方法论。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑