引言
相信大家对 IAST(Interactive Application Security Testing,交互式应用程序安全测试) 和 RASP(Runtime application self-protection,运行时应用自我保护)这两款产品已经不陌生了,那究竟是什么神仙技术能衍生出这么牛皮的安全产品?
带着疑问,我开始了这一次”修行“。
0x01 简介
在Java SE 5及后续版本中,开发者可以构建一个独立于应用程序的代理程序(Agent),用来监测和协助运行在 JVM 上的程序,甚至能够替换和修改某些类的定义。
利用这一特性衍生出了 IAST(Interactive Application Security Testing,交互式应用程序安全测试) 和 RASP(Runtime application self-protection,运行时应用自我保护)等相关安全产品。
-
问题:
- Java Agent 如何调试呢?
- Java Agent 实现原理是什么?
0x02 加载方式
在官方API文档中提到,Java Agent 有两种加载方式:
(1)premain
, 当以指示代理类的方式启动JVM时。 在这种情况下, Instrumentation实例被传递给代理类的premain方法。(-javaagent 启动)
(2)agentmain
, 当JVM在JVM启动后的某个时间提供启动代理的机制时。 在这种情况下, Instrumentation实例将传递给代理代码的agentmain方法。(利用attach api,动态启动)
premain 与 agentmain 的区别:
运行模式不同:
premain 相当于在main前类加载时进行字节码修改,而agentmain则是main后在类调用前通过重新转换类完成字节码修改。
部署方式不同:
由于加载方式不同,所以premain只能在程序启动时指定Agent文件进行部署,而agentmain需要通过Attach API在程序运行后根据进程ID动态注入agent到jvm中。
0x03 编译构建
(1)那如何构建一个Java Agent 呢?创建一个新项目并新建一个MyAgent
类:
public class MyAgent {
/**
* premain jvm 参数形式启动,运行此方法
*
* @param agentArgs
* @param inst
*/
public static void premain(String agentArgs, Instrumentation inst){
System.out.println("premainAgent Start");
}
/**
* agentmain 动态 attach 方式启动,运行此方法
*
* @param agentArgs
* @param inst
*/
public static void agentmain(String agentArgs, Instrumentation inst){
System.out.println("agentmainAgent Start");
}
}
(2)利用maven插件,将代码打包为jar包,通常有两种方式:
a. Pom 指定配置
在 pom.xml 文件中,添加如下配置:
org.apache.maven.plugins
maven-jar-plugin
3.1.0
true
com.bug1024.MyAgent
com.bug1024.MyAgent
true
true
true
在配置的打包参数中,通过manifestEntries
的方式添加属性到MANIFEST.MF
文件中,解释下里面的几个参数:
- Premain-Class:包含
premain
方法的类,需要配置为类的全路径 - Agent-Class:包含
agentmain
方法的类,需要配置为类的全路径 - Can-Redefine-Classes:为
true
时表示能够重新定义Class - Can-Retransform-Classes:为
true
时表示能够重新转换Class,实现字节码替换 - Can-Set-Native-Method-Prefix:为
true
时表示能够设置native方法的前缀
配置完成后使用mvn
命令打包:
mvn clean package
打包完成后生成AgentTest-1.0-SNAPSHOT.jar
文件,解压jar
文件我们可以看到生成的MANIFEST.MF
文件:
Manifest-Version: 1.0
Premain-Class: com.bug1024.MyAgent
Built-By: 07
Agent-Class: com.bug1024.MyAgent
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Can-Set-Native-Method-Prefix: true
Created-By: Apache Maven 3.6.3
Build-Jdk: 1.8.0_261
此时,第一种打包方式就完成了。
b. MANIFEST.MF 配置文件
通过配置文件MANIFEST.MF
打包的方式也比较常见,操作如下:
1. 在资源目录(resources)下,新建目录`META-INF`
2. 在`META-INF`目录下,新建文件`MANIFEST.MF`
文件内容可以直接复制我们上述内容,然后在pom.xml
配置,做对应的修改,如下:
org.apache.maven.plugins
maven-jar-plugin
3.1.0
true
src/main/resources/META-INF/MANIFEST.MF
同样通过mvn clean package
打包即可。
(3)Agent 打包完成后,接下来开始对两种加载方式进行调试。
a. premain jvm 参数形式启动 ,新建一个demo运行HelloWorld程序,代码示例如下:
public class Demo {
public void say() throws InterruptedException {
for (int i = 0; i < 7; i++) {
Thread.sleep(1000); // 为方便后续延时attach加载agent添加延时并循环
System.out.println("Hello World");
}
}
public static void main(String[] args) throws InterruptedException {
Demo d = new Demo();
d.say();
}
}
程序运行结果:
Hello World
Hello World
Hello World
...
添加jvm参数启动:
-javaagent:/Users/.../AgentTest/target/AgentTest-1.0-SNAPSHOT.jar
程序运行结果:
premainAgent Start
Hello World
注意此时我们上面MyAgent
中的premain
方法已经执行,并输出了premainAgent Start
表示我们第一种加载方式执行成功。
b. agentmain 动态 attach 方式启动
在上文也有提到过agentmain需要通过Attach API在程序运行后根据进程ID动态注入agent到jvm中,我们利用VirtualMachine
的attach方法连接目标虚拟机,代码如下:
public class AttachMain {
public void attachAgent() throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
List vm_list = VirtualMachine.list();
for(VirtualMachineDescriptor v : vm_list){ // 遍历程序列表
if (v.displayName().equals("com.bug1024.Demo")){ // 判断我们要注入的程序
VirtualMachine vm = VirtualMachine.attach(v.id()); // 获取目标程序进程id并根据进程id连接目标程序
vm.loadAgent("/Users/.../AgentTest/target/AgentTest-1.0-SNAPSHOT.jar"); //加载Agent
}
}
}
public static void main(String[] args) throws AgentLoadException, IOException, AttachNotSupportedException, AgentInitializationException {
AttachMain attach = new AttachMain();
attach.attachAgent();
}
}
运行我们的Demo
测试类后运行AttachMain
输出如下结果:
premainAgent Start
Hello World
Hello World
Hello World
agentmainAgent Start
Hello World
...
注意此时我们上面MyAgent
中的agentmain
方法已经执行,并输出了agentmainAgent Start
表示我们第二种加载方式也执行成功。
(4)小结
上述内容描述了JavaAgent打包调试全过程,两种agent加载方式:
加载方式 | 说明 | 操作说明 |
---|---|---|
premain() | agent以jvm方式加载时调用,在目标应用启动时指定agent | -javaagent:/Users/.../AgentTest/target/AgentTest-1.0-SNAPSHOT.jar |
agentmain() | agent以attach方式运行时调用,在目标程序启动后,通过attach api 注入agent | VirtualMachine vm = VirtualMachine.attach(v.id()); // 获取目标程序进程id并根据进程id连接目标程序 |
vm.loadAgent("/Users/.../AgentTest/target/AgentTest-1.0-SNAPSHOT.jar"); //加载Agent |
两种打包方式:
- 在pom.xml中指定配置
- 在配置文件
META-INF/MANIFEST.MF
中配置
那JavaAgent实现原理是什么?安全产品是如何利用的呢?继续往下探索。
0x04 Instrumentation & ASM 实现简单AOP
Java Agent 是通过使用Instrumentation构建出来的一个独立于应用程序的代理程序,用来监测和协助运行在 JVM 上的程序,甚至能够替换和修改某些类的定义。
Instrumentation 是 Java SE 5 的新特性,它把 Java 的 instrument 功能从本地代码中解放出来,使之可以用 Java 代码的方式解决问题。
在 Java SE 6 里面,instrumentation 包被赋予了更强大的功能:启动后的 instrument、本地代码(native code)instrument,以及动态改变 classpath 等等。这些改变,意味着 Java 具有了更强的动态控制、解释能力,它使得 Java 语言变得更加灵活多变。
java.lang.instrument包结构【官方API】:
- ClassFileTransformer 接口
// 转换类文件的代理接口,我们可以在获取到Instrumentation对象后通过addTransformer方法添加自定义类文件转换器。
public interface ClassFileTransformer {
/**
* 类文件转换方法,重写transform方法可获取到待加载的类相关信息
*
* @param loader 定义要转换的类加载器;如果是引导加载器,则为 null
* @param className 类名,如:java/lang/Runtime
* @param classBeingRedefined 如果是被重定义或重转换触发,则为重定义或重转换的类;如果是类加载,则为 null
* @param protectionDomain 要定义或重定义的类的保护域
* @param classfileBuffer 类文件格式的输入字节缓冲区(不得修改)
* @return 返回一个通过ASM修改后添加了防御代码的字节码byte数组。
*/
byte[]
transform( ClassLoader loader,
String className,
Class> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}
- Instrumentation 接口
/**
* 注册一个Transformer,从此之后的类加载都会被Transformer拦截。
* Transformer可以直接对类的字节码byte[]进行修改
*/
void addTransformer(ClassFileTransformer transformer);
/**
* 对JVM已经加载的类重新触发类加载。使用的就是上面注册的Transformer。
* retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性
*/
void retransformClasses(Class>... classes) throws UnmodifiableClassException;
/**
* 获取一个对象的大小
*/
long getObjectSize(Object objectToSize);
/**
* 将一个jar加入到bootstrap classloader的 classpath里
*/
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
/**
* 获取当前被JVM加载的所有类对象
*/
Class[] getAllLoadedClasses();
以上是几个比较常用的方法,其他可详细看官方API文档;addTransformer 方法配置后,后续的类加载会被Transformer拦截。对于已经加载过的类,可以执行retransformClasses来重新触发Transformer拦截。
类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
结合上面的描述,我们要通过ASM实现简单AOP操作,肯定是要利用Transformer对类进行拦截后操作,代码示例如下:
/**
* premain jvm 参数形式启动,运行此方法
*
* @param agentArgs
* @param inst
*/
public static void premain(String agentArgs, Instrumentation inst){
System.out.println("premainAgent Start");
// Class>[] classes = inst.getAllLoadedClasses();
// for (Class> cls : classes){
// System.out.println("premainAgent get loaded class : " + cls.getName());
// }
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("premainAgent get loaded class : " + className);
return classfileBuffer;
}
});
}
运行结果:
remainAgent Start
premainAgent get loaded class : sun/nio/cs/ThreadLocalCoders
premainAgent get loaded class : sun/nio/cs/ThreadLocalCoders$1
premainAgent get loaded class : sun/nio/cs/ThreadLocalCoders$Cache
premainAgent get loaded class : sun/nio/cs/ThreadLocalCoders$2
premainAgent get loaded class : sun/misc/URLClassPath$JarLoader$2
premainAgent get loaded class : java/util/jar/Attributes
premainAgent get loaded class : java/util/jar/Manifest$FastInputStream
premainAgent get loaded class : java/util/jar/Attributes$Name
premainAgent get loaded class : sun/misc/ASCIICaseInsensitiveComparator
premainAgent get loaded class : com/intellij/rt/execution/application/AppMainV2$Agent
premainAgent get loaded class : com/intellij/rt/execution/application/AppMainV2
premainAgent get loaded class : java/lang/NoSuchMethodException
premainAgent get loaded class : java/lang/reflect/InvocationTargetException
...
获取加载的类之后,怎么实现简单AOP操作呢?我们需要修改字节码来实现,常用的字节码修改工具主要有ASM、Javassist和byte buddy,下面主要已ASM框架来实现需求。
ASM 简介:
ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。
ASM 实现简单AOP:
由于 ASM 是直接对class
文件的字节码进行操作,因此,要修改class
文件内容时,也要注入相应的java
字节码。所以,在注入字节码之前,我们还需要了解下class
文件的结构,JVM指令等知识。(还没搞明白就不展开写了,直接贴代码)
为了方便测试,在Demo测试类新增了exp()方法:
public void say() throws InterruptedException {
for (int i = 0; i < 2; i++) {
Thread.sleep(1000); // 为方便后续延时attach加载agent添加延时循环
String say_str = "普通方法---say()---不操作";
System.out.println(say_str);
}
}
public void exp() throws InterruptedException {
for (int i = 0; i < 2; i++) {
Thread.sleep(1000); // 为方便后续延时attach加载agent添加延时循环
String exp_str = "目标方法---exp()---拦截并退出";
System.out.println(exp_str);
}
}
目标是当程序运行到exp()方法时做出相应操作,下面以premain加载方式为例进行调试;
/**
* premain jvm 参数形式启动,运行此方法
*
* @param agentArgs
* @param inst
*/
public static void premain(String agentArgs, Instrumentation inst) {
System.out.println("premainAgent Start");
inst.addTransformer(new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
className = className.replace("/", ".");
if (className.equals("com.bug1024.Demo")) {
ClassReader classReader = new ClassReader(classfileBuffer);
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_MAXS);
ClassVisitor classVisitor = new ClassVisitor(Opcodes.ASM5, classWriter) {
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
if ("exp".equals(name)) {
return new MethodVisitor(Opcodes.ASM5, mv) {
@Override
public void visitCode() {
// 文章下面单独展示
}
};
}
return mv;
}
};
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES);
classfileBuffer = classWriter.toByteArray();
}
return classfileBuffer;
}
});
}
同样利用Transformer进行拦截,正常情况将Transform
抽出来单独写逻辑比较好,这里为了方便采用的流式写法。
整个流程如下:
- 根据
className
来判断当前agent拦截的类是否要hook,如果是进入ASM修改流程; - 使用ASM提供的
ClassReader
类对字节码进行读取&遍历,然后新建一个ClassWriter
对ClassReader
读取的字节码进行拼接;- 在
ClassVisitor
中调用visitMethod
方法访问hook类中的每个方法,根据方法名判断是否为需要hook的方法,如果是,则调用visitCode
方法实现后续逻辑,在目标方法exp()前插入输出代码并输出:“目标方法即将运行”;
- 在
@Override
public void visitCode() {
mv.visitCode();
mv.visitFieldInsn(Opcodes.GETSTATIC,
Type.getInternalName(System.class),
"out",
Type.getDescriptor(PrintStream.class));
mv.visitLdcInsn("目标方法即将运行");
mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL,
Type.getInternalName(PrintStream.class), //"java/io/PrintStream"
"println",
"(Ljava/lang/String;)V",//方法描述符
false);
mv.visitEnd();
super.visitCode();
}
实现效果如下:
premainAgent Start
普通方法---say()---不操作
普通方法---say()---不操作
目标方法即将运行
目标方法---exp()---拦截并退出
目标方法---exp()---拦截并退出
当然我们也可以在目标方法运行前插入其他方法的调用,代码示例:
// 新建在目标法发前要执行的方法
public static void Test() {
System.out.println("拦截目标方法并退出程序");
System.exit(0);
}
// 修改visitCode()方法,添加调用Test()方法的逻辑
@Override
public void visitCode() {
mv.visitCode();
mv.visitMethodInsn(Opcodes.INVOKESTATIC,MyAgent.class.getName().replace(".","/"),"Test","()V",false);
mv.visitEnd();
super.visitCode();
}
运行结果如下:
premainAgent Start
普通方法---say()---不操作
普通方法---say()---不操作
拦截目标方法并退出程序
如上述结果,当程序执行到目标方法exp()时直接退出不在往下进行,这样一次简单的AOP就实现了。
0x05 小结
在这次学习过程中发现不光是IAST 还是 RASP 中使用这种技术,其实在安全防御实践中很多场景都可以尝试使用AOP技术去解决,如:日志审计、权限控制等等。(下图为蚂蚁集团的安全切面防御体系 - 安全切面:安全防御的平行空间)
当然,如果我们要在Java中实现这些技术,还是要好好了解下class文件结构、jvm命令、ASM等相关知识。
参考链接:
https://www.bilibili.com/video/av841675771/
https://my.oschina.net/ta8210/blog/162796
https://www.jianshu.com/p/a85e8f83fa14
https://www.jianshu.com/p/abd1b1b8d3f3
https://mp.weixin.qq.com/s/qZDvset94O2_2G-NTIvQUg
https://paper.seebug.org/1041/