前言
这篇用于记录RASP实现的具体入门的实现code以及原理的分析学习,性质上更属于笔记。参考的文章在文末。
0x00 环境配置
RASP本质上是一份Agent,当前用于测试和学习的环境是由一份RASP-Agent以及一份测试环境决定的。这里的测试环境可以自己写一个漏洞环境,也可以是直接采用网上的java漏洞靶场项目,只要有漏洞即可,主要是用来模拟生产环境。
1x01 RASP Agent初步结构
首先是Agent的部分
对于一个Agent有两种方式来处理字节码,一种是在主程序启动之前,也就是main方法执行前,我们能够通过编写premain方法,指定相关的ClassFileTransformer实现main函数执行前(整个程序执行前)修改字节码。还有一种是通过编写agentmain方法,依旧是利用ClassFileTransformer的transform实现已运行JVM中的字节码修改。
先采用premain的方式。
在一个大的项目中创建名为agent的maven项目,然后按照如下结构写入项目:
先写一份transformer,具体是继承ClassFileTransformer,然后在实现的transform中写自己想要实现的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| package cn.org.javaweb.agent;
import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.security.ProtectionDomain;
public class AgentTransform implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { className=className.replace('/', '.'); System.out.println("load Class:"+className); return classfileBuffer; } }
|
之后编写premain方法,添加上我们自定义的Transformer
1 2 3 4 5 6 7 8 9 10
| package cn.org.javaweb.agent;
import java.lang.instrument.Instrumentation;
public class Agent { public static void premain(final String agentArgs, final Instrumentation inst) { inst.addTransformer(new AgentTransform()); } }
|
下一步打包生成agent.jar,在此之前先把MANIFEST文件写好。
1 2 3 4 5 6
| Manifest-Version: 1.0 Can-Retransform-Classes: true Can-Redefine-Classes: true Can-Set-Native-Method-Prefix: true Premain-Class: cn.org.javaweb.agent.Agent
|
推荐是idea里面自定义个maven启动项,能够一键重新编译生成jar,方便后续调试。
1x02 漏洞环境案例
然后我们可以随意找一个漏洞靶场,或者像我一样自建一个springboot的项目,之后自己编写漏洞环境方便调试。
我这里的做法是首先先定义一个maven的application,也是将整个springboot项目重新编译和打包。然后再自定义一个大的application作为启动springboot项目并且添加vm参数。
这里没有vmoptions的选项的话,在Modify options中可以找到。之后是springboot的打包
在大的application启动项的vm options中添加如下选项
1
| -Dfile.encoding=UTF-8 -noverify -Xbootclasspath/p:H:\ASecuritySearch\RaspLearn\javawebAgent\agent\target\agent.jar -javaagent:H:\ASecuritySearch\RaspLearn\javawebAgent\agent\target\agent.jar
|
这里的启动参数有一个可能没见过—Xbootclasspath/p,主要的作用是将我们的指定路径中的类加入项目中,避免出现类缺失的情况(在OpenRasp的下载指令中也能够见到)
然后就可以开始调试了,直接启动整个application,由于我们指定了启动参数-javaagent,所以jvm会启动 Instrumentation 的代理程序,调用到premain中指定的transformer的transform逻辑,这里我们写的逻辑是打印出transform方法中第一个形参–className。只要有新的类加载,transform就会被触发,将当前加载的类的全类名传入,进而我们能够打印该类的名称。下面所有打印出来的全类名,就是springboot整个项目启动之后的要加载到的类
但是这么做就存在一个分歧,由于我们采用的agent形式是premain,所以只能在项目启动的时候指定javaagent参数进行启动。也就是说如果此前项目并没有采用Rasp,就需要重启。这是不太符合实际的。另一种就是agentmain,直接通过遍历jvm之后attach到对应的进程中,这么做也有一个问题,就是如果HOOK点更新,那么就需要重新Attach,但是原本的我们修改过的字节码又会被修改一次,造成重复告警(JVM进程保护要求不能够自由修改类),增加业务压力。
下面的例子中还是首先通过premain的方式进行学习。
0x01 RASP机制探究
1x01 premain
用一个premain的例子来感受一下RASP的运行机制。
在上一节的最后我提到了transform方法的触发时机—当一个新类被加载时。实际上transform方法还有两种情况下会被触发,总结为:
- 新的 class 被加载。(已经加载过的就不会触发transform了,这一点在agentmain中有体现)
- Instrumentation.redefineClasses 显式调用。
- addTransformer 第二个参数为 true 时,Instrumentation.retransformClasses 显式调用
第一种和第三种都是我们之后会遇到的,premain能够直接利用到第一条特性,agentmain方式需要我们利用第三种特性。
那既然一个类被加载时就会触发transform,如果我们在transform写一段类名判断,为常见的恶意类的话,就对其进行拦截和丢弃处理,是不是就和自己心里想的那个RASP实现的功能一样?虽然具体到产品中这么想还是肤浅了,这里只是先做测试用例。
我们知道Runtime.getRuntime.exec的最终是通过构建ProcessBuilder
类中所需的命令参数以及本身,调用其start方法实现命令执行的。假如此时项目中没有任何正常逻辑需要调用到系统命令,我想在自己的项目中实现HOOK掉外部调用Runtime命令执行的可能,就可以这么写transform(提前准备好一些ASM和字节码的知识,如果看不懂字节码可以随便找一篇字节码的文章或者表格对照着看就行):
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 43
| package cn.org.javaweb.agent;
import cn.org.javaweb.Visitor.ProcessorBuilderVisitor; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter;
import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.security.ProtectionDomain;
public class AgentTransform implements ClassFileTransformer { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { className=className.replace('/', '.'); try {
if (className.contains("ProcessBuilder")) {
System.out.println("load Class"+className); ClassReader classReader=new ClassReader(classfileBuffer); ClassWriter classWriter=new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS); ClassVisitor ProcessorBuilderVisitor= new ProcessorBuilderVisitor(classWriter);
classReader.accept(ProcessorBuilderVisitor, ClassReader.EXPAND_FRAMES); classfileBuffer=classWriter.toByteArray(); } }catch (Exception e) { e.printStackTrace(); }
return classfileBuffer; } }
|
抽出重点来讲一下:
ClassReader,ClassWriter,ClassVisitor都是ASM中的接口,他们的一整套逻辑都已经写在注释里面了,我们最终是通过ClassVisitor的访问者接口去拦截具体的方法或者变量,之后再实现拦截逻辑。而当前样例代码块中总的实现目的就是为了能够拦截到ProcessBuilder类的新加载,之后具体的对方法的拦截和逻辑改写要看ProcessorBuilderVisitor,如下:
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
| package cn.org.javaweb.Visitor;
import cn.org.javaweb.Hook.ProcessBuilderHook; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes; import org.objectweb.asm.commons.AdviceAdapter;
public class ProcessorBuilderVisitor extends ClassVisitor implements Opcodes { public ProcessorBuilderVisitor(ClassVisitor cv) { super(Opcodes.ASM5, cv); }
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if("start".equals(name) &&"()Ljava/lang/Process;".equals(desc)) { System.out.println(name +" 方法描述符为 :"+desc); return new AdviceAdapter(Opcodes.ASM5,mv, access, name, desc) { @Override public void visitCode() { System.out.println("visitCode"); mv.visitVarInsn(ALOAD, 0); mv.visitFieldInsn(GETFIELD, "java/lang/ProcessBuilder", "command", "Ljava/util/List;"); mv.visitMethodInsn(INVOKESTATIC, ProcessBuilderHook.class.getName().replace(".", "/"), "Hook", "(Ljava/util/List;)V", false);
} };
} return mv; }
}
|
关键部分就是对于方法名是否为start的判断开始了,大部分内容都是用于return一个新定义的MethodVisitor(AdviceAdapter),用来具体对字节码进行操作。注意visitVarInsn
visitFieldInsnvisitMethodInsn
等一系列操作实际上是对于ProcessBuilder中start方法所表示字节码区进行改写了。最终实现的目的注释中也解释清楚了。
然后是ProcessBuilderHook的内容。我所采用的HOOK方式是直接抛出异常了,这样有什么不好呢?如果当前项目确实是要利用到一些正常的系统命令执行的接口,如果按照我这么HOOK会出现格杀勿论的情况,所以另一种合理但是有风险的方法是给予黑名单或白名单,因为我们已经获取到了当前Command内容,进行判断之后HOOK即可。因为是演示,所以可能不会注意很多。
1 2 3 4 5 6 7 8 9 10 11 12 13
| package cn.org.javaweb.Hook;
import java.util.Arrays; import java.util.List;
public class ProcessBuilderHook { public static void Hook(List<String> commands) { String[] commandArr = commands.toArray(new String[commands.size()]); System.out.println(Arrays.toString(commandArr)); throw new SecurityException("Blocked dangerous MethodCall: start(\"" + commands+"\""); } }
|
之后就是将当前RASPAgent以及用来测试的项目进行重新打包,执行最开始我提到的那个大的项目,也就是-Dfile.encoding=UTF-8 -noverify -Xbootclasspath/p:H:\ASecuritySearch\RaspLearn\javawebAgent\agent\target\agent.jar -javaagent:H:\ASecuritySearch\RaspLearn\javawebAgent\agent\target\agent.jar
这里用来测试的环境代码也很简单,直接Runtime执行一遍
传参过去之后执行,控制台打印如下信息,验证Runtime的exec确实是被HOOK了:
1 2 3 4 5 6 7 8 9 10 11
| load Classjava.lang.ProcessBuilder start 方法描述符为 :()Ljava/lang/Process; visitCode [calc] 2025-02-06 21:55:14.419 ERROR 5960 --- [nio-8080-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.SecurityException: Blocked dangerous MethodCall: start("[calc]"] with root cause
java.lang.SecurityException: Blocked dangerous MethodCall: start("[calc]" at cn.org.javaweb.Hook.ProcessBuilderHook.Hook(ProcessBuilderHook.java:10) ~[na:1.0.0] .....省略大部分无关调用栈信息 at java.lang.Thread.run(Thread.java:748) [na:1.8.0_202]
|
1x02 agentmain
其实大部分的实现原理已经在premain中粗浅的分析了一遍,agentmain是换了一种形式的加载方式。首先回忆一下在环境搭建的结尾,我留了一个描述:
但是这么做就存在一个分歧,由于我们采用的agent形式是premain,所以只能在项目启动的时候指定javaagent参数进行启动。也就是说如果此前项目并没有采用Rasp,就需要重启。这是不太符合实际的。另一种就是agentmain,直接通过遍历jvm之后attach到对应的进程中,这么做也有一个问题,就是如果HOOK点更新,那么就需要重新Attach,但是原本的我们修改过的字节码又会被修改一次,造成重复告警(JVM进程保护要求不能够自由修改类),增加业务压力。
相信实践完premain的应该对这段话会有新的理解。agentmain方式为了能够具体插入HOOK点,有可能是要对已经在运行的类进行方法的增添和删除的。如果此前机器并没有被Runtimeexec执行过,恶意类并没有被JVM加载,此时agentmain的Hook仍然有效,也就是新加载类之后触发transform如果之前已经有正常业务或者说已经被打过RuntimeRCE了,那此时的Processorbuilder已经被JVM加载过了,agentmain再想去Hook就只能去修改已经加载过的类内容,这个时候就会遇到JVM的线程保护机制,导致修改失败(正常的在方法块内修改还是没问题的)以及报错。
premain其实也是如此,但是premain增添或者说直接修改恶意类的时机肯定是在被恶意执行前(和程序一条命令同时启动的),还没有被JVM加载,所以能够直接修改。
如何解决agentmain绕过JVM线程保护的问题,这里先按下不表,问题一个一个解决,我们先看看正常思路下agentmain该如何加载进对应的JVM
首先是正常的agentmain方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package cn.org.javaweb.agent;
import java.lang.instrument.Instrumentation; import java.lang.instrument.UnmodifiableClassException;
public class Agent { public static void agentmain(final String agentArgs, final Instrumentation inst)throws UnmodifiableClassException { AgentTransform agentTransform= new AgentTransform(inst); agentTransform.retransform();
} }
|
以及具体的Transformer,这里我自定义的是AgentTransform
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 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62
| package cn.org.javaweb.agent;
import cn.org.javaweb.Visitor.ProcessorBuilderVisitor; import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter;
import java.lang.instrument.ClassFileTransformer; import java.lang.instrument.IllegalClassFormatException; import java.lang.instrument.Instrumentation; import java.lang.instrument.UnmodifiableClassException; import java.security.ProtectionDomain; import java.util.LinkedList;
public class AgentTransform implements ClassFileTransformer { private Instrumentation inst;
public AgentTransform(Instrumentation inst) { this.inst = inst; inst.addTransformer(this, true); }
@Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { className=className.replace('/', '.'); try { if (className.contains("ProcessBuilder")) {
System.out.println("load Class"+className); ClassReader classReader=new ClassReader(classfileBuffer); ClassWriter classWriter=new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS); ClassVisitor ProcessorBuilderVisitor= new ProcessorBuilderVisitor(classWriter);
classReader.accept(ProcessorBuilderVisitor, ClassReader.EXPAND_FRAMES); classfileBuffer=classWriter.toByteArray(); } }catch (Exception e) { e.printStackTrace(); } return classfileBuffer; }
public void retransform() throws UnmodifiableClassException { LinkedList<Class> retransformClasses = new LinkedList<Class>(); Class[] loadedClasses = inst.getAllLoadedClasses(); for (Class clazz : loadedClasses) { if ("java.lang.ProcessBuilder".equals(clazz.getName())&& inst.isModifiableClass(clazz)) { inst.retransformClasses(clazz); } } } }
|
我前文提到过,ClassFileTransformer类的transform方法触发有三种情况,这里为了帮助回忆,再列一遍:
- 新的 class 被加载。
- Instrumentation.redefineClasses 显式调用。
- addTransformer 第二个参数为 true 时,Instrumentation.retransformClasses 显式调用。
第三种对应上了我在自定义Transformer中写的retransform()方法,首先先将已加载类全部遍历一遍之后,找出ProcessBuilder,并且判断它是否可以被修改,如果可以的话就调用nst.retransformClasses(clazz);对其进行修改,进行调用到transform的逻辑。之后就是相同的给start方法块字节码加上Hook逻辑。
agentRasp部分写完之后还要写一个Attach的API用来将agent注入对应的JVM中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| import com.sun.tools.attach.VirtualMachine; import com.sun.tools.attach.VirtualMachineDescriptor; import java.util.List;
public class AgentMainStart { public static void main(String[] args) throws Exception { List<VirtualMachineDescriptor> list = VirtualMachine.list();
for (VirtualMachineDescriptor descriptor : list) {
if (descriptor.displayName().endsWith("TestSpringbootApplication")) { VirtualMachine virtualMachine = VirtualMachine.attach(descriptor.id()); virtualMachine.loadAgent("H:\\ASecuritySearch\\RaspLearn\\javawebAgent\\agent\\target\\agent-jar-with-dependencies.jar", "Attach!"); System.out.println("ok"); virtualMachine.detach(); } } } }
|
具体实施的话可以参考我idea的application的设置。
运行完这个springboot的application,就可以执行Attach API进行agent的注入了。但是为什么启动选项里面还是设置了Xbootclasspath呢?先别急,这里我们设置之后才能正常的体验到Agentmain的拦截的功能,主要还是Hook的功能API报错java.lang.NoClassDefFoundError的问题,也是我下文要提到的。
2x01 agentmain中NoClassDefFoundError的解决
首先我们要明确两个类加载器,一个是ProcessBuilder是由哪个类加载器加载的,以及我们agent中自定义的ProcessBuilderHook是由谁加载的。
java.lang.ProcessBuilder
,位于java.lang
包下,而javalang包属于java的核心类库,也就是说不论哪种应用,只要涉及到它的加载,一定是由Bootstrap ClassLoader去加载的。
而ProcessBuilderHook所属我们自定义的agent,JVM在接收到agent之后,通常会将agent中的类经由ApplicationClassLoader加载。
此时当JVM解析ProcessBuilder的符号引用时,也就是进行到start方法时,第一行代码是ProcessBuilderHook.Hook(),他会直接找当前主类,也就是ProcessBuilder的类加载器–Bootstrap ClassLoader去加载ProcessBuilderHook。但是至始至终ProcessBuilderHook都只有applicationClassLoader加载过,Bootstrap ClassLoader并不存在直接访问applicationClassLoader的途径,所以此时并不会找到ProcessBuilderHook类的定义,也就会报错NoClassDefFoundError
清楚了原因,如何去解决这个问题呢?我选择的方式是直接在agentmain中调用Instrumentation.appendToBootstrapClassLoaderSearch(JarFile)
,将我们自定义agent强行塞入Bootstrap ClassLoader的类搜索路径。
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| package cn.org.javaweb.agent;
import java.lang.instrument.Instrumentation; import java.util.jar.JarFile;
public class Agent { public static void agentmain(final String agentArgs, final Instrumentation inst)throws Exception { AgentTransform agentTransform= new AgentTransform(inst); JarFile jarFile = new JarFile("/Youragent.jar"); inst.appendToBootstrapClassLoaderSearch(jarFile); agentTransform.retransform();
} }
|
实际上这么做相当于就是premain中的启动命令Xbootclasspath参数。
0x02 小结
大部分都是一些基础的内容,用来了解原理以及实践操作。后续会更新一些其他Rasp产品的分析和学习。