WeChat 8.7.0 ClientCheckData 白盒 AES:从 AES-CBC 到 03/08 表解释器还原
WeChat 8.7.0 ClientCheckData 白盒 AES:从 AES-CBC 到 03/08 表解释器还原
在前两篇 RQTX 文章里,我们把重点放在 VM runtime、trace 聚合和语义化哈希还原上。这一篇换一个方向:WeChat 8.7.0 client check data 链路里的白盒 AES。
它看起来不像 RQTX 那样有一段长 VM,也不会在代码里直接露出 AES-CBC(key, iv, data) 这种标准库调用。它更像一个“表解释器”:运行时代码稳定,算法实例藏在一份 PB 模板里。模板里有 IV、TableKey、TableValue、FinalTable 和少量 profile 字段;解释器把这些表一轮轮喂进去,最终输出与 AES-128-CBC 等价的密文。
这类设计在业界很常见:业务想继续使用标准 AES-CBC 的协议语义,又不希望 raw key 以普通常量形式出现在客户端。于是 key schedule、SBox、MixColumns、最后一轮 key 等内容被折叠进查表数据。源码里看不到 key,并不等于 key 不存在。白盒 AES 的核心矛盾就在这里:它把秘密摊进表里,又必须让表完整地参与计算。
本文按由浅入深的方式展开:先看普通 AES-CBC,再看这套 03/08 表解释器如何模拟 AES;然后说明 trace 如何定位到算法边界,如何从表结构恢复 key/IV,最后用页面刷新生成的随机向量做反向验证。
关键结论
- 这条 client check data 加密链路对外语义是 AES-128-CBC,输入按 16 字节 block 处理,先做 CBC XOR,再进入表解释器。
- 运行时代码不是普通 AES 实现,而是 PB 模板驱动的白盒表解释器:
Go/Native code = interpreter,PB template = concrete AES instance。 00000008的 FinalTable 形态很薄,单独拟合 16 个 byte lane 就能恢复最后轮 key,再逆 AES-128 key schedule 得到 master key。00000003相比 08 增加了 byte-level 编码,不能直接从 FinalTable 拿 key,但仍可从 TableValue 恢复隐藏 XOR 坐标,再从第一轮 TableKey 拟合 master key byte。- 8.7.0 的 VER08 模板可以按“descriptor + template blob + length + transform gateway”理解;gateway 先把 protobuf wire 记录物化为 typed object,再交给后续 OLLVM 风格表解释逻辑。
- IV 不是密码分析问题,它在模板字段里作为 CBC 初始链块参与计算。真正的安全边界应来自 per-message IV、模板完整性、表编码和跨字节融合,而不是“源码里看不到 key”。
- 页面里的验证向量由边缘函数生成:刷新页面会得到一组随机 hex 输入,以及 03/08 两个 profile 的 AES-CBC 密文,适合和本地 hook、离线解释器或还原出的标准 AES 实现比对。
页面刷新生成的白盒 AES 验证向量
下面的向量由边缘函数在页面加载时生成。每次刷新页面都会得到新的 24 字节 hex 输入,服务端按 client check data 的 block padding 语义处理后,分别给出 00000008 和 00000003 profile 的 AES-CBC 密文。
浏览器只看到输入、长度、padding 方式和密文。key/IV 作为服务端 profile 配置参与计算,不进入页面脚本。
如果页面显示服务端 profile 未配置,说明部署环境还没有填入 03/08 等价 profile;边缘函数会失败关闭,不返回任何占位密文。
- input_kind
- hex_bytes
- input_hex
- loading
- input_length
- - bytes
- padding
- -
- padded_length
- - bytes
- 00000008 cipher_hex
- -
- 00000008 profile_source
- -
- 00000003 cipher_hex
- -
- 00000003 profile_source
- -
- generated_at
- -
先把标准 AES-CBC 讲清楚
白盒 AES 最容易被讲复杂。对初学者来说,先不要被“白盒”两个字吓住。它最终仍然要完成一个标准问题:
ciphertext = AES-CBC-Encrypt(key, iv, plaintext)CBC 模式的外层逻辑很短:
C[-1] = IV
for each 16-byte plaintext block P[i]: X[i] = P[i] xor C[i-1] C[i] = AES_Encrypt(K, X[i])也就是说,CBC 本身不改变 AES 的单 block 加密,只是在每个 block 进入 AES 前,把它和上一个密文 block 异或。第一个 block 没有上一个密文,所以用 IV。
AES-128 单 block 内部则是另一层结构:
state = inputstate = AddRoundKey(state, K0)
for round 1..9: state = SubBytes(state) state = ShiftRows(state) state = MixColumns(state) state = AddRoundKey(state, Kround)
state = SubBytes(state)state = ShiftRows(state)state = AddRoundKey(state, K10)这里有几个关键词:
SubBytes是 SBox,逐 byte 非线性替换。ShiftRows是 4x4 state 的行移位。MixColumns是每列 4 byte 的线性混合。AddRoundKey是和 round key XOR。- AES-128 有 11 组 round key:
K0..K10。
如果代码直接调用标准库,逆向时通常能看到 AES 常量、SBox、T-table、key schedule 或 AES_set_encrypt_key 类边界。白盒 AES 要做的事情,就是把这些明显的结构拆散、折叠、编码到表里。
白盒 AES 的直觉:把公式变成查表
标准 AES 的一轮可以写成公式:
output_column = MixColumns(SubBytes(input_column xor round_key_column))白盒实现不希望运行时显式出现 round_key,也不希望每一步都像 AES 教科书那样清楚。于是它会把“某个输入 byte 经过 key XOR、SBox、MixColumns 后对输出列的贡献”预先算成表:
TableKey[position][input_byte] -> contribution bytes运行时拿当前 state byte 去查表,得到 contribution,再用另一个表把多个 contribution 合成下一轮 state:
TableValue[encoded_contribution_0, encoded_contribution_1, ...] -> output_byte这样,源码里没有 SBox[x xor key],也没有 2*x ^ 3*y ^ z 这种 MixColumns 公式。看起来只是很多数组索引、偏移和 byte 组合。
这就是当前 03/08 的核心模型:
运行时代码 = 通用表解释器PB 模板 = 具体 AES-CBC 实例只要 PB 变了,同一个解释器就可以表现成不同 profile 的 AES-CBC。
ClientCheckData 加密链路的数据流
从数据流角度看,这条链路可以拆成四层:
白盒解释器内部再展开,是下面这个形态:
注意几个非常重要的观察点:
- CBC XOR 是显式的,所以 block 边界很清楚。
Transpose和TransposeBack是为了在一维 byte array 和 AES 4x4 state 之间换布局。ShiftRows仍然显式存在,而且每轮后都会执行。- 9 轮核心变成
TableKey -> TableValue -> ShiftRows。 - Final round 被压成
FinalTable。
这不是“看不见 AES”,而是“把 AES 关键步骤藏进表里,只留下解释表的机器”。
8.7.0 VER08 的模板边界
在 8.7.0 的 client check data 链路里,VER08 不应该被理解成一个孤立的 AES_encrypt() 函数。更准确的边界是:
collect CCD fields -> build dataBody -> build middle wrapper with field 3 = dataBody -> zlib-compatible compression -> VER08 AES-CBC-compatible envelope -> outer ccData也就是说,白盒 AES 只负责其中的 envelope 层。Hook 或离线验证时,如果把 raw 业务字段、压缩前 wrapper、压缩后 bytes 和白盒入口 bytes 混在一起,就会得到看似“算法不一致”的假错误。
8.7.0 的 VER08 入口可以抽象成四个东西:
template descriptortemplate blobtemplate lengthtransform gatewayIDA callsite 对照
IDA 里对应的 collector callsite 大致是这个形态:
ADRL X8, dword_11D077548LDR W21, [X8]...ADRL X1, unk_11D036528ADRL X2, unk_11D037454MOV X3, X21MOV X4, X20BL sub_1129E7A20这段汇编给了非常直接的参数关系:
dword_11D077548 -> template length wordunk_11D036528 -> template descriptorunk_11D037454 -> template blobX21 -> template length valueX20 -> scratch / arena-like contextsub_1129E7A20 -> VER08 template/table expansion gateway在当前 8.7.0 视图里,template length 是 0x400f3。这不是 ciphertext 长度,而是整个 VER08 template blob 的长度。它和后面的 field layout、TableKey/TableValue/FinalTable 尺寸能互相对上。
collector 把 descriptor、blob、length 和 scratch arena 交给 gateway。gateway 的第一阶段不是 AES round,而是 protobuf wire 记录解析:它把 wire field 解析成小型 record,再按 descriptor row 物化到 typed object slot。长度字段不会立刻复制整块大表,而是以 source pointer + length 的形式挂到对象上,后续解释器再按窗口访问。
这解释了一个常见误判:如果只盯着 gateway 的体量、switch 和控制流,会觉得它像一个“巨大的 AES 函数”。但从语义看,它更像:
wire-format template parser -> descriptor-backed object materializer -> table expansion / transform gateway在这条路径上没有普通标准库 AES-CBC API 边界。真正的 AES 语义藏在模板字段、表窗口和解释器访问节奏里。
Gateway 解析细节
gateway 可以先按下面的签名理解:
void sub_1129E7A20( VerObject *out, VerDescriptor *descriptor, uint8_t *blob, int blob_len, Arena *arena_or_scratch);它开头会把 template blob 解析成 12 字节 wire record:
struct WireRecord { uint32_t tag; // protobuf wire tag, field_number = tag >> 3 uint32_t lo; // scalar low bits or value start offset uint32_t hi; // scalar high bits or value end offset};这个 parser 的关键行为是:
initial record storage: stack scratch, capacity 18growth path: heap array, capacity grows in 0x40-record chunksrecord size: 0x0csupported wire types: 0, 1, 2, 5rejected wire types: 3, 4length-delimited values: stored as source offsets, not copied immediately第二阶段由 descriptor 驱动。descriptor 指向一组固定大小的 entry,当前顶层 entry 可以按下面的类型阅读:
entry array pointer: 0x11d036108entry count: 22entry size: 0x30
fields 1,2: type 12, flag 1field 3: type 5, flag 1fields 4-8: type 12, flag 0fields 9-12: type 12, flag 1fields 13-21: type 12, flag 0field 50: type 13, flag 0其中 type 12 处理 length-delimited bytes/string-like 字段,type 5 处理 scalar lane,type 13 处理 field 50 这类 profile scalar。字段真正落入对象时,还会走一个 hash/table insert/resize helper。几个可对照的 gateway 区间是:
0x1129ea26c..0x1129ea288 type-12 bytes/slice materialization: stores source pointer and length
0x1129e8a54..0x1129e8a70 scalar/simple materialization into typed field object
0x1129ea000..0x1129ea044 nested-message handler exists, but top-level VER08 table fields use bytes/slice slots所以 sub_1129E7A20 更适合标成“VER08 template/table expansion gateway”,不是 compact AES round function。它体量很大、basic block 很多,也能看到 switch 和递归形态,但当前 client check data 路径没有普通 AES-CBC API 调用边界。
PB 模板里有哪些东西
这类模板是 protobuf wire-format blob,字段语义可以按功能理解:
| 字段 | 角色 | 逆向价值 |
|---|---|---|
| field 1 | profile/version 字符串 | 区分 00000003、00000008 等表生成策略 |
| field 2 | 16 字节 IV | CBC 初始链块 |
| field 3 | scalar control parameter | 8.7.0 VER08 中可视为 round plan 控制参数 |
| field 9/11 | 小型辅助表 | profile 辅助信息 |
| field 10 | TableKey,9 轮大表 | 吸收 round key、SBox、MixColumns contribution |
| field 12 | TableValue,9 轮 combiner | 合并 contribution,08 近似 XOR,03 是编码二元组合器 |
| field 18 | FinalTable | 吸收 final SubBytes 和最后轮 key |
| field 50 | profile scalar | 模板参数 |
对 8.7.0 VER08 来说,字段角色还可以再具体一点:
field 1 version selector: "00000008"field 2 16-byte CBC IVfield 3 scalar control parameter, observed as 0x3060field 9 small required table block, 0x80 bytesfield 10 large TableKey block, 0x24000 bytesfield 11 small required table block, 0x40 bytesfield 12 large TableValue block, 0x1b000 bytesfield 18 FinalTable block, 0x1000 bytesfield 50 scalar profile parameter, observed as 3同一 descriptor family 里还预留了若干可选 slot。它们不是当前 VER08 的主要 table carrier。对迁移分析来说,这一点很有用:新版本如果字段号改变,不要先找 key,而要先恢复“version、IV、large table blocks、scalar control、gateway xref”这些角色。
三个大表的尺度有明显结构:
TableKey = 9 rounds * 16 positions * 256 inputs * 4 contribution bytesTableValue = 9 rounds * 16 output positions * 0x300-byte combiner windowsFinalTable = 16 positions * 256 entries这几个尺寸不是随机数字。它们刚好贴合 AES 的 16 byte state、9 个包含 MixColumns 的主轮、最后一轮无 MixColumns 的结构。
单 block 的语义化伪代码
把实现细节收敛后,单 block 可以写成这样:
state = Transpose(plainBlock xor chainingBlock)state = ShiftRows(state)
for round = 0..8: secTable = TableKey[round](state) state = TableValue[round](secTable) state = ShiftRows(state)
state = FinalTable(state)cipherBlock = TransposeBack(state)这段伪代码很重要。它把逆向目标从“看懂一堆表偏移”变成了“证明这套表解释器等价 AES-CBC”。
在 8.7.0 VER08 上,这个 plan 可以进一步落到字段窗口:
CBCXor with field 2 IVTranspose4x4ShiftRows
for round = 0..8: read field 10 TableKey window at round * 0x4000 read field 12 TableValue window at round * 0x3000 ShiftRows
read field 18 FinalTableTransposeBack4x4这也是 trace 里最值得标记的节奏:0x4000 级别的 TableKey 轮窗口、0x3000 级别的 TableValue 轮窗口、稳定的 ShiftRows 间隔,以及最后单独出现的 FinalTable。
对照标准 AES:
| 标准 AES | 白盒表解释器 |
|---|---|
AddRoundKey | 折叠进 TableKey 和 FinalTable |
SubBytes | 折叠进 TableKey 和 FinalTable |
MixColumns | 拆成 contribution,再由 TableValue 合成 |
ShiftRows | 仍然显式保留 |
CBC XOR | 仍然显式保留 |
| final round | 16 个 FinalTable byte lane |
如果 trace 里能稳定看到这些边界,AES 结构就已经露出来一半。
Trace 怎么定位到这个算法
定位白盒 AES 不能只搜 AES、SBox、CBC 这些字符串。真正有效的是从行为边界往里压。
第一层看长度。client check data 是一段 byte buffer,输出长度总是 16 的倍数。输入不是 16 的倍数时,尾部会按缺口长度补齐;输入刚好对齐时,不额外追加一整块。这个 padding 行为和很多标准 PKCS#7 封装不完全一样,是定位边界的第一个特征。
第二层看链式依赖。第一个 block 会和某个 16 字节初始链块 XOR;后续 block 会和上一个 ciphertext block XOR。只要 trace 中出现“当前明文 block 与上一轮输出交叉”的数据依赖,就能把 CBC 外壳剥出来。
第三层看 state layout。CBC XOR 后,16 字节不是直接进入普通 AES 函数,而是被转成 4x4 state。随后出现行移位:第二行左移 1,第三行左移 2,第四行左移 3。Transpose + ShiftRows 连续出现,是很强的 AES 指纹。
第四层看 64 字节中间表。每轮 TableKey 会从 16 个 state byte 里各查出 4 个 contribution,所以中间 buffer 是 64 字节。这个 buffer 不像普通 AES state,也不像普通 T-table 的 32-bit word 输出,但它正好对应“16 个输入 byte,每个给 4 个列贡献”。
第五层看 TableValue 的访问节奏。它不是一次普通 XOR,而是每个 output byte 走 3 段 nibble lookup,把 4 个 contribution 合成一个 byte。08 里这个组合器语义接近 XOR;03 里它被编码成隐藏坐标下的二元操作。
把这五层串起来,trace 就不再是“很多查表”,而是一条可命名的数据流:
padding -> CBC chaining -> state transpose -> visible ShiftRows -> TableKey contribution -> TableValue combiner -> FinalTable -> ciphertext这就是从 trace 到语义的关键转折。
00000008:FinalTable 为什么会泄露最后轮 key
00000008 是比较直观的一版。它的 TableValue 虽然写成查表,但语义上接近普通 XOR combiner;TableKey 的 contribution 也更像 raw MixColumns contribution。最关键的是 FinalTable。
FinalTable 的逻辑形态是:
FinalTable[position][x] -> y每个 position 都是一张 256 项 byte 表。对 08 来说,它可以拟合成:
T[x] = SBox[x xor a] xor b这里 a 是 final table 输入侧吸收的 material,b 对应最后一轮 key 的某个 byte。因为 AES SBox 是非线性置换,只要枚举 a,就能判断整张 256 项表是否成立:
for a in 0..255: b = T[0] xor SBox[a] if T[x] == SBox[x xor a] xor b for every x: recover b16 个 lane 分别恢复 16 个 b,再按照 state 到 ciphertext 的映射重排,就得到 AES round10 key:
FinalTable -> 16 lane SBox fit -> round10 key -> inverse AES-128 key schedule -> master keyAES-128 的 key schedule 是可逆的。已知 K10,就能从 w43 倒推到 w0,最终还原 master key。
IV 更直接:它作为 CBC 初始链块保存在模板字段中。对 08 来说,恢复路径非常短:
field18 FinalTable -> round10 key -> master keyfield2 IV -> IV这说明 08 更准确的定位是“表驱动 AES-CBC”,而不是强抗提取的白盒 AES。
00000003:更像白盒,但仍然能剥开
00000003 比 08 更有白盒味道。它没有让 FinalTable 直接满足 SBox[x xor a] xor b,TableKey 的 contribution 也不是当前 byte 坐标下的 raw MixColumns contribution。
表面上看,03 好像挡住了 08 那条最短路径:
FinalTable -> round10 key但它仍然保留了完整的 truth table。TableValue 的每个 0x100 子表可以看成一个编码后的二元组合器:
encoded_op(encoded_a, encoded_b) -> encoded_sum真实语义仍然是 XOR,只是输入、输出都换了坐标。恢复思路不是直接找 XOR,而是先把这个二元操作归一化:
read binary combiner table -> find left/right projections -> recover hidden identity -> build XOR-isomorphic coordinates -> decode contribution bytes当隐藏 XOR 坐标恢复以后,第一轮 TableKey 的 contribution 就能被放回 AES 语义里拟合:
decoded_contribution ~= affine(coeff * SBox[x xor keyGuess])这里的 coeff 来自 MixColumns 的 01/02/03 系数。枚举每个 key byte,能让 256 个输入全部匹配的候选就是这一字节的 key。第一轮使用的是 master key 本身,所以 03 不必先恢复最后轮 key,也不必逆 key schedule。
03 的恢复路径可以概括成:
TableValue -> recover hidden XOR coordinates -> decode first-round contribution -> fit coeff * SBox[x xor keyGuess] -> master key
field2 -> IV这比 08 难不少,但本质仍是 PB-only 恢复:表里保存了完整算法实例,解释器边界又足够稳定。
03 和 08 的安全差异
| 维度 | 00000008 | 00000003 |
|---|---|---|
| FinalTable | 可直接拟合 SBox lane | 编码 permutation,不能直接拟合 |
| TableValue | 近似普通 XOR combiner | 编码二元组合器 |
| TableKey contribution | 更接近 raw MixColumns contribution | contribution 带 byte-level 编码 |
| PB-only key 恢复 | 极短,FinalTable 即可 | 中等,需要恢复隐藏 XOR 坐标 |
| 自动化特征识别 | 容易 | 更难 |
| 强白盒安全性 | 不足 | 相对更好,但仍不足 |
03 的价值在于提高简单模式匹配和静态拟合成本。它让“搜索 SBox、拟合 FinalTable、提取 key”的脚本不能直接套用。但它没有改变几个根本事实:
- table truth table 完整可读;
- output byte 之间没有足够强的融合;
- 轮边界稳定;
- CBC 边界稳定;
- IV 仍然由模板给出;
- 没有 per-build 绑定材料或运行时派生表参与核心语义。
所以它更像“编码表驱动 AES”,还不是强白盒。
从恢复 key/IV 到证明算法正确
恢复 key/IV 只是第一步。真正让人放心的是等价性证明。
我们需要做三层验证:
第一层是单 block 验证。选择多个非对齐长度输入,让 padding 分支参与计算;同时选择 16 字节对齐输入,确认不会额外追加 block。两者都必须与表解释器一致。
第二层是多 block 验证。多 block 会覆盖 CBC chaining:第二块的输入依赖第一块 ciphertext,不能只验证 AES 单 block。
第三层是 profile 差异验证。08 和 03 应该走不同的恢复路径,但最终都能落到标准 AES-CBC 等价实现。也就是说,不管表编码多复杂,最终密文都应能由同一个抽象接口表达:
encrypt_client_check_data(profile, plaintext_hex) -> cipher_hex页面里的向量就是这一层验证的轻量入口。刷新页面生成随机输入,拿到 03/08 的密文,再和本地还原实现、hook 边界或离线解释器比对。
用 Hook 做反向验证时看哪里
这类验证不应该修改返回值。返回值一旦被改,验证就失去意义。推荐只在加密边界替换一次输入,然后观察真实运行路径给出的密文。
边界可以抽象成:
encrypt_client_check_data(input_bytes) -> cipher_bytes验证记录四个字段:
page.input_hexruntime input after patchruntime cipher_hexpage.cipher_hex四者对齐,说明三个东西在同一条边界上:
- 页面服务端 AES-CBC 等价实现;
- 本地 hook 到的 client check data 加密入口;
- 已还原的 03/08 profile。
Frida 脚本保持只读优先。先观察入参长度、输出长度和调用时机,确认命中边界后,再做一次单次替换:
实践时按四步走:
- 先只观察,不写内存。确认命中的函数确实收到 envelope plaintext,而不是 raw CCD 字段或压缩前 wrapper。
- 记录输入指针、输入长度、输出位置和输出长度。输出长度必须是 16 的倍数;非对齐输入应只补到下一个 block。
- 只替换一次输入。替换长度必须和页面生成的
input_hex字节数一致,避免破坏上层 buffer 生命周期。 - 在
onLeave读取真实 runtime 给出的 ciphertext,再和页面上的00000008或00000003cipher_hex 比对。
越狱 attach 和重打包 Frida Gadget 的差异只在加载方式,不在验证逻辑。越狱环境可以在进程稳定后 attach;重打包环境可以让 Gadget 在初始化后加载同一份脚本。脚本里不应写死公开地址,而应在本地授权环境里完成 slide 映射和目标边界解析。
const challenge = { profile: "00000008", inputHex: "<page-input-hex>", expectedCipherHex: "<page-cipher-hex>",};
const targetAddress = ptr("<locally-resolved-envelope-entry>");let patchedOnce = false;
function hexToBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) { out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return out;}
function readHex(ptrValue, length) { return Array.from(new Uint8Array(ptrValue.readByteArray(length))) .map((value) => value.toString(16).padStart(2, "0")) .join("");}
Interceptor.attach(targetAddress, { onEnter(args) { const input = resolveInputBuffer(args); const length = resolveInputLength(args); const expectedLength = challenge.inputHex.length / 2;
this.check = false; if (patchedOnce || length !== expectedLength) { return; }
this.beforeHex = readHex(input, length); input.writeByteArray(hexToBytes(challenge.inputHex)); patchedOnce = true; this.check = true; }, onLeave(retval) { if (!this.check) return; const cipher = resolveOutputBuffer(retval, this); const cipherLength = challenge.expectedCipherHex.length / 2; const cipherHex = readHex(cipher, cipherLength);
console.log(JSON.stringify({ profile: challenge.profile, input_before: this.beforeHex, input_after: challenge.inputHex, cipher_runtime: cipherHex, cipher_expected: challenge.expectedCipherHex, matched: cipherHex === challenge.expectedCipherHex, })); },});这里的 resolveInputBuffer、resolveInputLength 和 resolveOutputBuffer 都应该由本地边界决定。不要在更上游改业务对象,也不要在更下游改 ciphertext。越靠近白盒 AES envelope 入口,验证越干净。
如果运行时输出和页面密文不一致,优先排查下面几件事:
- Hook 点是否在压缩之后、白盒 envelope 之前;
- 注入输入长度是否和页面
input_hex完全一致; - 读取的是当前调用的输出 buffer,而不是复用的上一次结果;
- profile 是否选错,
00000008和00000003不能混比; - 对齐输入是否误加了额外 padding block。
为什么白盒 AES 很难真正安全
白盒密码的目标,是在攻击者能观察、调试、复制运行时的环境里仍然保护 key。这个目标比普通服务端加密难得多。
当前 03/08 的主要问题不是“有没有混淆”,而是混淆是否破坏了可恢复结构:
- 如果 FinalTable 仍是 16 个独立 byte lane,就容易被单 byte 分析。
- 如果 TableValue 的完整 truth table 可见,就能恢复隐藏 XOR 坐标。
- 如果 TableKey 每项仍对应一个 input byte 的 contribution,就能拟合 SBox/key 偏移。
- 如果 IV 固定在模板里,它就不是安全边界。
- 如果模板没有完整性保护,就可能被替换、降级或离线分析。
更强的方向通常是组合拳:
input/output external encoding+ cross-byte fusion+ fused rounds+ per-build table layout+ runtime-derived transient tables+ template signature+ per-message IV+ anti-downgrade policy这并不意味着白盒 AES 没有工程价值。它能提高直接提取成本,也能让协议兼容旧服务端。但它不能被当作“key 永远不会被恢复”的保证。
给新手的阅读路线
如果第一次接触白盒 AES,可以按这个顺序理解:
- 先掌握 AES-CBC:block、IV、padding、chaining。
- 再掌握 AES round:SBox、ShiftRows、MixColumns、AddRoundKey。
- 把
SBox[x xor key]想象成一张表。 - 把 MixColumns 想象成 4 个 contribution 的 XOR 合成。
- 再看白盒:TableKey 生成 contribution,TableValue 合成 contribution,FinalTable 收尾。
- 最后看攻击:08 从 final lane 拿最后轮 key,03 从编码 combiner 恢复隐藏 XOR 坐标。
用这个顺序读,白盒 AES 就不是黑魔法,而是标准 AES 被表格化、编码化之后的工程形态。
FAQ
WeChat client check data 白盒 AES 是标准 AES-CBC 吗?
对外语义等价 AES-128-CBC,但运行时代码不是普通标准库调用。它使用 PB 模板里的 TableKey、TableValue 和 FinalTable 解释执行,最终密文可用恢复出的 key/IV 与标准 AES-CBC 对齐验证。
00000003 和 00000008 是两套不同算法吗?
它们更像同一个 AES-CBC 表解释器的两种表生成策略。08 更接近未编码或弱编码表驱动 AES;03 对 contribution 和 combiner 做了 byte-level 编码,静态恢复成本更高。
为什么 00000008 可以从 FinalTable 恢复 key?
因为 08 的 FinalTable byte lane 可以拟合成 SBox[x xor a] xor b。16 个 lane 拿到最后轮 key 后,可以逆 AES-128 key schedule 得到 master key。
为什么 00000003 仍然能恢复?
03 的 FinalTable 不能直接拟合,但 TableValue 保存了完整的编码二元组合器 truth table。恢复隐藏 XOR 坐标后,第一轮 TableKey 的 contribution 可以拟合到 coeff * SBox[x xor keyGuess],从而恢复 master key byte。
页面里的随机向量有什么用?
它用于反向验证。读者可以刷新页面获得新的 hex 输入和 03/08 密文,再用本地 hook、离线解释器或标准 AES-CBC 等价实现比对,确认边界和算法语义一致。
8.7.0 的 VER08 入口应该怎么定位?
不要从 key 搜索开始。更稳定的路线是先找 client check data collector 到 envelope transform 的数据流,再恢复 version、IV、large table blocks、scalar control 和 gateway 之间的关系。命中后,用“压缩后 bytes -> VER08 envelope -> ciphertext”的边界做验证。
白盒 AES 是否能彻底防止 key 被提取?
不能简单这样理解。白盒 AES 提高的是提取成本。若完整表、稳定轮边界、独立 FinalTable、固定 IV 都可被分析,key/IV 仍可能被恢复。更强方案需要外部编码、跨字节融合、模板完整性和运行时多样化共同参与。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
保持匿名、聚焦技术讨论;昵称和站点都可留空。