在之前的文章中有了解过:https://blog.csdn.net/weixin_54902210/article/details/129614431
Javassist (JAVA programming ASSISTant) 是在 Java 中编辑字节码的类库;它使 Java 程序能够在运行时定义一个新类, 并在 JVM 加载时修改类文件。原理与反射类似,但开销相对较低。
getDefault : 返回默认的 ClassPool 是单例模式的,一般通过该方法创建我们的 ClassPool;
appendClassPath, insertClassPath : 将一个 ClassPath 加到类搜索路径的末尾位置 或 插 入到起始位置。通常通过该方法写入额外的类搜索路径,以解决多个类加载器环境中 找不到类的尴尬;
get , getCtClass : 根据类路径名获取该类的 CtClass 对象,用于后续的编辑。
makeClass:创建一个新的类。
freeze : 冻结一个类,使其不可修改;
isFrozen : 判断一个类是否已被冻结;
defrost : 解冻一个类,使其可以被修改。如果事先知道一个类会被 defrost, 则禁止 调用 prune 方法;
prune : 删除类不必要的属性,以减少内存占用。调用该方法后,许多方法无法将无 法正常使用,慎用;
detach : 将该 class 从 ClassPool 中删除;
writeFile : 根据 CtClass 生成 .class 文件;
toClass : 通过类加载器加载该 CtClass。
insertBefore : 在方法的起始位置插入代码;
insterAfter : 在方法的所有 return 语句前插入代码以确保语句能够被执行,除非遇到 exception;
insertAt : 在指定的位置插入代码;
setBody : 将方法的内容设置为要写入的代码,当方法被 abstract 修饰时,该修饰符被 移除;
make : 创建一个新的方法。
public class Demo1 { public static void main(String[] args) throws NotFoundException, IOException, CannotCompileException { ClassPool cp = ClassPool.getDefault(); CtClass ctClass = cp.makeClass("Javassist.Hello"); ctClass.writeFile(); } }
public static void main(String[] args) throws NotFoundException, IOException, CannotCompileException { //1、创建Hello类 ClassPool cp = ClassPool.getDefault(); CtClass ctClass = cp.makeClass("Javassist.Hello"); //2、添加属性 CtField name = new CtField(cp.get("java.lang.String"), "name", ctClass); name.setModifiers(Modifier.PUBLIC); ctClass.addField(name,CtField.Initializer.constant("Sentiment")); ctClass.writeFile(); }
属性赋值时也可用:
ctClass.addField(name,"name=\"Sentiment\"");
但这种赋值偏向于用构造器等进行初始化
可以设置的返回类型:
public static CtClass booleanType; public static CtClass charType; public static CtClass byteType; public static CtClass shortType; public static CtClass intType; public static CtClass longType; public static CtClass floatType; public static CtClass doubleType; public static CtClass voidType;
这里可以发现不支持String类型,在Java字节码中,String类型在方法的参数列表和返回值类型中,通常不是直接使用字符串,而是使用字符串在常量池中的索引值。如果想设置String类型的话可以用:cp.getCtClass("java.lang.String")
CtMethod ctMethod = new CtMethod(CtClass.voidType, "Hello1", new CtClass[]{CtClass.intType, CtClass.charType}, ctClass); ctMethod.setModifiers(Modifier.PUBLIC); ctClass.addMethod(ctMethod); ctClass.writeFile();
ctMethod.setBody("System.out.println(\"This is test !\");");
这里有参构造的形参是var1,如果要输出var1,就要用到特殊变量$1、$2(具体使用后边再说)
CtMethod ctMethod = new CtMethod(CtClass.voidType, "Hello1", new CtClass[]{CtClass.intType, CtClass.charType}, ctClass); ctMethod.setModifiers(Modifier.PUBLIC); ctMethod.setBody("System.out.println(\"This is test !\");"); ctClass.addMethod(ctMethod); ctMethod.insertBefore("System.out.println(\"我在前面插入:\"+$1);"); ctMethod.insertAfter("System.out.println(\"我在后面插入了:\"+$2);"); ctClass.writeFile();
直接添加的有参构造,无参构造去掉中间的参数即可
CtConstructor cons = new CtConstructor(new CtClass[]{cp.getCtClass("java.lang.String")}, ctClass); cons.setBody("{name=\"Sentiment\";}"); ctClass.addConstructor(cons);
设置name=var1
可以通过ClassPool的get方法获取已有类,并进行修改
package Javassist; import javassist.*; import java.io.IOException; public class Demo02 { public static void main(String[] args) throws NotFoundException, CannotCompileException, IOException { ClassPool cp = ClassPool.getDefault(); CtClass ctClass = cp.get("Javassist.Test"); CtConstructor test = ctClass.getConstructors()[0]; test.setBody("{System.out.println(\"Changing......\");}"); ctClass.writeFile(); } } class Test { public static String name = "Sentiment"; public Test() { System.out.println("This is test !"); } }
通过自带的toBytecode()转换下即可
ctClass.toBytecode(); ctClass.toClass().newInstance();
标识符 | 作用 |
---|---|
$0、$1、$2、 3 、 3、 3、… | this和方法参数(1-N是方法参数的顺序) |
$args | 方法参数数组,类型为Object[] |
$$ | 所有方法参数,例如:m($$)相当于m($1,$2,…) |
$cflow(…) | control flow 变量 |
$r | 返回结果的类型,在强制转换表达式中使用。 |
$w | 包装器类型,在强制转换表达式中使用。 |
$_ | 方法的返回值 |
$sig | 类型为java.lang.Class的参数类型对象数组 |
$type | 类型为java.lang.Class的返回值类型 |
$class | 类型为java.lang.Class的正在修改的类 |
$0代表this,$1、$2代表方法的形参,通过上边例子也不难看出。这里需要注意:静态方法是没有$0的
$args变量表示所有参数的数组,它是一个Object类型的数组(new Object[]{…}),如果参数中有原始类型的参数,会被转换成对应的包装类型。
$$是方法所有参数的简写
public static void main(String[] args) throws NotFoundException, CannotCompileException, IOException { ClassPool cp = ClassPool.getDefault(); CtClass ctClass = cp.makeClass("Javassist.SpecialVariables"); CtMethod ctMethod = new CtMethod(CtClass.voidType, "Test1", new CtClass[]{CtClass.intType, CtClass.doubleType}, ctClass); ctMethod.setModifiers(Modifier.PUBLIC); ctMethod.setBody("System.out.println($args);"); ctClass.addMethod(ctMethod); //Test2方法调用Test1 CtMethod ctMethod1 = new CtMethod(CtClass.voidType, "Test2", new CtClass[]{CtClass.intType, CtClass.doubleType}, ctClass); ctMethod1.setModifiers(Modifier.PUBLIC); ctMethod1.setBody("Test1($$);"); ctClass.addMethod(ctMethod1); ctClass.writeFile(); }
这里在定义一个Test2方法调用Test1,传参时写成Test1($$)
就相当于Test1($1,$2)
剩下的遇到了再看吧。
Javassist 仅允许修改一个方法体中的表达式。javassist.expr.ExprEditor 是一个用来替换 方法体内表达式的类。用户可以定义 ExprEditor 的子类来制定表达式的修改
当修改某个方法中的代码时,可以用MethodCall进行回环调用找到我们要改的函数,并通过replace()进行修改。
这里定义了一个print方法,并用到了print和println两个方法,之后通过MethodCall的getMethodName获取到该方法中调用的方法
所以这里可以做一个判断,当getMethodName等于print时既可以使用replace方法进行替换,由于只改方法不该参数,所以用$$直接代替原来的参数即可
package Javassist; import javassist.*; import javassist.expr.ExprEditor; import javassist.expr.MethodCall; import java.io.IOException; public class Demo04 { public static void main(String[] args) throws NotFoundException, CannotCompileException, IOException { ClassPool cp = ClassPool.getDefault(); CtClass ctClass = cp.get("Javassist.Change"); CtMethod ctMethod = ctClass.getDeclaredMethod("print"); ctMethod.instrument( new ExprEditor(){ public void edit(MethodCall m) throws CannotCompileException{ if (m.getClassName().equals("java.io.PrintStream") &&m.getMethodName().equals("print")){ m.replace("System.out.println($$);"); } } } ); ctClass.writeFile(); } } class Change { public static String name = "Sentiment"; public void print() { System.out.println("This is one !"); System.out.print("This is two !"); } }
修改控制器的与上同理
RASP(Runtime application self-protection,应用程序运行时防护),其与WAF等传统安全防护措施的主要区别于其防护层级更加底层——在功能调用前或调用时能获取访问到当前方法的参数等信息,根据这些信息来判定是否安全。
简单解释下就是,如下三个类,在JVM加载某个类时,会先从BootstrapClassLoader进行加载,如果它没有则会从ExtClassLoader,还没有则到AppClassLoader,最终到自定义的类加载器
而默认情况下premain,agentmain都是由AppClassLoader加载的,用一个实例看一下
Main.java
内容随便,这个只是后边会用到
public class Main { public static void main(String[] args) throws IOException { ProcessBuilder command = new ProcessBuilder().command("cmd", "/c", "chdir"); Process process = command.start(); InputStream inputStream = process.getInputStream(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); System.out.println(bufferedReader.readLine()); } }
ClassLoaderDemo
public class ClassLoaderDemo { public static void premain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException { System.out.println(ClassLoader.getSystemClassLoader().toString()); } }
分别打成jar包后执行:
java -javaagent:agent.jar=Sentiment -jar Main.jar
可以看到当前使用的类加载器是AppClassLoader
而这就会引起一个问题
这里直接引用的参考文章里师傅写的内容,但是好像有些不准确的地方,因此简单了解下就好
agentmain和main都是同一个appClassLoader加载的,并且我们写好的各种类都是AppClassLoader加载的,那BootstrapClassLoader和extClassLoader加载的类调用我们写好的代理方法,这些类加载器向上委派寻找类时,扩展类加载器和引导类加载器都没有加过,直接违背双亲委派原则!举个例子,因为我们可以在transform函数里面获取到类字节码,并加以修改,如果我们在系统类方法前面插了代理方法,由于这些系统类是被Bootstrap ClassLoader加载的,当BootstrapClassLoader检查这些代理方法是否被加载时,直接就报错了,因为代理类是appClassLoader加载的
要解决这个问题,我们就应该想办法把代理类通过BootstrapClassLoader进行加载,从百度的OpenRASP可以学到解决方案:
// localJarPath为代理jar包的绝对路径 inst.appendToBootstrapClassLoaderSearch(new JarFile(localJarPath))
通过appendToBootstrapClassLoaderSearch
方法,可以把一个jar包放到Bootstrap ClassLoader的搜索路径,也就是说,当Bootstrap ClassLoader检查自身加载过的类,发现没有找到目标类时,会在指定的jar文件中搜索,从而避免前面提到的违背双亲委派问题。
这里编写一个Hook ProcessBuilder执行cmd的简单例子,同时也遇到了上述提到的双亲委派问题,之后会提到。
Main.java
主程序不变
public class Main { public static void main(String[] args) throws IOException { ProcessBuilder command = new ProcessBuilder().command("cmd", "/c", "chdir"); Process process = command.start(); InputStream inputStream = process.getInputStream(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); System.out.println(bufferedReader.readLine()); } }
PreMainDemo
public class PreMainDemo { public static void premain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException { Class[] classes = inst.getAllLoadedClasses(); for (Class aClass : classes) { if (aClass.getName().equals("java.lang.ProcessBuilder") && inst.isModifiableClass(aClass)){ inst.addTransformer(new TransformerDemo(),true); inst.retransformClasses(aClass); } } } }
TransformerDemo
public class TransformerDemo implements ClassFileTransformer { public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) { byte[] bytes = null; if (className.equals("java/lang/ProcessBuilder")) { ClassPool cp = ClassPool.getDefault(); CtClass ctClass = null; try { ctClass = cp.get("java.lang.ProcessBuilder"); CtMethod[] methods = ctClass.getMethods(); String source = "if ($0.command.get(0).equals(\"cmd\")){\n" + " System.out.println(\"Dangerous....\");\n" + " System.out.println($0);\n" + " return null;\n" + "}"; for (CtMethod method : methods) { if (method.getName().equals("start")) { method.insertBefore(source); break; } } bytes = ctClass.toBytecode(); } catch (NotFoundException | CannotCompileException | IOException e) { e.printStackTrace(); } finally { if (ctClass != null) { ctClass.detach(); } } } return bytes; } }
上述PreMainDemo中,通过if判断,来找到JVM加载的ProcessBuilder类,进而触发transform对该类进行修改
if (aClass.getName().equals("java.lang.ProcessBuilder") && inst.isModifiableClass(aClass)){
但是当执行之后发现,JVM并没有加载ProcessBuilder类,那就无法通过if判断,触发transform
上述问题我上网找了一些资料,但各抒己见,所以说说我的看法。(仅个人观点,望师傅们指正!!!)
上边提到premain函数默认使用AppClassLoader进行加载的,并且我们在代码中也没有加载ProcessBuilder类,因此默认不会加载ProcessBuilder类。因为该类在rt.jar包中,而该包是由BootstrapClassLoader
进行加载的
既然没有加载ProcessBuilder类,那就可以在遍历JVM加载类之前,用ProcessBuilder processBuilder = new ProcessBuilder();
对其进行加载即可。
public class PreMainDemo { public static void premain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException, ClassNotFoundException, InstantiationException, IllegalAccessException { ProcessBuilder processBuilder = new ProcessBuilder(); Class[] classes = inst.getAllLoadedClasses(); for (Class aClass : classes) { if (aClass.getName().equals("java.lang.ProcessBuilder") && inst.isModifiableClass(aClass)){ inst.addTransformer(new TransformerDemo(),true); inst.retransformClasses(aClass); } } } }
这里其实就可以在复制一遍Main中的代码,因为这样的话即可看出在触发Transofrom前后执行cmd
的结果
最终代码
public class PreMainDemo { public static void premain(String agentArgs, Instrumentation inst) throws IOException, UnmodifiableClassException, ClassNotFoundException, InstantiationException, IllegalAccessException { ProcessBuilder command = new ProcessBuilder().command("cmd", "/c", "chdir"); Process process = command.start(); InputStream inputStream = process.getInputStream(); BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); System.out.println(bufferedReader.readLine()); Class[] classes = inst.getAllLoadedClasses(); for (Class aClass : classes) { if (aClass.getName().equals("java.lang.ProcessBuilder") && inst.isModifiableClass(aClass)){ inst.addTransformer(new TransformerDemo(),true); inst.retransformClasses(aClass); } } } }
接着将Main.java和 打包
Main.jar
<build> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <configuration> <archive> <manifestEntries> <Main-Class>RASP.Main</Main-Class> </manifestEntries> </archive> </configuration> </plugin> </plugins> </build>
agent.jar
<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>RASP.PreMainDemo</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>
执行agent
java -javaagent:AgentMemory-1.0-SNAPSHOT-jar-with-dependencies.jar=Sentiment -jar AgentMemory-1.0-SNAPSHOT.jar
可以看到一开始执行了chdir输出了D:\java\AgentMemory\target
,因为此时还没触发transform,但之后触发后,输出了Dangerous,并返回了null,所以这里在InputStream inputStream = process.getInputStream();
爆了空指针异常,成功hook