吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 445|回复: 0
上一主题 下一主题
收起左侧

[Android 原创] 从 RegisterNatives 到逐字节复现:一次 Android `ttEncrypt` 封包逆向

[复制链接]
跳转到指定楼层
楼主
bu^shan 发表于 2026-8-13 16:30 回帖奖励

从 RegisterNatives 到逐字节复现:一次 Android ttEncrypt 封包逆向

我先给 ttEncrypt 喂了三个字节:

61 62 63    # ASCII "abc"

返回值是 118 字节。

紧接着连续调用三次,三次结果完全一样;把每次调用间隔拉到一秒以上,结果又会
变化。更奇怪的是,无论输出怎么变,前 6 字节始终固定,后面的 32 字节和密文会
一起变化。

最开始我怀疑过缓存。但换成不同输入以后,32 字节区域仍然相同,后面的密文却会
跟着输入变化。这说明它不是简单缓存,更像是同一批随机材料在短时间内被重复使用。

这几个现象把问题缩小成了三部分:

固定的 6 字节是什么?
紧跟着的 32 字节从哪里来?
剩余内容是不是标准分组加密?

顺着这三个问题往下追,最后得到了一条可以离线编码、离线解包,并且与 Native
结果逐字节一致的完整数据流。

这篇记录的是整个定位过程,不涉及线上请求构造。文中的输入均为本地合成字节,
固定常量和版本偏移使用占位符;完整证据仍保留在仓库里,方便复核。

样本和环境

这次分析固定在下面这个样本上:

APP 版本:39.5.0
versionCode:390501
架构:arm64-v8a
APK 大小:352,046,739 bytes
SHA-256:76e609f25917358a334a85d62c7df957c7399df4f0aaad7528cb0f369b8cdd7e
设备:Android 13 / arm64

APK 的 v1、v2、v3 签名校验均通过。JADX 一共加载了 54 个 DEX,APK 中包含
274 个 arm64 Native 库。

后面所有静态地址和运行时偏移,都只对这个固定样本负责。

从 Java 入口开始

JADX 中的 Java 包装类并不复杂:

package com.bytedance.frameworks.encryptor;

public class EncryptorUtil {
    public static native byte[] ttEncrypt(byte[] data, int length);

    static {
        try {
            loadLibrary("Encryptor");
        } catch (UnsatisfiedLinkError ignored) {
        }
    }

    public static byte[] encrypt(byte[] data, int length) {
        if (data == null || length <= 0) return null;
        if (data.length != length) return null;
        return ttEncrypt(data, length);
    }
}

这里能确认几件事:

  • 输入是原始字节数组,不是字符串;
  • 第二个参数必须等于数组长度;
  • 主要逻辑在 libEncryptor.so
  • Java 层没有额外处理输出结构。

继续看调用点,可以看到它并不只服务于一个接口:

  • Retrofit raw byte 请求体;
  • form-url-encoded 请求体;
  • 加密后再 Base64 的部分字符串字段;
  • 某些遥测或上报通道;
  • 外部 Encryptor 不存在时的回退实现。

请求体路径还存在 102,400 字节的长度上限。这个调用范围说明它更像一个通用
客户端封包层,而不是只绑定某一个业务字段。

导出表里找不到 ttEncrypt

libEncryptor.so 的动态符号很少,能直接看到 JNI_OnLoad,但看不到
Java_com_..._ttEncrypt 这种静态 JNI 导出。

这通常意味着两种可能:

  1. 函数名被隐藏,只保留内部跳板;
  2. Native 方法通过 RegisterNatives 动态注册。

我从 JNI_OnLoad 往下看,很快碰到了一段长度固定的 XOR 循环:

mov     w11, #0x73

decode_loop:
ldrb    w13, [x10, x9]
eor     w13, w13, w11
strb    w13, [x12, x9]
add     x9, x9, #1
cmp     x9, #0x30
b.ne    decode_loop

它把一段 0x30 字节的数据逐字节与 0x73 异或,然后把结果交给 JNI 类查找。
解码后对应的类是:

com/bytedance/frameworks/encryptor/EncryptorUtil

后面的注册表只有一个 Native 方法:

