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 重写字段定义及读写引用
参数/局部变量名 p0IlI 位于 debug_info,可单独清理
源文件名 a.java 位于 source_file_idx
泛型签名 Signature 注解 可能包含类描述符
内部类信息 InnerClassEnclosingClass 名称需要同步
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

更实用的命名依据包括:

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

建议生成确定性名称:

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

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


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

常见混淆包括:

  • Il1i 混用
  • 拉丁字母 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  阅读(2)  评论(0)    收藏  举报