复现结果

图:脱敏前的复现结果截图。本文正文不公开截图中未必要的业务字段、样本 token、密钥材料或实现脚本。

摘要

在移动端二进制分析中,最终看到的字符串往往不是静态常量,而是经过对象组装、压缩、定制流变换、标准分组密码和编码层层生成的结果。只看最后一层 Base64,通常无法判断输入来源,也无法区分真正的密码算法和业务层封装。

本文记录一次基于运行时 trace 与静态反汇编的脱敏分析过程。目标不是公布某个产品的密钥或实现细节,而是展示如何从最终字符串反向恢复数据流,并准确描述以下几类生成链:

  • a5:JSON、zlib、定制状态表流变换和 Base64。
  • a7:带业务封装的 AES-128-CBC 和 Base64。
  • a8:CRC-32、十六进制解码、固定 XOR 派生和大写十六进制编码。
  • a9:JSON、zlib、CRC-32 前缀、PKCS#7、Twofish-128-CBC 和 Base64。
  • a2:尚未被证明为标准摘要算法的 16 字节 tagged-value 数据流及小写十六进制序列化。

文章还给出一套可重复的工作流程:先定位最终写入,再沿缓冲区和长度向前追踪,最后用静态代码确认循环和用独立实现验证边界。所有真实函数偏移、内存地址、密钥、IV、mask、样本字符串和可执行脚本均已移除或符号化。

公开范围与脱敏规则

本文只保留适合公开讨论的算法结构和证据方法:

  • 函数以“JSON 序列化器”“状态表初始化器”“Base64 编码器”等职责称呼,不公开真实函数名、偏移或地址。
  • 真实密钥、IV、XOR mask、UUID、时间值、完整输入输出 token 均使用符号表示。
  • 只保留能够说明算法的长度关系和边界条件,不复制可直接用于解密的样本。
  • 不给出 Python、Shell 或其他语言的解密脚本;文中的公式只用于说明算法,不构成可直接运行的实现。
  • 未被 trace 或静态代码直接证明的内容,标记为“未确认”,不根据字段名称猜测其业务语义。

1. 分析对象

目标记录是一个包含多个字符串字段的 JSON 对象。字段名在本文中保留为匿名标签,便于描述数据流,但字段的产品语义不作判断。

其中,a5a7a8a9 都能从运行时证据中恢复出明确的生成或逆向路径;a2 只能闭合到一段 16 字节结果的格式化层,不能据此命名为 MD5、SHA、AES、Twofish 或其他标准摘要。

从结构上看,这不是一条统一的“加密算法”,而是多条相互关联但用途不同的编码链:

记录字段
├── a5: 结构化 JSON -> zlib -> 定制状态表流变换 -> Base64
├── a7: a8 与时间封装 -> PKCS#7 -> AES-128-CBC -> Base64
├── a8: 动态组件 -> CRC-32 -> hex -> 固定 XOR -> 大写 hex
├── a9: 结构化 JSON -> zlib -> CRC-32 前缀 -> PKCS#7
│       -> Twofish-128-CBC -> Base64
└── a2: tagged-value 字节流 -> 16 字节结果 -> 小写 hex(实锤vm层算法,待解决)

a7 依赖 a8 的文本,因此字段之间存在生产顺序;a5a9 分别使用自己的输入对象和密码路径,不能因为最终形式都是 Base64 就把它们视为同一算法。

2. 证据来源与工具

分析使用两类互相校验的证据:

  1. 运行时证据:函数调用参数、输入输出指针、缓冲区长度、内存读写、字符串对象更新和返回值。
  2. 静态证据:反汇编和反编译得到的循环、分支、查表、字节序、轮函数和调用关系。

大体积 trace 不适合用普通文本编辑器或一次性读取。本文使用的 trace-search 已开源,项目地址为:

https://github.com/AndroidReverser-Test/trace_search

它适合对超大日志建立索引后执行精确字符串搜索、分段扫描、上下文读取和结果导出。工具本身不负责判断算法,算法判断仍需要把动态记录与静态代码逐项对应。

3. 总体工作流程

3.1 建立可搜索的运行时快照

