JavaAgent与RASP(上)——javaagent基础

本文最后更新于:几秒前

agent相关官方文档:https://docs.oracle.com/javase/10/docs/api/java/lang/instrument/package-summary.html

最早接触agent技术可以回溯到去年拟态的一道java沙箱的题目,用的阿里云的jvm-sandbox,通过premain的方式启动agent,在jvm底层对关键代码进行hook,从而能阻止命令执行等危险行为

那么这个javaagent到底是何方神圣?

Java Agent可以类比于Spring中的拦截器,在进入main函数之前执行一些特定的操作,比如在类加载的阶段进行动态修改jvm字节码(premain),或者在程序运行的过程对类进行附加操作来增强类(agentmain)

它通过java的Instrumentation API接口来实现,一些可能需要依赖于热部署的技术像rasp或者是jvm性能监控等,在实际业务中都可以通过agent来实现。

举个大家更熟悉的例子:使用idea进行java的debug

premain

在程序启动前使用-javaagent:agent.jar参数来加载agent,在启动时,会先加载agent的类并执行premain方法

那怎么用这个东西呢?

我们先创建一个非常基础的maven项目:

一个premain agent应该满足什么条件,官方文档已经给出了答案

  • 在mainfest的清单中包含agent的主类
  • 实现public static premain方法

一个基础的premain agent类需要实现premain方法,premain()方法有两种实现方式,jvm会优先调用第一种实现方式的premain()方法

创建一个简单的agent示例:

1
2
3
4
5
6
7
8
9
package com.potato;

import java.lang.instrument.Instrumentation;

public class PremainAgent {
public static void premain(String args, Instrumentation instrumentation){
System.out.println("这个agent调用了"+args+"参数");
}
}

随后在resources/META-INF下创建MANIFEST.MF

最主要的是Premain-Class这个选项,用于指定agent的入口类;Can-Redefine-Classes和Can-Retransform-Classes,这两个属性控制Agent是否能重新定义和转换类,redefine是替换类的定义,而retransform是通过转换器修改字节码,后面两者后续会介绍,简易使用中不做赘述,主要还是通过Instrumentation的方法来对已加载的类进行操作

1
2
3
4
5
Manifest-Version: 1.0
Premain-Class: com.potato.PremainAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true

然后在pom中指定MAINFEST.MF清单的位置

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
<?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.potato</groupId>
<artifactId>agent</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>
</plugins>
</build>
</project>

或者直接在pom中写入mainfest参数即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.potato.PremainAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>

最后mvn clean package打包

我们写一个简单的主程序

1
2
3
4
5
6
7
package com.potato;

public class Main {
public static void main(String[] args) {
System.out.println("main method called");
}
}

给主程序添加一个jvm参数

成功触发agent

当然,premain方式的启动有一个比较大的缺陷,如下图

如果我们的premain抛出了异常,那么整个jvm将中止,也就意味着后续的主程序也无法启动

agentmain

在jdk1.6引入了另一种agent启动方式,agentmain

也就是我们前言提到的在主程序运行过程中,通过attach方式注入jvm中,动态修改jvm字节码的方式

agent的打包方式和premain的类似

下图是条件

实现agentmain()方法:

1
2
3
4
5
6
7
8
9
package com.potato;

import java.lang.instrument.Instrumentation;

public class AgentmainAgent {
public static void agentmain(String args, Instrumentation instrumentation){
System.out.println("调用了agentmain,参数为"+args);
}
}

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Agent-Class>com.potato.AgentmainAgent</Agent-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
</manifestEntries>
</archive>
</configuration>
</plugin>
</plugins>
</build>

随后需要介绍两个有关类:

com.sun.tools.attach.VirtualMachine

这个类提供了一个API,下面的一些静态方法能让我们直接在运行时动态注入

(若是jdk8及以下,请手动添加tools.jar依赖)

1
2
3
4
5
6
7
VirtualMachine.attach():静态方法。传入一个JVM的PID作为参数,连接到该JVM上。返回一个VirtualMachine对象

virtualMachine.loadAgent():非静态方法,向JVM注册一个agent

VirtualMachine.list():静态方法,获取当前所有JVM的列表,返回一个List<VirtualMachineDescriptor>列表

virtualMachine.detach():非静态方法,解除与当前JVM虚拟机的绑定

com.sun.tools.attach.VirtualMachineDescriptor

该类用于表示一个特定的虚拟机,如上面VirtualMachine.list()返回的List<VirtualMachineDescriptor>列表中,每一个元素都是一个特定的虚拟机

注入

我们环境采用的是jdk1.8,所以这里需要引入一下tools.jar依赖

idea直接导入即可

要attach()到jvm,第一步便是获取当前进程的jvm的pid

一种方法是通过上面介绍的两种类来获取

1
2
3
4
5
List<VirtualMachineDescriptor> vmList = VirtualMachine.list();
for(VirtualMachineDescriptor descriptor : list){
if(descriptor.displayName().contains("com.potato.Main"))
System.out.println(descriptor.id());
}

当我们并不清楚当前进程相关的一些信息比如类名之类的,也可以使用下面的方式来获取:

1
2
3
String jvmName = ManagementFactory.getRuntimeMXBean().getName();
String pid = jvmName.split("@")[0];
System.out.println(pid);

运行主程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.potato;


import com.sun.tools.attach.VirtualMachine;
import com.sun.tools.attach.VirtualMachineDescriptor;

import java.lang.management.ManagementFactory;
import java.util.List;

