初识 Java Agent - 实现简单AOP操作

引言

相信大家对 IAST(Interactive Application Security Testing,交互式应用程序安全测试) 和 RASP(Runtime application self-protection,运行时应用自我保护)这两款产品已经不陌生了,那究竟是什么神仙技术能衍生出这么牛皮的安全产品?

带着疑问,我开始了这一次”修行“。

3333.jpg

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文件中,解释下里面的几个参数:

  1. Premain-Class:包含premain方法的类,需要配置为类的全路径
  2. Agent-Class:包含agentmain方法的类,需要配置为类的全路径
  3. Can-Redefine-Classes:为true时表示能够重新定义Class
  4. Can-Retransform-Classes:为true时表示能够重新转换Class,实现字节码替换
  5. 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类对字节码进行读取&遍历,然后新建一个ClassWriterClassReader读取的字节码进行拼接;
    • 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/

你可能感兴趣的:(初识 Java Agent - 实现简单AOP操作)