第63章 Java逆向(二):Java混淆器(ProGuard、ZKM)、反混淆
上一章我们聊了Java字节码的基础结构,这一章直接上硬菜——混淆与反混淆。说实话,我在做逆向的这些年里,Java混淆器见得最多的就是ProGuard和ZKM(Zelix KlassMaster)。一个开源免费,一个商业闭源,但它们的核心思路其实差不多。
你可能会问:为什么要有混淆器?说白了,Java的.class文件太容易被反编译了。你写个商业软件,别人拿个JD-GUI一拖,源码几乎原样出来。这谁受得了?所以混淆器就诞生了——它不是加密,而是让你反编译出来的代码没法看。
混淆器的核心手段
我习惯把混淆手段分成三类:名字混淆、控制流混淆、字符串加密。咱们一个一个说。
1. 名字混淆(Name Obfuscation)
这是最基础也最常用的手段。ProGuard默认做的就是这件事。它把类名、方法名、字段名改成a、b、c这种无意义的短名字。
举个例子,你原来有个方法叫validateUserLogin(),混淆后变成a()。反编译出来你根本不知道这个方法是干嘛的。
// 混淆前
public class LoginService {
public boolean validateUserLogin(String username, String password) {
// 业务逻辑
}
}
// ProGuard混淆后
public class a {
public boolean a(String a, String b) {
// 业务逻辑还在,但名字全变了
}
}
2. 控制流混淆(Control Flow Obfuscation)
ZKM在这块做得比ProGuard狠得多。它会把正常的if-else、循环结构打乱,插入大量无用的跳转和死代码。反编译出来的代码,逻辑上是对的,但读起来像一团乱麻。
我记得有一次逆向一个ZKM混淆过的Java桌面应用,反编译出来的代码里全是goto和switch的乱序组合。一个简单的赋值操作,中间跳转了七八次。我当时就一个感觉:这作者是真不想让人看懂啊。
// 原始代码
int result = a + b;
if (result > 100) {
System.out.println("大");
} else {
System.out.println("小");
}
// ZKM控制流混淆后的伪代码
int result = a + b;
int flag = 0;
switch (result % 3) {
case 0: flag = 1; break;
case 1: flag = 2; break;
case 2: flag = 3; break;
}
if (flag == 1) goto L1;
if (flag == 2) goto L2;
// ... 大量无用跳转
L1: if (result > 100) { /* 实际逻辑 */ }
3. 字符串加密
这个手段很实用。你程序里硬编码的API密钥、URL地址、错误提示信息,都会被加密成一段乱码。运行时通过一个解密函数还原。反编译出来你看到的是一堆new String(decrypt(bytes))。
我曾经逆向过一个ZKM混淆的Android应用,里面所有的网络请求URL都是加密的。我花了整整两天,才从so文件里找到了解密算法的密钥。嗯,那两天我基本没怎么睡觉。
反混淆的思路
反混淆说白了就是逆向混淆过程的逆操作。但实际操作起来,比想象中麻烦得多。我总结了一套自己的流程:
- 静态分析阶段:先用反编译器(如CFR、Procyon)把class转成Java源码,看看混淆程度。
- 识别混淆器特征:ProGuard的混淆类名通常是a、b、c这种单字母;ZKM会插入大量
synthetic方法和奇怪的switch结构。 - 名字还原:通过分析调用关系,手动或半自动地给方法、字段重命名。比如一个方法里调用了
Connection和Statement,那它大概率是数据库操作,可以命名为executeQuery。 - 控制流还原:这是最头疼的部分。我一般先用工具(如
deobfuscate脚本)把goto和switch结构简化,再人工梳理。 - 字符串解密:找到解密函数,写个脚本批量解密所有加密字符串。
核心观点:反混淆不是100%还原原始源码,而是让代码变得可读、可理解。你不需要恢复原来的变量名,只要逻辑清晰就够了。
实战:ProGuard反混淆示例
咱们拿一个实际案例走一遍。假设你拿到了一个ProGuard混淆后的jar包,里面全是a.class、b.class这种文件。
第一步,用CFR反编译:
java -jar cfr.jar obfuscated.jar --outputdir ./src
打开一看,代码长这样:
public class a {
private b c;
public void a() {
this.c.a("admin");
}
}
嗯,完全不知道在干嘛。这时候我会用jd-gui配合ProGuard的mapping文件来还原。如果你有mapping文件,那就简单了:
# mapping.txt 内容示例
a -> com.example.LoginService:
b -> userDao
a() -> login()
用ProGuard自带的retrace工具就能把堆栈和类名还原回去。但问题是,很多时候你拿不到mapping文件。那怎么办?
我的做法是:基于行为推断。比如上面那个a()方法里调用了this.c.a("admin"),参数是"admin"——这明显是个用户名。那c字段很可能是个DAO或者Service,a()方法就是登录操作。我会手动把类重命名为LoginService,方法重命名为login。
ZKM反混淆的难点
ZKM比ProGuard难搞多了。它有几个让我头疼的特点:
- 插入大量synthetic桥接方法:这些方法本身不干活,就是跳转用的。反编译出来一堆空壳方法。
- 异常表混淆:ZKM会修改异常处理逻辑,让try-catch结构变得极其复杂。
- 局部变量表清空:反编译后所有变量名都是
var1、var2,没有任何语义信息。
我记得有一次,一个ZKM混淆的Java程序,反编译出来有3000多个类,其中2000多个是ZKM生成的synthetic类。我写了个脚本,根据类的继承关系和调用频率,自动过滤掉那些明显无用的类,才把核心逻辑提取出来。
常用反混淆工具对比
| 工具 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| CFR | ProGuard、基础ZKM | 反编译质量高,支持Java 8+ | 对ZKM高级混淆支持一般 |
| Procyon | 通用反编译 | 支持泛型、枚举还原 | 速度较慢 |
| JADX | Android DEX反编译 | 支持APK直接反编译 | 对纯Java jar支持不如CFR |
| Bytecode Viewer | 多引擎集成 | 同时查看字节码和源码 | 界面略复杂 |
| 自定义脚本 | ZKM深度混淆 | 可定制化高 | 需要写大量代码 |
知识体系图
下面这张图是我自己整理的Java混淆与反混淆的核心知识结构,你可以对照着理解:
避坑指南
最后,分享几个我踩过的坑:
- 别迷信自动化工具:没有哪个工具能100%还原ZKM混淆的代码。我见过有人花一周调参数,最后发现手动分析更快。
- 注意反射调用:如果目标程序大量使用反射(比如Spring框架),混淆器可能会保留这些类的原名。这是你的突破口。
- 字符串加密不一定在Java层:有些ZKM混淆会把解密逻辑放到native层(JNI),这时候你得去逆向so文件。
- 我曾经...有一次没注意异常表混淆,导致反编译出来的代码逻辑完全错了。后来我直接看字节码,才发现ZKM在异常表里藏了真正的跳转逻辑。
嗯,这一章的内容差不多就这些。混淆与反混淆是个对抗的过程,没有银弹。多动手、多分析,慢慢就能找到感觉。
公众号:蓝海资料掘金营,微信deep3321