从 getToken 的空转发,到 A_e 的七个分块:识货 Enviroment Token 逆向复盘
从 getToken 的空转发,到 A_e 的七个分块:识货 Enviroment Token 逆向复盘
本文记录的是一次在自有测试环境中完成的动态分析。公开版已经移除完整 token、真实调用参数、精确时间戳与设备数据。文中所有地址均以模块相对偏移表示;一次运行里的绝对地址没有复用价值。
这次分析真正费时间的地方,并不是判断出“AES-128-ECB”,而是承认我们一开始把入口选大了。
最初拿到的方法签名很诱人:
1
2
3
4
java.lang.String com.shihuo.shsecsdk.Enviroment.getToken(
java.lang.String,
java.lang.String
)名字已经把答案写在脸上,第一反应当然是围着 getToken 追。结果 trace 越跑越大,能看到大量 JNI、字符串处理、内存操作和混淆控制流,却迟迟找不到一条能把输入、密钥、密文和最终返回值连起来的短路径。
后来回头看,这段弯路反而很典型:Java 层的方法名告诉了我们“业务从哪里进”,却不一定告诉我们“算法在哪里做”。getToken 在这条执行路径里更像一层调度和转发。继续守着它,只会把上层噪声和真正的 native 计算一起装进 trace。
真正的突破,是把问题从“getToken 做了什么”换成了“最终 token 在哪一层被构造出来”。
从 getToken 一层层下沉
我们先确认了 getToken 所在的 native 位置:
1
libldp.so + 0x181d10沿调用关系继续向下,进入同一个类的另一个 native 方法:
1
2
3
4
5
6
7
java.lang.String com.shihuo.shsecsdk.Enviroment.initRiskInfo(
java.lang.String,
java.lang.String,
java.lang.String
)
libldp.so + 0x1c2cc8initRiskInfo 仍然不是最终计算点。它把环境信息整理后,又交给 nativeInfo:
1
2
3
4
5
java.lang.String com.shihuo.shsecsdk.Enviroment.nativeInfo(
java.lang.String
)
libsh_security.so + 0xf92d8到了 nativeInfo,分析对象才从 libldp.so 转进 libsh_security.so。也就是在这里,我们第一次接近真正负责数据封装和加密的代码。
最后收敛出的主干并不长:
1
2
3
4
5
6
7
8
getToken
-> initRiskInfo
-> nativeInfo
-> A_P_E (libsh_security.so + 0xc80e0)
-> 7 × A_e (libsh_security.so + 0xc74dc)
-> AES inner (libsh_security.so + 0xc76c0)
-> b64_encode (libsh_security.so + 0xc6ef0)
-> JNI NewString (libsh_security.so + 0xddc00)A_P_E 可以理解为这一段封装逻辑的总入口,A_e 则处理单个 16 字节分块。这个判断不是先看反编译器给函数起了什么名字,而是从动态调用次数、内存内容和输入输出长度逐步对上的。
41 MB trace 为什么没有继续硬啃
最早那份 trace 超过 41 MB。理论上当然可以继续搜索,但实际分析时,每向前追一次值,都可能遇到大量不相关分支;而且脚本在加载时立刻开启 Stalker,会把应用启动、SDK 初始化和其他线程也卷进来。在目标设备上,这种“从进程一开始就全程 trace”的方式还容易让 Frida 会话直接崩掉。
于是我们把触发方式改成了手动 gate:
- 脚本加载时只安装必要的 hook,不立即开始 trace;
- 由主动调用 Enviroment 方法的 RPC 入口打开 gate;
- 只跟踪执行主动调用的当前线程;
- 调用结束后立即关闭 gate、停止 Stalker 并落盘;
- 输出写进应用自己的 files 目录,避开无权限的临时目录。
这不是单纯为了把文件变小。更重要的是,trace 的边界第一次和“我们主动发起的那一次调用”对齐了。后来重新打开的收窄 trace 约 6.4 MB,包含 67,235 个 Trace UI 的 0-based seq、79 个动态 function node。规模依然不算小,但已经可以沿 call tree 做人工复核。
call tree 里出现了七个相同的孩子
在 Trace UI 中定位到 A_P_E 后,call tree 给出的形状非常干净:下面连续出现 7 个 A_e 分支,每个分支都有一个 AES 内层调用;七个分块之后,又出现一个 Base64 子调用。
这和输入长度正好吻合。
nativeInfo 接收到的紧凑 JSON 是 105 字节。PKCS#7 补齐到 16 的倍数后变成 112 字节,也就是:
1
2
3
4
105-byte JSON
+ 7 bytes of 0x07 padding
= 112 bytes
= 7 × 16-byte blocks在 trace 里,seq 292、295、296、303 附近可以看到这次从原始 JSON 到补齐缓冲区的变化。第一轮 A_e 从 seq 40803 附近开始,第七轮出现在 seq 61095 附近。随后 seq 64475 进入 b64_encode,seq 67174 则在 Base64 结果偏移 0x98 的位置写入字符串终止符。
这些 seq 的价值不在于“记住几个数字”,而在于它们把三个原本分散的观察连在了一起:105 字节输入、7 次固定大小调用、112 字节密文。到这一步,七次 A_e 已经很难再解释成偶然的循环。
trace 看不到的 key,用 capture 补上
这里还有一个容易误判的问题:trace 的第一条指令并不等于数据的出生点。
A_P_E 进入 trace 之前,某些关键内存已经由上游准备好了。只看指令写入历史,会发现 key18 像是“凭空出现”的;如果据此断言 trace 不完整或者算法不可还原,就会在边界上卡住。
我们在 A_P_E 和 A_e 的边界增加了 Frida capture,把寄存器指针指向的内存按已知长度保存下来,再导入 Trace UI 与指令序列对齐。收窄后的数据中一共有 17 个 capture event,其中包含 7 次 A_e 的入口材料。这样既保留了 Stalker 的控制流,也补齐了 trace 开始之前已经存在的 key 和明文块。
对单次 A_e 逐项对照后,调用约定可以写成:
1
2
3
X0 = plaintext16
X1 = key18
X2 = ciphertext16 outputX1 指向的是 18 字节小写十六进制文本,但 AES 实际只读取它前 16 个 ASCII 字节。这里的“ASCII”很关键:它不是把十六进制字符串解码成 8 字节,也不是使用 MD5 的 16 字节 raw digest。
Unknown 不是失败,完整复算才是算法证据
自动结构扫描曾经给部分局部窗口标成 Unknown 或 Related。这个结果并不奇怪:我们面对的是经过编译和混淆的 native 实现,单靠某一小段指令形状,不一定能稳定识别出标准 AES 轮函数。
我们没有把结构标签当作结论,而是换成更直接的验证方式:
- 从 capture 取出完整 key18;
- 从指定 seq 重建每个 16 字节输入块;
- 从输出地址重建 7 个密文块;
- 用 key18 前 16 个 ASCII 字节分别做 AES-128-ECB 加密和解密;
- 按字节与动态内存中的真实结果比较。
Trace UI 对完整 Encrypt 和 Decrypt 都给出了 VerifiedFull:
1
2
7 / 7 blocks matched
112 / 112 bytes matched因此本文把“自动扫描看到类似 AES 的结构”和“完整 key、input、output 复算一致”分成两个证据等级。前者只能帮助找方向,后者才足以支撑这里使用 AES-128-ECB 的判断。
复算同时确认了这几个细节:
- 分组长度为 16 字节;
- 模式为 ECB,没有 IV;
- 明文先做 PKCS#7 padding;
- 7 个分块都使用同一把 16 字节 ASCII key;
- 密文拼接后再统一 Base64。
r=57 这一份样本,key 到底怎么来
加密本身确认以后,剩下的是 key18 的派生。
在其中一份真实样本里,随机值 r 为 57。派生过程可以还原为:
1
2
3
4
5
6
7
r = 57
shift = r % 10 + 1 = 8
transformed = sMuOwAyCa[c]e_gQiSkUmWoIqK
MD5 hex = 2b48dce6e6db58d1c589d4c9f1048fba
key18 = 2b48dce6e6db58d1c5
AES key = ASCII("2b48dce6e6db58d1")transformed 的来源是对小写字母表做循环移位,再对 0-based 奇数下标的字节与 r 做 XOR。随后计算 MD5,但 native 代码使用的是 MD5 的小写 hex 文本:
1
2
3
4
MD5(raw transformed bytes)
-> 32-char lowercase hex
-> take first 18 chars as key18
-> take first 16 ASCII bytes as AES key这处很容易写错。若直接使用 MD5 raw digest,长度虽然也是 16 字节,输出却会完全不同;若把前 16 个 hex 字符再做十六进制解码,得到的只有 8 字节,同样不符合 trace。
另一份独立样本走的是 r=70。它得到另一组 key18,仍按完全相同的方式取前 16 个 ASCII 字节完成加密。两份样本都能从 JSON 正向逐字符复现捕获到的 token,这比只对一个样本拟合公式可靠得多。
真正让事情突然简单的,是 token 中间那 24 个字符
分析 key 派生时,我们原本默认“解密 token 必须先知道 r”。这也是为什么前面花了不少时间去追随机数、移位、XOR 和 MD5。
直到把最终字符串按长度拆开,才发现这个前提是错的。
当前版本的 token 格式是:
1
2
3
token[0:10] = prefix10
token[10:34] = Base64(key18)
token[34:] = Base64(ciphertext)key18 是 18 字节 ASCII 文本,标准 Base64 编码后恰好占 24 个字符。因此,解密一枚已有 token 的最短路径其实是:
1
2
3
4
key18 = Base64Decode(token[10:34])
aes_key = key18[0:16]
ciphertext = Base64Decode(token[34:])
plaintext = PKCS7Unpad(AES-128-ECB-Decrypt(ciphertext, aes_key))也就是说,实际解密不需要猜 r,不需要复现 arc4random,也不需要重走字母表移位、XOR 和 MD5。那些步骤对理解“key 如何生成”仍然重要,也是在已知 r 时正向构造 token 的一部分;但对“拿到 token 后如何解开 JSON”不是必要条件。
前 10 个字符也不参与 AES。动态实现会从数字、大写字母、小写字母三类字符中生成这个前缀。它属于最终封装格式,而不是密钥材料。
把 A_P_E 写成等价逻辑
省略内存管理、JNI 转换和错误分支后,当前执行路径可以整理成下面的等价伪代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def build_token(json_text, r, prefix10):
shift = r % 10 + 1
rotated = rotate_lowercase_alphabet(shift)
transformed = xor_odd_indexes(rotated, r)
md5_hex = md5(transformed).hexdigest()
key18 = md5_hex[:18]
aes_key = key18[:16].encode("ascii")
padded = pkcs7_pad(json_text.encode("utf-8"), 16)
ciphertext = aes_128_ecb_encrypt(aes_key, padded)
return (
prefix10
+ base64_encode(key18.encode("ascii"))
+ base64_encode(ciphertext)
)这里保留了调用方传入 JSON 的原始字节序列。即使两个 JSON 解析后的对象相同,只要字段顺序、空白或数字文本形式不同,密文都会改变。所谓“逐字符复现”,比较的是最终 token 的每个字符,而不只是解密后得到一个语义相同的对象。
Python 闭环不是只做了一次解密
为了把动态分析结果从“能讲通”推进到“能重复”,我们整理了脚本:
1
shihuo_token_generate_verify.py脚本没有设计 CLI 参数。需要更换 JSON、r 或 10 字符前缀时,修改文件顶部配置,然后直接运行:
1
python3 shihuo_token_generate_verify.py它做了四类工作:复现已知样本、从 token 自身解密、检查 r 的候选空间,以及生成一枚新 token 再做回环。
第一份真实样本唯一对应 r=57。Python 从原始 JSON、r 和样本前缀正向计算后,最终 token 与捕获值逐字符相同;反向拆分 token,也能恢复相同 key18、112 字节密文和原始 JSON。
上一份真实样本则唯一对应 r=70。脚本不仅能解密它,也能从它的原始 JSON 再次正向得到完全相同的 token。换了一次 r、key、明文和密文后,算法仍然成立。
我们还枚举了 r=1..100:
1
2
3
candidate r count = 100
unique key18 = 100
collision groups = 0在当前这 100 个候选中,每个 r 都得到不同的 key18。两份真实样本分别只命中 r=57 和 r=70,没有出现一个 key18 对应多个 r 的情况。
不过,仅让同一份 Python 代码“生成后再自己解密”,多少还有同实现自证的嫌疑。为此又随机生成了一份 r=63 的新 token,只把最终 token 交给独立的 Node 验证器。Node 端没有读取 Python 的中间变量,而是自行完成分段、Base64 解码、PKCS#7 检查和 AES-ECB 解密,得到与 Python 输入完全相同的 JSON,并从 1..100 中唯一反推出 r=63。
这次交叉验证解决的是实现错误风险:即使 Python 的生成与解密恰好共享同一个 bug,独立 Node 实现仍需要对最终字节给出相同解释。
两个容易把日志带偏的小问题
动态分析中还有两处小坑,虽然不属于算法本身,却很影响判断。
一处是 C 字符串读取。第一版为了限制读取范围,使用了 readUtf8String(maxLength)。但这次 Frida 会按给定长度做严格 UTF-8 解码,JSON 和 Base64 末尾的 NUL 也落进了解码范围,于是分别在第 105 和第 152 字节报错。错误看上去发生在“明文”和“密文返回值”上,其实算法数据并没有丢:7 个分块和最终 token 都还完整。后来改成 readCString(maxLength),既保留最大读取长度,又会在第一个 NUL 停下,这才符合 A_P_E 两端都是 C string 的情况。
另一处是 hook 与 trace 边界。Stalker 没记录到某次写入,不代表该值没有来源;它可能在开始跟踪前就已经存在。key18 正是如此。最后采用“trace 负责指令路径,Frida capture 负责边界内存”的组合,才补上完整证据链。
这也是为什么文章里不把某一次日志输出直接当作最终事实。长度、调用次数、内存字节和独立复算能够相互约束时,结论才比较稳。
目前能够下到哪一个结论
现在已经可以确认:在所分析的当前应用版本、当前 Enviroment 执行路径中,JSON 会经过 PKCS#7 补齐,以 key18 前 16 个 ASCII 字节作为 AES-128-ECB key,加密结果再与 Base64(key18) 和 10 字符前缀拼装。这个算法与封装格式已经可以离线正向生成,也可以只依赖最终 token 离线反解。
但“本地能够生成格式和算法一致的 token”,不等于“服务端一定接受任意自生成 token”。
服务端还可能校验时间窗口、设备状态、账号上下文、请求参数之间的一致性、重放状态,或者存在这次 native 路径之外的关联字段。本文的 VerifiedFull 证明的是本地 AES 语义与动态输入输出完全一致;两个真实样本的逐字符复现证明的是本地封装公式成立;独立 Node 验证证明的是新样本可以被另一实现按同一格式解释。它们都不能替代真实请求上的服务端接受性实验。
这次逆向最后留下的经验很朴素:方法名适合找入口,数据流才适合找算法。getToken 没有错,只是离答案太远。真正把问题拆开的,是 nativeInfo 下面那 7 次 A_e、边界处捕获到的 key18,以及最终字符串中那段一开始被我们当成普通 Base64 前缀的 24 个字符。