先将原始 trace 交给索引工具,并等待索引状态变为可查询。对目标字符串执行大小写敏感的 literal 搜索,记录命中数量、最终字符串长度以及相邻的对象更新记录。

这一步的目标不是马上识别算法,而是找到一个可靠的终点:

  • 最终字符串在哪里被写入。
  • 写入长度是多少。
  • 写入前的源缓冲区是什么。
  • 哪个调用负责把源缓冲区包装成最终字符串。

如果日志服务有单次扫描上限,应按行区间分段搜索,并合并结果。搜索策略、分段范围和命中数属于证据链的一部分,应记录在分析笔记中。

3.2 从最终写入点向前追踪

找到最终写入后,沿以下字段逐层回溯:

记录项 作用
缓冲区标识 判断前后是否是同一对象或搬迁后的对象
长度 确认编码、填充和压缩边界
输入输出关系 区分原地变换、复制、追加和重新分配
调用参数 判断压缩、加密和编码的模式
首块或首字节 用于验证状态初始化和字节序

每确认一层,就把“当前输出”变成“上一层输入”,继续向前。不要跳过中间的 realloc、字符串追加或临时缓冲区,否则很容易把同一个地址在不同生命周期中的内容混为一谈。

3.3 用静态代码确认循环

运行时 trace 常能告诉我们“读了什么、写了什么”,但不一定能给出完整循环。静态分析用于确认:

  • 状态表的初始化顺序。
  • 索引是否按字节取模。
  • 查表前是否发生交换。
  • 密码轮数、key schedule 和 whitening 的布局。
  • CRC 的初始值、更新方向和最终取反。
  • CBC 的 chaining block 何时更新。

静态代码不能单独证明某个常量在本次运行中确实被使用。因此每个算法结论都应同时绑定到动态参数、代表性内存访问或首块结果。

3.4 将结果写成可验证的中间边界

不要只记录“解密成功”。应至少记录以下边界:

  • Base64 解码后的字节长度。
  • 压缩流的首部和长度。
  • PKCS#7 的填充值和总长度。
  • CBC 第一个分组的输入、输出或 chaining 行为。
  • CRC 的输入范围,而不是只记录 CRC 值。
  • 解压后的 JSON 长度和语法是否成立。

这些边界可以在不公开密钥和样本 token 的情况下证明算法路径是否一致。

3.5 区分直接证据与逆向复现

本文使用三个证据等级:

  • 直接记录:trace 明确打印了参数、长度、读写或返回值。
  • 静态确认:反汇编明确显示了对应循环或变换。
  • 确定性复现:使用前两类信息逆向执行后得到与下一层长度、格式或边界一致的结果。

如果某个字段只在最终 JSON 中出现,但没有找到它的生产者,不能把字段名解释成设备 ID、时间戳或摘要。应保留为匿名组件,并明确上游仍未确认。

4. a5:JSON、zlib 与定制状态表流变换

4.1 生成链

a5 的完整结构为:

JSON 字节串
    -> zlib deflate
压缩字节串
    -> 定制 KSA 与状态表 XOR
变换后的字节串
    -> 标准 Base64
a5 文本

参考样本中,JSON、压缩流和最终 Base64 的长度分别处于“数百字节”量级;具体长度取决于对象字段和运行时数据。长度关系本身应作为验证条件保存,但不需要公开真实样本。

4.2 动态 key source

key 不是一个可以从固定样本复制的常量,而是在调用前由三个运行时字段拼接得到:

source = field_1 || decimal(field_2) || decimal(field_3)

在已观察的 profile 中:

  • field_1 是固定长度的文本组件。
  • field_2field_3 先转成 ASCII 十进制,不带分隔符。
  • 拼接后的 source 长度为 48 字节。

这一步很重要。若把某一次运行中的时间或数值写死,脚本只能解开同一条样本,不能处理另一个记录。正确的复现器必须从当前记录重新生成 source。

4.3 派生 key

source 经过一个周期为 16 字节的固定 XOR mask 变换,得到 48 字节工作 key:

K[i] = source[i] XOR M[i mod 16]

其中 M 是应用内的静态 16 字节材料。本文不公开 M 或任何由它产生的工作 key,只保留索引关系、周期和长度。

4.4 状态初始化与 KSA

