基于 Recaf 4.x Headless 模式的 Java JAR 反混淆完整工作流
| 工具 | 用途 |
|---|---|
| Recaf 4.x (平台特定 JAR) | 字节码分析、反编译、映射、重命名(内置 Vineflower/CFR/Procyon) |
| JDK 21+ | 运行 Recaf 4.x |
| Maven / Gradle | 构建可编译工程(阶段6) |
说明:Recaf 4.x 已内置反编译引擎(Vineflower、CFR、Procyon),无需额外下载。仅在需要特定版本反编译器时,才需单独获取其 CLI JAR。
# 基本格式
java -jar recaf-<platform>.jar -h --input <target.jar> --script <script.java>
# -h : Headless 模式,不启动 GUI
# --input : 加载目标 JAR 文件
# --script : 执行 Java 脚本(编译为 Runnable 匿名类)| 变量 | 类型 | 说明 |
|---|---|---|
workspace |
Workspace |
当前工作区,包含加载的 JAR 所有类信息 |
获取其他服务需通过 CDI:
import jakarta.enterprise.inject.spi.CDI;
SomeService service = CDI.current().select(SomeService.class).get();Recaf 脚本引擎将脚本编译为匿名类执行,存在以下约束,需在编写导出脚本时遵循:
| 约束 | 应对 |
|---|---|
| 不支持匿名内部类语法 | 使用 Recaf 内置 API(如 BasicMappingsRemapper),或定义命名内部类 |
| Lambda 只能捕获 effectively final 变量 | 对 ZipOutputStream 等资源声明 final 桥接引用 |
try-with-resources 不可用于 forEach lambda 内部 |
在 lambda 外层用 try-finally 手动管理资源生命周期 |
flowchart LR
A[阶段1:结构分析] --> B[阶段2:类名恢复]
B --> C[阶段3:包名恢复]
C --> D[阶段4:方法/字段重命名]
D --> E[阶段5:导出与验证]
E --> F[阶段6:构建可编译工程]
subgraph 阶段1输出
T[第三方库过滤清单]
M[元数据线索清单]
end
subgraph 阶段2输出
S1[class mappings]
end
subgraph 阶段3输出
S2[package mappings]
end
subgraph 阶段4输出
S3[method/field mappings]
end
subgraph 阶段5输出
O[deobfuscated.jar]
end
subgraph 最终验证
P[Maven/Gradle可编译工程]
end
A -.-> T
A -.-> M
B -.-> S1
C -.-> S2
D -.-> S3
E -.-> O
F -.-> P
以下是工作流中必须依赖的 Recaf 能力(均通过 Headless 脚本使用),以及推荐的"脚本化工具"拆分方式。
workspace:脚本引擎自动注入的Workspace,用于访问 primary resource、枚举类、读取SourceFile等。CDI.current():通过 CDI 获取 Recaf 服务(如反编译服务)。
DecompilerManager:反编译验证与语义分析(getJvmDecompiler("Vineflower")+decompile(...))。MappingApplierService / MappingApplier:在"需要 Recaf 管理映射"的场景下使用(本项目导出采用 ASM Remap,可选)。
ListAppClasses(阶段1):列出类、按包名前缀过滤第三方库、定位入口点候选。DecompileClass/VerifyDecompile(阶段1/5):反编译单个或关键类用于语义推断与验证。SourceFileRename(阶段2):基于JvmClassInfo.getSourceFileName()生成类名映射(高优先级、低风险)。FullDeobfuscate(阶段2/3/4/5 一体化):合并"SourceFile 类名 + 包名 +(可选)方法/字段"映射,并通过 ASMClassRemapper导出新 JAR。
- ASM
ClassReader+ClassRemapper+ClassWriter:在 headless 下绕过 UI 导出器,直接对字节码做 Remap 并写入 Jar/Zip。- 关键注意:ZIP 条目名(
newName + ".class")应由"原名→新名"的映射表驱动,而不是依赖remapper.mapType(...)的返回值。
- 关键注意:ZIP 条目名(
- 识别第三方库 vs 应用核心代码
- 确定应用入口点
- 审计元数据,提取原始结构线索
优先级从高到低:
- MANIFEST.MF 分析:检查
Main-Class、Class-Path等属性 - 包名模式匹配:已知库的包名前缀(如
org/apache/commons/) - 类数量统计:第三方库通常类数量多且包结构规整
- 字符串常量分析:版本号、URL、License 等特征字符串
| 应用类型 | 识别方法 |
|---|---|
| 独立应用 | MANIFEST.MF → Main-Class |
| 插件/扩展 | 实现特定框架接口的类(如 IBurpExtender、Plugin 等) |
| Spring Boot | @SpringBootApplication 注解 |
| Servlet | 实现 Servlet/Filter 接口 |
混淆器通常只处理类名和包名,但 JAR 中存在多种不被混淆器处理的元数据,可直接暴露原始结构信息。这是结构分析阶段最重要的产出之一。
审计清单:
| 元数据来源 | 可提取信息 | 审计方法 |
|---|---|---|
module-info.class |
exports 指令保留原始包名 |
unzip -p target.jar module-info.class | javap -v - | grep exports |
SourceFile 属性 |
原始源文件名 → 类名 | 见阶段 2 |
InnerClasses 属性 |
内部类原始名称 | javap -v -p ClassName.class | grep InnerClass |
LocalVariableTable |
局部变量/参数原始名称 | javap -v -p ClassName.class | grep LocalVariableTable |
| 注解常量 | 字符串常量、默认值 | javap -v -p AnnotationClass.class |
| 资源文件 | 配置、SPI 声明 | jar tf target.jar | grep -v '\.class$' |
核心原则:在进入任何语义推断之前,先穷尽所有元数据线索。元数据是客观事实,语义推断是主观判断,前者永远优先。
import software.coley.recaf.workspace.model.Workspace;
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
import software.coley.recaf.workspace.model.bundle.Bundle;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
List<String> thirdPartyPrefixes = List.of(
"org/yaml/snakeyaml/", "jregex/", "org/antlr/",
"org/apache/commons/", "javax/", "kotlin/",
"okhttp3/", "org/json/", "org/jsoup/"
);
WorkspaceResource primary = workspace.getPrimaryResource();
List<String> appClasses = new ArrayList<>();
primary.jvmClassBundleStream().flatMap(Bundle::stream).forEach(cls -> {
String name = cls.getName();
boolean isThirdParty = false;
for (String prefix : thirdPartyPrefixes) {
if (name.startsWith(prefix)) { isThirdParty = true; break; }
}
if (!isThirdParty) appClasses.add(name);
});
appClasses.sort(Comparator.naturalOrder());
System.out.println("Application classes: " + appClasses.size());
for (String name : appClasses) System.out.println(" " + name);flowchart TD
A[审计 SourceFile 属性] --> B{SourceFile 有效?}
B -->|是| C[直接恢复类名]
B -->|否| D[语义推断]
D --> E[反编译分析类职责]
E --> F[推断类名]
C --> G[验证:javap 确认]
F --> G
决策原则:元数据优先,语义推断回退。
Java 编译器在 .class 文件中保留 SourceFile 属性,记录原始源文件名(如 Config.java)。即使类名被混淆为 a,SourceFile 仍为 Config.java,可直接恢复原始类名。
可靠性验证:使用 SourceFile 前必须验证其有效性。混淆器可能伪造(替换为统一字符串)或删除该属性。验证方法:
# 抽样检查 SourceFile 是否有区分度
for f in $(find . -name '*.class' -not -name 'module-info.class' | head -10); do
sourcefile=$(javap -v "$f" 2>/dev/null | grep 'SourceFile:' | awk '{print $2}')
echo "$f -> $sourcefile"
done若所有类返回相同字符串(如 SourceFile),则该属性已被伪造,需回退到语义推断。
当元数据不可用时,需逐类反编译分析其职责来推断类名:
| 分析线索 | 推断规则 |
|---|---|
| 继承关系 | extends Exception → XxxException;extends RuntimeException → XxxRuntimeException |
| 实现的接口 | @interface → 注解类;Function<A,B> → 转换/映射类 |
| 方法签名模式 | 有 parse(ByteBuffer) 方法 → XxxParser;有 build() → XxxBuilder |
| 字符串常量 | 包含领域关键词 → 类名包含该词 |
| 枚举特征 | 大量 static final Predicate 字段 + 枚举语法 → 类型定义枚举 |
import software.coley.recaf.info.JvmClassInfo;
import software.coley.recaf.workspace.model.Workspace;
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
import software.coley.recaf.workspace.model.bundle.Bundle;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
WorkspaceResource primary = workspace.getPrimaryResource();
List<String> results = new ArrayList<>();
primary.jvmClassBundleStream().flatMap(Bundle::stream).forEach(cls -> {
String name = cls.getName();
String sourceFile = cls.getSourceFileName();
if (sourceFile != null && sourceFile.endsWith(".java")) {
String originalSimpleName = sourceFile.substring(0, sourceFile.length() - 5);
String currentSimpleName = name.contains("/")
? name.substring(name.lastIndexOf('/') + 1) : name;
// 处理内部类:a$1 -> Original$1
int dollarIdx = currentSimpleName.indexOf('$');
String baseSimpleName = dollarIdx > 0
? currentSimpleName.substring(0, dollarIdx) : currentSimpleName;
String innerPart = dollarIdx > 0
? currentSimpleName.substring(dollarIdx) : "";
if (!baseSimpleName.equals(originalSimpleName)) {
String packageName = name.contains("/")
? name.substring(0, name.lastIndexOf('/')) : "";
String newFullName = packageName.isEmpty()
? originalSimpleName + innerPart
: packageName + "/" + originalSimpleName + innerPart;
if (!name.equals(newFullName)) {
results.add(name + " -> " + newFullName);
}
}
}
});
System.out.println("SourceFile-based renames: " + results.size());
for (String r : results) System.out.println(" " + r);- 不改变包名:
pkg/a/Config中的a包名不会改变 - 不改变方法/字段名:仅恢复类名
- 可能被伪造:混淆器可能替换为无意义字符串
- 可能被删除:混淆器可能移除该属性
flowchart TD
A[审计 module-info exports] --> B{exports 有效?}
B -->|是| C[直接建立包名映射]
B -->|否| D[语义推断]
D --> E[分析包内类职责]
E --> F[推断包名]
C --> G[验证:类职责与包名语义一致]
F --> G
决策原则:与类名恢复相同——元数据优先,语义推断回退。
JPMS 模块系统中的 module-info.class 的 exports 指令声明了模块对外暴露的包。混淆器通常不处理此文件,因此它保留了原始包名。
# 提取 module-info.class 中的原始包结构
unzip -p target.jar module-info.class | javap -v - 2>/dev/null | grep 'exports'将 exports 中的原始包名与混淆后的包结构建立一一映射,再通过包内类职责验证映射的正确性。
当 module-info 不可用时,通过分析包内类的职责推断包的语义名称:
- 收集信号:查看包内恢复后的类名、方法、字符串常量
- 寻找共性:这些类共同服务于什么功能/领域?
- 遵循惯例:参考 Java 社区常见的包名命名习惯
config: 配置、设置util: 工具方法api: 接口定义model: 数据模型service: 业务服务controller: 控制层
- 最后决策:选择最能概括该包功能的名称
BasicMappingsRemapper 不支持链式映射(A→B + B→C 不会产生 A→C),因此类名重命名和包名重命名必须合并为单一步骤。
包路径匹配规则:包名映射必须基于类的实际包路径(lastIndexOf('/') 前的部分)进行精确匹配,而非前缀匹配。前缀匹配会导致子包的类被错误映射到父包。
// 按包名长度降序排列,确保最长前缀优先匹配
List<Map.Entry<String, String>> sortedPkgRenames = new ArrayList<>(packageRenames.entrySet());
sortedPkgRenames.sort((a, b) -> Integer.compare(b.getKey().length(), a.getKey().length()));
for (Map.Entry<String, String> entry : classSimpleRenames.entrySet()) {
String oldName = entry.getKey();
String newSimpleName = entry.getValue();
// 提取类的实际包路径
int lastSlash = oldName.lastIndexOf('/');
String classPackage = lastSlash > 0 ? oldName.substring(0, lastSlash) : "";
// 精确匹配包名(不是前缀匹配)
String newPackage = classPackage;
for (Map.Entry<String, String> pkgEntry : sortedPkgRenames) {
if (classPackage.equals(pkgEntry.getKey())) {
newPackage = pkgEntry.getValue();
break;
}
}
String newFullName = newPackage.isEmpty() ? newSimpleName : newPackage + "/" + newSimpleName;
if (!oldName.equals(newFullName)) {
mappings.addClass(oldName, newFullName);
classNameMap.put(oldName, newFullName);
}
}| Java术语 | 对应字节码/Recaf能力 | 说明 |
|---|---|---|
| 函数 / 方法 | Method |
mappings.addMethod(...) |
变量(类成员) / 常量(static final) |
Field |
mappings.addField(...) |
| 局部变量 / 参数 | 无直接映射API | 除非有 LocalVariableTable 属性(混淆器一般会删除) |
优先级线索(从高到低):
- 字符串常量:方法内包含
"convert to xml"→ 提示 XML 转换功能 - 返回类型推断:返回
Context/Config等 → 可能是getContext()/loadConfig() - 参数类型推断:参数为
String, String且返回int→compare()/indexOf() - 调用链分析:被单例类调用 →
getInstance(),被工具类调用 →util - 接口实现:实现接口的方法名优先从接口推断
- 设计模式识别:识别 Factory、Builder、Strategy 等常见模式
Recaf 4.x 的 IntermediateMappings API 参数顺序为 (ownerName, descriptor, oldName, newName),其中 descriptor 位于 oldName 之前:
IntermediateMappings mappings = new IntermediateMappings();
// 类重命名
mappings.addClass("old/pkg/OldName", "new/pkg/NewName");
// 字段重命名 — (owner, descriptor, oldName, newName)
mappings.addField("owner/class", "Ltype/descriptor;", "oldName", "newName");
// 方法重命名 — (owner, descriptor, oldName, newName)
mappings.addMethod("owner/class", "(Lparam/Type;)Lreturn/Type;", "oldName", "newName");重要:参数顺序错误不会产生编译错误或运行时异常,但映射完全不生效。这是映射不生效时的首要排查项。
映射 API 中的 descriptor 必须与字节码中的描述符完全一致,否则映射不生效。
关键规则:
| 场景 | 规则 | 示例 |
|---|---|---|
| 普通字段 | 使用字段声明类型的描述符 | Ljava/lang/String; |
| 枚举常量 | 描述符是枚举类型本身,不是字段声明类型 | Lcom/example/TlvType; 而非 Ljava/util/function/Predicate; |
| 重载方法 | 同名方法不同描述符可映射为不同名称 | a()V → close, a(I)I → read |
验证方法:用 javap -p -v ClassName.class 查看字节码中的精确描述符。
BasicMappingsRemapper 不会自动将接口方法映射传播到实现类。对接口方法的重命名,必须为接口和每个实现类分别添加映射条目:
// 接口方法映射
mappings.addMethod("com/example/TlvElement", "()B", "is", "getType");
// 每个实现类必须单独映射
mappings.addMethod("com/example/TlvStructure", "()B", "is", "getType");
mappings.addMethod("com/example/TlvValueElement", "()B", "is", "getType");完整性检查:在提交映射前,对每个接口方法,列出其所有实现类(通过 javap 或 Recaf 的类型层次查询),确保映射覆盖完整。
| Java 类型 | 描述符 |
|---|---|
void |
V |
int |
I |
boolean |
Z |
byte |
B |
long |
J |
String |
Ljava/lang/String; |
byte[] |
[B |
List<String> |
Ljava/util/List; |
Recaf 的 PathExportingManager 在 headless 模式下会触发 UI 对话框,不可用。需使用 ASM 手动导出:
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.commons.ClassRemapper;
import software.coley.recaf.services.mapping.BasicMappingsRemapper;
BasicMappingsRemapper remapper = new BasicMappingsRemapper(mappings);
Set<String> writtenEntries = new HashSet<>();
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(new FileOutputStream(outputPath));
final ZipOutputStream finalZos = zos;
primary.jvmClassBundleStream().forEach(bundle -> {
bundle.stream().forEach(cls -> {
try {
String originalName = cls.getName();
byte[] originalBytecode = cls.getBytecode();
ClassReader cr = new ClassReader(originalBytecode);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassRemapper classRemapper = new ClassRemapper(cw, remapper);
cr.accept(classRemapper, 0);
// ZIP 条目名由 classNameMap 驱动,不依赖 remapper
String newName = classNameMap.getOrDefault(originalName, originalName);
String entryName = newName + ".class";
// 防止重复条目
if (writtenEntries.contains(entryName)) {
System.out.println("WARNING: Duplicate skipped: " + entryName);
return;
}
writtenEntries.add(entryName);
finalZos.putNextEntry(new ZipEntry(entryName));
finalZos.write(cw.toByteArray());
finalZos.closeEntry();
if (!originalName.equals(newName)) {
System.out.println(" " + originalName + " -> " + newName);
}
} catch (Exception e) {
System.out.println("ERROR: " + cls.getName() + " - " + e.getMessage());
}
});
});
} finally {
if (zos != null) { try { zos.close(); } catch (Exception ignored) {} }
}import software.coley.recaf.services.decompile.DecompilerManager;
import software.coley.recaf.services.decompile.JvmDecompiler;
import software.coley.recaf.services.decompile.DecompileResult;
DecompilerManager decompilerManager = CDI.current().select(DecompilerManager.class).get();
JvmDecompiler decompiler = decompilerManager.getJvmDecompiler("Vineflower");
DecompileResult result = decompilerManager.decompile(decompiler, workspace, target).get();
String decompiled = result.getText();- 类名是否正确恢复
- 包名是否正确恢复
- 方法/字段名是否正确恢复
- 跨类引用是否一致(import 语句正确)
- 内部类/匿名类引用是否正确
- 第三方库类未被误改
- 接口方法映射是否覆盖所有实现类
- 枚举常量描述符是否使用枚举类型本身
方案优先级:Recaf 内置反编译器(headless 脚本) > 反编译引擎 CLI
Recaf 4.x 内置 Vineflower、CFR、Procyon 三个反编译器,可通过 DecompilerManager 在 headless 脚本中直接调用,无需额外下载任何工具。
优势:
- 无需额外下载反编译引擎
- 反编译与反混淆在同一 Recaf 会话中完成,可直接对 workspace 中的类操作
- 可选择不同引擎对比反编译质量
可用引擎(通过 decompilerManager.getJvmDecompilers() 查询):
| 引擎 | 特点 |
|---|---|
Vineflower |
推荐。基于 Fernflower 改进,Java 21+ 支持最好 |
CFR |
对旧版 Java 兼容性好,Java 21+ switch 有已知缺陷 |
Procyon |
泛型推断能力较强 |
批量反编译脚本模板:
import software.coley.recaf.services.decompile.DecompilerManager;
import software.coley.recaf.services.decompile.JvmDecompiler;
import software.coley.recaf.services.decompile.DecompileResult;
import software.coley.recaf.workspace.model.Workspace;
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
import software.coley.recaf.workspace.model.bundle.Bundle;
import software.coley.recaf.info.JvmClassInfo;
import jakarta.enterprise.inject.spi.CDI;
import java.io.*;
import java.nio.file.*;
import java.util.*;
WorkspaceResource primary = workspace.getPrimaryResource();
DecompilerManager decompilerManager = CDI.current().select(DecompilerManager.class).get();
// 选择反编译引擎(优先 Vineflower,回退 CFR)
JvmDecompiler decompiler = decompilerManager.getJvmDecompiler("Vineflower");
if (decompiler == null) decompiler = decompilerManager.getJvmDecompiler("CFR");
String outputDir = "src-recaf";
new File(outputDir).mkdirs();
int success = 0, fail = 0;
var classes = primary.jvmClassBundleStream()
.flatMap(Bundle::stream)
.filter(cls -> cls instanceof JvmClassInfo)
.map(cls -> (JvmClassInfo) cls)
.toList();
for (JvmClassInfo cls : classes) {
String name = cls.getName();
try {
DecompileResult result = decompilerManager.decompile(decompiler, workspace, cls).get();
String text = result.getText();
if (text != null && !text.isEmpty()) {
String filePath = outputDir + "/" + name.replace('/', File.separatorChar) + ".java";
new File(filePath).getParentFile().mkdirs();
Files.writeString(Path.of(filePath), text);
success++;
} else {
System.out.println("EMPTY: " + name);
fail++;
}
} catch (Exception e) {
System.out.println("FAIL: " + name + " - " + e.getMessage());
fail++;
}
}
System.out.println("Done: " + success + " succeeded, " + fail + " failed");注意:此脚本需配合
--input加载反混淆后的 JAR(阶段5的输出),而非原始混淆 JAR。
当 Recaf 内置引擎无法满足需求时(如需要特定版本的反编译器),可直接使用引擎 CLI:
# Vineflower CLI(推荐)
java -jar vineflower.jar deobfuscated.jar src
# CFR CLI
java -jar cfr.jar deobfuscated.jar --outputdir src
# Procyon CLI
java -jar procyon-decompiler.jar -jar deobfuscated.jar -o src反编译器在处理 Java 21+ 字节码时存在系统性缺陷,反编译输出不能直接编译。需建立适配层,将反编译输出修复为合法 Java 源码:
| 缺陷类型 | 根因 | 修复模式 |
|---|---|---|
MatchException 引用 |
Java 21 switch 表达式的内部异常类泄露到反编译输出 | 删除 default: { throw new MatchException } 分支,让 default 落入 switch 后的异常处理 |
Switch 表替换 1.DH[ordinal] |
编译器将 enum switch 优化为查找表,反编译器未能还原 | 将 switch-table 模式转换为 enum switch 语句 |
| 变量类型复用 | 编译器复用局部变量槽位,反编译器将不同类型赋值给同一变量 | 为每种类型引入独立的局部变量 |
枚举类反编译为 extends Enum |
反编译器输出语法不合法 | 改写为 enum 关键字声明 |
| 泛型擦除 | 反编译器无法恢复擦除前的泛型参数 | 根据上下文推断并补全泛型通配符 |
不同引擎的适配差异:
| 缺陷 | Vineflower | CFR | Procyon |
|---|---|---|---|
| MatchException | 较少出现 | 频繁出现 | 较少出现 |
| Switch-table | 可能出现 | 频繁出现 | 较少出现 |
| 变量类型复用 | 偶尔出现 | 频繁出现 | 较少出现 |
| 枚举 extends Enum | 不出现 | 频繁出现 | 不出现 |
| 泛型擦除 | 部分恢复 | 部分恢复 | 恢复较好 |
反编译后的源码通常需要手动修复才能通过编译。修复模式按优先级:
- 类型推断修复:变量复用 → 引入独立变量
- Switch 语句修复:MatchException / switch-table → 标准 enum switch
- 泛型修复:原始类型 → 正确的泛型参数
- 抽象方法实现:反编译遗漏
@Override方法 → 添加实现 - 注解方法调用:
annotation.value()在Annotation基类型上 → 先强转为具体注解类型
mkdir -p src/main/java
cp -r <反编译输出>/com src/main/java/
# 创建 pom.xml(根据依赖分析结果)
mvn compile| 服务类 | 用途 |
|---|---|
DecompilerManager |
反编译(Vineflower/Cfr/Fernflower) |
MappingApplierService |
映射应用(inCurrentWorkspace() 获取 MappingApplier) |
WorkspaceManager |
工作区管理 |
PathExportingManager |
导出(headless 下不可用,需手动导出) |
问题:映射 A→B 和 B→C 不会自动产生 A→C。
解决:在构建映射时,将所有变换合并为单一步骤。
排查清单(按优先级排序):
- API 参数顺序:确认使用
(owner, desc, oldName, newName)顺序 - 描述符精确匹配:用
javap -p -v验证描述符与字节码完全一致 - 枚举常量描述符:枚举常量的描述符是枚举类型本身
- 接口-实现覆盖:实现类需要单独添加方法映射
问题:remapper.mapType() 可能返回 null 或原始名称。
解决:维护独立的 Map<String, String> classNameMap 用于 ZIP 条目名映射。
解决:改用 ClassWriter.COMPUTE_FRAMES,或使用 ClassWriter(0) 并手动处理帧。
解决:在 putNextEntry 前用 Set<String> writtenEntries 检查重复。
将所有阶段合并为一个可执行的 Recaf 脚本:
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.commons.ClassRemapper;
import software.coley.recaf.info.JvmClassInfo;
import software.coley.recaf.services.mapping.IntermediateMappings;
import software.coley.recaf.services.mapping.BasicMappingsRemapper;
import software.coley.recaf.workspace.model.Workspace;
import software.coley.recaf.workspace.model.resource.WorkspaceResource;
import software.coley.recaf.workspace.model.resource.WorkspaceResourceBuilder;
import software.coley.recaf.workspace.model.bundle.JvmClassBundle;
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.zip.*;
// ===== 配置区 =====
List<String> thirdPartyPrefixes = List.of(/* 第三方库包名前缀 */);
Map<String, String> packageRenames = new LinkedHashMap<>();
// packageRenames.put("old/pkg", "new/pkg");
// 类名映射(SourceFile 或语义推断)
Map<String, String> classSimpleRenames = new LinkedHashMap<>();
// classSimpleRenames.put("old/pkg/OldClass", "NewClass");
// classSimpleRenames.put("old/pkg/OldClass$a", "NewClass$Inner");
String outputJarPath = "output-deobfuscated.jar";
// ===== 阶段 2+3:构建类名+包名映射 =====
WorkspaceResource primary = workspace.getPrimaryResource();
IntermediateMappings mappings = new IntermediateMappings();
Map<String, String> classNameMap = new LinkedHashMap<>();
// 按包名长度降序排列,确保最长前缀优先匹配
List<Map.Entry<String, String>> sortedPkgRenames = new ArrayList<>(packageRenames.entrySet());
sortedPkgRenames.sort((a, b) -> Integer.compare(b.getKey().length(), a.getKey().length()));
for (Map.Entry<String, String> entry : classSimpleRenames.entrySet()) {
String oldName = entry.getKey();
String newSimpleName = entry.getValue();
// 提取类的实际包路径(精确匹配,不是前缀匹配)
int lastSlash = oldName.lastIndexOf('/');
String classPackage = lastSlash > 0 ? oldName.substring(0, lastSlash) : "";
// 查找包名映射
String newPackage = classPackage;
for (Map.Entry<String, String> pkgEntry : sortedPkgRenames) {
if (classPackage.equals(pkgEntry.getKey())) {
newPackage = pkgEntry.getValue();
break;
}
}
String newFullName = newPackage.isEmpty() ? newSimpleName : newPackage + "/" + newSimpleName;
if (!oldName.equals(newFullName)) {
mappings.addClass(oldName, newFullName);
classNameMap.put(oldName, newFullName);
}
}
// ===== 阶段 4:方法/字段映射 =====
// API 参数顺序:(owner, descriptor, oldName, newName)
// 方法映射
// mappings.addMethod("owner/class", "(Lparam/Type;)Lreturn/Type;", "oldName", "newName");
// 字段映射
// mappings.addField("owner/class", "Ltype/descriptor;", "oldName", "newName");
// 枚举常量:描述符是枚举类型本身
// mappings.addField("com/example/TlvType", "Lcom/example/TlvType;", "oldName", "BYTE");
// 接口-实现覆盖:为每个实现类单独映射
// mappings.addMethod("com/example/TlvElement", "()B", "is", "getType");
// mappings.addMethod("com/example/TlvStructure", "()B", "is", "getType");
// ===== 阶段 5:导出 =====
BasicMappingsRemapper remapper = new BasicMappingsRemapper(mappings);
Set<String> writtenEntries = new HashSet<>();
ZipOutputStream zos = null;
try {
zos = new ZipOutputStream(new FileOutputStream(outputJarPath));
final ZipOutputStream finalZos = zos;
primary.jvmClassBundleStream().forEach(bundle -> {
bundle.stream().forEach(cls -> {
try {
String originalName = cls.getName();
byte[] originalBytecode = cls.getBytecode();
ClassReader cr = new ClassReader(originalBytecode);
ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassRemapper classRemapper = new ClassRemapper(cw, remapper);
cr.accept(classRemapper, 0);
String newName = classNameMap.getOrDefault(originalName, originalName);
String entryName = newName + ".class";
if (writtenEntries.contains(entryName)) {
System.out.println("WARNING: Duplicate skipped: " + entryName);
return;
}
writtenEntries.add(entryName);
finalZos.putNextEntry(new ZipEntry(entryName));
finalZos.write(cw.toByteArray());
finalZos.closeEntry();
if (!originalName.equals(newName)) {
System.out.println(" " + originalName + " -> " + newName);
}
} catch (Exception e) {
System.out.println("ERROR: " + cls.getName() + " - " + e.getMessage());
}
});
});
} finally {
if (zos != null) { try { zos.close(); } catch (Exception ignored) {} }
}
System.out.println("Done: " + outputJarPath);
System.out.println("Class mappings: " + classNameMap.size());- Recaf 官方文档:https://recaf.coley.software/
- GitHub 仓库:https://github.com/Col-E/Recaf
- ASM 文档:https://asm.ow2.io/
flowchart TD
A[元数据优先原则] --> A1[SourceFile → 类名]
A --> A2[module-info exports → 包名]
A --> A3[InnerClasses → 内部类名]
A --> A4[LocalVariableTable → 参数名]
B[映射原子化原则] --> B1[类名+包名合并为单步映射]
B --> B2[不依赖链式映射]
C[描述符精确匹配原则] --> C1[javap 验证描述符]
C --> C2[枚举常量用枚举类型作描述符]
D[映射完整性原则] --> D1[接口方法覆盖所有实现类]
D --> D2[提交前验证映射条目数]
E[反编译适配原则] --> E1[反编译输出 ≠ 可编译源码]
E --> E2[建立适配层修复系统性缺陷]
classDef principle fill:#fbb,stroke:#333,stroke-width:2px;
class A,B,C,D,E principle;
- 元数据优先:穷尽所有元数据线索后再进行语义推断。元数据是客观事实,语义推断是主观判断。
- 映射原子化:所有维度的重命名(类名、包名、方法名、字段名)合并为单一步骤提交,不依赖链式映射。
- 描述符精确匹配:映射 API 的描述符必须与字节码完全一致,用
javap -p -v逐条验证。枚举常量的描述符是枚举类型本身。 - 映射完整性:接口方法映射必须覆盖所有实现类。提交前验证映射条目数是否与预期一致。
- 反编译适配:反编译器输出不等于可编译源码,需建立适配层修复系统性缺陷(变量复用、switch-table、MatchException 等)。