public class Main {
public static void main(String[] args) throws Exception{
System.out.println("main method called");
String jvmName = ManagementFactory.getRuntimeMXBean().getName();
String pid = jvmName.split("@")[0];
VirtualMachine vm = VirtualMachine.attach(pid);
vm.loadAgent("D:\\Desktop\\AgentmainAgent\\target\\AgentmainAgent-1.0-SNAPSHOT.jar","test");
vm.detach();
}
}

动态修改字节码

这里最主要的需要关注前文提到的Instrumentation接口下定义的一些方法

一些相对常用的方法做了标注,自行按照参数规则验证功能即可

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
public interface Instrumentation {

//注册字节码转化器,参数canRetransform设置是否允许重新转化,即调用retransformClasses()
void
addTransformer(ClassFileTransformer transformer, boolean canRetransform);

//注册字节码转化器,canRetransform默认为false
void
addTransformer(ClassFileTransformer transformer);

//注销字节码转化器
boolean
removeTransformer(ClassFileTransformer transformer);

//判断当前虚拟机是否支持修改字节码
boolean
isRetransformClassesSupported();

//重转化类
void
retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

//判断当前虚拟机是否支持重定义类
boolean
isRedefineClassesSupported();

//重定义一个类
void
redefineClasses(ClassDefinition... definitions)
throws ClassNotFoundException, UnmodifiableClassException;

//传入一个类,判断该类是否能被修改
boolean
isModifiableClass(Class<?> theClass);

//返回所有已加载的类
Class[]
getAllLoadedClasses();

//返回所有被指定类加载器实例化的类
Class[]
getInitiatedClasses(ClassLoader loader);

long
getObjectSize(Object objectToSize);

void
appendToBootstrapClassLoaderSearch(JarFile jarfile);

void
appendToSystemClassLoaderSearch(JarFile jarfile);

boolean
isNativeMethodPrefixSupported();

void
setNativeMethodPrefix(ClassFileTransformer transformer, String prefix);

}

其中最重要的便是涉及到Transformer的几个方法了,但是注意到有一个重定义方法redifineClasses(),有必要花一点点时间解释一下重定义和重转化是什么意思,重定义即绕过jvm的所有转化器,直接将字节码注入到原来的类中;而重转化则是重新调用所有的jvm转化器,然后调用transform()方法去进行一个重转化

这一篇文章我们先起一个头,简单地写一个能hook exec()方法的超级超级简约的rasp吧

首先我们写一个类去实现ClassFileTransformer类,并重写他的transform()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.potato;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.lang.instrument.IllegalClassFormatException;
import java.security.ProtectionDomain;

public class BlockRuntimeTransformer implements ClassFileTransformer {

@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// System.out.println(className);
return null;
}

}

然后写入一点点逻辑,结合前面学习过的javassist来进行动态注入字节码

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
package com.potato;

import javassist.ClassClassPath;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;

import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;

public class BlockRuntimeTransformer implements ClassFileTransformer {

@Override
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) {
// System.out.println(className);
if (className.equals("java/lang/Runtime"))
try {
// System.out.println(12121212);
return modifyExecMethod(classfileBuffer);
}catch (Exception e){
e.printStackTrace();
}
return null;
}


private byte[] modifyExecMethod(byte[] classfileBuffer) throws Exception
{


ClassPool classPool = ClassPool.getDefault();
classPool.insertClassPath(new ClassClassPath(Class.forName("java.lang.Runtime")));

CtClass ctClass = classPool.get(Runtime.class.getName());

CtMethod[] ctMethods = ctClass.getDeclaredMethods("exec");
for (CtMethod ctMethod:ctMethods){
ctMethod.setBody("{ throw new SecurityException(\"泥在做什么!\"); }");
}

byte[] bytecode = ctClass.toBytecode();
ctClass.detach();


return bytecode;

}
}

随后注入

1
2
3
4
5
6
7
8
9
10
package com.potato;

import java.lang.instrument.Instrumentation;

public class PremainAgent {
public static void premain(String args, Instrumentation instrumentation){
instrumentation.addTransformer(new BlockRuntimeTransformer(),true);
}
}

但是我程序就这么一跑,诶!怎么还是弹窗呢~

想一想其实也比较好理解,使用premain方式启动的时候,已经完成了部分java基础类的加载,导致了Runtime错过了此次的transform()转化

如何解决呢?思考一下其实也比较简单,那就让他重加载一次呗~

1
2
3
4
5
6
7
8
9
10
11
package com.potato;

import java.lang.instrument.Instrumentation;

public class PremainAgent {
public static void premain(String args, Instrumentation instrumentation) throws Exception{
instrumentation.addTransformer(new BlockRuntimeTransformer(),true);
instrumentation.retransformClasses(Runtime.class);
}
}

但是这样搓出来的”rasp”,还是让命令成功执行了

是哪里没做到位呢?

经过一番测试我发现代码压根就无法走到ClassPool classPool = ClassPool.getDefault();这一行后面!

那么问题可能是出在了agent包本身

在询问ai的过程中突然想到,会不会是javassist的依赖压根没有加入进来!

好家伙还真是,,

那么罪魁祸首就出在这:

我们将maven-jar-plugin改为maven-assembly-plugin,让依赖随之打包

修改pom的build字段重新打包

(记得修改javaagent参数的值为附带依赖的jar包)

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
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.potato.PremainAgent</Premain-Class>
<Can-Redefine-Classes>true</Can-Redefine-Classes>
<Can-Retransform-Classes>true</Can-Retransform-Classes>
<Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
</manifestEntries>
</archive>
</configuration>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

再次运行,成功拦截!

emmmm,但是好像还是轻松被远程类加载给绕过了啊!

万事开头难~这一篇先到这里,下一篇我们再继续琢磨琢磨

(这里的问题十分戏剧性,可以马上衔接到下一篇)