状态区包含一个 256 字节置换表,以及用于保存两个索引的状态字节。初始化时:

S[0] = 0, S[1] = 1, ..., S[255] = 255
j = 0

随后执行 256 轮 KSA。每轮的精确定义为:

t = S[i]
j = (j + i + t + K[i mod 48]) mod 256
swap(S[i], S[j])

这里的 + i 是该实现区别于标准 RC4 KSA 的关键。虽然数据阶段采用了类似 RC4 的置换表流生成方式,但不能直接把它命名为标准 RC4;它使用了不同的 key schedule 输入和不同的 KSA 累加项。

4.5 字节流变换

KSA 完成后,数据阶段从 i = 0, j = 0 开始。每个输入字节执行:

i = (i + 1) mod 256
j = (j + S[i]) mod 256
swap(S[i], S[j])
q = S[(S[i] + S[j]) mod 256]
output[n] = input[n] XOR q

输入和输出在本次调用中使用同一缓冲区,因此它是原地变换。由于状态由 key 和固定初始状态唯一决定,解码时重新建立同一状态并再次执行 XOR 即可恢复压缩流;XOR 本身是自逆的。

4.6 Base64 与逆向顺序

数据变换完成后使用标准 Base64:每三个输入字节拆成四个 6-bit 索引,使用标准 64 字符表输出。由于参考压缩长度刚好落在三字节边界上,最终没有额外的 = 填充字符。

逆向顺序为:

Base64 解码
    -> 重新执行相同状态表 XOR
    -> zlib 解压
    -> JSON 解析

主要验证条件是:Base64 解码后的长度符合压缩输出长度、变换结果具有合法 zlib 首部、解压后长度与 JSON 语法均成立。

5. a7:业务封装与 AES-128-CBC

5.1 明文封装

a7 的 AES 明文不是单独的随机字段,而是由 a8 和时间字段拼接得到:

plain = ASCII("0")
        || a8
        || ASCII("1")
        || lowercase_hex(epoch_seconds, width=8)

其中时间先取秒级整数,再格式化为 8 个小写十六进制字符。两个单字节标记用于界定前后的字段。该封装的长度、标记位置和时间格式都可以通过字符串对象的追加顺序与最终长度确认。

5.2 PKCS#7

AES 的分组长度为 16 字节。设明文长度为 L,填充长度为:

p = 16 - (L mod 16)

随后追加 p 个值为 p 的字节。参考样本的明文不是 16 的整数倍,因此末尾填充字节数量大于零;具体明文和填充值不在本文公开。

5.3 AES-128-CBC

静态代码显示 key schedule 选择 128-bit 密钥,即 AES-128 的 10 轮结构。实现使用查表优化,但算法语义仍是标准 AES:

  • 字节状态按 AES 规定排列。
  • 每轮包含 SubBytes、ShiftRows、MixColumns 和 AddRoundKey;最后一轮不执行 MixColumns。
  • 逆向时使用逆 ShiftRows、逆 SubBytes、逆 MixColumns 和逆轮密钥。

CBC 加密公式为:

C[0] = AES_K(P[0] XOR IV)
C[n] = AES_K(P[n] XOR C[n-1])

解密公式为:

P[0] = AES_K^-1(C[0]) XOR IV
P[n] = AES_K^-1(C[n]) XOR C[n-1]

本文不公开应用内的 128-bit key 和 16-byte IV。复现时只需要确认它们是同一 profile 的静态参数,并验证 CBC chaining block 在每个分组后更新为当前 ciphertext block。

5.4 Base64 与验证

PKCS#7 填充后的 ciphertext 经过标准 Base64 得到 a7。解码验证顺序如下:

  1. Base64 严格解码,确认长度是 16 字节整数倍。
  2. 按 AES-128-CBC 解密。
  3. 检查 PKCS#7 的最后一个字节和整段填充。
  4. 检查明文首尾标记、a8 长度和时间字段格式。

因此,即使不公开 key 和 IV,也能准确描述并验证整个算法边界。

6. a8:CRC-32、hex 解码与固定 XOR 派生

6.1 生成链

a8 不是简单的固定前缀字符串。完整链为:

两个动态文本组件
    -> 固定格式拼接