name      = ttEncrypt
signature = ([BI)[B

JNI 签名拆开就是:

[B    byte[]
I     int
[B    byte[]

到这里,Java 方法和 Native 实现已经连起来了。相比在大段 ARM64 代码里猜哪个
函数“像加密”,从 RegisterNatives 反推入口可靠得多。

先不要读算法,先看输出形状

入口确定后,我没有马上啃完整反汇编,而是先用四组很小的输入调用它:

Java.perform(function () {
  const Encryptor = Java.use(
    'com.bytedance.frameworks.encryptor.EncryptorUtil'
  );

  const tests = [
    [0x41],
    [0x61, 0x62, 0x63],
    Array.from({ length: 16 }, (_, i) => i),
    Array.from({ length: 32 }, (_, i) => i)
  ];

  tests.forEach(function (input) {
    const signed = input.map(x => x > 127 ? x - 256 : x);
    const bytes = Java.array('byte', signed);
    const result = Encryptor.ttEncrypt(bytes, input.length);

    console.log(JSON.stringify({
      inputLength: input.length,
      outputLength: result.length
    }));
  });
});

得到的长度是:

输入长度 输出长度
1 118
3 118
16 134
32 150

后面又补了更多边界:

输入长度 输出长度
15 118
17 134
31 134
1024 1142

输出长度每次跨过 16 字节边界就增加 16,这个特征很像带 PKCS#7 填充的分组
密码。问题是固定开销并不是一个简单的 16 字节 IV。

把多次输出并排对比后,结构开始清楚:

74 63 05 10 00 00 | 32 bytes changing data | 16-byte-aligned tail
└──── 6 bytes ────┘ └──────── 32 ───────────┘ └──── ciphertext ────┘

前 6 字节固定为:

74 63 05 10 00 00

剩余密文长度可以写成:

cipher_len = output_len - 6 - 32

对于输入 abc

118 - 6 - 32 = 80 bytes

80 正好是 5 个 AES 分组。

但这时还不能直接宣布它是 AES-CBC。16 字节对齐只能说明它很像分组密码,模式、
Key、IV 和实际明文都还没有证据。

连续输出相同,是缓存还是随机数问题

同一个输入连续调用三次,结果完全一致。

我先做了两组对照:

  1. 同一秒内调用不同输入;
  2. 固定输入,每次间隔 1150 ms。

第一组中,不同输入共享相同的 32 字节区域,但密文区域不同。这排除了完整结果
缓存:函数确实重新处理了输入,只是复用了同一批随机材料。

第二组中,四次调用的 32 字节区域全部不同。时间分别相隔约 1.15 秒。

结合反汇编和运行时调用,最终确认这 32 字节来自:

srand(time(NULL))

repeat 32 times:
    output[i] = rand() & 0xff

也就是说,种子只有秒级精度。

同一秒内重新执行 srand(time(NULL)),会从同一初始状态开始,于是 32 字节随机
材料完全相同。随机材料相同,后面派生出的 Key 和 IV 也会相同。

这里真正让我警觉的不是 rand() 本身,而是它每次调用都按当前秒重新播种。若只
在进程启动时播种一次,同一秒内连续调用也不一定重复;现在这种写法则会稳定复现。

用分段 Hook 代替整函数硬读

确认 32 字节区域后,我开始追它怎样进入密文计算。

ttEncrypt 内部代码不算特别大,但直接从头翻 ARM64 仍然很费劲。我最后采用的
办法是只 Hook 三类边界:

随机字节生成函数
SHA-512 函数
分组加密封装

为了避免文章里的脚本和某个版本偏移绑死,下面用占位符表示偏移。仓库探针中保存
了固定样本的实际值。

function hex(pointer, length) {
  const bytes = new Uint8Array(pointer.readByteArray(length));
  return Array.from(bytes)
    .map(x => ('0' + x.toString(16)).slice(-2))
    .join('');
}

const module = Process.getModuleByName('libEncryptor.so');

Interceptor.attach(module.base.add(RANDOM_OFFSET), {
  onEnter(args) {
    this.output = args[0].readPointer();
    this.length = args[0]
      .add(Process.pointerSize)
      .readU64()
      .toNumber();
  },
  onLeave() {
    console.log(
      'RANDOM len=' + this.length +
      ' hex=' + hex(this.output, this.length)
    );
  }
});

Interceptor.attach(module.base.add(SHA512_OFFSET), {
  onEnter(args) {
    this.input = args[0];
    this.length = args[1].toInt32();
    this.output = args[2];
  },
  onLeave() {
    console.log(
      'SHA512 len=' + this.length +
      ' output=' + hex(this.output, 64)
    );
  }
});

Interceptor.attach(module.base.add(CIPHER_OFFSET), {
  onEnter(args) {
    this.keyLength = args[1].toInt32();
    this.key = args[0];
    this.iv = args[2];
    this.plaintext = args[3];
    this.plaintextLength = args[4].toInt32();
    this.ciphertext = args[5];
    this.outputLength = args[6];
  },
  onLeave() {
    const length = this.outputLength.readU64().toNumber();
    console.log(
      'CIPHER key_len=' + this.keyLength +
      ' plaintext_len=' + this.plaintextLength +
      ' ciphertext_len=' + length
    );
  }
});

abc 跑一次,函数调用顺序非常干净:

RANDOM len=32
SHA512 len=32
SHA512 len=128
SHA512 len=3
CIPHER key_len=16 plaintext_len=67 ciphertext_len=80
FINAL len=118

这 6 行基本把主链骨架暴露出来了。接下来只要确认每一次 SHA-512 的输入来源。

三次 SHA-512,不是同一个用途

第一次 SHA-512 的输入正好是刚生成的 32 字节随机材料:

H1 = SHA-512(RANDOM32)

第二次 SHA-512 的输入长度是 128 字节。前 64 字节等于 H1,后 64 字节是一段
APK 内固定常量:

H2 = SHA-512(H1 || CONST64)

H2 的前 32 字节随后被拆成两部分:

KEY = H2[0:16]
IV  = H2[16:32]

加密封装报告的 Key 长度为 16 字节,AES key schedule 也报告 128 bit、10 轮。
这一步确认算法不是 AES-256,而是 AES-128。

第三次 SHA-512 的输入则是原始消息本身:

DIGEST = SHA-512(MESSAGE)

对于 abc,输入长度为 3,输出是标准 SHA-512:

ddaf35a193617aba...54ca49f

这个摘要没有拿去派生 Key,而是直接拼到了消息前面:

PLAINTEXT = SHA-512(MESSAGE) || MESSAGE

abc 的长度是 3,所以进入分组加密前的明文长度为:

64 + 3 = 67 bytes

PKCS#7 再补 13 个 0x0d,最后正好是 80 字节。这个长度与运行时捕获完全一致。

三次 SHA-512 到这里终于分工明确:

SHA512 #1    随机材料预处理
SHA512 #2    派生 AES Key 和 IV
SHA512 #3    原始消息完整性摘要

确认 AES-CBC,而不是“像 AES-CBC”

分组加密入口已经给出:

Key:16 bytes
IV:16 bytes
明文:67 bytes
输出:80 bytes

反汇编中的块处理逻辑符合 CBC:当前明文块先与上一状态异或,再进入 AES 加密,
输出同时成为下一块的状态。

为了避免把反汇编看错,我又用 OpenSSL 独立计算了一遍。

printf '%s' "$PLAINTEXT_HEX" \
  | xxd -r -p \
  | openssl enc -aes-128-cbc \
      -K "$KEY_HEX" \
      -iv "$IV_HEX" \
      -nosalt \
  | xxd -p -c 4096

这里不使用 -nopad,让 OpenSSL 按 PKCS#7 填充。输出的 80 字节与 Native 捕获
密文逐字节一致。

所以这一步的结论不是“看到 AES 字符串”,而是三种证据互相吻合:

  • Key 长度和轮数符合 AES-128;
  • 静态分组关系符合 CBC;
  • OpenSSL 对相同 Key、IV、明文的输出逐字节相同。

完整封包结构

到这里,整个容器可以写成:

M       = 原始消息
R       = PRNG(32)
H1      = SHA-512(R)
H2      = SHA-512(H1 || CONST64)
KEY     = H2[0:16]
IV      = H2[16:32]
P       = SHA-512(M) || M
C       = AES-128-CBC-PKCS7(KEY, IV, P)

BLOB    = MAGIC6 || R || C

各区域长度:

MAGIC6    6 bytes
R        32 bytes
C        16-byte aligned

因此输出长度是:

len(BLOB) = 38 + len(PKCS7(SHA512(M) || M, 16))

这也解释了前面的边界表。只要 64 + len(M) 跨过 16 字节边界,最终输出就增加
16 字节。

离线解包器

解包不需要从设备读取密钥。原因很直接:

  • R 明文保存在包内;
  • KDF 使用的固定材料存在于 APK;
  • Key 和 IV 都能由 R 重新推导。

下面是一个删去固定常量的离线结构。CONST64 由调用者提供,避免把文章变成某个
固定版本的直接套用脚本。

from __future__ import annotations

import hashlib
import secrets

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad

MAGIC = bytes.fromhex("746305100000")

def derive(seed32: bytes, const64: bytes) -> tuple[bytes, bytes]:
    if len(seed32) != 32:
        raise ValueError("seed32 must be 32 bytes")
    if len(const64) != 64:
        raise ValueError("const64 must be 64 bytes")

    h1 = hashlib.sha512(seed32).digest()
    h2 = hashlib.sha512(h1 + const64).digest()
    return h2[:16], h2[16:32]

def unpack(blob: bytes, const64: bytes) -> bytes:
    if len(blob) < 54:
        raise ValueError("container is too short")
    if blob[:6] != MAGIC:
        raise ValueError("unexpected magic")
    if (len(blob) - 38) % 16 != 0:
        raise ValueError("ciphertext is not block aligned")

    seed32 = blob[6:38]
    ciphertext = blob[38:]
    key, iv = derive(seed32, const64)

    padded = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext)
    plaintext = unpad(padded, 16)
    if len(plaintext) < 64:
        raise ValueError("plaintext is truncated")

    expected_digest = plaintext[:64]
    message = plaintext[64:]
    actual_digest = hashlib.sha512(message).digest()

    if not secrets.compare_digest(expected_digest, actual_digest):
        raise ValueError("SHA-512 check failed")
    return message

def pack(message: bytes, seed32: bytes, const64: bytes) -> bytes:
    key, iv = derive(seed32, const64)
    plaintext = hashlib.sha512(message).digest() + message
    ciphertext = AES.new(key, AES.MODE_CBC, iv).encrypt(
        pad(plaintext, 16)
    )
    return MAGIC + seed32 + ciphertext

这个实现只操作调用者提供的本地字节,不包含网络行为。

最后一次对拍

验证时我没有只检查“能解出一段可读文本”,而是做了两层对拍。

第一层使用 Native 捕获的一组 abc 样本:

captured_blob == reproduced_blob    true
unpacked_message                    61 62 63
sha512_valid                        true

离线重新封包与 Native 返回的 118 字节完全相同,不只是长度相等或前缀相同。

第二层覆盖输入长度边界:

输入长度 预期输出 实际输出 解包回环 摘要校验
1 118 118 通过 通过
3 118 118 通过 通过
15 118 118 通过 通过
16 134 134 通过 通过
17 134 134 通过 通过
31 134 134 通过 通过
32 150 150 通过 通过
1024 1142 1142 通过 通过

到这里,格式、KDF、分组模式、填充方式、消息摘要和长度公式都能同时解释动态结果。

这条链里最容易看错的三个地方

固定的 32 字节不是 AES Key

它只是随包携带的随机材料 R。真正的 Key 和 IV 要经过两次 SHA-512 才得到。
如果只在最终包上切片,很容易把 R 直接当成 AES-256 Key。

消息前面的 SHA-512 不是 HMAC

摘要没有使用秘密 Key,只是:

SHA-512(MESSAGE)

它可以检查解包后的消息是否自洽,但不提供基于秘密的真实性证明。知道公开派生
流程的一方可以重新计算摘要和整个容器。

同秒重复不是业务缓存

不同输入共享相同 R,但密文随输入改变,说明函数仍然执行了。真正的问题是每次
调用都使用秒级时间重新播种 PRNG。

这三个点如果没有运行时中间值,只看最终密文都很容易下错结论。

它到底算不算“加密”

从实现上说,它当然使用了 AES-128-CBC,也确实把原始消息变成了不可直接阅读的
字节。

但从密钥模型看,这一层并不持有服务端秘密:

  • Key 和 IV 由包内公开的 R 与 APK 固定材料推导;
  • 消息完整性使用普通 SHA-512,不是 HMAC;
  • CBC 没有 AEAD tag;
  • 同秒调用还会重复 R、Key 和 IV。

所以我更愿意把它叫作“可逆封包层”或“协议编码层”。它能增加静态观察成本,
也能让服务端统一检查格式和消息摘要,但真正的链路机密性仍然依赖 HTTPS/TLS。

这个判断比“它用了 AES,所以很安全”或者“它能解,所以毫无作用”都更准确。算法
是否标准是一回事,Key 从哪里来、攻击者能看到哪些材料,是另一回事。

版本边界

文章中的逐字节动态验证对应 39.5.0。

仓库对 39.5.0 和 39.7.0 的 Native 库做过哈希比较,libEncryptor.so 在两个
版本中逐字节相同,因此 Native 实现可以继承旧结论。不过这不自动证明所有 Java
调用点和服务端使用策略也完全没变。

迁移到新版本时,我至少会重新确认:

  • APK 和 libEncryptor.so 的 SHA-256;
  • RegisterNatives 类名和签名;
  • Java 调用入口是否仍然存在;
  • 固定输入的长度矩阵;
  • 一组 Native 与离线逐字节对拍。

只要其中一项不一致,就应该建立新的版本基线,而不是继续沿用旧偏移。

回头看这次定位过程

真正节省时间的不是某个反编译器,而是分析顺序:

先固定小输入
  → 看输出长度和稳定区域
  → 从 RegisterNatives 找入口
  → 只 Hook 随机数、摘要和分组边界
  → 用 OpenSSL 独立确认模式
  → 写离线解包器
  → 做逐字节对拍和长度回归

如果一开始就顺着 580 行 ARM64 反汇编从头读,我很可能会在 AES 表和内存管理代码
里花掉大量时间。固定输入和阶段 Hook 先给出了数据关系,反汇编只需要回答剩下的
具体问题。

这也是这条链最值得留下来的地方:最后的公式并不复杂,难的是证明每个字段为什么
在那个位置、每次 SHA-512 分别做什么,以及同秒重复到底来自缓存还是 PRNG。

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
无名 + 1 + 1 这纯ai文吧

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-8-17 10:25

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表