64、Java逆向(三):Java Agent技术、运行时修改字节码

说实话,Java逆向做到一定程度,你一定会遇到一个坎——怎么在程序已经跑起来之后,还能动它的字节码?

静态分析改class文件,那叫“手术前准备”。真正上战场,很多时候你得在运行时动手。Java Agent技术,就是干这个的。我当年第一次接触它,是在一个线上故障排查的场景里——某个第三方jar包死活不兼容,我又没源码,最后靠Agent硬生生把它的字节码给“掰”过来了。

嗯,今天我们就来聊聊这个。

什么是Java Agent?

Java Agent,说白了就是一个“寄生”在JVM里的插件。它可以在类加载之前、甚至加载之后,拦截并修改类的字节码。

你想想看,这有多可怕?

  • 你可以给任意类加日志
  • 你可以篡改某个方法的返回值
  • 你甚至可以“偷”到别人类的私有字段

当然,逆向工程里,我们用它来还原逻辑、绕过校验、动态分析

Agent的两种启动方式

我个人习惯把Agent分成两类:

类型 启动参数 时机 典型场景
Premain -javaagent:agent.jar main方法之前 类加载前修改字节码
Agentmain 通过Attach API动态加载 运行时附加 热替换、动态诊断

Premain用得最多,因为它简单。但如果你要搞“无侵入式逆向”,Agentmain才是王道——程序已经跑起来了,你还能从外面“插”进去。

核心要点:Premain在类加载前拦截,Agentmain在类加载后重新转换。两者都需要实现ClassFileTransformer接口。

手写一个最简单的Agent

来,我们直接上代码。我建议你先从Premain入手,理解流程再说。

// 第一步:写一个Agent类
public class SimpleAgent {
    public static void premain(String args, Instrumentation inst) {
        System.out.println("[Agent] 启动,参数:" + args);
        inst.addTransformer(new MyTransformer());
    }
}

// 第二步:实现ClassFileTransformer
public class MyTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader,
                            String className,
                            Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,
                            byte[] classfileBuffer) {
        // 只拦截目标类
        if (!"com/example/Target".equals(className)) {
            return null; // 返回null表示不修改
        }
        System.out.println("[Agent] 拦截到:" + className);
        // 这里用ASM或Javassist修改字节码
        return modifyClass(classfileBuffer);
    }
}

// 第三步:MANIFEST.MF里声明
// Premain-Class: SimpleAgent
// Can-Retransform-Classes: true

你看,核心就三步:写Agent类、实现Transformer、打包时配好MANIFEST。

小提示:MANIFEST.MF文件最后一定要空一行,否则JVM不认。我曾经因为这个bug排查了整整两个小时……嗯,说出来都是泪。

运行时修改字节码的两种武器

拿到classfileBuffer之后,怎么改?我推荐两个库:

  • ASM:底层、高效、但写起来像受刑。适合对性能有极致要求的场景。
  • Javassist:高层API,源码级操作。我90%的项目都用它,因为快。

举个例子,用Javassist给目标方法加一行日志:

public byte[] modifyClass(byte[] classBytes) {
    ClassPool pool = ClassPool.getDefault();
    CtClass ctClass = pool.makeClass(new ByteArrayInputStream(classBytes));
    
    CtMethod method = ctClass.getDeclaredMethod("login");
    method.insertBefore("System.out.println(\"[Agent] 调用login方法\");");
    
    return ctClass.toBytecode();
}

就这么几行,你就能在目标类的login()方法执行前,插入任意代码。逆向分析时,我经常用这招来打印参数、篡改返回值、甚至跳过校验

实战:绕过License校验

我记得有一次,一个商业软件每次启动都要联网验证License。我没源码,但找到了校验的核心类LicenseValidator

思路很简单:

  1. 用Agent拦截LicenseValidator.check()方法
  2. 用Javassist把它的返回值改成true
  3. 或者直接让方法体变成空实现
public byte[] transform(...) {
    if (!"com/vendor/LicenseValidator".equals(className)) return null;
    
    ClassPool pool = ClassPool.getDefault();
    CtClass ctClass = pool.makeClass(new ByteArrayInputStream(classfileBuffer));
    CtMethod method = ctClass.getDeclaredMethod("check");
    
    // 直接替换方法体
    method.setBody("{ return true; }");
    
    return ctClass.toBytecode();
}

搞定。程序启动时带上-javaagent:bypass.jar,License校验直接失效。

警告:这里只是技术演示。实际逆向中,请遵守法律法规,不要用于破解商业软件。我讲这个是为了让你理解Agent的能力边界。

Agentmain:运行时“插管”

Premain虽然好用,但有个硬伤——你得在启动时指定。如果程序已经跑起来了呢?

这时候就要用Attach API了。我当年做线上诊断工具时,就是靠这个“无侵入”地挂到目标JVM上。

// 客户端代码:附加到目标JVM
VirtualMachine vm = VirtualMachine.attach("目标进程PID");
vm.loadAgent("agent.jar", "参数");
vm.detach();

目标JVM里需要实现agentmain方法:

public static void agentmain(String args, Instrumentation inst) {
    inst.addTransformer(new MyTransformer(), true);
    // 触发已加载类的重新转换
    try {
        inst.retransformClasses(Class.forName("com.example.Target"));
    } catch (Exception e) {
        e.printStackTrace();
    }
}

注意这里有个坑:retransformClasses只能重新转换那些原本就支持retransform的类。如果目标类被某些框架做了特殊处理,可能会失败。我建议你在MANIFEST里加上Can-Retransform-Classes: true

知识体系总览

下面这张图,是我梳理的Java Agent技术核心脉络。你看一眼,心里就有谱了。

Java Agent 技术体系 Premain(启动时) Agentmain(运行时) ClassFileTransformer.transform() ASM(底层) Javassist(高层) ByteBuddy 方法插桩 / 日志 返回值篡改 / 绕过 热替换 / 动态诊断

避坑指南

做Agent逆向,有几个坑我踩过,你注意一下:

  • 类加载顺序问题:如果你的Agent依赖了某些第三方库,而这些库还没加载,就会报NoClassDefFoundError。我建议把依赖都shade进Agent jar里。
  • retransform失败:有些类被JVM标记为“不可重转换”,比如数组类、基础类型包装类。别去碰它们。
  • MANIFEST格式:每行冒号后面必须有一个空格,最后一行必须是空行。这个坑我至少栽过三次。
  • 权限问题:Agentmain需要和目标JVM有相同的用户权限,否则Attach会失败。

写在最后

Java Agent技术,是逆向工程师的“瑞士军刀”。它能让你在运行时“看到”别人看不到的东西,也能让你“改变”别人改不了的行为。

我个人觉得,掌握Agent的关键不在于背API,而在于理解“类加载时机”“字节码结构”。你只要搞懂了这两点,剩下的就是工具熟练度的问题。

嗯,今天就聊到这儿。下次遇到需要运行时动字节码的场景,你知道该用什么了。


公众号:蓝海资料掘金营,微信deep3321