48 字节 ASCII preimage
    -> CRC-32/ISO-HDLC
8 个小写十六进制校验字符
    -> 拼接
56 个小写十六进制字符
    -> hex decode
28 个原始字节
    -> 固定 28 字节 XOR mask
28 个派生字节
    -> 大写 hex
56 字节 a8

6.2 CRC-32 输入格式

设两个动态组件分别为长度 32 和 11 的 ASCII 文本,则 preimage 的结构为:

preimage = ASCII("0000")
           || component_32
           || component_11
           || ASCII("0")

总长度为 48 字节。两个组件的真实内容和业务语义未公开;trace 只证明它们被以这两个长度复制到输入缓冲区。

该 CRC 是 reflected CRC-32/ISO-HDLC,状态更新为:

state = 0xffffffff
for each byte b:
    state = table[(state XOR b) AND 0xff] XOR (state >> 8)
crc = bitwise_not(state) AND 0xffffffff

最终 checksum 使用 8 位小写十六进制格式化,并追加到 preimage 后面,形成 56 字节的 source 文本。

6.3 hex decode、XOR 和输出

source 文本每两个十六进制字符组成一个字节,因此 56 个字符被解码为 28 字节。随后与静态 28 字节 mask 逐字节 XOR:

derived[i] = decoded[i] XOR mask[i], 0 <= i < 28

最后使用大写十六进制字母表把 28 个字节重新编码为 56 字符文本。

调用方对结果的前缀进行比较属于生成后的校验或业务筛选,不是上述派生链的一部分。分析时必须把“生成值”和“生成后检查”分开,否则容易误以为前缀本身参与了 key derivation。

6.4 复现边界

从两个动态组件到 a8 的每一步都能独立复现:拼接长度、CRC 输入范围、checksum 格式、hex 解码长度、XOR 长度和最终大写编码均有明确边界。

两个动态组件更早的 Java/native 生产者没有在当前 trace 中闭合,因此不能在文章中把它们命名为设备 ID、安装标识、时间戳或摘要。

7. a9:JSON、zlib、CRC-32、Twofish-CBC 与 Base64

7.1 JSON 序列化

a9 的输入是一个运行时对象图,而不是预先存在的字符串。序列化器递归处理对象、数组、键、冒号、逗号、字符串和值,生成紧凑 JSON 字节串。

参考 trace 中,序列化后的 JSON 处于约 774 字节的 profile,随后进入 zlib。对象节点的业务来源没有全部继续向上追踪,本文只描述序列化和加密边界。

7.2 zlib 与 CRC-32 前缀

JSON 使用 zlib deflate,压缩级别采用库默认路径并以完成模式结束。参考 profile 中,JSON 压缩为 216 字节 zlib stream。

CRC-32 计算范围非常关键:它覆盖的是未填充的 zlib stream,不是 JSON、PKCS#7 后的数据,也不是最终 ciphertext。设压缩流为 Z,则:

prefix = lowercase_hex(CRC32_ISO_HDLC(Z), width=8)

这个 8 字符前缀位于最终字段最外层,后面才是 Base64 body。它的作用是让解码器在解压前验证压缩流是否完整。

7.3 PKCS#7

Twofish 的分组长度同样为 16 字节。将 zlib stream 按 PKCS#7 补齐到 16 的整数倍:

p = 16 - (len(Z) mod 16)
padded = Z || p repeated bytes whose value is p

参考 profile 中,216 字节流增加 8 个填充字节,得到 224 字节的 CBC 输入。必须先去除填充,再对剩余的 zlib stream 做 CRC 校验。

7.4 Twofish-128 参数与轮结构

静态代码和动态 context 写入共同确认该分组变换为标准 Twofish-128 的优化实现,而不是自定义的“类似 Twofish”算法。其结构包括:

  • 128-bit key。
  • 16 轮数据变换。
  • 40 个 32-bit round subkeys,包括输入和输出 whitening 使用的子密钥。
  • 4 张由 key 派生的 256 项 keyed S-box。
  • RS 编码生成 S-box key,q permutation 和 MDS 矩阵参与 h() 函数。
  • 以小端序加载 32-bit word,并使用 T-table 加速 keyed S-box 与 MDS 组合。

