~/posts/mobile/android-dex-dgc-runtime-bytecode-restoration.md

Android 双层保护:DEX、DGC 与方法修复

从八份隐藏 DEX 追踪外部方法代码池,区分两套 RC4 密钥派生、DGC 索引与 Dalvik 指令边界,再核对 ART 方法入口提供的修复时机。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
移动端
目录
  1. 0x00引导代码与容器大小的矛盾
  2. 0x01RC4 的识别和第一层密钥
  3. 0x02debug_info_off 暴露外部代码池
  4. 0x03DGC 密钥来自数据块与确定性序列
  5. 0x04索引字节序与 code_item 边界分开检查
  6. 0x05ART 入口提供修复时机,但有条件
  7. 0x06opcode 必须沿指令边界恢复
  8. 0x07官方参考

DJI Pilot 2.5.1.17 的保护层把两个问题叠在一起:先隐藏 DEX 容器,再把方法指令放进外部代码池。恢复出能被反编译器打开的文件,只解决了第一层;方法声明齐全、方法体却大量出现 nop,正是继续追踪运行时修复路径的理由。

这里分析 2024 年 2 月的样本记录。APK 标识为 SHA-256 642aa123437c259eea5895fe01dc4210c4a3a430842b79612074d88745f54714,包名为 com.dji.industry.pilot。完整 APK、设备构建和运行时转储未随记录保留,因此下面区分可见字节、算法关系和本机离线校验,不把它们合并成一次新设备实验。

以双层透明机身与电路板表现字节码分层保护的无人机主题插画

双层机身是主题插画,不表示设备的真实内部结构。

引导代码与容器大小的矛盾

初始类树只有少量引导内容:

初始包视图
  • com
    • amap.api
    • autonavi
    • dji.industry.pilot
      • R
    • secneo.apkwrapper
2 个目录,4 个文件

引导代码依据 H.isNeedLoadX86() 选择 DexHelper-x86 或 DexHelper,再调用 System.loadLibrary。分析路径进入后者对应的 libDexHelper.so;磁盘 ELF 自身也受保护,记录中的原生分析基于运行时取得的映像。原生库解包过程未完整展开,不据此推导通用的壳处理方案。

decrypt_jar_128K 是分析时使用的功能名。它返回后的缓冲区出现以下 DEX 035 标识,同一路径共记录八份输出:

DEX 035 文件标识
000000006465780A30333500
  1. magic0x00–0x03
  2. version0x04–0x06035
  3. terminator0x07

表面只有引导类的 classes.dex 却约有 63 MB。熵分布显示八段接近每字节 8 bit 的高熵区间,记录将它们与八份业务 DEX 的前 128 KiB 对应。曲线没有保留采样窗口和原始数列,因此这里保留定性结论,不补画具有虚构精度的测量曲线。

高熵本身并不证明加密;压缩数据也可能产生类似分布。真正需要闭合的是文件区间、函数输入、输出长度与 DEX 头之间的对应关系。导出时还要核对 file_size、可读映射和实际缓冲区长度,仅凭 magic 不足以决定读取范围。

RC4 的识别和第一层密钥

原生控制流虽被压平,仍能辨认出状态数组初始化、密钥调度(KSA)与伪随机生成(PRGA)三段。PRGA 的关系如下:

RC4 PRGA:语义形式text
i = (i + 1) & 0xff
j = (j + S[i]) & 0xff
swap(S[i], S[j])
output = input ^ S[(S[i] + S[j]) & 0xff]

单独发现异或不够;256 字节状态、索引推进、交换和输出依赖共同支持 RC4 判断。前向追踪输出、反向追踪 KSA 的输入,才能把算法名与具体容器联系起来。

DEX 层使用 16 字节常量与包名前 16 字节逐字节异或。这里的前缀恰好是 com.dji.industry,末尾不含点号:

DEX 密钥派生:仅展示关系python
package = b"com.dji.industry.pilot"
assert package[:16] == b"com.dji.industry"

