初识 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 文件中,添加如下配置:

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestEntries>
                            <Premain-Class>com.bug1024.MyAgent</Premain-Class>
                            <Agent-Class>com.bug1024.MyAgent</Agent-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>
            </plugin>
        </plugins>
    </build>

在配置的打包参数中,通过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配置,做对应的修改,如下:

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.1.0</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                        </manifest>
                        <manifestFile>
                            src/main/resources/META-INF/MANIFEST.MF
                        </manifestFile>
<!--                        <manifestEntries>-->
<!--                            <Premain-Class>com.bug1024.MyAgent</Premain-Class>-->
<!--                            <Agent-Class>com.bug1024.MyAgent</Agent-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>
            </plugin>
        </plugins>
    </build>

同样通过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<VirtualMachineDescriptor> 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/
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,843评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,538评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,187评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,264评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,289评论 6 390
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,231评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,116评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,945评论 0 275
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,367评论 1 313
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,581评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,754评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,458评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,068评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,692评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,842评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,797评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,654评论 2 354