跳过正文

某安全检测工具资源加密分析

·6 分钟· loading
asm2apex
作者
asm2apex
目录

正文开始
#

这次分析的核心问题只有一个:高敏规则文件从“高熵字节”到“可解析明文”的完整链路到底在哪里闭合。

真正的难点不在某一个加密 API,而在几层干扰叠在一起:

  • 外层文件名像标准容器,但文件头不对
  • 内层条目仍然是高熵数据,说明不是单层包装
  • 密钥不是完整字符串,而是多段派生
  • 部分派生逻辑跨过 Go / native 边界
  • 中间还有一个小型 VM,把直线逻辑拆成状态机
  • 标准 AEAD 解密前,还有一层自定义预处理

下面按实际突破顺序拆。

一、从文件形态判断层数
#

最开始看到的是两个不同形态的规则包。一个能被标准容器工具识别,但容器里的条目是高熵字节;另一个连标准容器头都没有,整体熵值也接近随机。

这个现象很关键,它说明规则保护至少分成两层:

package A:
  standard container
    -> encrypted entries

package B:
  custom wrapper
    -> standard container
      -> encrypted entries

如果一开始只把它当“压缩包密码”去试,会直接走偏。这里先定下两个分析目标:

  1. 找到 package B 外层 wrapper 的逆变换
  2. 找到容器内单个规则条目的解密链

随后在二进制字符串里能看到一批规则路径和分类名。抽象后类似:

rules/<tier>/<redacted-rule-001>
rules/<tier>/<redacted-rule-002>
rules/<tier>/<redacted-rule-003>

这些字符串本身不是解密点,但它们能帮助定位加载路径。顺着交叉引用往下追,可以把调用链缩到类似这样的范围:

buildRuleIndex()
  -> openRuleContainer()
  -> readEncryptedEntry()
  -> decryptEntry()
  -> parseRule()

后面的分析基本都围绕 decryptEntry() 展开。

二、从 native wrapper 锁定密钥派生入口
#

直接搜完整密码没有意义,因为密钥不是明文常量。更有效的入口是搜 keypassdecrypt 这类符号残留,再结合函数大小和调用关系看。

其中两个 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 password

outer 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

这里的验证顺序很重要:

  1. 外层 wrapper 解开后,容器 magic 正确
  2. 内层 entry 预处理后,AEAD tag 校验通过
  3. AEAD 输出能被解压
  4. 解压结果能被原解析器识别

四个条件都满足,说明 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 和预处理层,最后用标准库边界验证整条链路。