我的作品 --- 安卓安全 之 同形异义字符混淆
视觉上相似但含义不同的字符(大写I、小写l、小写i、数字1、大写L),恶意开发者用它们替换正常命名,这个项目旨在去除这些无意义的字符
https://gitee.com/null_465_7266/fapkobfs
DEX 混淆名称替换:完整技术文档
前言
本文系统梳理 DEX 中替换混淆名称的所有常用方法,并给出每种方法的核心约束、适用场景与实现要点。
一、DEX 中需要处理的名称范围
混淆不只发生在类名,涉及范围如下表:
| 对象 | 示例 | 处理重点 |
|---|---|---|
| 类描述符 | Lcom/a/Il1I; |
必须同时更新所有类型引用 |
| 包名 | com/a/b |
Manifest、反射、JNI 可能引用 |
| 方法名 | lIlI() |
重写方法定义及调用引用 |
| 字段名 | I1l1 |
重写字段定义及读写引用 |
| 参数/局部变量名 | p0、IlI |
位于 debug_info,可单独清理 |
| 源文件名 | a.java |
位于 source_file_idx |
| 泛型签名 | Signature 注解 |
可能包含类描述符 |
| 内部类信息 | InnerClass、EnclosingClass |
名称需要同步 |
| Kotlin 元数据 | kotlin.Metadata |
简单替换可能破坏反编译结果 |
| 注解 | 类名、枚举、方法引用 | 必须按编码值解析 |
| 普通字符串 | "com.a.Il1I" |
可能用于反射,也可能只是文本 |
| MethodHandle/CallSite | 动态调用引用 | 新版 DEX 中需要检查 |
| Manifest 组件名 | Activity/Service 等 | APK 中同步更新 |
| JNI 符号 | Java_com_a_b_method |
SO 中可能存在硬编码符号 |
二、所有常用替换方法
方法 1:反编译器显示层重命名
在 JADX、JEB、Ghidra、IDA 等工具里为类、方法和字段设置别名。
特点:
- 不修改原始 DEX
- 风险最低
- 适合分析和阅读
- 导出的伪代码名称变了,但 APK 实际运行名称没变
JADX 可自动处理:
- 不可打印名称
- Unicode 混淆字符
- 名称冲突
- 保留字
- 大小写冲突
适用场景: 只想看懂代码,不需要生成可运行 APK。
方法 2:使用原始 Mapping 恢复
如果能够得到 R8/ProGuard 的 mapping.txt,这是最准确的方法。
com.example.LoginActivity -> a.b:
void login() -> a
java.lang.String token -> b
可以:
- 在反编译器中加载 mapping
- 用 ReTrace 恢复堆栈
- 根据 mapping 批量重命名
- 构建"旧名称 → 新名称"映射表
优点: 能恢复真实语义。
缺点: 没有原始 mapping 时无法使用。
方法 3:Smali 拆解、替换、重组
典型流程:
classes.dex
↓ baksmali
smali 文件
↓ 按映射修改描述符、方法名、字段名
smali
↓ smali
new_classes.dex
示例,原始:
.class public Lcom/a/Il1I;
.super Landroid/app/Activity;
修改后:
.class public Lcom/example/LoginActivity;
.super Landroid/app/Activity;
所有引用也必须同步:
new-instance v0, Lcom/example/LoginActivity;
invoke-virtual {v0}, Lcom/example/LoginActivity;->startLogin()V
优点:
- 实现直观
- 容易人工检查
- 适合少量修改
缺点:
- 会重新排列字符串池、类型池、方法池
- DEX 偏移、文件大小、部分索引可能改变
- 大型 DEX 处理速度较慢
- 容易遗漏反射字符串和外部资源
方法 4:使用 dexlib2 解析后整体重写
这是开发自动化去混淆工具时最推荐的方法之一。
流程:
- 加载 DEX
- 遍历 ClassDef
- 为类、字段、方法建立映射
- 使用 Rewriter 重写定义和引用
- 重新生成 DEX
核心重写对象通常包括:
TypeRewriter
FieldReferenceRewriter
MethodReferenceRewriter
MethodProtoReferenceRewriter
AnnotationRewriter
EncodedValueRewriter
InstructionRewriter
DebugItemRewriter
类名转换示意:
fun renameType(type: String): String {
return classMapping[type] ?: type
}
不能只修改 ClassDef.type,必须连同以下内容一起处理:
- superclass
- interfaces
- field type
- method return type
- parameter types
- 指令中的 type/field/method reference
- catch handler exception type
- annotations
- method handles
- call sites
优点:
- 适合批量自动化
- 可以严格区分定义、引用和普通字符串
- 比文本替换可靠
缺点:
- 重新生成的 DEX 结构通常会变化
- 需要正确处理多 DEX
- 对 Kotlin metadata、反射、JNI 仍需额外分析
方法 5:直接操作 DEX 字符串池
DEX 的很多名称最终由 string_ids 指向 string_data_item。
可以:
- 解析
string_ids - 找到字符串数据位置
- 判断它属于类名、方法名、字段名还是普通字符串
- 修改字符串
- 更新 DEX 校验信息
这种方法又分两种:
5.1 原位等长替换
例如原字符串:
Il1I
替换为相同编码长度的:
ClsA
优点:
- 可以保持
string_ids索引不变 - 可以保持后续数据偏移不变
- 指令中使用的字符串索引不变
- 对"保持 DEX 结构"最友好
严格要求:
旧字符串的 MUTF-8 字节数 = 新字符串的 MUTF-8 字节数
旧 UTF-16 code unit 数 = 新 UTF-16 code unit 数
不能只比较 Kotlin/Java 的 String.length,因为:
- 中文字符与 ASCII 编码字节数不同
- 补充平面字符可能占两个 UTF-16 code unit
- DEX 使用 Modified UTF-8
utf16_size使用 ULEB128- 字符串以
0x00结尾
修改后还要更新:
- DEX header 中的 SHA-1 signature
- DEX header 中的 Adler-32 checksum
- APK 签名
5.2 重新分配字符串数据
如果新名称长度不同,就需要:
- 创建新的
string_data_item - 修改
string_id_item.string_data_off - 调整数据区、map、offset
- 更新 file size、data size
- 更新 SHA-1、Adler-32
这已经接近重新构建 DEX,难以保证原始布局不变。
方法 6:直接二进制搜索替换
直接在 DEX 字节数组中搜索混淆字符串并替换。
只适用于:
- 已确认命中的位置是
string_data_item - 新旧 MUTF-8 长度完全相等
- 不涉及共享字符串的歧义
- 不改变终止符和
utf16_size
不能简单使用:
bytes.replace(oldBytes, newBytes)
因为相同文本可能同时用于:
- 方法名称
- 字段名称
- 日志文本
- 反射字符串
- 注解
- 资源路径
如果字符串池中的同一个字符串条目被多个对象共享,修改一次会影响全部引用。例如方法和字段可能同时叫 a,但你只想修改方法,这时不能直接改共享字符串,必须新增不同字符串条目并修改对应索引——DEX 结构随之改变。
方法 7:利用 ASM/dex2jar 转换后重写
一种间接方法:
DEX → JVM class/JAR → ASM 重命名 → DEX
可以使用:
- dex2jar
- ASM
- D8
这种方法适合实验或分析,但不适合要求保持 DEX 结构的场景,因为转换过程中可能发生:
- 指令重构
- 寄存器重新分配
- 异常表变化
- debug 信息丢失
- DEX 特有语义转换失败
- Kotlin/Invoke-custom 兼容问题
方法 8:基于规则自动生成可读名称
没有 mapping 时,可以根据规则生成稳定的新名称。
例如:
Lcom/a/Il1I; → Lcom/deobfuscated/Class0001;
方法 lIlI()V → method0001()V
字段 I1l1:I → field0001:I
更实用的命名依据包括:
| 依据 | 示例 |
|---|---|
| 父类 | Activity001、Fragment003 |
| 接口 | Runnable005、Callback012 |
| 方法返回类型 | requestResponse |
| Android 生命周期方法 | — |
| 字段类型 | database(SQLiteDatabase) |
| 字符串常量 | — |
| 资源 ID | — |
| 注解 | — |
| 网络路径 | — |
| XML 布局名称 | — |
建议生成确定性名称:
新名称 = 类型分类 + 原始描述符哈希/稳定序号
这样重复运行不会产生不同 mapping。
方法 9:Unicode 混淆字符规范化
常见混淆包括:
I、l、1、i混用- 拉丁字母
a与西里尔字母а - 零宽字符
- 组合字符
- RTL/Bidi 控制字符
- 不可见空白
- 全角字符
- 同形异码字符
检测时可以检查:
Unicode Script
Unicode Category
是否包含 Format 字符
是否包含 Bidi 控制符
是否混用多个文字系统
NFC/NFKC 后是否变化
Confusable Skeleton 是否相同
处理方式有两种:
规范化显示名称
只在 UI 中把可疑字符显示成:
Class_u0049_u006C_u0031
不修改 DEX,最安全。
真正重命名
把可疑名称映射为纯 ASCII:
Lcom/test/аctivity; → Lcom/test/Class0001;
不要直接对整个类名执行 NFKC 后写回,因为不同原始名称可能规范化为同一个名称,造成冲突。
方法 10:基于引用类型选择性拆分字符串
如果同一个字符串 "a" 同时被多个成员使用:
字段 a
方法 a()
普通字符串 "a"
可以建立不同的新字符串:
field0001
method0001
"a"
然后分别更新:
field_id_item.name_idxmethod_id_item.name_idx- 普通
const-string保持不变
这是比"全局替换字符串"更正确的方法,但会新增字符串条目,因此不能保持原有 DEX 索引和布局。
方法 11:只修改 Debug 信息
如果目的只是改善反编译结果,可以只修改:
- 参数名
- 局部变量名
- 源文件名
- 行号信息
这通常不会改变程序行为,但重新编码 debug info 后,其偏移和长度可能变化。
也可以直接删除混乱的 debug 信息,让反编译器自动生成:
p0、p1、v0、v1
不过删除 debug info 仍会改变 DEX 文件结构。
方法 12:运行时 Hook 或名称映射
不修改磁盘上的 DEX,而是在分析时:
- Hook
ClassLoader.loadClass() - Hook
Class.forName() - Hook 反射 API
- Hook
DexFile - 记录真实加载的类、字段和方法
- 在分析工具中应用别名
这种方法适合:
- 动态生成名称
- 加固壳
- 运行时解密 DEX
- 反射调用非常多的程序
它并不真正重命名类,只是帮助建立映射。
方法 13:脱壳后处理真实 DEX
如果 APK 使用加固,静态 DEX 中可能只有壳代码,真正类名在运行时解密。
处理顺序应为:
加载壳 APK
→ 找到运行时真实 DexFile
→ 导出/修复 DEX
→ 解析类和成员
→ 建立重命名映射
→ 再执行静态改写
如果先修改壳 DEX,通常无法覆盖真正业务类。
三、不能遗漏的外部引用
重命名 DEX 后,APK 其他文件也可能需要同步。
1. AndroidManifest.xml
需检查:
<activity android:name="com.a.Il1I"/>
<service android:name="com.a.ServiceA"/>
<receiver android:name="com.a.ReceiverA"/>
<provider android:name="com.a.ProviderA"/>
<application android:name="com.a.App"/>
还包括:
- instrumentation
- appComponentFactory
- backupAgent
- targetActivity
- authorities 中的硬编码包名
2. XML 资源
需检查:
- 自定义 View 类名
- Fragment 类名
- Navigation Graph
- Data Binding
- Preference
- provider paths
tools:context
3. 反射字符串
例如:
Class.forName("com.a.Il1I");
getDeclaredMethod("lIlI");
getDeclaredField("I1l1");
这些字符串可能需要同步,但不能见到相同字符串就全部替换。应先通过数据流分析确认用途。
4. JNI/SO
两种 JNI 绑定方式:
Java_com_a_Class_method
或:
RegisterNatives(...)
修改 Java 类/方法名后,可能需要同步修改 SO;如果不修改 SO,应用会出现 UnsatisfiedLinkError。
5. 序列化和框架配置
需要检查:
- Gson/Moshi/Jackson
- Room
- Parcelable
- Java Serialization
- JSON 中的类名
- ServiceLoader
- 依赖注入生成代码
- WebView JavaScript Interface
- Flutter/React Native 插件通道
- Kotlin serialization
- Compose 导航字符串
四、针对"DEX 结构不能改变"的可行方案
如果你的要求是:
- class/method/field 数量不变
string_ids/type_ids/method_ids/field_ids数量不变- 所有索引不变
- 所有数据偏移不变
- DEX 文件大小不变
- 指令不重组
那么只能使用:
解析 DEX
→ 精确定位 string_data_item
→ 筛选可疑标识符
→ 生成相同 UTF-16 长度且相同 MUTF-8 字节长度的新名称
→ 原位覆盖
→ 更新 SHA-1
→ 更新 Adler-32
→ 重新签名 APK
例如:
Il1I → C001
lI1l → m001
I11I → f001
但有两个限制:
- 不能把短名称恢复成较长的语义名称
- 同一个字符串条目被多个对象共享时,不能给这些对象分配不同名称
因此比较合理的是提供两种工作模式:
Strict 模式
- 只做等长原位替换
- 保持 DEX 布局、索引和偏移
- 新名称以固定长度编号生成
- 遇到共享字符串冲突就跳过或统一改名
Rebuild 模式
- 使用 dexlib2 按引用类型重写
- 允许增加和重排字符串
- 可以生成
LoginActivity等语义名称 - 同步 Manifest、反射、JNI
- 不保证 DEX 二进制结构不变
五、推荐的实现架构
解析 APK / DEX
↓
扫描类、方法、字段及 Unicode
↓
建立旧名称 → 新名称映射
↓
处理模式?
/ \
Strict Rebuild
↓ ↓
等长 MUTF-8 dexlib2 完整
原位替换 引用重写
↓ ↓
更新 DEX 校验 同步 Manifest、
反射与资源
↓
ZipAlign 与重新签名
↓
安装和运行验证
名称映射最好分别保存:
{
"classes": {
"Lcom/a/Il1I;": "Lcom/a/C001;"
},
"methods": {
"Lcom/a/Il1I;->lI1l(I)V": "m001"
},
"fields": {
"Lcom/a/Il1I;->I11I:Ljava/lang/String;": "f001"
}
}
方法和字段不能只使用名称作为 key,应该包含:
- 所属类
- 原名称
- 参数列表
- 返回类型或字段类型
否则重载方法和同名成员会产生映射冲突。
六、关键结论
如果要求 DEX 的字节布局、索引、偏移和文件大小完全不变,只能做等 MUTF-8 字节长度原位替换;想把
Il1I改成有意义的LoginActivity,通常必须重新构建 DEX。
你的现有项目应把"等长字符替换"和"语义化重建"彻底分成两个引擎。前者保证 DEX 结构稳定,后者保证名称可读;两者无法同时做到完全自由的重命名和完全不改变二进制结构。
Android 端嵌入式 DEX 重命名:完整实现指南
依赖配置
// build.gradle.kts
repositories {
google()
mavenCentral()
}
dependencies {
// DEX 读写核心库
implementation("com.android.tools.smali:smali-dexlib2:3.0.9")
// 混淆成员高性能查找(可选)
implementation("org.luckypray:dexkit:2.0.0")
// Manifest 和资源修改
implementation("io.github.reandroid:ARSCLib:1.3.2")
// APK 重新签名
implementation("com.android.tools.build:apksig:8.3.0")
}
一、核心数据结构
// ── 映射键,用完整签名避免重载冲突 ──────────────────────────────
data class ClassKey(val descriptor: String) // "Lcom/a/Il1I;"
data class MethodKey(
val owner: String, // "Lcom/a/Il1I;"
val name: String, // "lIlI"
val parameters: List<String>, // ["Ljava/lang/String;", "I"]
val returnType: String // "V"
)
data class FieldKey(
val owner: String, // "Lcom/a/Il1I;"
val name: String, // "I1l1"
val type: String // "Ljava/lang/String;"
)
// ── 完整映射表 ────────────────────────────────────────────────
data class DexRenameMapping(
val classes: Map<String, String>, // 旧描述符 → 新描述符
val methods: Map<MethodKey, String>, // 键 → 新方法名
val fields: Map<FieldKey, String> // 键 → 新字段名
)
二、Rebuild 模式:dexlib2 完整重写
2.1 RewriterModule 实现
import com.android.tools.smali.dexlib2.iface.reference.FieldReference
import com.android.tools.smali.dexlib2.iface.reference.MethodReference
import com.android.tools.smali.dexlib2.rewriter.RewriterModule
import com.android.tools.smali.dexlib2.rewriter.Rewriter
import com.android.tools.smali.dexlib2.rewriter.Rewriters
class RenameRewriterModule(
private val mapping: DexRenameMapping
) : RewriterModule() {
// ── 1. 类型描述符(覆盖所有类型引用位置) ──────────────────────
override fun getTypeRewriter(rewriters: Rewriters): Rewriter<String> =
Rewriter { type -> mapping.classes[type] ?: type }
// ── 2. 方法引用(方法定义 + 所有调用点) ──────────────────────
override fun getMethodReferenceRewriter(
rewriters: Rewriters
): Rewriter<MethodReference> = Rewriter { ref ->
// 先让父类重写器处理 owner / 参数 / 返回值中的类型引用
val base = rewriters.methodReferenceRewriter.rewrite(ref)
val key = MethodKey(
owner = ref.definingClass,
name = ref.name,
parameters = ref.parameterTypes.map { it.toString() },
returnType = ref.returnType
)
val newName = mapping.methods[key] ?: ref.name
// 只在名称有变化时才生成新对象,避免不必要的分配
if (newName == base.name) base
else ImmutableMethodReference(
base.definingClass,
newName,
base.parameterTypes,
base.returnType
)
}
// ── 3. 字段引用(字段定义 + 所有读写点) ──────────────────────
override fun getFieldReferenceRewriter(
rewriters: Rewriters
): Rewriter<FieldReference> = Rewriter { ref ->
val base = rewriters.fieldReferenceRewriter.rewrite(ref)
val key = FieldKey(
owner = ref.definingClass,
name = ref.name,
type = ref.type
)
val newName = mapping.fields[key] ?: ref.name
if (newName == base.name) base
else ImmutableFieldReference(
base.definingClass,
newName,
base.type
)
}
}
2.2 重写入口
import com.android.tools.smali.dexlib2.DexFileFactory
import com.android.tools.smali.dexlib2.Opcodes
import com.android.tools.smali.dexlib2.rewriter.DexRewriter
import java.io.File
fun rebuildDex(
inputDex: File,
outputDex: File,
mapping: DexRenameMapping
) {
val dexFile = DexFileFactory.loadDexFile(inputDex, Opcodes.getDefault())
val module = RenameRewriterModule(mapping)
val rewriter = DexRewriter(module)
val result = rewriter.dexFileRewriter.rewrite(dexFile)
DexFileFactory.writeDexFile(outputDex.absolutePath, result)
}
2.3 TypeRewriter 覆盖的所有位置
DexRewriter 会将 TypeRewriter 自动传播到以下位置,不需要手动处理每一处:
ClassDef.type
ClassDef.superclass
ClassDef.interfaces
FieldDef.type
MethodDef.returnType
MethodDef.parameterTypes
指令: new-instance / check-cast / instance-of / filled-new-array
指令: const-class
catch handler exception type
annotation 中的 type value
method handle 的 owner type
call site 的引用类型
三、Strict 模式:等长原位替换
3.1 MUTF-8 工具函数
object Mutf8 {
/**
* 计算字符串在 MUTF-8 编码下的字节数(不含 null 终止符)。
* DEX 使用 Modified UTF-8,补充平面字符以两个代理对各占 3 字节编码。
*/
fun byteLength(s: String): Int {
var len = 0
var i = 0
while (i < s.length) {
val cp = s[i].code
len += when {
cp == 0 -> 2 // MUTF-8 将 U+0000 编码为 2 字节
cp < 0x80 -> 1
cp < 0x800 -> 2
else -> 3 // 包含代理对,每个代理占 3 字节
}
i++
}
return len
}
/** UTF-16 code unit 数,即 String.length()。 */
fun utf16Size(s: String): Int = s.length
/** 两个字符串在 DEX 中的存储布局完全相同时才可原位替换。 */
fun canInPlace(oldStr: String, newStr: String): Boolean =
utf16Size(oldStr) == utf16Size(newStr) &&
byteLength(oldStr) == byteLength(newStr)
/** 将字符串编码为 MUTF-8 字节数组(不含 null 终止符)。 */
fun encode(s: String): ByteArray {
val buf = ByteArray(byteLength(s))
var pos = 0
for (ch in s) {
val cp = ch.code
when {
cp == 0 -> {
buf[pos++] = 0xC0.toByte()
buf[pos++] = 0x80.toByte()
}
cp < 0x80 -> {
buf[pos++] = cp.toByte()
}
cp < 0x800 -> {
buf[pos++] = (0xC0 or (cp shr 6)).toByte()
buf[pos++] = (0x80 or (cp and 0x3F)).toByte()
}
else -> {
buf[pos++] = (0xE0 or (cp shr 12)).toByte()
buf[pos++] = (0x80 or ((cp shr 6) and 0x3F)).toByte()
buf[pos++] = (0x80 or (cp and 0x3F)).toByte()
}
}
}
return buf
}
}
3.2 DEX 二进制解析与原位写入
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
class InPlaceDexPatcher(dexBytes: ByteArray) {
private val buf = ByteBuffer.wrap(dexBytes.copyOf())
.also { it.order(ByteOrder.LITTLE_ENDIAN) }
// ── DEX header 偏移常量 ────────────────────────────────────
companion object {
private const val OFF_CHECKSUM = 8 // Adler-32,4 字节
private const val OFF_SIGNATURE = 12 // SHA-1,20 字节
private const val OFF_STR_IDS_SIZE = 56
private const val OFF_STR_IDS_OFF = 60
}
// ── 读取 ULEB128 ────────────────────────────────────────────
private fun readUleb128(offset: Int): Pair<Int, Int> {
var result = 0
var shift = 0
var pos = offset
while (true) {
val b = buf.get(pos++).toInt() and 0xFF
result = result or ((b and 0x7F) shl shift)
if (b and 0x80 == 0) return result to pos
shift += 7
}
}
// ── 主入口 ──────────────────────────────────────────────────
/**
* 尝试在所有 string_data_item 中原位替换。
* 要求新旧字符串的 utf16_size 和 MUTF-8 字节数均相同。
* 返回实际替换的次数。
*/
fun patch(replacements: Map<String, String>): Int {
// 预检:所有替换必须满足等长约束
replacements.forEach { (old, new) ->
require(Mutf8.canInPlace(old, new)) {
"字符串长度不匹配,无法原位替换:" +
"\"$old\"(utf16=${Mutf8.utf16Size(old)}, " +
"mutf8=${Mutf8.byteLength(old)}) → " +
"\"$new\"(utf16=${Mutf8.utf16Size(new)}, " +
"mutf8=${Mutf8.byteLength(new)})"
}
}
val strIdsSize = buf.getInt(OFF_STR_IDS_SIZE)
val strIdsOff = buf.getInt(OFF_STR_IDS_OFF)
var count = 0
for (i in 0 until strIdsSize) {
val dataOff = buf.getInt(strIdsOff + i * 4)
// 读取 utf16_size(ULEB128)
val (utf16Size, dataStart) = readUleb128(dataOff)
// 读取实际字符串字节
val mutf8Len = (0 until 4096).firstOrNull {
buf.get(dataStart + it) == 0x00.toByte()
} ?: continue
val strBytes = ByteArray(mutf8Len)
buf.position(dataStart)
buf.get(strBytes)
val original = decodeMutf8(strBytes)
// 查找是否有对应替换
val replacement = replacements[original] ?: continue
// 写入新字节
val newBytes = Mutf8.encode(replacement)
buf.position(dataStart)
buf.put(newBytes)
count++
}
return count
}
// ── 校验值更新 ──────────────────────────────────────────────
fun updateChecksums() {
updateSha1()
updateAdler32()
}
private fun updateSha1() {
val bytes = buf.array()
val sha1 = java.security.MessageDigest.getInstance("SHA-1")
// SHA-1 覆盖范围:signature 之后的全部内容(从偏移 32 开始)
sha1.update(bytes, 32, bytes.size - 32)
val digest = sha1.digest()
buf.position(OFF_SIGNATURE)
buf.put(digest)
}
private fun updateAdler32() {
val bytes = buf.array()
var s1 = 1L
var s2 = 0L
// Adler-32 覆盖范围:checksum 字段之后的全部内容(从偏移 12 开始)
for (i in 12 until bytes.size) {
s1 = (s1 + (bytes[i].toLong() and 0xFF)) % 65521
s2 = (s2 + s1) % 65521
}
val checksum = ((s2 shl 16) or s1).toInt()
buf.putInt(OFF_CHECKSUM, checksum)
}
fun toByteArray(): ByteArray = buf.array().copyOf()
// ── MUTF-8 解码 ─────────────────────────────────────────────
private fun decodeMutf8(bytes: ByteArray): String {
val sb = StringBuilder()
var pos = 0
while (pos < bytes.size) {
val b = bytes[pos].toInt() and 0xFF
when {
b == 0xC0 && pos + 1 < bytes.size &&
(bytes[pos + 1].toInt() and 0xFF) == 0x80 -> {
sb.append('\u0000'); pos += 2
}
b and 0x80 == 0 -> { sb.append(b.toChar()); pos++ }
b and 0xE0 == 0xC0 -> {
val cp = ((b and 0x1F) shl 6) or
(bytes[pos + 1].toInt() and 0x3F)
sb.append(cp.toChar()); pos += 2
}
else -> {
val cp = ((b and 0x0F) shl 12) or
((bytes[pos + 1].toInt() and 0x3F) shl 6) or
(bytes[pos + 2].toInt() and 0x3F)
sb.append(cp.toChar()); pos += 3
}
}
}
return sb.toString()
}
}
3.3 Strict 模式使用示例
fun strictPatch(inputDex: File, outputDex: File, replacements: Map<String, String>) {
val patcher = InPlaceDexPatcher(inputDex.readBytes())
val count = patcher.patch(replacements)
patcher.updateChecksums()
outputDex.writeBytes(patcher.toByteArray())
println("原位替换完成,共修改 $count 处字符串")
}
// 调用示例
strictPatch(
inputDex = File(cacheDir, "classes.dex"),
outputDex = File(cacheDir, "classes_patched.dex"),
replacements = mapOf(
"Il1I" to "C001",
"lI1l" to "M001",
"I11I" to "F001"
)
)
四、DexKit 定位混淆成员
import io.github.luckypray.dexkit.DexKitBridge
fun buildMappingWithDexKit(apkPath: String): DexRenameMapping {
val classMappingResult = mutableMapOf<String, String>()
val methodMappingResult = mutableMapOf<MethodKey, String>()
val fieldMappingResult = mutableMapOf<FieldKey, String>()
DexKitBridge.create(apkPath).use { bridge ->
// ── 示例 1:根据字符串常量定位类 ────────────────────────
bridge.findClass {
matcher { usingStrings("Authorization", "Bearer ") }
}.forEach { classMeta ->
val old = classMeta.descriptor // "Lcom/a/Il1I;"
val new = "Lcom/deobfuscated/AuthClient;"
classMappingResult[old] = new
}
// ── 示例 2:根据调用关系定位方法 ────────────────────────
bridge.findMethod {
matcher {
usingStrings("login")
returnType("V")
}
}.forEach { methodMeta ->
val key = MethodKey(
owner = methodMeta.classInstance.descriptor,
name = methodMeta.name,
parameters = methodMeta.paramTypeNames,
returnType = methodMeta.returnTypeName
)
methodMappingResult[key] = "login"
}
// ── 示例 3:根据字段类型定位字段 ─────────────────────────
bridge.findField {
matcher { type("Landroid/database/sqlite/SQLiteDatabase;") }
}.forEach { fieldMeta ->
val key = FieldKey(
owner = fieldMeta.classInstance.descriptor,
name = fieldMeta.name,
type = fieldMeta.typeName
)
fieldMappingResult[key] = "database"
}
}
return DexRenameMapping(classMappingResult, methodMappingResult, fieldMappingResult)
}
五、Manifest 同步修改
import com.reandroid.apk.ApkModule
import java.io.File
fun syncManifest(apkFile: File, classMapping: Map<String, String>) {
val module = ApkModule.loadApkFile(apkFile)
val manifest = module.androidManifestBlock
// 将 DEX 描述符转换为 Manifest 中的点分类名
// "Lcom/example/LoginActivity;" → "com.example.LoginActivity"
fun descriptorToClassName(descriptor: String): String =
descriptor.removePrefix("L").removeSuffix(";").replace('/', '.')
// 需要检查的属性名
val componentAttrs = listOf("name", "targetActivity", "authorities")
manifest.allElements.forEach { element ->
componentAttrs.forEach { attrName ->
val attrValue = element.searchAttributeByName(attrName)
?.valueAsString ?: return@forEach
// 尝试匹配(支持简写的 .ClassName 形式)
classMapping.forEach { (oldDescriptor, newDescriptor) ->
val oldName = descriptorToClassName(oldDescriptor)
val newName = descriptorToClassName(newDescriptor)
if (attrValue == oldName || attrValue.endsWith(".${oldName.substringAfterLast('.')}")) {
element.searchAttributeByName(attrName)
?.setValueAsString(newName)
}
}
}
}
module.writeApk(apkFile)
}
六、完整流程编排
import java.io.File
class DexDeobfuscator(private val workDir: File) {
enum class Mode { STRICT, REBUILD }
fun process(
inputApk: File,
outputApk: File,
mode: Mode = Mode.REBUILD
) {
workDir.mkdirs()
// ── 第 1 步:提取 DEX ─────────────────────────────────
val dexFiles = extractDexFiles(inputApk)
// ── 第 2 步:建立映射 ─────────────────────────────────
val mapping = when {
mode == Mode.REBUILD ->
buildMappingWithDexKit(inputApk.absolutePath)
else ->
buildStrictMapping(dexFiles) // 只保留等长候选
}
// ── 第 3 步:重写 DEX ─────────────────────────────────
val processedDexFiles = dexFiles.mapIndexed { index, dexFile ->
val output = File(workDir, "classes${if (index == 0) "" else index + 1}.dex")
when (mode) {
Mode.REBUILD -> rebuildDex(dexFile, output, mapping)
Mode.STRICT -> strictPatch(dexFile, output, buildFlatReplacements(mapping))
}
output
}
// ── 第 4 步:同步 Manifest ─────────────────────────────
if (mode == Mode.REBUILD) {
syncManifest(inputApk, mapping.classes)
}
// ── 第 5 步:重新打包并签名 ────────────────────────────
repackageAndSign(inputApk, processedDexFiles, outputApk)
println("处理完成:$outputApk")
}
// ── 辅助:从 APK 提取所有 DEX ─────────────────────────────
private fun extractDexFiles(apk: File): List<File> {
val result = mutableListOf<File>()
java.util.zip.ZipFile(apk).use { zip ->
zip.entries().asSequence()
.filter { it.name.matches(Regex("classes\\d*\\.dex")) }
.forEach { entry ->
val out = File(workDir, entry.name)
zip.getInputStream(entry).use { input ->
out.outputStream().use { input.copyTo(it) }
}
result.add(out)
}
}
return result.sortedBy { it.name }
}
// ── 辅助:为 Strict 模式生成扁平化替换表 ───────────────────
private fun buildFlatReplacements(mapping: DexRenameMapping): Map<String, String> {
val result = mutableMapOf<String, String>()
mapping.classes.forEach { (old, new) ->
// 只取简单名称部分用于字符串池替换
val oldName = old.substringAfterLast('/').removeSuffix(";")
val newName = new.substringAfterLast('/').removeSuffix(";")
if (Mutf8.canInPlace(oldName, newName)) result[oldName] = newName
}
return result
}
// ── 辅助:重新打包(简化示意) ────────────────────────────
private fun repackageAndSign(
originalApk: File,
newDexFiles: List<File>,
outputApk: File
) {
// 1. 复制原 APK,替换其中的 DEX 条目
// 2. 使用 apksig 或 keytool 重新签名
// 具体实现依赖签名密钥配置,此处省略
}
private fun buildStrictMapping(dexFiles: List<File>): DexRenameMapping =
DexRenameMapping(emptyMap(), emptyMap(), emptyMap()) // 由具体规则填充
}
七、映射文件格式
{
"version": "1.0",
"mode": "rebuild",
"classes": {
"Lcom/a/Il1I;": "Lcom/deobfuscated/AuthClient;",
"Lcom/a/lI1l;": "Lcom/deobfuscated/TokenManager;"
},
"methods": {
"Lcom/a/Il1I;->lIlI(Ljava/lang/String;)V": {
"newName": "login",
"owner": "Lcom/a/Il1I;",
"params": ["Ljava/lang/String;"],
"returnType": "V"
}
},
"fields": {
"Lcom/a/Il1I;->I1l1:Ljava/lang/String;": {
"newName": "authToken",
"owner": "Lcom/a/Il1I;",
"type": "Ljava/lang/String;"
}
}
}
八、两种模式对比
| 维度 | Strict 模式 | Rebuild 模式 |
|---|---|---|
| DEX 布局 | 完全不变 | 会重新排列 |
| 文件大小 | 不变 | 可能变化 |
| 字符串索引 | 不变 | 可能重新分配 |
| 名称长度限制 | 必须等长 | 无限制 |
| 语义名称 | 不支持 | 支持 |
| 共享字符串 | 统一改名或跳过 | 可按引用类型拆分 |
| Manifest 同步 | 不处理 | 自动同步 |
| 实现复杂度 | 低 | 高 |
| 主要工具 | 自定义二进制解析器 | dexlib2 + DexKit |
浙公网安备 33010602011771号