前言

这篇用于记录RASP实现的具体入门的实现code以及原理的分析学习,性质上更属于笔记。参考的文章在文末。

0x00 环境配置

RASP本质上是一份Agent,当前用于测试和学习的环境是由一份RASP-Agent以及一份测试环境决定的。这里的测试环境可以自己写一个漏洞环境,也可以是直接采用网上的java漏洞靶场项目,只要有漏洞即可,主要是用来模拟生产环境。

1x01 RASP Agent初步结构

首先是Agent的部分

对于一个Agent有两种方式来处理字节码,一种是在主程序启动之前,也就是main方法执行前,我们能够通过编写premain方法,指定相关的ClassFileTransformer实现main函数执行前(整个程序执行前)修改字节码。还有一种是通过编写agentmain方法,依旧是利用ClassFileTransformer的transform实现已运行JVM中的字节码修改。

先采用premain的方式。

在一个大的项目中创建名为agent的maven项目,然后按照如下结构写入项目:

image

先写一份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,方便后续调试。

image

1x02 漏洞环境案例

然后我们可以随意找一个漏洞靶场,或者像我一样自建一个springboot的项目,之后自己编写漏洞环境方便调试。

我这里的做法是首先先定义一个maven的application,也是将整个springboot项目重新编译和打包。然后再自定义一个大的application作为启动springboot项目并且添加vm参数。

image

这里没有vmoptions的选项的话,在Modify options中可以找到。之后是springboot的打包

image

在大的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整个项目启动之后的要加载到的类

image

但是这么做就存在一个分歧,由于我们采用的agent形式是premain,所以只能在项目启动的时候指定javaagent参数进行启动。也就是说如果此前项目并没有采用Rasp,就需要重启。这是不太符合实际的。另一种就是agentmain,直接通过遍历jvm之后attach到对应的进程中,这么做也有一个问题,就是如果HOOK点更新,那么就需要重新Attach,但是原本的我们修改过的字节码又会被修改一次,造成重复告警(JVM进程保护要求不能够自由修改类),增加业务压力。

下面的例子中还是首先通过premain的方式进行学习。

0x01 RASP机制探究

1x01 premain

用一个premain的例子来感受一下RASP的运行机制。

在上一节的最后我提到了transform方法的触发时机—当一个新类被加载时。实际上transform方法还有两种情况下会被触发,总结为:

  1. 新的 class 被加载。(已经加载过的就不会触发transform了,这一点在agentmain中有体现)
  2. Instrumentation.redefineClasses 显式调用。
  3. 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);
//此时新的ProcessBuilder被加载,ASM使用的规则是首要声明ClassReader,来获取类定义内容
ClassReader classReader=new ClassReader(classfileBuffer);
//如果需要对类中的内容进行修改,就需要声明ClassWriter它是继承于ClassReader的
ClassWriter classWriter=new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
//具体的访问操作需要通过自建visitor去实现,visitor的构建又必须传入classWriter,以访问者的身份进入类结构进行操作
//具体对类操作中,移步到ProcessorBuilderVisitor的visitMethod方法
ClassVisitor ProcessorBuilderVisitor= new ProcessorBuilderVisitor(classWriter);

//将transform处理后的当前加载类的classBuffer返回。classfileBuffer整个过程中可以通过ASM的classWriter.toByteArray();进行转化得到。
//ClassWriter实际上会接收到ClassVisitor操作后的字节码,所以一般思路是最终都通过ClassWriter.toByteArray转化获得
classReader.accept(ProcessorBuilderVisitor, ClassReader.EXPAND_FRAMES);
classfileBuffer=classWriter.toByteArray();
}
}catch (Exception e) {
e.printStackTrace();
}

//System.out.println("load Class:"+className);
//将transform处理后的classBuffer返回
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) {
//一般ASM中对类方法进行修改都是利用MethodVisitor及其接口,也可以采用AdviceAdapter实现
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);

//拦截到了ProcessBuilder的start方法执行,继续往下对start方法的操作字节码进行修改
if("start".equals(name) &&"()Ljava/lang/Process;".equals(desc)) {
System.out.println(name +" 方法描述符为 :"+desc);
//返回新的MethodVisitor也是可以的,两者是字符类关系,只是MethodVisitor的实例化方法只有前面两个参数需要传入
return new AdviceAdapter(Opcodes.ASM5,mv, access, name, desc) {
@Override
public void visitCode() {
System.out.println("visitCode");
//加载到的当前方法的局部变量表的索引为0的变量,这里索引为0的及代表当前ProcessBuilder实例本身
mv.visitVarInsn(ALOAD, 0);
//开始获取形参变量,名为command,类型为List。这里ProcessBuilder中存储的command字段就是exec方法传入的命令
mv.visitFieldInsn(GETFIELD, "java/lang/ProcessBuilder", "command", "Ljava/util/List;");
//在当前方法出生成调用方法的指令,也就是插入ProcessBuilderHook.start(),参数由于我们刚才已经调用visitFieldInsn,command参数会直接被送入Hook方法
mv.visitMethodInsn(INVOKESTATIC, ProcessBuilderHook.class.getName().replace(".", "/"), "Hook", "(Ljava/util/List;)V", false);

//整体的效果其实就是相当于给当前加载的ProcessBuilder类的start方法加上ProcessBuilderHook.Hook(this.command)这段代码
}
};

}
return mv;
}

}

关键部分就是对于方法名是否为start的判断开始了,大部分内容都是用于return一个新定义的MethodVisitor(AdviceAdapter),用来具体对字节码进行操作。注意visitVarInsnvisitFieldInsnvisitMethodInsn​等一系列操作实际上是对于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执行一遍

image

传参过去之后执行,控制台打印如下信息,验证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);
//如果是agentmain模式就开启,否则注释
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);
//此时新的ProcessBuilder被加载,ASM使用的规则是首要声明ClassReader,来获取类定义内容
ClassReader classReader=new ClassReader(classfileBuffer);
//如果需要对类中的内容进行修改,就需要声明ClassWriter它是继承于ClassReader的
ClassWriter classWriter=new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
//具体的访问操作需要通过自建visitor去实现,visitor的构建又必须传入classWriter,以访问者的身份进入类结构进行操作
//具体对类操作中,移步到ProcessorBuilderVisitor的visitMethod方法
ClassVisitor ProcessorBuilderVisitor= new ProcessorBuilderVisitor(classWriter);

//将transform处理后的当前加载类的classBuffer返回。classfileBuffer整个过程中可以通过ASM的classWriter.toByteArray();进行转化得到。
//ClassWriter实际上会接收到ClassVisitor操作后的字节码,所以一般思路是最终都通过ClassWriter.toByteArray转化获得
classReader.accept(ProcessorBuilderVisitor, ClassReader.EXPAND_FRAMES);
classfileBuffer=classWriter.toByteArray();
}
}catch (Exception e) {
e.printStackTrace();
}
return classfileBuffer;
}


//采用Agentmain模式时开启
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)) {
//System.out.println("retransforming......");
inst.retransformClasses(clazz);
}
}
}
}

我前文提到过,ClassFileTransformer类的transform方法触发有三种情况,这里为了帮助回忆,再列一遍:

  1. 新的 class 被加载。
  2. Instrumentation.redefineClasses 显式调用。
  3. 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) {

// 根据进程名字获取进程ID, 并使用 loadAgent 注入进程
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的设置。

image

运行完这个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产品的分析和学习。