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。
思路很简单:
- 用Agent拦截
LicenseValidator.check()方法 - 用Javassist把它的返回值改成
true - 或者直接让方法体变成空实现
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技术核心脉络。你看一眼,心里就有谱了。
避坑指南
做Agent逆向,有几个坑我踩过,你注意一下:
- 类加载顺序问题:如果你的Agent依赖了某些第三方库,而这些库还没加载,就会报
NoClassDefFoundError。我建议把依赖都shade进Agent jar里。 - retransform失败:有些类被JVM标记为“不可重转换”,比如数组类、基础类型包装类。别去碰它们。
- MANIFEST格式:每行冒号后面必须有一个空格,最后一行必须是空行。这个坑我至少栽过三次。
- 权限问题:Agentmain需要和目标JVM有相同的用户权限,否则Attach会失败。
写在最后
Java Agent技术,是逆向工程师的“瑞士军刀”。它能让你在运行时“看到”别人看不到的东西,也能让你“改变”别人改不了的行为。
我个人觉得,掌握Agent的关键不在于背API,而在于理解“类加载时机”和“字节码结构”。你只要搞懂了这两点,剩下的就是工具熟练度的问题。
嗯,今天就聊到这儿。下次遇到需要运行时动字节码的场景,你知道该用什么了。