GKLBB

当你经历了暴风雨,你也就成为了暴风雨

导航

我的作品 --- 安卓安全 之 同形异义字符混淆

视觉上相似但含义不同的字符(大写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,这是最准确的方法。

text
com.example.LoginActivity -> a.b:
    void login() -> a
    java.lang.String token -> b

可以:

  • 在反编译器中加载 mapping
  • 用 ReTrace 恢复堆栈
  • 根据 mapping 批量重命名
  • 构建"旧名称 → 新名称"映射表

优点: 能恢复真实语义。
缺点: 没有原始 mapping 时无法使用。


方法 3:Smali 拆解、替换、重组

典型流程:

text
classes.dex
   ↓ baksmali
smali 文件
   ↓ 按映射修改描述符、方法名、字段名
smali
   ↓ smali
new_classes.dex

示例,原始:

smali
.class public Lcom/a/Il1I;
.super Landroid/app/Activity;

修改后:

smali
.class public Lcom/example/LoginActivity;
.super Landroid/app/Activity;

所有引用也必须同步:

smali
new-instance v0, Lcom/example/LoginActivity;
invoke-virtual {v0}, Lcom/example/LoginActivity;->startLogin()V

优点:

  • 实现直观
  • 容易人工检查
  • 适合少量修改

缺点:

  • 会重新排列字符串池、类型池、方法池
  • DEX 偏移、文件大小、部分索引可能改变
  • 大型 DEX 处理速度较慢
  • 容易遗漏反射字符串和外部资源

方法 4:使用 dexlib2 解析后整体重写

这是开发自动化去混淆工具时最推荐的方法之一。

流程:

  1. 加载 DEX
  2. 遍历 ClassDef
  3. 为类、字段、方法建立映射
  4. 使用 Rewriter 重写定义和引用
  5. 重新生成 DEX

核心重写对象通常包括:

text
TypeRewriter
FieldReferenceRewriter
MethodReferenceRewriter
MethodProtoReferenceRewriter
AnnotationRewriter
EncodedValueRewriter
InstructionRewriter
DebugItemRewriter

类名转换示意:

Kotlin
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。

可以:

  1. 解析 string_ids
  2. 找到字符串数据位置
  3. 判断它属于类名、方法名、字段名还是普通字符串
  4. 修改字符串
  5. 更新 DEX 校验信息

这种方法又分两种:

5.1 原位等长替换

例如原字符串:

text
Il1I

替换为相同编码长度的:

text
ClsA

优点:

  • 可以保持 string_ids 索引不变
  • 可以保持后续数据偏移不变
  • 指令中使用的字符串索引不变
  • 对"保持 DEX 结构"最友好

严格要求:

text
旧字符串的 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

不能简单使用:

Kotlin
bytes.replace(oldBytes, newBytes)

因为相同文本可能同时用于:

  • 方法名称
  • 字段名称
  • 日志文本
  • 反射字符串
  • 注解
  • 资源路径

如果字符串池中的同一个字符串条目被多个对象共享,修改一次会影响全部引用。例如方法和字段可能同时叫 a,但你只想修改方法,这时不能直接改共享字符串,必须新增不同字符串条目并修改对应索引——DEX 结构随之改变。


方法 7:利用 ASM/dex2jar 转换后重写

一种间接方法:

text
DEX → JVM class/JAR → ASM 重命名 → DEX

可以使用:

  • dex2jar
  • ASM
  • D8

这种方法适合实验或分析,但不适合要求保持 DEX 结构的场景,因为转换过程中可能发生:

  • 指令重构
  • 寄存器重新分配
  • 异常表变化
  • debug 信息丢失
  • DEX 特有语义转换失败
  • Kotlin/Invoke-custom 兼容问题

方法 8:基于规则自动生成可读名称

没有 mapping 时,可以根据规则生成稳定的新名称。

例如:

text
Lcom/a/Il1I;    → Lcom/deobfuscated/Class0001;
方法 lIlI()V   → method0001()V
字段 I1l1:I     → field0001:I

更实用的命名依据包括:

依据示例
父类 Activity001、Fragment003
接口 Runnable005、Callback012
方法返回类型 requestResponse
Android 生命周期方法 —
字段类型 database(SQLiteDatabase)
字符串常量 —
资源 ID —
注解 —
网络路径 —
XML 布局名称 —

建议生成确定性名称:

text
新名称 = 类型分类 + 原始描述符哈希/稳定序号

这样重复运行不会产生不同 mapping。


方法 9:Unicode 混淆字符规范化

常见混淆包括:

  • I、l、1、i 混用
  • 拉丁字母 a 与西里尔字母 а
  • 零宽字符
  • 组合字符
  • RTL/Bidi 控制字符
  • 不可见空白
  • 全角字符
  • 同形异码字符

检测时可以检查:

text
Unicode Script
Unicode Category
是否包含 Format 字符
是否包含 Bidi 控制符
是否混用多个文字系统
NFC/NFKC 后是否变化
Confusable Skeleton 是否相同

处理方式有两种:

规范化显示名称

只在 UI 中把可疑字符显示成:

text
Class_u0049_u006C_u0031

不修改 DEX,最安全。

真正重命名

把可疑名称映射为纯 ASCII:

text
Lcom/test/аctivity;  →  Lcom/test/Class0001;

不要直接对整个类名执行 NFKC 后写回,因为不同原始名称可能规范化为同一个名称,造成冲突。


方法 10:基于引用类型选择性拆分字符串

如果同一个字符串 "a" 同时被多个成员使用:

text
字段   a
方法   a()
普通字符串 "a"

可以建立不同的新字符串:

text
field0001
method0001
"a"

然后分别更新:

  • field_id_item.name_idx
  • method_id_item.name_idx
  • 普通 const-string 保持不变

这是比"全局替换字符串"更正确的方法,但会新增字符串条目,因此不能保持原有 DEX 索引和布局。


方法 11:只修改 Debug 信息

如果目的只是改善反编译结果,可以只修改:

  • 参数名
  • 局部变量名
  • 源文件名
  • 行号信息

这通常不会改变程序行为,但重新编码 debug info 后,其偏移和长度可能变化。

也可以直接删除混乱的 debug 信息,让反编译器自动生成:

text
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 中可能只有壳代码,真正类名在运行时解密。

处理顺序应为:

text
加载壳 APK
→ 找到运行时真实 DexFile
→ 导出/修复 DEX
→ 解析类和成员
→ 建立重命名映射
→ 再执行静态改写

如果先修改壳 DEX,通常无法覆盖真正业务类。


三、不能遗漏的外部引用

重命名 DEX 后,APK 其他文件也可能需要同步。

1. AndroidManifest.xml

需检查:

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. 反射字符串

例如:

Java
Class.forName("com.a.Il1I");
getDeclaredMethod("lIlI");
getDeclaredField("I1l1");

这些字符串可能需要同步,但不能见到相同字符串就全部替换。应先通过数据流分析确认用途。

4. JNI/SO

两种 JNI 绑定方式:

text
Java_com_a_Class_method

或:

C
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 文件大小不变
  • 指令不重组

那么只能使用:

text
解析 DEX
→ 精确定位 string_data_item
→ 筛选可疑标识符
→ 生成相同 UTF-16 长度且相同 MUTF-8 字节长度的新名称
→ 原位覆盖
→ 更新 SHA-1
→ 更新 Adler-32
→ 重新签名 APK

例如:

text
Il1I → C001
lI1l → m001
I11I → f001

但有两个限制:

  1. 不能把短名称恢复成较长的语义名称
  2. 同一个字符串条目被多个对象共享时,不能给这些对象分配不同名称

因此比较合理的是提供两种工作模式:

Strict 模式

  • 只做等长原位替换
  • 保持 DEX 布局、索引和偏移
  • 新名称以固定长度编号生成
  • 遇到共享字符串冲突就跳过或统一改名

Rebuild 模式

  • 使用 dexlib2 按引用类型重写
  • 允许增加和重排字符串
  • 可以生成 LoginActivity 等语义名称
  • 同步 Manifest、反射、JNI
  • 不保证 DEX 二进制结构不变

五、推荐的实现架构

text
解析 APK / DEX
       ↓
扫描类、方法、字段及 Unicode
       ↓
建立旧名称 → 新名称映射
       ↓
   处理模式?
  /          \
Strict      Rebuild
  ↓            ↓
等长 MUTF-8  dexlib2 完整
原位替换     引用重写
  ↓            ↓
更新 DEX 校验  同步 Manifest、
              反射与资源
       ↓
  ZipAlign 与重新签名
       ↓
  安装和运行验证

名称映射最好分别保存:

JSON
{
  "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 重命名:完整实现指南

依赖配置

Kotlin
// 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")
}

一、核心数据结构

Kotlin
// ── 映射键,用完整签名避免重载冲突 ──────────────────────────────

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 实现

Kotlin
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 重写入口

Kotlin
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 自动传播到以下位置,不需要手动处理每一处:

text
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 工具函数

Kotlin
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 二进制解析与原位写入

Kotlin
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 模式使用示例

Kotlin
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 定位混淆成员

Kotlin
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 同步修改

Kotlin
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)
}

六、完整流程编排

Kotlin
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())  // 由具体规则填充
}

七、映射文件格式

JSON
{
  "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

 

posted on 2026-08-21 11:25  GKLBB  阅读(11)  评论(0)    收藏  举报