简介:flaming-shame 是一款面向 Java 逆向工程与安全分析场景的开源反混淆工具,适合需要阅读、调试被混淆字节码的开发者与安全研究人员。它通过静态分析手段比较 Java 程序的结构图,尝试自动还原被混淆的类名、方法名与变量名,降低人工逆向成本;受字节码优化、多态与动态绑定影响,其映射结果属于最佳猜测而非精确还原,使用时需结合人工判断。资源包共 23 个文件,以 18 个 Java 源码为核心,另含 README 说明文档、LICENSE 授权文件、.gitignore 及 asm 相关 jar 与源码压缩包,整体约 550KB,便于直接阅读实现或二次开发。目前已有 455 人学习下载。借助源码与说明,读者可理解基于 ASM 字节码操作的反混淆思路、结构图比对算法及工程组织方式,为分析混淆代码、研究代码保护与还原技术提供可运行的参考实现。
1. flaming-shame:当混淆后的 Java 字节码变成一坨黑匣子
接手一个老系统做二次开发,反编译出来的代码里全是a、b、c这种类名,方法体里塞满了无意义的异或运算和永远为真的分支跳转,字符串常量被拆成单字符数组再拼回去——这种场景做过 Java 逆向的人都不陌生。flaming-shame 就是冲着这个痛点来的:一个用 Java 写的 Java 反混淆工具,目标是把经过 ProGuard、Allatori、ZKM 这类混淆器处理过的字节码,尽可能还原成可读、可调试、可继续维护的形态。它不依赖反编译器做文本层面的正则替换,而是直接操作 class 文件的常量池、指令序列和异常表,在字节码层面做模式识别与语义恢复。适合谁用?做安全审计的、维护遗留系统的、搞 CTF 逆向题的,以及需要分析第三方 SDK 内部逻辑的 Android 开发者。前提是你得能看懂 javap 的输出,知道什么是栈帧映射表,否则工具跑完了你也不知道它改了什么。
2. 字节码层面的反混淆:为什么不能只靠正则替换
2.1 混淆器到底改了哪些东西
要理解 flaming-shame 的设计,先得搞清楚主流 Java 混淆器在字节码里动了什么手脚。常见手段分四类:标识符重命名、控制流平坦化、字符串加密、元信息擦除。重命名最好处理,改常量池里的 Utf8 项就行。控制流平坦化麻烦得多——原本线性的指令被拆成基本块,塞进一个 while-switch 分发器里,每个块执行完修改一个状态变量,switch 再根据状态变量跳到下一个块。字符串加密通常是静态方法调用,传入密文和密钥,运行时解密后返回。元信息擦除包括删除 LocalVariableTable、LineNumberTable,让调试信息全部丢失。
flaming-shame 的处理策略是分阶段流水线:先做常量池分析,再做控制流图重建,然后识别字符串解密模式,最后做指令简化。每个阶段输出中间表示,方便你单独调试某一阶段的输出是否符合预期。
2.2 控制流平坦化的识别与还原
控制流平坦化的核心特征是存在一个分发变量,它的值在循环体内被修改,且 switch 的 case 标签与基本块的入口地址一一对应。flaming-shame 的做法是先用 ASM 的 AnalyzerAdapter 构建控制流图,然后找强连通分量,识别出分发器模式。
// 用 ASM 构建控制流图并定位分发器 ClassReader cr = new ClassReader(classBytes); ClassNode cn = new ClassNode(); cr.accept(cn, ClassReader.EXPAND_FRAMES); for (MethodNode mn : cn.methods) { // 跳过抽象方法和构造器 if ((mn.access & Opcodes.ACC_ABSTRACT) != 0) continue; if ("<init>".equals(mn.name)) continue; // 用 AnalyzerAdapter 追踪栈帧,构建基本块 AnalyzerAdapter analyzer = new AnalyzerAdapter(cn.name, mn.access, mn.name, mn.desc, mn); mn.accept(analyzer); // 遍历指令,找 switch 指令和对应的状态变量 AbstractInsnNode[] insns = mn.instructions.toArray(); for (int i = 0; i < insns.length; i++) { if (insns[i].getOpcode() == Opcodes.TABLESWITCH) { TableSwitchInsnNode tsin = (TableSwitchInsnNode) insns[i]; // 检查 switch 的 key 是否来自一个局部变量 // 且该变量在循环体内被重新赋值 // 满足条件则标记为候选分发器 System.out.println("发现候选分发器: " + mn.name + mn.desc + " 位置: " + i + " case数: " + tsin.labels.size()); } } }这段代码的逻辑是:对每个非抽象方法,用 AnalyzerAdapter 模拟执行一遍,拿到每条指令处的栈帧状态。然后扫描 TABLESWITCH 指令,如果 switch 的索引来自局部变量,且该变量在 switch 之前被赋值、在 case 块内被修改,就标记为候选分发器。参数说明:EXPAND_FRAMES让 ASM 展开栈帧映射表,方便后续修改;AnalyzerAdapter是 ASM 的 tree API 里做数据流分析的核心类,它会自动处理类型合并。实际跑的时候你会发现,一个方法里可能有多个 switch,但只有那个被循环包裹的才是真正的分发器。
识别出分发器之后,还原的步骤是:按 case 标签的顺序提取基本块,根据状态变量的赋值关系重建跳转边,最后用 GOTO 指令把基本块串起来。这个过程需要处理嵌套分发器和虚假分支——有些混淆器会插入永远为真的条件跳转,比如if (1 > 0),这种在字节码里表现为ICONST_1, IFEQ的固定模式,直接删掉即可。
2.3 字符串解密的模式匹配
字符串加密的还原相对直接,但需要处理多种变体。常见模式是:LDC 密文→INVOKESTATIC 解密方法→ASTORE。解密方法内部可能是异或、AES、Base64 或者自定义算法。flaming-shame 的策略是先用启发式规则定位解密方法,然后尝试用反射调用它来批量解密。
// 定位并调用字符串解密方法 for (MethodNode mn : cn.methods) { // 解密方法通常满足:静态、返回 String、参数少、方法体小 if ((mn.access & Opcodes.ACC_STATIC) == 0) continue; if (!mn.desc.endsWith(")Ljava/lang/String;")) continue; if (mn.instructions.size() > 200) continue; // 太大的不是解密方法 // 检查方法体内是否有 XOR 或数组操作 boolean hasXor = false; for (AbstractInsnNode insn : mn.instructions) { if (insn.getOpcode() == Opcodes.IXOR) { hasXor = true; break; } } if (!hasXor) continue; // 尝试反射调用:先加载类,再调用方法 try { Class<?> clazz = Class.forName(cn.name.replace('/', '.')); Method target = clazz.getDeclaredMethod(mn.name, String.class); target.setAccessible(true); // 遍历所有 LDC 指令,找到密文并解密 for (AbstractInsnNode insn : mn.instructions) { if (insn.getOpcode() == Opcodes.LDC) { LdcInsnNode ldc = (LdcInsnNode) insn; if (ldc.cst instanceof String) { String decrypted = (String) target.invoke(null, ldc.cst); // 用解密后的字符串替换原指令 mn.instructions.set(insn, new LdcInsnNode(decrypted)); } } } } catch (Exception e) { // 反射失败则回退到静态分析 System.err.println("反射解密失败: " + mn.name + " -> " + e.getMessage()); } }逻辑说明:先筛选出可能的解密方法——静态、返回 String、参数不超过两个、指令数少、包含 XOR 操作。然后通过反射加载类并调用该方法,把方法体内所有 LDC 字符串常量替换成解密后的值。参数方面,setAccessible(true)是为了绕过私有方法访问限制;mn.instructions.set()是 ASM 提供的指令替换 API,会保持指令链表结构不变。注意反射调用有风险:如果解密方法依赖外部状态或做了副作用操作,可能会失败或产生意外结果,所以代码里加了 try-catch 回退到静态分析。
3. 用 flaming-shame 跑通第一个混淆样本
3.1 环境准备与最小依赖
flaming-shame 本身是 Java 项目,构建工具用 Maven 或 Gradle 都行。核心依赖是 ASM 9.x,用于字节码读写。如果你要处理 Java 8 以上的 class 文件,ASM 版本不能低于 9.0,否则会报Unsupported class file major version。JDK 建议用 11 或 17,太老的 JDK 对 invokedynamic 指令支持不完整。
# 克隆项目并构建(假设你已经有了源码) git clone <flaming-shame-repo> cd flaming-shame mvn clean package -DskipTests # 构建产物在 target/ 下,通常是一个可执行 jar ls target/*.jar构建完成后,你会得到一个包含所有依赖的 fat jar。运行方式有两种:命令行模式和嵌入模式。命令行模式适合快速验证,嵌入模式适合集成到自己的分析流水线里。
3.2 命令行参数与输入输出约定
flaming-shame 的命令行接口设计得比较克制,核心参数就几个:
| 参数 | 含义 | 默认值 | 备注 |
|---|---|---|---|
-i | 输入路径 | 无 | 可以是单个 class 文件或目录 |
-o | 输出路径 | 无 | 目录不存在会自动创建 |
-p | 处理阶段 | all | 可选 cp/cf/str/all |
-v | 日志级别 | info | debug 会打印每条指令的修改 |
-d | 是否反编译 | false | 开启后调用 CFR 输出 Java 源码 |
处理阶段参数-p很关键。cp只做常量池重命名,cf只做控制流还原,str只做字符串解密,all是全流水线。实际用的时候建议分阶段跑:先-p cp看重命名效果,再-p cf看控制流还原是否引入错误,最后-p str处理字符串。一次性跑all出问题了很难定位是哪个阶段引入的。
# 分阶段处理一个混淆过的 jar 包 java -jar flaming-shame.jar -i obfuscated.jar -o output/ -p cp -v debug java -jar flaming-shame.jar -i output/ -o output2/ -p cf -v debug java -jar flaming-shame.jar -i output2/ -o final/ -p str -v debug注意输入如果是 jar 包,工具会自动解压并遍历所有 class 文件。输出目录会保持原有的包结构。-v debug会打印每个被修改的类和方法名,方便你对照 javap 的输出验证。
3.3 验证反混淆效果:用 javap 和 CFR 交叉检查
跑完 flaming-shame 之后,怎么知道效果好不好?最直接的办法是用 javap 看字节码,再用 CFR 反编译看 Java 源码。
# 用 javap 查看反混淆后的方法指令 javap -c -p -v output/com/example/TargetClass.class > after.txt # 用 CFR 反编译成 Java 源码 java -jar cfr.jar output/com/example/TargetClass.class > after.java # 对比反混淆前后的差异 diff <(javap -c -p original/TargetClass.class) <(javap -c -p output/TargetClass.class)验证要点:控制流还原后,方法体内不应该再有 TABLESWITCH 分发器;字符串解密后,LDC 指令加载的应该是可读文本而不是乱码;重命名后,类名和方法名应该符合 Java 命名规范。如果 CFR 反编译出来的代码能通过 javac 编译,说明字节码结构没有被破坏。这一步很关键——有些反混淆操作会生成非法的栈帧映射,导致 JVM 拒绝加载,CFR 反编译也可能崩溃。
4. 避坑指南:反混淆翻车的五种典型场景
4.1 反射解密触发类初始化异常
现象:跑字符串解密阶段时,工具报ExceptionInInitializerError或NoClassDefFoundError,然后整个类被跳过。
原因:解密方法所在的类可能有静态初始化块,依赖了外部资源或做了环境检查。反射调用Class.forName会触发类初始化,如果初始化失败,后续所有操作都做不了。
解决:改用Class.forName(name, false, loader)禁止初始化,然后手动调用解密方法。但这样又可能因为静态字段未初始化而失败。折中方案是先用静态分析提取解密算法,再用纯计算的方式批量解密,不依赖反射。flaming-shame 在检测到反射失败后会自动回退到静态分析模式,但静态分析只能处理简单的异或和位移运算,遇到 AES 就无能为力了。
4.2 控制流还原后栈帧映射表错位
现象:反混淆后的 class 文件用java -verify加载时报VerifyError: Bad local variable type。
原因:ASM 在修改指令后没有重新计算栈帧映射表(StackMapTable)。Java 7 以后 JVM 要求 class 文件必须包含正确的栈帧信息,手动修改指令很容易让原有的映射表失效。
解决:用 ASM 的COMPUTE_FRAMES选项重新计算。但COMPUTE_FRAMES需要加载所有依赖类来推断类型,如果类路径不完整会报TypeNotPresentException。实际做法是先用COMPUTE_MAXS跑一遍,再用ClassWriter的getCommonSuperClass方法自定义类加载逻辑,把缺失的类用占位符替代。
// 自定义 ClassWriter 处理缺失的依赖类 ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES) { @Override protected String getCommonSuperClass(String type1, String type2) { try { return super.getCommonSuperClass(type1, type2); } catch (TypeNotPresentException e) { // 依赖类找不到时,退化为 Object return "java/lang/Object"; } } };4.3 字符串解密方法被内联导致模式匹配失效
现象:明明知道某个类里有加密字符串,但工具就是识别不出解密方法。
原因:混淆器在优化阶段把解密方法内联到了调用点,原本的INVOKESTATIC变成了直接的指令序列。这时候没有独立的方法可以反射调用,模式匹配自然失效。
解决:需要做指令序列的模式识别,而不是方法级别的匹配。具体来说,就是扫描方法体,找LDC后面跟着一串算术运算和数组操作的片段,尝试提取其中的常量并模拟执行。这个难度比方法级匹配高一个数量级,flaming-shame 目前只支持简单的异或内联模式,复杂的需要手动写规则。
4.4 处理大 jar 包时内存溢出
现象:java -jar flaming-shame.jar -i huge.jar跑了几分钟就抛OutOfMemoryError: Java heap space。
原因:工具默认把整个 jar 的所有 class 读进内存再处理,大 jar 包动辄几万个类,堆内存不够用。
解决:加 JVM 参数-Xmx4g起步,或者用流式处理模式。flaming-shame 支持--stream参数,逐个 class 读取、处理、写出,不保留中间结果。代价是无法做跨类的全局分析,比如继承关系推断和接口实现检查会受限。实际用的时候,如果 jar 包超过 500MB,建议先解压再用目录模式处理,配合--stream和-Xmx8g。
4.5 反混淆后的代码逻辑与原始行为不一致
现象:反混淆后的代码能编译能运行,但跑出来的结果和混淆版本不一样。
原因:控制流还原时错误地合并了基本块,或者字符串解密时用错了密钥。更隐蔽的情况是,混淆器故意插入了虚假的解密路径——同一个密文用不同密钥解密会得到不同结果,只有一个是对的。
解决:建立回归测试。用原始混淆版本和反混淆版本分别跑同一组输入,对比输出。如果行为不一致,用-v debug定位到具体的方法和指令,逐步回退修改。flaming-shame 提供了--dry-run模式,只分析不修改,输出每个阶段的决策日志,方便你判断哪一步可能出了问题。
5. 进阶技巧:把反混淆接入自动化分析流水线
5.1 用 Java Agent 做运行时反混淆
静态反混淆有个天然局限:有些字符串是在运行时动态拼接的,静态分析拿不到最终值。这时候可以用 Java Agent 在类加载时做转换。思路是写一个ClassFileTransformer,在transform方法里调用 flaming-shame 的处理逻辑,把解密后的字符串直接写回字节码。
public class DeobfuscateAgent { public static void premain(String args, Instrumentation inst) { inst.addTransformer(new ClassFileTransformer() { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain pd, byte[] classfileBuffer) { // 跳过 JDK 自身的类 if (className.startsWith("java/") || className.startsWith("sun/")) { return null; } try { // 调用 flaming-shame 的核心处理逻辑 return Deobfuscator.process(classfileBuffer); } catch (Exception e) { // 处理失败时返回原始字节码,不影响程序运行 return null; } } }); } }这个 Agent 的用法是java -javaagent:deobfuscate-agent.jar -jar target.jar。关键点:transform方法返回 null 表示不修改,返回字节数组表示用新字节码替换。异常处理必须兜底,否则一个类处理失败会导致整个应用启动失败。另外,Agent 模式下无法做跨类分析,只能做方法内的局部优化,所以字符串解密和简单的控制流还原可以做,复杂的继承关系推断做不了。
5.2 批量处理与结果比对
如果你要处理多个样本,手动一个个跑效率太低。写个脚本批量执行,然后用哈希值比对处理前后的差异。
#!/bin/bash # 批量反混淆并生成报告 INPUT_DIR="samples/" OUTPUT_DIR="results/" REPORT="report.txt" > "$REPORT" # 清空报告文件 for jar in "$INPUT_DIR"*.jar; do name=$(basename "$jar" .jar) out="$OUTPUT_DIR$name/" mkdir -p "$out" # 记录处理前的类数量 before=$(unzip -l "$jar" | grep -c '\.class$') # 执行反混淆 java -Xmx4g -jar flaming-shame.jar -i "$jar" -o "$out" -p all 2>> "$REPORT" # 记录处理后的类数量 after=$(find "$out" -name '*.class' | wc -l) echo "$name: before=$before after=$after" >> "$REPORT" done这个脚本的逻辑很直白:遍历输入目录下所有 jar,逐个调用 flaming-shame,记录处理前后的 class 文件数量。数量不一致说明有类在处理过程中丢失了,需要单独排查。报告文件里还会记录工具的标准错误输出,方便定位失败原因。实际用的时候建议加个超时控制,用timeout 300 java -jar ...防止某个样本卡死导致整个批次中断。
5.3 一个我常踩的坑:别在反混淆后的代码上直接做二次开发
最后说个血泪经验。反混淆工具的输出看起来像正常代码,但它终究是机器生成的,变量命名可能还是var1、var2,控制流虽然还原了但结构可能很别扭。我见过有人直接把反混淆结果当成源码基线,在上面改业务逻辑,结果下次混淆版本更新,反混淆结果完全对不上,之前改的东西全丢了。正确做法是:反混淆结果只用于理解和分析,真正的修改要么基于原始源码(如果你能拿到),要么在反混淆结果上做最小化补丁并单独维护。把反混淆当成调试手段,别当成版本管理方案。希望帮到你。
本文还有配套的精品资源,点击获取