def derive_dex_key(constant):
    if len(constant) != 16:
        raise ValueError("expected 16 bytes")
    return bytes(a ^ b for a, b in zip(constant, package[:16]))

这段只描述派生形状,不包含样本常量。处理各 DEX 的前 128 KiB 后,容器能显示更多类,但部分方法仍缺失有效逻辑:容器解密与方法恢复是两个独立检查点。

debug_info_off 暴露外部代码池

getRequiredPermissions 的记录中,方法保留声明与异常框架,中间却主要是 nop,并出现 const v0, 0x8854372。同一个值也在对应的 debug_info_off 字段中出现。

同一空壳方法的字段6 行
字段 值
registers_size 3
ins_size 1
outs_size 2
tries_size 1
debug_info_off 0x08854372
insns_size 0x23 个 16 位 code unit

按正常 DEX 语义,debug_info_off 是相对文件起点的调试信息偏移,而不是方法编号。记录中的解析器尝试访问十进制 142951282,超过其 9930016 的缓冲区上限;这与 0x08854372 的十进制值一致。该错误提示字段被挪作其他用途,却还不足以单独证明其索引算法。AOSP DEX 格式

进一步对应 assets/classes.dgc 与原生方法修复函数后,关系才变得完整:空壳方法携带标识,DGC 索引定位外部 code_item,运行时恢复指令。DGC 的开头也出现约 128 KiB 的高熵区间,但它使用另一套密钥派生。

DGC 密钥来自数据块与确定性序列

DGC 处理函数入口的前 16 字节与文件起点对应:

classes.dgc:加密前缀观测
00000000EFBDDE508BBB81C7806335CA956E1D1D

另一段控制流同样呈现 RC4 的状态操作。密钥来源则是原生库内 mthfilekey 标记附近的 4096 字节块,而非包名前缀;该块在所示磁盘库中可见。

flowchart TD
  A["4096 字节数据块"] --> B["MD5:16 字节摘要"]
  A --> C["按确定性序列访问数据块"]
  D["F(0)=0,F(1)=1"] --> C
  C --> E["取得 16 字节"]
  B --> X["逐字节 XOR"]
  E --> X
  X --> K["DGC 的 RC4 密钥"]

序列函数显示 F(0)=0、F(1)=1 与 F(i)=F(i-2)+F(i-1)。这些初值已有证据,不应再列为未知;但整数宽度、溢出行为、序列到数据块位置的映射和取模规则仍需目标函数确认。一个“Fibonacci + MD5”的标签不足以构造准确解密器。

MD5 的轮逻辑与常量支持摘要算法判断,但用于处理 512 bit 消息块的压缩函数,不应被误说成只对 64 字节输入计算整个摘要。完整的分块、填充与输出路径属于另一个核对层次。

索引字节序与 code_item 边界分开检查

DGC 索引视图标注了 0x00167e38 的代码区基址和若干相对偏移。例如 0x38 相加得到 0x00167e70,与另一段代码项视图的位置相符。索引中可见 00 16 7E 38 这样的排列;不要因为 DEX 通常采用小端,就对自定义 DGC 的所有字段统一套用小端读取。

这只验证地址关系,不代表已核实完整索引格式或每个标识配对。下面的 code_item 来自另一个方法,其 debug_info_off = 0x046d63dd,与前面的 0x08854372 不是同一项:

DGC code_item:16 字节头部
00167E700300020002000100DD636D0412000000
  1. registers_size0x167E70–0x167E713
  2. ins_size0x167E72–0x167E732
  3. outs_size0x167E74–0x167E752
  4. tries_size0x167E76–0x167E771
  5. debug_info_off0x167E78–0x167E7B0x046d63dd
  6. insns_size0x167E7C–0x167E7F0x12

insns_size = 0x12 表示 18 个 code unit,也就是 36 字节,不是 18 字节。16 字节头部之后的指令区结束于相对偏移 0x34;指令数量为偶数,此处不需要为 tries 增加两字节对齐填充。

