正文开始 #
这次分析的核心问题只有一个:高敏规则文件从“高熵字节”到“可解析明文”的完整链路到底在哪里闭合。
真正的难点不在某一个加密 API,而在几层干扰叠在一起:
- 外层文件名像标准容器,但文件头不对
- 内层条目仍然是高熵数据,说明不是单层包装
- 密钥不是完整字符串,而是多段派生
- 部分派生逻辑跨过 Go / native 边界
- 中间还有一个小型 VM,把直线逻辑拆成状态机
- 标准 AEAD 解密前,还有一层自定义预处理
下面按实际突破顺序拆。
一、从文件形态判断层数 #
最开始看到的是两个不同形态的规则包。一个能被标准容器工具识别,但容器里的条目是高熵字节;另一个连标准容器头都没有,整体熵值也接近随机。
这个现象很关键,它说明规则保护至少分成两层:
package A:
standard container
-> encrypted entries
package B:
custom wrapper
-> standard container
-> encrypted entries如果一开始只把它当“压缩包密码”去试,会直接走偏。这里先定下两个分析目标:
- 找到 package B 外层 wrapper 的逆变换
- 找到容器内单个规则条目的解密链
随后在二进制字符串里能看到一批规则路径和分类名。抽象后类似:
rules/<tier>/<redacted-rule-001>
rules/<tier>/<redacted-rule-002>
rules/<tier>/<redacted-rule-003>这些字符串本身不是解密点,但它们能帮助定位加载路径。顺着交叉引用往下追,可以把调用链缩到类似这样的范围:
buildRuleIndex()
-> openRuleContainer()
-> readEncryptedEntry()
-> decryptEntry()
-> parseRule()后面的分析基本都围绕 decryptEntry() 展开。
二、从 native wrapper 锁定密钥派生入口 #
直接搜完整密码没有意义,因为密钥不是明文常量。更有效的入口是搜 key、pass、decrypt 这类符号残留,再结合函数大小和调用关系看。
其中两个 native 侧函数很突出:名字像“取第 N 段秘密”,函数体很短,而且都调用同一个核心混合函数。
简化后的 ARM64 形态大概是:
secret_part_N:
stp x29, x30, [sp, #-0x20]!
mov x29, sp
adrp x0, obf_block_N@PAGE ; 8B 混淆输入
add x0, x0, obf_block_N@PAGEOFF
mov w1, #IDX_N ; 小整数索引
bl block_mix
ldp x29, x30, [sp], #0x20
ret这里能读出三个信息:
- 每段输入是固定长度的小块
- 每段都有一个独立索引
- 真正的算法集中在
block_mix
所以这一阶段的突破点不是“拿到密码”,而是把“密码由多段小块派生”这个事实钉住。
三、还原 block_mix
#
block_mix 是一个 8 字节输入、1 字节索引、8 字节输出的字节级变换。它不是标准密码算法,更像一层定制混淆。
抽象后的结构如下:
void block_mix(uint8_t out[8], uint8_t in[8], uint8_t idx) {
uint8_t buf[8] = copy(in);
for (i = 0; i < 8; i++)
buf[i] = sbox[buf[i]];
for (i = 0; i < 8; i++)
buf[i] = finite_field_inverse_mix(buf[i], idx, i);
for (i = 0; i < 8; i++)
buf[i] ^= mask_a(idx, i);
rotate_each_byte(buf, idx & 7);
for (i = 0; i < 8; i++)
out[i] = buf[i] ^ mask_b(idx, i);
}这段逻辑有几个容易误判的点:
sbox看起来像密码学组件,但它只是固定置换表- 有限域乘逆让代码显得很数学,但参数都由
idx和位置派生 - 两轮 xor 和 rotate 都是可逆操作
- 输入块、索引、置换表都在客户端里
还原时我先不急着写完整解密器,而是只复现 block_mix,然后拿 native wrapper 的两段输入做最小验证。
验证标准也很直接:输出是否落在可读编码字符集里。如果两段输出拼起来像编码串尾部,说明方向基本对了。
四、突破多段密钥 #
native 侧只给了后几段。继续追调用方,会发现前几段由 Go 侧生成,最后和 native 输出拼接。
抽象后的结构是:
Go part #1 -> block_mix(raw_1, idx_1) -> segment_1
Go part #2 -> block_mix(raw_2, idx_2) -> segment_2
Go part #3 -> block_mix(raw_3, idx_3) -> segment_3
Go part #4 -> block_mix(raw_4, idx_4) -> segment_4
native part -> block_mix(raw_5, idx_5) -> segment_5
native part -> block_mix(raw_6, idx_6) -> segment_6
concat(segment_1..segment_6) -> encoded_inner_material这里卡了一下:Go 侧索引不是所有都以立即数出现,有些来自运行时变量或 getter。静态读过去会得到默认值,算出来的结果自然不对。
解决方式是反过来利用输出特征。每段输出都应该是编码串的一部分,所以可以枚举 0..255 的索引,筛选可读编码字符:
for raw in raw_blocks:
for idx in range(256):
out = block_mix(raw, idx)
if looks_like_encoded_segment(out):
print(idx, out)这个枚举空间很小,候选也很少。几段索引呈连续关系后,可信度进一步提高。
拼接后的 inner material 看起来非常像最终 key,长度也很容易让人联想到某类对称算法。但直接拿它进标准解密链会失败。
这个失败很有价值:它说明 inner material 还不是终态密码,后面还有一层处理。
五、拆 VM:找真正生效的 opcode #
inner material 后续进入了一个小型解释器。反编译结果是状态机,不是直线代码。
骨架可以抽象成:
while (true) {
switch (state) {
case FETCH:
opcode = program[pc++];
state = dispatch(opcode);
continue;
case LOAD_OUTER:
obj.outer = build_outer_material();
break;
case BUILD_INNER:
obj.inner = decode(encoded_inner_material);
break;
case MIX:
obj.final = xor_fold(obj.inner, obj.outer);
break;
case RETURN:
return obj.final;
case NOISE_A:
case NOISE_B:
checksum_like_noise(obj);
break;
}
}分析 VM 时,重点不是把每个 opcode 都起一个漂亮名字,而是区分哪些状态会影响返回值。
这里主要看三件事:
- 数据是否流入最终返回对象
- 数据是否影响解密 API 的参数
- 数据是否参与条件分支或校验
有些 opcode 只是在更新临时变量,甚至存在互相抵消的操作:
eor w8, w8, #MASK_X
eor w8, w8, #MASK_X ; 同一 mask 再做一次,回到原值
strb w8, [sp, #tmp]真正有效的链路是:
build outer material
build inner material
xor_fold(inner, outer)
return final passwordouter material 也不是明文常量,而是另外几段输入走同一个 block_mix 派生出来的短材料。
最终密码派生可以压缩成:
inner = decode(concat(block_mix(inner_parts)))
outer = concat(block_mix(outer_parts))
password = xor_fold(inner, outer)到这一步,密钥派生链闭合。
六、标准解密失败后的预处理层 #
拿到终态 password 后,按常规链路走:
key = hash(password)
payload = nonce | ciphertext | tag
plain = AEAD.open(key, nonce, ciphertext, tag)结果 tag 校验失败。
这时不能急着怀疑 password。回到调用点看密文进入标准库之前有没有被改写,能看到一个自定义预处理函数。
抽象后的逻辑:
def unwrap_bytes(data, p):
tmp = bytearray(data)
for i in range(len(tmp)):
tmp[i] ^= stream_mask(i, p)
tmp.reverse()
return rotate_by_param(tmp, p)参数 p 来自密文长度。ARM64 附近能看到这种模式:
and xP, xLen, #LOW_BITS_MASK
cmp xP, #0
csel xP, xFallback, xP, eq
mov x0, xBlob
mov x1, xLen
mov x2, xCap
mov x3, xP
bl unwrap_bytes这个点是最后一公里。外层 package B 做一次 unwrap_bytes() 后恢复标准容器头;容器内每个条目再走:
entry bytes
-> unwrap_bytes()
-> split nonce / ciphertext / tag
-> AEAD.open()
-> decompress()
-> rule plain text这里的验证顺序很重要:
- 外层 wrapper 解开后,容器 magic 正确
- 内层 entry 预处理后,AEAD tag 校验通过
- AEAD 输出能被解压
- 解压结果能被原解析器识别
四个条件都满足,说明 wrapper、password、AEAD 参数和压缩层全部闭合。
七、最终链路 #
最终规则条目的解密链可以抽象成:
[encrypted entry]
│
│ 1. unwrap_bytes()
│ xor stream -> reverse -> rotate
▼
[AEAD payload]
│
│ 2. split nonce / ciphertext / tag
│
│ 3. AEAD.open(hash(password), payload)
▼
[compressed bytes]
│
│ 4. decompress()
▼
[rule plain text]password 的生成链:
inner raw blocks
-> block_mix(raw, idx)
-> concat segments
-> decode
-> inner material
outer raw blocks
-> block_mix(raw, idx)
-> concat segments
-> outer material
VM effective path
-> xor_fold(inner, outer)
-> password外层资源来源的差异:
package A:
standard container
-> encrypted entries
package B:
unwrap_bytes()
-> standard container
-> encrypted entries
embedded resource:
embedded container image
-> encrypted entries八、这次分析里最有用的几个指纹 #
后续遇到同类版本,不必依赖函数名。更稳定的是结构指纹:
- native wrapper:短函数,加载固定 8B 输入,传小索引,调用同一个 mix 函数
block_mix:置换表、有限域乘逆、位置 xor、byte rotate、二次 xor- 分段密钥:多个 8B block 输出拼成编码材料
- 索引恢复:枚举 0..255,用编码字符集筛选候选
- VM:状态机分发,真正影响返回值的是 inner / outer mix
- 预处理层:xor 位置流、整块 reverse、按长度参数 rotate
- 解密边界:hash -> AEAD -> decompress
这几个指纹串起来,比单独搜某个字符串或函数名稳定得多。真正的突破点也基本都在这里:先判断层数,再还原单块变换,再补齐多段材料,然后拆掉 VM 和预处理层,最后用标准库边界验证整条链路。