Twofish key schedule 的关键步骤是:

  1. 将 128-bit key 拆分为偶数和奇数 32-bit/64-bit 片段。
  2. 使用 Reed-Solomon 矩阵从 key material 生成 S-box key。
  3. 通过 q permutation、key-dependent byte XOR 和 MDS 组合生成四张 keyed S-box。
  4. 对交错输入生成 40 个 round words,并按标准旋转规则排列。

数据分组使用 Twofish 标准的输入 whitening、16 轮 Feistel-like 变换和输出 whitening。每轮使用两个 g() 结果:一个来自四张 S-box 按低到高字节取表,另一个按相反字节顺序取表,再经过加法、旋转和轮密钥组合。T-table 只是把这些查表和 MDS 操作预计算,不能改变 Twofish 的数学结构。

7.5 CBC 模式

设 padded stream 分组为 P[0]...P[n],应用内固定 IV 记为 IV

C[0] = Twofish_K(P[0] XOR IV)
C[n] = Twofish_K(P[n] XOR C[n-1])

每次分组输出后,当前 ciphertext block 被复制到 chaining 状态,作为下一块的 XOR 输入。参考 profile 共有 14 个 16-byte block,数量由 224 字节 CBC 输入决定,而不是由 JSON 字段数量决定。

本文不公开 Twofish key、IV、S-box 表或 round subkeys。公开这些值没有必要,因为算法类型、key schedule、CBC 边界和校验顺序已经足以描述实现。

7.6 外层拼接与逆向顺序

最终 a9 结构为:

a9 = prefix || Base64(ciphertext)

其中 prefix 是未填充 zlib stream 的 8 字符小写 CRC-32,Base64 body 对应 PKCS#7 后的 Twofish-CBC ciphertext。

逆向流程为:

拆出 8 字符 CRC 前缀和 Base64 body
    -> Base64 解码
    -> Twofish-128-CBC 解密
    -> 检查并移除 PKCS#7
    -> 对 zlib stream 计算 CRC-32 并比较前缀
    -> zlib 解压
    -> JSON 解析

长度关系是很强的验证信号:224 字节 ciphertext 应编码为 300 字节 Base64 body,外层再加 8 字符前缀。任何不满足 block 对齐或长度关系的输入,都不应强行套用该 profile。

8. a2:已确认的格式化层与未闭合的数据流

a2 的最终层是一个 16 字节原始块到 32 字符小写十六进制文本的序列化:

raw16[0..15]
    -> 每个字节格式化为两个小写 hex 字符
    -> 32 字符 a2

trace 还显示,raw16 由一个更早的 20-byte opaque input 经 tagged-value getter、字节读取、XOR 和分散的 byte store 得到。部分字节的输入位置、XOR 操作和目标位置已经可以逐项对应,但完整 flattened control-flow 的高层语义尚未闭合。

因此目前只能确认:

  • raw16 是一段 16 字节数据。
  • 最终编码是 %02x 语义的小写十六进制。
  • 上游包含 tagged-value 的字节级 XOR 和覆盖写入。
  • 没有足够证据把它命名为任何标准 hash 或密码算法。

这是逆向分析中重要的边界:格式化层已知,不代表生成层就是哈希。

9. 版本差异与 profile 识别

同一字段名不一定永远使用同一个格式。后续样本中可能出现:

  • 带版本标记的 KMS envelope,而不是已确认的 AES-CBC 外壳。
  • Base64 解码后长度不同,无法满足旧 Twofish profile 的 16-byte 分组边界。
  • a8 仍是 56 字符 hex,但动态组件或大小写形式发生变化。
  • a5 的 key source 随当前记录变化,不能复用前一次运行的 source。

因此解码器应先识别 profile,再执行算法:

  1. 检查字段的外层版本标记和字符集。
  2. 检查 Base64、hex、block size 和长度关系。
  3. 只有全部前置条件满足时才进入 AES 或 Twofish 路径。
  4. 对未知 profile 输出“不支持该格式”的诊断,而不是使用旧 key 和旧算法强行解密。

这种策略比“遇到失败就换一个 key”更可靠,也能避免把格式升级误判为密钥错误。