tries_size = 1 意味着仍有异常表和相应 handler 数据需要检查。因此“头部 + 指令长度有效”只是一项边界检查,不等于整个代码项有效。修复到新 DEX 时,还要处理重定位后的偏移、索引、调试信息与文件校验,而不是整块覆盖就结束。

ART 入口提供修复时机,但有条件

样本在 Instrumentation::InitializeMethodsCode 入口转入自定义逻辑。功能名 PatchMethodCode 对应取标识、查 DGC、分配恢复缓冲区、调用 DecryptMethodCode、更新方法关联的代码位置,然后通过 trampoline 衔接原函数。

flowchart TD
  A["类验证后的更新路径"] --> B{"运行时与已有入口满足条件?"}
  B -->|是| C["InitializeMethodsCode"]
  B -->|否| N["此处不走该初始化分支"]
  C --> P["PatchMethodCode"]
  P --> D["DGC 查找与 opcode 恢复"]
  D --> U["更新方法代码关联"]
  U --> T["trampoline / 原初始化逻辑"]
  T --> R["返回调用者"]

公开 ART 提交 82e525a4f5f08a72ea1b6907c0a10dacb77a8a87 的 UpdateClassAfterVerification 只有在 CanRuntimeUseNterp() 成立、方法当前入口是 quick-to-interpreter bridge 时,才在该循环调用 InitializeMethodsCode。固定源码用于解释条件,未与样本设备构建建立一一对应。

因此,路径图不是“每个 ART 版本的所有方法都无条件经过该入口”。入口初始化、类验证与方法实际执行也不是同一个时刻。记录的修复结果应与相关方法在该时点的代码关联核对,而不是只看导出的 DEX 是否能被打开。

opcode 必须沿指令边界恢复

DecryptMethodCode 的核心是替换 opcode 字节:

单个 opcode 的转换python
def decode_opcode(encoded_opcode, debug_info_off, substitution):
    if not 0 <= encoded_opcode <= 255:
        raise ValueError("opcode must fit in one byte")
    if len(substitution) != 256:
        raise ValueError("expected a 256-entry table")
    return substitution[encoded_opcode ^ (debug_info_off & 0xff)]

替换表保存在原生库中,不是 RC4 执行过程中不断交换的状态数组。二者都可能被分析者记作 S,但对象生命周期和用途完全不同。

Dalvik 指令以 16 位 code unit 编排,opcode 通常占首个 code unit 的低字节。遍历器需要使用恢复后的指令格式推进,保留寄存器、立即数和分支偏移;遇到 switch 或 array-data payload 还需单独识别其标识、尺寸与对齐。逐字节应用上面的函数,会连操作数一起损坏。AOSP Dalvik 指令格式

恢复后的示例中出现了连贯的 checkAccountManager 调用和首选项写入逻辑。这证明可读性改善,却不单凭一段反编译输出保证所有方法都与运行时一致。至少应比较:标识对应是否唯一、指令遍历是否完整、引用索引是否有效、控制流与异常区间是否合理。

本机离线检查使用 RFC 6229 的公开 RC4 测试材料,不使用样本密钥;另外以明确构造的替换表验证单字节映射,并解析上面的头部:

离线检查log
PASS: 2 RC4 vectors; 65536 synthetic opcode round trips; 4 boundary rejections
DEX key prefix: com.dji.industry
DGC code_item: 0x167e70 insns bytes: 36 tries relative offset: 52

这些结果验证 RC4 实现、长度算术和映射形状,不验证缺失的 DGC 派生参数、完整解包结果或设备执行。可信的恢复链最终要同时闭合三个关系:容器字节从哪里来、方法标识指向哪里、恢复指令为何与运行时相符。

官方参考

NORMAL~/posts/mobile/android-dex-dgc-runtime-bytecode-restoration.md§--
0%zh-CN