最近面试有点多,停更了两周,接着上一篇继续写java RASP的实现过程。
2 RASP-demo
通过前面的例子已经可以看到,通过Instrumentation对象添加Transformer,在transform方法中可以做到对加载的类进行动态修改,如果transform方法可以获取到所有系统类的加载,岂不是就可以有针对性的对有风险的类进行修改,在其危险方法执行前后获取参数加以判断,从而实现RASP。但事情不是那么美好的
以前面javaagent使用流程举例,对主要方法进一些修改,看看效果:
package com.bitterz;
import java.io.IOException;
import java.lang.Runtime;
import java.lang.String;
public class Main {
public static class A{
public void t(){System.out.println("Main$A.t()");}
}
public static void main(String[] args) throws InterruptedException, IOException {
System.out.println("-------Main.main() start-------");
Runtime.getRuntime().exec("calc");
String a = "a";
System.out.println(a);
A a1 = new A();
a1.t();
System.out.println("-------Main.main() end-------");
}
}
package com.bitterz;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
public class PreMain {
public static void premain(String agentArgs, Instrumentation inst) throws IOException {
System.out.println("++++++++Premain start++++++++");
System.out.println(ClassLoader.getSystemClassLoader().toString()); // 查看当前代理类是被哪个类加载器加载的
inst.addTransformer(new DefineTransformer(), true);
System.out.println("++++++++Premain end++++++++");
}
public static class DefineTransformer implements ClassFileTransformer {
@Override // 添加override会让transform只接收到appClassLoader加载的类,去掉就可以接收重要的系统类
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println(className.toString() + " " + loader.toString()); // 类名 和 类加载器
System.out.println("");
return classfileBuffer;
}
}
}
输出结果如下
这里注意两个细节
- premain和main都是被同一个类加载器锁加载的
- main方法要new一个A类对象时,会先进入transform函数,且类加载器和前面一样,然后再执行A类中的方法
除了这两个细节外,我们应该注意到,像String、Runtime这些类,也被调用了,transform函数却获取不到!这里就跟类加载机制有关了!
2.1 类加载机制
双亲委派
首先要理解一下类加载的双亲委派机制,每一个类加载器创建时都要指定一个父加载器,当类加载器A要加载B类时,会先由其父加载器对B类进行加载,如果父加载器无法加载,则由它自己进行加载。看一下ClassLoader的源代码就很好理解了
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name); // 查看是不是本身已经加载过的类
if (c == null) {
long t0 = System.nanoTime();
try { // 父类先尝试加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) { // 父类加载失败,自己加载
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
结合源代码也就很好理解。特别需要指出的是Class<?> c = findLoadedClass(name);
这一行可以看出,每个ClassLoader都记录着一个自己加载过的类。
BootStrap ClassLoader
jvm中所有的类都是由类加载器加载的,那类加载器又是谁加载的呢?首先,jvm启动之后,会执行一段机器码,也就是C写好的程序,这段程序被成为Bootstrap ClassLoader,注意它不是可以访问的java对象。由它去加载系统类,以及扩展类加载器(extClassLoader)和应用程序类加载器(AppClassLoader)。
- 其中extClassLoader负责加载
<JAVA_HOME>/lib/ext
目录下或者由系统变量-Djava.ext.dir指定位路径中的类库 - AppClassLoader负责加载系统类路径
java -classpath
或-D java.class.path
指定路径下的类库,也就是我们经常用到的classpath路径
他们的关系如下:
-
启动类加载器,由C++实现,没有父类。
-
拓展类加载器(ExtClassLoader),由Java语言实现,父类加载器为null
-
系统类加载器(AppClassLoader),由Java语言实现,父类加载器为ExtClassLoader
另外需要指出的是,一个Java类,由它的全限定名称和加载它的classloader两者确定。
说了这么多,回到2.1章节最开始的问题,agentmain和main都是同一个appClassLoader加载的,并且我们写好的各种类都是AppClassLoader加载的,那BootstrapClassLoader和extClassLoader加载的类调用我们写好的代理方法,这些类加载器向上委派寻找类时,扩展类加载器和引导类加载器都没有加过,直接违背双亲委派原则!举个例子,因为我们可以在transform函数里面获取到类字节码,并加以修改,如果我们在系统类方法前面插了代理方法,由于这些系统类是被Bootstrap ClassLoader加载的,当BootstrapClassLoader检查这些代理方法是否被加载时,直接就报错了,因为代理类是appClassLoader加载的(见2.1 类加载机制
这个章节上面的图),要解决这个问题,我们就应该想办法把代理类通过BootstrapClassLoader进行加载,从百度的OpenRASP可以学到解决方案:
// localJarPath为代理jar包的绝对路径
inst.appendToBootstrapClassLoaderSearch(new JarFile(localJarPath))
通过appendToBootstrapClassLoaderSearch
方法,可以把一个jar包放到Bootstrap ClassLoader的搜索路径,也就是说,当Bootstrap ClassLoader检查自身加载过的类,发现没有找到目标类时,会在指定的jar文件中搜索,从而避免前面提到的违背双亲委派问题。参考官方文档和翻译文档
2.2 Instrumentation介绍
为了方便用户对JVM进行操作,JDK1.5之后引入了这个Instrumentation特性,通过Instrumentation的实例对象,可以对jvm进行一定的操作,例如修改字节码、插桩等等。它的实现原理是JVMTI(JVM Tool Interface),也就是JVM向用户提供的操作jvm的接口。JVMTI是事件驱动的,当发生一定的处理逻辑时,才会调用回调接口,而这些接口可以让用户扩展一些逻辑。例如前面的transform函数调用,就是JVMTI监听到类加载,就会基于这个事件,回调instrumentation中的所有ClassTransformer.transform函数,进行类转换(Class transform)。所以我们可以理解为获得instrumentation对象,就可以实现对一个jvm的一定操作,获取的这个对象的方法就是前文提到的javaagent和attach方法。
Instrumentation类中常用方法
void addTransformer(ClassFileTransformer transformer, boolean canRetransform)//注册ClassFileTransformer实例,注册多个会按照注册顺序进行调用。所有的类被加载完毕之后会调用ClassFileTransformer实例,相当于它们通过了redefineClasses方法进行重定义。布尔值参数canRetransform决定这里被重定义的类是否能够通过retransformClasses方法进行回滚。
void addTransformer(ClassFileTransformer transformer)//相当于addTransformer(transformer, false),也就是通过ClassFileTransformer实例重定义的类不能进行回滚。
boolean removeTransformer(ClassFileTransformer transformer)//移除(反注册)ClassFileTransformer实例。
void retransformClasses(Class<?>... classes)//已加载类进行重新转换的方法,重新转换的类会被回调到ClassFileTransformer的列表中进行处理。
void appendToBootstrapClassLoaderSearch(JarFile jarfile)//指定 JAR 文件,放到Bootstrap ClassLoader搜索路径
void appendToSystemClassLoaderSearch(JarFile jarfile)//将某个jar加入到Classpath里供AppClassloard去加载。
Class[] getAllLoadedClasses()//返回 JVM 当前加载的所有类的数组
Class[] getInitiatedClasses(ClassLoader loader)//获取所有已经被初始化过了的类。
boolean isModifiableClass(Class<?> theClass)//确定一个类是否可以被 retransformation 或 redefinition 修改
void redefineClasses(ClassDefinition... definitions)//重定义类,也就是对已经加载的类进行重定义,ClassDefinition类型的入参包括了对应的类型Class<?>对象和字节码文件对应的字节数组。
Instrumentation触发流程
摘自https://www.cnblogs.com/rickiyang/p/11368932.html
JVMTIAgent
是一个利用JVMTI
暴露出来的接口提供了代理启动时加载(agent on load)、代理通过attach形式加载(agent on attach)和代理卸载(agent on unload)功能的动态库。instrument agent
可以理解为一类JVMTIAgent
动态库,别名是JPLISAgent(Java Programming Language Instrumentation Services Agent)
,也就是专门为java语言编写的插桩服务提供支持的代理。
启动时加载instrument agent过程:
- 创建并初始化 JPLISAgent;
- 监听
VMInit
事件,在 JVM 初始化完成之后做下面的事情:- 创建 InstrumentationImpl 对象 ;
- 监听 ClassFileLoadHook 事件 ;
- 调用 InstrumentationImpl 的
loadClassAndCallPremain
方法,在这个方法里会去调用 javaagent 中 MANIFEST.MF 里指定的Premain-Class 类的 premain 方法 ;
- 解析 javaagent 中 MANIFEST.MF 文件的参数,并根据这些参数来设置 JPLISAgent 里的一些内容。
运行时加载instrument agent过程:
通过 JVM 的attach机制来请求目标 JVM 加载对应的agent,过程大致如下:
- 创建并初始化JPLISAgent;
- 解析 javaagent 里 MANIFEST.MF 里的参数;
- 创建 InstrumentationImpl 对象;
- 监听 ClassFileLoadHook 事件;
- 调用 InstrumentationImpl 的
loadClassAndCallAgentmain
方法,在这个方法里会去调用javaagent里 MANIFEST.MF 里指定的Agent-Class
类的agentmain
方法。
Instrumentation的局限性
摘自https://www.cnblogs.com/rickiyang/p/11368932.html
- premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。
- 类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
- 新类和老类的父类必须相同;
- 新类和老类实现的接口数也要相同,并且是相同的接口;
- 新类和老类访问符必须一致。 新类和老类字段数和字段名要一致;
- 新类和老类新增或删除的方法必须是private static/final修饰的;
- 可以修改方法体。
除了上面的方式,如果想要重新定义一个类,可以考虑基于类加载器隔离的方式:创建一个新的自定义类加载器去通过新的字节码去定义一个全新的类,不过也存在只能通过反射调用该全新类的局限性。
另外由于JVM维护运行线程和逻辑的安全,禁止对运行时的类新加方法并重新定义该方法,只允许修改方法体中的逻辑,如果多次attach时,显然就会出现重复插桩,造成重复警告等,影响业务线程。
2.3 javassist
前面提到修改字节码以实现在系统类或其它重要类执行前后插入代码,运行时防御恶意攻击,基于javassist容易上手的特点,所以直接用javassist修改字节码。javassist有几个重要的类及其方法:
ClassPool:
- getDefault:返回一个ClassPool对象,ClassPool是单例模式,保存了当前运行环境中创建过的CtClass
- get/getCtClass:根据类名获取该类的CtClass对象,而后进一步操作CtClass对象
CtClass:对class文件的解析,将其描述为一个java对象
-
detach,将一个class从ClassPool中删除,减小内存消耗
-
getDeclaredMethod(String arg), 获取一个CtMethod对象
-
getDeclaredMethods,获取所有的这个类中所有的方法,并返回CtMethod数组
-
getConstructor(String arg),获取一个指定的构造方法,返回CtConstructor对象
-
getConstructors,获取所有的构造方法,返回CtConstructor数组
-
toBytecode,将ctClass对象的字节码转换成字节数组,这个方法在rasp中非常需要
-
writeFile,将ctClass对象的修改保存到文件中。
CtMethod:对类中的方法的描述,可以通过这个对象,在一个方法前后添加代码
- insertBefore,很明显,在给定的方法前插入一段代码
- insertAfter,也很明显,在给定方法的所有return前,插入一段代码,报错会掠过这些代码
- insertAt,在指定位置插入代码,一般不这么操作,改变系统类中方法的逻辑,可能会引发更多的问题
- setBody,将方法的内容设置为指定的代码,如果是abstrict修饰的方法,该修饰符将被移除。指定的代码用$1,$2代表实际传入的参数
ctMethod.setBody("{$1='a';if ($2==1) return 0;.....}")
问题来了:如何修改jvm中的字节码
根据前面提到的这些方法,我们可以思考这样一个问题,就算用了javassist提供的各种方法,给字节码中添加了各类方法,但也只能保存到class文件中,而这个类jvm在运行时,我们修改class文件并不会影响jvm中的字节码,想要执行插入的代码,只能重新运行class文件,能不能动态修改jvm中的字节码,改变程序运行结果呢??
方法1,通过ClassLoader#defineClass方法覆盖jvm中的字节码
package com.bitterz.assist;
import javassist.*;
import java.io.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.security.ProtectionDomain;
public class Test {
private final String name="laowang";
@Override
public boolean equals(Object o){
System.out.println(this.name);
return false;
}
public static void main(String[] args) throws NotFoundException, CannotCompileException, IOException, ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
// 先测试一次equals方法的输出
Test test1 = new Test();
test1.equals("a");
System.out.println("+++++++++++++++++分割+++++++++++++++++");
// 获取当前class文件位置
Process chdir = Runtime.getRuntime().exec("cmd /c chdir");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(chdir.getInputStream(), "gbk"));
String baseDir = bufferedReader.readLine();
String classDir = baseDir + "/target/classes".replace("/", "\");
// 添加class路径并找到Test类的字节码
ClassPool classPool = new ClassPool(true);
classPool.appendClassPath(classDir);
CtClass ctClass = classPool.get("com.bitterz.assist.Test");
CtMethod[] methods = ctClass.getMethods();
String src = "System.out.println("+ "" insert by javassist "" +");";
// 找到对应的method,并在前后插入代码
for (CtMethod method : methods) {
if (method.getName().contains("equals")){
method.insertBefore(src);
}
}
// 获取字节码
byte[] bytes = ctClass.toBytecode();
// 反射调用defineClass方法,将字节码覆盖到jvm中
ClassLoader classLoader = new ClassLoader() {
};
String name = Test.class.getName();
Method defineClass = Class.forName(ClassLoader.class.getName()).getDeclaredMethod("defineClass", String.class, byte[].class, int.class, int.class, ProtectionDomain.class);
defineClass.setAccessible(true);
Class<?> test = (Class<?>) defineClass.invoke(classLoader, name, bytes, 0, bytes.length, null);
// 这里取巧用了equals方法,是因为所有类都继承自Object类,因此能过够编译通过,前面重写了equals方法,所以会调用到重写的equals方法中
test.newInstance().equals("a");
}
}
输出结果如下:
可见,通过javassist修改字节码后,利用ClassLoader#defineClass方法可以覆盖jvm中的字节码,实现对jvm中已加载类的动态修改。
- 不过这种方式受限于类加载机制和jvm对重复类定义的检查,每个类最好用新的类加载器去加载,否则在defineClass时会出现报错
- 并且,受限于jvm的安全机制,无法修改系统类,例如java.lang下的类
方法2,通过ClassFileTransformer#transform方法修改字节码
在前一篇笔记中,记录了通过javaagent机制可以对jvm加载过的类进行类转换(Class Transform),也就是在ClassFileTransformer#transform中返回新的字节码,会覆盖原有的字节码,下面来试试对java.lang.ProcessBuilder的字节码进行修改,实现hook
首先时需要执行的main方法
// 项目名为test,跟后面的agent不在一个项目
package com.bitterz;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.lang.String;
public class Main {
public static void main(String[] args) throws InterruptedException, IOException {
System.out.println("main start!");
ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.command("cmd", "/c", "chdir");
Process process = processBuilder.start();
InputStream inputStream = process.getInputStream();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream, "gbk"));
System.out.println(bufferedReader.readLine());
}
}
test项目对应的pom.xml文件,可以直接打包成jar,并且指定了启动类
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.bitterz</groupId>
<artifactId>test</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Main-Class>com.bitterz.Main</Main-Class>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>
接下来是agent项目
// premain,这个类会在前面那个项目的main方法启动前执行
package com.bitterz;
import com.bitterz.hook.ProcessBuilderHook;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.lang.instrument.Instrumentation;
import java.lang.instrument.UnmodifiableClassException;
public class PreMain {
public static void premain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException {
// 先测试一次使用ProcessBuilder获取当前路径
System.out.println("
");
ProcessBuilder processBuilder = new ProcessBuilder();
processBuilder.command("cmd", "/c", "chdir");
Process process = processBuilder.start();
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(process.getInputStream(), "gbk"));
System.out.println(bufferedReader.readLine());
// 添加ClassFileTransformer类
ProcessBuilderHook processBuilderHook = new ProcessBuilderHook(inst);
inst.addTransformer(processBuilderHook, true);
// 获取所有jvm中加载过的类
Class[] allLoadedClasses = inst.getAllLoadedClasses();
for (Class aClass : allLoadedClasses) {
if (inst.isModifiableClass(aClass) && !aClass.getName().startsWith("java.lang.invoke.LambdaForm")){
// 调用instrumentation中所有的ClassFileTransformer#transform方法,实现类字节码修改
inst.retransformClasses(new Class[]{aClass});
}
}
System.out.println("++++++++++++++++++hook finished++++++++++++++++++
");
}
}
// 在transform中执行类转换的逻辑,插入过滤代码
package com.bitterz.hook;
import java.io.IOException;
import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.Instrumentation;
import java.security.ProtectionDomain;
import javassist.*;
public class ProcessBuilderHook implements ClassFileTransformer {
private Instrumentation inst;
private ClassPool classPool;
public ProcessBuilderHook(Instrumentation inst){
this.inst = inst;
this.classPool = new ClassPool(true);
}
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
if (className.equals("java/lang/ProcessBuilder")){
CtClass ctClass = null;
try {
// 找到ProcessBuilder对应的字节码
ctClass = this.classPool.get("java.lang.ProcessBuilder");
// 获取所有method
CtMethod[] methods = ctClass.getMethods();
// $0代表this,这里this = 用户创建的ProcessBuilder实例对象
String src = "if ($0.command.get(0).equals("cmd"))" +
"{System.out.println("危险!");" +
"System.out.println();"+
"return null;}";
for (CtMethod method : methods) {
// 找到start方法,并插入拦截代码
if (method.getName().equals("start")){
method.insertBefore(src);
break;
}
}
classfileBuffer = ctClass.toBytecode();
}
catch (NotFoundException | CannotCompileException | IOException e) {
e.printStackTrace();
}
finally {
if (ctClass != null){
ctClass.detach();
}
}
}
return classfileBuffer;
}
}
然后是agent项目对应的pom.xml文件,因为要用javassist包,所以把依赖也打包进去了
premain对应的pom.xml,可以直接用maven打包成jar,并且自动填写了MANIFEST.MF中需要的字段
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.bitterz</groupId>
<artifactId>java-agent</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.12.0.GA</version>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
</plugin>
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Premain-Class>com.bitterz.PreMain</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
做好前面的准备工作之后,执行一下,看看效果
- 很明显,agent项目中使用ProcessBuilder执行系统命令时,因为还没有插入检测逻辑,所以没有被拦截,成果返回当前路径
- hook完成后,main方法启动,执行start后,触发插入的检测代码,并返回一个null
- Main.java的15行报错,出现了null值,看看15行是啥
InputStream inputStream = process.getInputStream();
,很明显了,hook成功,返回一个null,所以15行这里会报错
2.4 总结
到此java RASP的雏形就出来了,通过javaagent机制,在项目启动前,添加类转换方法,利用javassist、ASM这些修改字节码的工具对关键系统类,在类转换方法中修改字节码,并返回给jvm,这样可以绕过defineClass对系统类的保护,完成对系统类和基础类的字节码修改,实现对这些类的hook。
针对不同类,还需要进一步实现不同的方法的hook以及检测逻辑,考虑到通用性,或许可以用json、Yaml这类规则,以便于在php、python、go等语言中能够通用规则。或者使用OpenRASP的思路,java负责hook方法,把参数交给js来实现检测逻辑,同时也能兼顾php等语言的规则通用。或许,使用机器学习或深度学习模型,也能对参数进行检测,这就涉及到很广阔的思路了。
另外就是各种心跳、云控的设计了,不是RASP的重点,所以没有进一步实现,java RASP的研究就到这里了(好像有点浅尝则止呢?不过结合OpenRASP的源码,很容易就能理解java RASP的实现思路了)
参考
https://www.cnblogs.com/rickiyang/p/11368932.html
https://www.cnblogs.com/kendoziyu/p/maven-auto-build-javaagent-jar.html