10. 复现与验证策略

公开文章不需要附带脚本,也可以通过以下检查验证实现是否准确。

10.1 a5 验证

  • 当前记录重新拼接 key source,不能使用固定时间值。
  • source 长度必须符合 profile,派生 key 的 mask 索引按 16 字节循环。
  • KSA 每轮必须包含 i、当前 S[i]、key 字节和交换操作。
  • 状态流变换首字节应同时满足 zlib 首部、XOR 关系和写回位置。
  • Base64 解码、状态逆变换和 zlib 解压后必须得到合法 JSON。

10.2 a7 验证

  • 明文中 a8 的位置和长度正确。
  • 时间字段是固定宽度的小写十六进制秒值。
  • PKCS#7 填充后的长度是 16 的整数倍。
  • AES-128 的 10 轮 key schedule 和 CBC chaining block 顺序正确。
  • 去除填充后,首尾标记、a8 和时间字段均能解析。

10.3 a8 验证

  • CRC 输入必须是 48 字节 preimage,而不是包含 checksum 的完整 source。
  • CRC 使用 reflected ISO-HDLC 参数,最终取反并按 8 位小写 hex 格式化。
  • 56 字符 source 必须先 hex decode 为 28 字节。
  • XOR mask 长度必须是 28 字节,最终输出必须重新编码为大写 hex。

10.4 a9 验证

  • CRC 输入是未填充 zlib stream。
  • PKCS#7 在 Twofish-CBC 之前添加,在 CRC 比较前移除。
  • Twofish 使用 128-bit key、16 轮、key-dependent S-box 和 whitening。
  • CBC 初始块使用 IV,后续使用前一块 ciphertext。
  • 外层 8 字符前缀与解密后的 zlib stream CRC 一致。
  • zlib 解压结果是合法 JSON,而不是任意可打印字符串。

10.5 正向与逆向交叉验证

最可靠的验证不是只做一次解密,而是双向检查:

  1. 从最终字段逆向得到中间 JSON 或封装明文。
  2. 对中间结果按相同顺序正向生成。
  3. 只比较长度、格式、校验和以及内部测试样本的最终字节,不在公开文档中公布实际 token。
  4. 对改变了动态组件的记录重新生成 a5 source,确认没有依赖旧样本缓存。

11. 尚未确认的边界

以下内容在当前证据范围内仍不应下结论:

  • a5 source 中文本组件和两个数值字段的最初业务生产 API。
  • a5 的 16-byte mask 更早来自哪里。
  • a7 所依赖的 a8 动态组件在 Java/native 层的业务语义。
  • a8 两个组件是否分别代表设备、安装、环境或其他属性。
  • a9 JSON 各节点对应的独立上游 API。
  • a2 的 20-byte opaque input 生产者和完整 tagged-value 派生程序。
  • 保护层中所有间接控制流是否构成完整通用虚拟机。

这些问题需要更多运行时样本、上游调用链或业务接口证据。仅凭字段名、固定字符串或一段混淆后的 XOR,不足以给出可靠命名。

12. 结论

本次分析的核心成果不是得到几个可复制的字符串,而是闭合了从对象构造到最终文本的多个可验证边界:

  • a5 是 JSON 经 zlib 后进入一个带非标准 KSA 项的状态表 XOR 流变换,再做 Base64。
  • a7 是由 a8 和时间字段构成的业务明文,经 PKCS#7、AES-128-CBC 和 Base64 封装。
  • a8 是 48 字节文本的 CRC-32 校验、hex 解码、28 字节固定 XOR 和大写 hex 派生。
  • a9 是 JSON、zlib、压缩流 CRC-32 前缀、PKCS#7、标准 Twofish-128-CBC 和 Base64 的组合。
  • a2 目前只闭合到 16 字节结果的小写 hex 序列化,不能被误命名为标准摘要。

在实际逆向中,最有效的原则是“先证据,后命名”:先用 trace-search 定位终点和缓冲区,再用静态代码确认循环,最后用长度、校验、填充和往返复现建立闭环。这样既能准确恢复算法,也能在公开发布时安全地隐藏真实函数位置、密钥材料和业务样本。


工具链接https://github.com/AndroidReverser-Test/trace_search