一、背景 & 场景
1.1 环境
设备:iPhone 14 / iOS 16.4.1 / 越狱(Dopamine) 工具链:Frida 16.1.4、IDA Pro 9.3、基于 Unicorn 自建的 iOS 模拟执行环境、Charles 目标:某 AI 对话 App,iOS 端
为避免针对具体业务,下文统一称“某 App”,主机名、bot_id、device_id 等做了脱敏,不影响技术结论。
1.2 引言
逆向一个 App 的网络协议,要把整条链拆清楚,绕不开三道关:
- 设备指纹采集——一个请求 body 里塞了几十个设备字段,得厘清每个从哪来;
- 请求体加密(TTEncrypt)——body 是二进制密文;
- 请求签名(metasec / Argus 套件)——
X-Gorgon等一堆头,算法藏在重度混淆的 native 代码里。
本文围绕两个核心接口——device_register(设备注册)和 chat/completion(AI 对话)——逐层拆。前两关可以静态还原;签名因 OLLVM + 国密混淆,静态逆向性价比极低。这里有个分析上的取舍:签名算法静态难以推进时,与其耗在静态分析上,不如把真实二进制放进受控模拟环境执行、直接观测它产出的签名头——「读懂算法」暂时换成「动态观测产物」,先把协议这条链验证通,再回头深挖算法本身(第六节)。
1.3 研究动机与目标
- 设备注册的 body 里那几十个字段,分别从哪采集、怎么组合、怎么加密?
- 纯协议生成的
device_id,服务器认不认、能不能用? - 那一堆
X-Gorgon / X-Argus签名头,哪些接口真校验?签名算法 OLLVM 混淆到读不动时,有没有不逆算法也能拿到正确签名的办法?
1.4 整体流程与框架
两条最终跑通的链路,整体架构如下:
图 1:整体架构——设备注册与 AI 对话两条链路
一个反直觉的结论先抛出来:device_register 服务端不校验签名,chat/completion 校验。 这决定了模拟执行产出的签名,在前者“可选”、在后者“必需”。
二、设备注册:采集 → 组合 → 加密 → 请求
2.1 整体流程
设备注册不是“填个表发出去”,而是一条流水线。先看全貌,再逐段拆:
图 2:设备注册流水线——采集 → 组合 → gzip+加密 → 拼请求
2.2 采集:设备字段从哪来
body 的 header{} 由 BDInstall(RangersAppLog)逐项采集。关键入口 +[BDInstallNetworkUtility commonURLParameters] / onTheFlyParameter,外加一层“混淆采集” +[BDInstallExtraParams extraParams](字段名用 base64 藏起来,搜索不到字符串)。
| body 字段 | 采集来源 |
|---|---|
device_model | sysctl hw.machine(如 iPhone14,7) |
hardware_model | sysctl hw.model(如 D27AP)——base64 key 混淆采集 |
resolution | UIScreen.nativeBounds × scale(1170*2532) |
vendor_id | IDFV,UIDevice.identifierForVendor |
idfa | ATT trackingIdentifier(未授权返回全 0) |
cdid | 本地持久化的 UUID(首启生成、长期不变) |
os_version | UIDevice.systemVersion |
mcc_mnc | CoreTelephony 运营商 |
access / ac | 网络类型(WIFI…) |
timezone / tz_name | NSTimeZone |
mb_time / device_init_time | SDK 首启 / 设备初始化时戳——base64 key 混淆采集 |
aid / channel / appName | 全局配置 |
device_id / install_id / device_token | 文件 / Keychain;首次注册为空,由服务器分配 |
带“base64-key 混淆采集”的那几个,反编译里长这样——字段名藏在 base64,值现场取:
// +[BDInstallExtraParams extraParams]
v8 = +[BDInstallAxtraLT getMB0]; // mb_time(SDK 首启时戳)
v12 = +[BDInstallAxtraModUtils getMO0]; // hardware_model
v20 = +[BDInstallAxtraLT getDI0]; // device_init_time
而 getMO0 往下就是一次 sysctlbyname,交叉引用看得很直白:
[api 'hw.model'] <- +[BDInstallAxtraModUtils getMO0] → "D27AP" (hardware_model)
[api 'hw.machine'] <- +[BDInstallNetworkUtility platform] → "iPhone14,7" (device_model)
关键洞察:真正决定“这是哪台设备”的,是
vendor_id(IDFV) +cdid这组客户端生成的稳定标识。服务器据此判断新老设备。把这两个换成全新 UUID、device_id留空,就等于“一台没见过的新手机”。
2.3 组合:拼成请求 body
采集完,BDInstall 序列化层把字段拼成一个固定结构的 JSON:
{ "magic_tag": "ss_app_log",
"header": { "device_id":"", "vendor_id":"<IDFV>", "cdid":"<UUID>",
"device_model":"iPhone14,7", "hardware_model":"D27AP",
"resolution":"1170*2532", "os_version":"16.4.1", "region":"TW",
"custom": { "mcc_mnc":"...", "flow_app_variant":"...", ... },
... } }
组合链:
-[BDInstallRequestSerializer commonHandleWithRequest:params:] @0x10d53f428
params → btd_safeJsonStringEncoded(紧凑 JSON,无空格)
→ dataByGZipCompressingWithError(gzip = P)
→ bd_dataByDecorated → bd_dataByRandom(@0x10071562c)
→ 核心 sub_10D48009C(@0x10d48009c)
2.4 加密:TTEncrypt
抓包里 body 是这样的:
Content-Type: application/octet-stream;tt-data=a
body: 74 63 05 10 00 00 | <32 字节 R> | <AES 密文>
头 4 字节 74 63 05 10(小端 magic 0x10056374)是 TTEncrypt 帧头。逐个 hook 核心里的原生原语 dump 出 R / key / iv / inner / out,算法就清晰了:
R = 32 随机字节 # 任意 32 字节都行
key = SHA512( SHA512(R) || CONST64 )[0:16] # AES-128 key
iv = SHA512( SHA512(R) || CONST64 )[16:32] # iv
inner = SHA512(P) || P # P = gzip(紧凑JSON)
body = AES-128-CBC-PKCS7(key, iv, inner)
out = 74 63 05 10 00 00 || R || body
图 3:TTEncrypt 加密流程——key/iv 派生、inner 组装与输出帧布局
派生函数 sub_100AB6AEC 的反编译把这套说得很清楚——SHA512(R) 先算一遍,紧接着把 CONST64 拼上去(两张表逐 16 字节异或、运行时算出),再 SHA512 一遍:
// sub_100AB6AEC:key/iv = SHA512( SHA512(R) || CONST64 )
v12 = malloc(0x80);
sub_100AB7164(R, len, &v20); // v20..v23 = SHA512(R)(前 64 字节)
*v12 = SHA512(R); // 拷到 v12[0..3]
do { // 后 64 字节 = CONST64,运行时算出:
v12[i+4] = veorq_s8( unk_117408288[i], unk_117408248[i] ); // 两表逐 16B 异或
} while ( ++i < 4 );
sub_100AB7164(v12, 0x80, out); // out = SHA512( SHA512(R) || CONST64 )
// key = out[0:16], iv = out[16:32]
sub_100AB7164 就是标准 SHA-512(IV xmmword_117408550 = SHA-512 初始向量)。而两张表里 unk_117408248 正是 AES 逆 S-box——CONST64 = 逆 S-box 两半异或,纯静态可取:
CONST64 = 4dd4c2e6b83162090e52b3c7a6733ba4 1cb2462b829ab58a196b39db57177524
f49baf7f08e8d68d26a72e37c1a95a2f 1f05a51892aef2949732b62a38aadd58
对应到 Python,干净得多:
def tt_encrypt(P, R=None):
R = R or os.urandom(32)
d2 = hashlib.sha512(hashlib.sha512(R).digest() + CONST64).digest()
key, iv = d2[:16], d2[16:32]
inner = hashlib.sha512(P).digest() + P
body = AES.new(key, AES.MODE_CBC, iv).encrypt(pad(inner, 16))
return MAGIC + R + body
R 内嵌在密文里(不需密钥协商),这套是完全离线、对称自包含的:能加密发包,也能直接解开抓到的任意 octet-stream 包体。多组真机向量验证,派生 key/iv 与整体密文跟真机逐字节一致。
2.5 请求与真机验证
把 url(明文设备公参 query)+ body(TTEncrypt)+ 头拼起来。第一发探路,用最小头、不带任何签名:
headers = {
"Content-Type": "application/octet-stream;tt-data=a",
"User-Agent": "<App UA> Cronet",
"x-ss-stub": md5(body).hexdigest(), # body 的 MD5
"x-khronos": str(int(time.time())), # 时间戳
}
r = requests.post(URL, data=body, headers=headers)
直接 HTTP 200,返回完整注册响应:
{"server_time":1781160581, "device_id":120218806******24, "install_id":120218806******20,
"device_token":"AAAXMHR62KT3...", "dtrait_pk1":"...", "new_user":0, ...}
前面那一整套要逆的签名,对 device_register 根本不需要——服务端不校验签名头。真正关键的是 body 加密(已完成)+ 正确的字段组合。
注册一台全新设备: body 里 device_id / old_did / device_token 清空,vendor_id(IDFV)、cdid、mb_time 全部重新生成 → 服务器当新设备处理:
{"device_id":429841463******51, "install_id":429841463******47, "new_user":1, ...}
new_user:1 —— 纯协议成功注册出一台全新设备,后续验证是否可以正常使用。
图 4:device_register 实跑——重新生成 vendor_id(IDFV)/cdid 后注册,服务端判为新设备(new_user=1),下发 device_id/install_id/device_token(HTTP 200,已脱敏)
device_id 真能用吗? 拿它 + cookie 调一个需要有效设备的鉴权接口 /im/conversation/main:
HTTP 200 status_code 0 → 返回真实主会话 conversation_id=384299...
服务器为这个纯协议注册的设备分配了真实会话 → device_id 有效可用。
2.6 服务端下发配置:动态指纹采集(GF/dtrait)
上面那些是写死在 body 里的“静态字段”。风控更深的一层,是服务端下发配置、客户端按配置动态采集的设备指纹(字节叫 GF / dtrait)。
线索在 device_register 的响应里就埋了——返回的 dtrait_pk1 / dtrait_pk2 是两把 RSA-2048 公钥,专门用来加密后续上报的 secDtrait。真正的动态采集走另一个接口:
POST /service/3/device_sdk/stats_collect/?...&fetch_config=true
它是三层套娃:TTEncrypt → gzip → base64 → bd_pack(bd_pack 是字节另一套对称编码,也已离线破解:AES-256-CBC,key/iv 同样走 SHA512(SHA512(R)||GF_CONST) 派生)。这个接口的响应下发采集配置:
{ "fields": ["d_c0","d_r1","d_m1","d_r2","d_o0","d_o1"],
"dtrait_fields": ["d_c0","d_o0","d_o1","d_m1","d_b0"],
"version": 3, "delay_sec": 10, "dtrait_mem_ttl_sec": 3600,
"configDO0Path": "/var/mobile/Library/Carrier Bundles/Overlay", ... }
关键点:要采哪些字段、每个字段对应哪个系统文件路径,都是服务端下发的,不写死。 客户端 +[BDInstallGFManager startStatusCollectWithFetchConfig:] → generateGFCollectBody → 一堆 collectDxx:path: 按配置去取。frida 跟下来各 d_* 的真身:
| 字段 | 采集内容(真机 frida 实测) |
|---|---|
d_c0 | /var/mobile/Library/Carrier Bundles 的创建时间 |
d_o0 | …/Carrier Bundles/Overlay 的时间 |
d_o1 | 另一运营商目录时间(路径服务端下发) |
d_m1 | SDK 首启时戳 |
d_b0 | 系统启动时间 |
d_r2 | App Store 收据(Apple WWDR 签名的 PKCS#7,内含 bundleid,约 4.8KB DER) |
d_r1 | 空桩(collectDR1 直接返回空——代码即空,非采集失败) |
这套为什么难伪造:它不是客户端随手能编的字段,而是一组真实系统文件的时间戳 + Apple 签名的收据。Carrier Bundles 的时间随设备激活/运营商更新固定、跨重装稳定,是强设备指纹;
d_r2那张收据是 Apple 私钥签的,伪不出来。再加上“采哪些文件”由服务端动态下发,连个固定靶子都不给你。
图 5:GF/dtrait 动态指纹采集——服务端下发配置 + 三层套娃上报
一个容易踩的坑:下发配置用的 bd_pack 常量和上报的设备数据不是同一个——配置用 GF_CONST1(派生分支 a7=1),设备数据用 GF_CONST(a7=0)。解码器两个都得试。
对协议分析的影响:device_register 本身不依赖这套(前面已经注册成功了),但若关心“注册的设备能否长期用、会不会被风控盯上”,GF/dtrait 这层动态指纹才是真正的硬骨头——它把“伪造一台设备”从“拼对几十个字段”,升级成了“伪造一组真实文件时间戳 + 一张 Apple 签名收据”。
三、请求签名:metasec / X-Gorgon
3.1 签名头有哪些
这版 TTNet 是 Rust 写的,发包前调 metasec 加签,一次产出一整套:
X-Argus / X-Gorgon / X-Helios / X-Khronos / X-Ladon / X-Medusa / X-Neptune / X-SS-STUB
X-SS-STUB = MD5(body)、X-Khronos = 时间戳 简单。难的是 X-Gorgon,格式形如 8404 | <2字节 nonce> | 0000 | <20字节摘要>。
3.2 静态逆向碰壁(OLLVM)
硬件断点(绕过反插桩)抓到活的签名入口 sub_111E08FF8(url, headers)——它一次产出全部 X 头。但往里走就是噩梦:
- 控制流平坦化:核心
sub_111E0A0C4是标准 OLLVM dispatcher,状态变量 + 魔数常量 + 不透明谓词 + 花指令; - 国密体系:全镜像扫密码常量,命中 SM3 / SM4 / SHA1 / MD5 / AES 混合,没现成标准库可对;
- 真算法靠运行时函数指针到达,静态 call-graph 解析不出。
动态路也都堵死:
| 方案 | 结果 |
|---|---|
| IDA 远程单步 | 太慢;LDAXR/STLXR 原子在单步下活锁(独占监视器每步被清) |
| Frida Stalker 跟进 | 一进 metasec 就崩(return-地址 gadget,见第七节) |
| Frida Interceptor hook | 碰一下就 Process terminated(反篡改,约 25s 杀进程) |
这套反插桩怎么实现的、metasec 背后还在采哪些设备风险信号,单开一节细拆(见第七节)。这里先记住结论:动态分析这条路对 metasec 本体是堵死的。
3.3 思路转变:不逆算法,模拟调用
签名函数的 I/O 是确定的:sub_111E08FF8(url, headers) → 全部 X 头。既然内部算法静态难以推进,就把这个函数原封不动地“跑起来”——脱离手机、脱离反调试,在可控的模拟器里加载真实二进制直接调。
用的是基于 Unicorn 自建的 iOS 模拟执行环境:Unicorn 跑 arm64 指令,外面补上 ObjC 运行时 / Foundation / 常用 libc。这类「模拟执行真实二进制」的思路,分析字节系的 X-Gorgon/X-Argus 时很常见。
四、把签名函数放进受控模拟环境执行
4.1 为什么用模拟执行
- 几百万指令/秒,没有 USB 往返;
- 没有反调试、没有 LL/SC 活锁(单线程模拟,独占监视器是对的)——之前真机单步卡死的那段 Arc 引用计数自增,这里根本不是问题;
- 跟单步一样是动态执行,能穿透 OLLVM 平坦化(只执行真实那条路径)。
4.2 三个关键修复(缺一不可)
直接调签名函数是跑不起来的。545MB 主二进制加载只要 13 秒,但要让函数真正跑通,得过三关:
① 必须全初始化。 用默认参数加载(跑 init_array + objc_init)。关掉 objc_init,_objc_msgSend 的 GOT 槽是 0,Rust 代码调它直接 br 到 0 崩溃。
② hook __tlv_bootstrap(TLS 引导)。 签名器访问 thread-local,经 libdyld 的 __tlv_bootstrap,模拟里跑不通撞 BRK。自己实现:按 descriptor 的 key 分配清零块返回。
def tlv_boot(uc, address, size, ud):
desc = emu.get_arg(0)
key, off = emu.read_u64(desc + 8), emu.read_u64(desc + 0x10)
if key not in arenas:
a = emu.create_buffer(0x80000); emu.write_bytes(a, b"\x00" * 0x80000)
arenas[key] = a
return arenas[key] + off
emu.add_interceptor("__tlv_bootstrap", tlv_boot)
③ patch semwait 返回成功。 模拟环境默认让信号量等待永远超时(ETIMEDOUT),导致 Rust 的 Once 误判失败 → panic。改成返回 0:
IosSyscallHandler._handle_sys_semwait_signal = lambda self: 0
(另:objc init 会找主程序 Info.plist,从 ipa 抽一份放到二进制旁边即可。)
4.3 执行一次,观测产出
三关一过,签名函数 2 秒跑完(550 万条指令)。给它一组 (url, headers),把它写回的那块响应头读出来,就是真实的全套 X 头:
输入 url + {x-ss-stub, Content-Type, ...}
输出 X-Gorgon:'8404a094...' / X-Khronos / X-Argus / X-Ladon
/ X-Helios / X-Medusa / X-Neptune
这一步的价值是把「黑盒签名头」变成可观测对象:同一组输入跑出确定结果,就能拿它做后续分析——比如对指令轨迹做密码扫描确认 X-Gorgon 的 20 字节摘要,命中 SM3(主)+ SHA1 + MD5,摘要是分字段打包(query 哈希→字节 0-3,时间/nonce→中段,末字节校验)。
注意这里只是让签名函数在受控环境里执行、观测它的输入输出,并没有「读懂」算法——签名内部到底怎么算、能不能闭式还原,是第六节的事(详细VM分析)。
五、对话流程:从拿会话到 AI 回复
5.1 整体流程
图 6:对话流程——注册 → 拿会话 → 进会话 → chat → SSE 回复
5.2 对话接口结构
POST https://api5-normal-hl.<host>/chat/completion?...
Content-Type: application/json; encoding=utf-8
惊喜:body 是纯 JSON 明文,不加密!
{ "client_meta": {"bot_id":"<默认助手>", "conversation_id":"...", ...},
"option": {"need_create_conversation":false, "send_message_scene":"keyboard", ...},
"messages": [{"content_block":[{"content":{"text_block":{"text":"用户的问题"}}, "block_type":10000}]}] }
- 鉴权 = device_register 下发的 cookie(
odin_tt/install_id/ttreq,Domain 跨 host 通用,requests.Session自动带); - 响应 = SSE 流式(
event: CHUNK_DELTA / STREAM_CHUNK / SSE_REPLY_END),AI 回复分块流回。
5.3 会话从哪来
新设备没有会话,body 里 conversation_id 留空报 710020202 invalid param。会话是设备绑定的 guest 主会话,App 本地持久化,抓包看不到创建那一刻。
试了下:全新设备调 /im/conversation/main(body {"ext":{}})——服务器直接返回一个真实主会话 id:
"conversation_info": {"conversation_id":"384300...", "name":"<App 名>", "conversation_type":3, ...}
每个新 device 都分到自己的 guest 主会话。拿到后再 /im/conversation/in_out(cmd 4210)进会话。
5.4 卡在 710022002:chat 验签名
万事俱备,chat 始终返回:
{"error_code":710022002, "error_msg":"当前服务访问频繁,请稍后重试", "extra":{"ack":"1"}}
错误像限流,于是冷却 15 分钟单发——仍然 710022002。排除限流后,回头看一个被误判的结论:
早期“chat 不验签名”的测试,是在无效会话上做的——会话错误在签名校验之前就返回了,从没真正走到验签名那一步。
补上模拟执行产出的 X-Gorgon 等头再发:
5.5 端到端验证:整条链跑通一次
把前面逆清的每一环串起来,给这一次 chat 请求补上模拟执行产出的签名头,发出去:
明文 JSON body
+ X-SS-STUB = MD5(body)
+ 模拟执行产出的 X-Gorgon / X-Khronos / X-Argus / …
→ POST chat/completion
SSE 流里终于出现真实文本:
event: CHUNK_DELTA data: {"text":"我是 XX 自研的"}
event: CHUNK_DELTA data: {"text":"AI 助手,乐于为你解答问题…"}
event: SSE_REPLY_END data: {"end_type":1, ...}
把上面整条链封进一个交互式客户端,就能直接对话——签名头由模拟执行实时产出,SSE 边收边解析:
图 7:chat/completion 实跑——纯协议注册的设备进入交互式对话,问答经签名校验放行,AI 回复以 SSE 流式逐字返回(已脱敏)
chat 确实验签名(与 device_register 不同)。 前面对签名函数的模拟执行,到这里正好验证了价值——整条链(采集 → 加密 → 拿会话 → 签名 → 发包 → SSE 回复)端到端跑通,metasec 这一路的分析在这一步闭环。这是对「逆向是否到位」的一次完整验证,而非一个可复用的发号工具。
六、深入签名算法:VM中计算签名
签名头能在模拟环境里观测、协议也端到端跑通了,但有个问题一直没答案:那 20 字节的 X-Gorgon 摘要、那串更长的 X-Medusa,里面到底算了什么?前面只是「观测产物」,没「读懂算法」,里面是否有隐藏的风险是未知的。这一节就回头把签名器拆开——结果发现它远不止「OLLVM 混淆的 native 函数」那么简单,而是VMP。
6.1 不是普通 OLLVM,是 VM
签名入口 sub_111E08FF8 往里走,不是常规函数调用链,而是进了一个解释器:
sub_111E08FF8(url, headers)
└─ sub_1002D85AC: vm_run(vm_code, &args, ...)
└─ sub_1002B3EDC ← 字节码解释器
dispatcher 0x1002B4114 (fetch → decode → br x8)
handler 区 0x1002B4000 – 0x1002BDFFF
指令编码也摸清了:每条 32 位字,OP = word[6:11](6 位),操作数 f0..3 = word[12:17]/[17:22]/[22:27]/[27:32](各 5 位,都是虚拟寄存器下标,vreg[i] = [x20 + i*8])。真机 trace 实测只有 26 个真实 opcode 在跑,全是纯算术:XOR / ADD / SUB / AND / OR / NOT / NEG / ROR / SHL / LSR / MUL,外加 LOAD(读输入)和 CALL(调外部,实测只有 memmove/memset)。没有 SM3/SM4/AES 硬件指令 —— 国密全是这些 opcode 指令拼出来的。
最难缠的是三层保护,每一层都是「改代码就崩」的被动自毁,比主动检测更难绕:
| 层 | 机制 |
|---|---|
| ① opcode 滚动解密 | 每个 handler 执行完,把「下一条」opcode 字 XOR 一个 handler 专属常量、就地写回。静态看全是密文,且路径相关(没走到的分支永远是密文) |
② 滚动密钥 var_60 | 逐指令 EOR #const 更新,参与 handler 地址解码 —— 指令顺序错一条,后面全乱 |
| ③ 自地址完整性 | 每个 handler 顶部 ADRL x8, 解释器; ...位运算...; CMP; B.CC,用函数自身地址的比特当派发密钥 X5/X6。patch 代码 → 地址派生变 → 派发跳飞 |
这也解释了为什么前面 Frida Stalker/Interceptor 一碰就崩:它们要搬代码/打补丁,而 ①③ 让任何代码改动都破坏密钥链。模拟环境原地执行、保留真实寄存器,反而免疫。
把签名器内部画出来,就是一台带三层护甲的虚拟机:
图 8:签名器内部——字节码 VM + 三层自毁保护
6.2 X-Medusa 拆开:明文 protobuf 提取
X-Medusa 是这套里最重的头,结构是 base64( [4字节 nonce] + 流密码加密(protobuf) )。每次签名 nonce 都变,所以密文每次不同 —— 这其实是个逐次 nonce 的流密码(差分扫描印证:明文⊕密文=keystream,nonce 喂 counter)。
要看明文 protobuf 得过一关:明文是「原地加密」的瞬态,签完内存里那块已经被密文覆盖了,事后 dump 只剩密文。解法是 「首读值」法 —— 在模拟环境里钩内存读,对每个地址只记第一次被读到的值(即被覆盖前的明文),配合把时间/熵全部固定让结果确定:
mem = {}
def on_read(uc, access, addr, size, value, ud):
if not (HEAP_LO <= addr < HEAP_HI): return
data = uc.mem_read(addr, size)
for i, b in enumerate(data):
mem.setdefault(addr + i, b) # 只记首读 = 加密前明文
emu.uc.hook_add(UC_HOOK_MEM_READ, on_read)
这样就把完整 flat protobuf 提取了出来,任意设备全字段可解:
field7 "13.6.0" # app 版本
field8 "v04.10.00-ml-iOS" # 安全 SDK 版本
field13 <20 字节> # 设备哈希 ← 本节主角
field14 <6 字节> # 请求摘要
field15 / field20 / ...
6.3 一个能闭式还原的字段:field14
field14 运气好,一层就到底。在 VM 里追 LOAD opcode 读的输入指针,还原出被哈希的明文串(注意 VM 加载时有个半字节交换 [1,0,3,2],ediv→devi),拼出来正是请求 query:
# 双设备验证通过
field14 = SM3("device_id=<did>&iid=<iid>&aid=<aid>")[:6]
干净的闭式,可移植。这说明签名里有能纯算法还原的部分。
6.4 难以还原的字段:field13 = 键控 SM3(设备指纹)
field13 是另一个故事。它 20 字节、随设备变,但不是 query 的任何哈希 —— sha1/sm3/md5/sha256、各种哈希链、key×query 全试否。它的输入是 VM 内部拼出来的二进制,不落明文。
于是上完整 trace + 反向数据流切片:在模拟环境里全量记录 VM 区每条内存读写(带值),逮住 field13 摘要写出的那一刻,再从输出点反推。落点很明确 —— field13 的首字节在地址 0x1002b64bc 写出,那是 VM 的 XOR opcode(vreg[f2] = vreg[f1] ^ vreg[f0]),正好是 SM3 的 feed-forward 终结(V = ABCDEFGH ^ V)。配上 trace 里那张 0x79cc4519 轮常量表 —— 坐实 field13 = SM3 输出。
再数压缩块(轮常量读次数 / 64):约 9 块 ≈ 512 字节消息。而 trace 窗口里出现的字符串碎片是关键证据:
common_key / sign_key # mssdk 的两把 base64 密钥(license protobuf 里)
…ers/Bundle/Application/… # App 沙盒路径
mssdk / Carrier / App 收据片段
也就是说:
field13 = SM3( mssdk_key , 设备指纹 blob )[:20] # 键控 SM3
输入是整块设备指纹(就是第 2.6 节那套 GF/dtrait:Carrier Bundles 时间戳、App Store 收据、路径……),不是闭式公式 —— 绑设备,换设备就变。
那能不能把这 512 字节消息从 trace 里复原出来?我写了SM3 感知提取器,用消息扩展公式反查 W 数组:
W[j] = P1(W[j-16] ^ W[j-9] ^ ROL(W[j-3],15)) ^ ROL(W[j-13],7) ^ W[j-6]
P1(x) = x ^ ROL(x,15) ^ ROL(x,23)
思路是:哪个内存 buffer 的值序列满足这个扩展,它的前 16 词就是消息块。四种方法都试了 —— 值导向后向切片、连续地址验扩展、每块 buffer 抽取、指针表解引用 —— 全部证伪:扩展公式在 flat 内存里怎么都不命中。最后跟到那张「W 数组指针表」,[0] 存的赫然是 OLLVM 不透明谓词常量。
结论铁证:W 数组完全 vreg 驻留 + 指针间接 + OLLVM 谓词交织,消息词不存在于任何连续 buffer。 要字节级还原 field13,只剩 IDA GUI + 微码去混淆(d810)反虚拟化那段 VM SM3 是另一个量级的工程。
图 9:field13 反虚拟化——从定位到四法证伪,最终只剩 d810
6.5 VM 字节码反汇编与还原高级代码
前面几节都在用 VM 的执行结果,这里单独把方法说清:怎么把这套字节码反汇编出来、再往高级代码还原。
图 10:VM 反汇编到还原高级代码的流水线
反汇编:只能动态,不能静态。 直接静态反汇编这套字节码会得到一堆乱码,原因有三:
- 滚动自解密:每条 opcode 执行时才被 XOR 解出(6.1 的 ① 层),静态看全是密文;没走到的分支永远不解密。
- 变长 + 数据交织:字节码里夹着常量数据,按固定长度线性扫描会错位。
- 路径相关:同一段字节随执行路径解出不同指令。
所以反汇编得从动态执行里取:在派发点(fetch → decode → br x8)插一下,每条 VM 指令记下它的 OP、操作数 f0..3、以及读写的虚拟寄存器值,得到一条已解密、可读的 VM 指令级 trace:
OP=0x06 XOR r[f2] = r[f1] ^ r[f0] ; r[f1]=0x.. r[f0]=0x..
OP=0x24 ADD r[f1] = r[f1] + r[f3]
OP=0x16 ROR r[f1] = ror(r[f0], r[f1])
实测只有 26 个真实 opcode 在跑,全是标量算术(XOR/ADD/SUB/AND/OR/NOT/NEG/ROR/SHL/LSR/MUL)+ LOAD/CALL。国密哈希就是这些 opcode 软件拼出来的。
反汇编器本身很短,核心就是「解码一条指令字 + 一张语义表 + 在派发点抓 vreg 值」:
# ① 解码:每条 VM 指令是一个 32 位字
def decode(word):
op = (word >> 6) & 0x3f # 操作码(6 位)
f = [(word >> 12) & 0x1f, (word >> 17) & 0x1f, # 4 个操作数
(word >> 22) & 0x1f, (word >> 27) & 0x1f] # = 虚拟寄存器下标
return op, f
# ② OP → 语义(实测在跑的 26 个,全是标量运算)
SEM = {
0x06: ('XOR', 'r[f0] = r[f0] ^ r[f3]'),
0x24: ('ADD', 'r[f1] = r[f1] + r[f3]'),
0x16: ('ROR', 'r[f1] = ror(r[f0], r[f1])'),
0x3f: ('AND', 'r[f2] = r[f1] & r[f3]'),
0x2e: ('OR', 'r[f3] = r[f1] | r[f2]'),
0x15: ('LOAD', '读输入字节'),
0x13: ('CALL', '调外部函数'),
# … NOT / NEG / SHL / ADDR 等
}
# ③ 在派发点插桩,按指令边界切,把每条的 vreg 读写值抓下来
FETCH = 0x1002B4118 # ldr w24,[x8] 取 opcode 字 -> word
DECODE = 0x1002B411C # ubfx x8,x24,#6,#6 OP = (word >> 6) & 0x3f
DISP = 0x1002B414C # br x8 派发到 handler
# 虚拟寄存器:基址 = vPC 槽地址 + 8,r[i] 存于 [base + i*8]
def resolve_vregs(base, mem_ops): # mem_ops: 该指令窗口的访存
return {(a - base)//8: v for rw, a, v, sz in mem_ops
if base <= a < base + 55*8 and (a - base) % 8 == 0}
把 decode() 的输出套上 SEM 表、再带上 resolve_vregs() 解出的实参值,就是上面那段可读 trace。这几十行就是「反汇编一台未知 VM」的全部脚手架——难的不是解码,是先在派发循环里认出 fetch/decode/br x8 这三个点。
识别语义:从轮常量认出 SM3。 有了可读 trace,下一步把「一堆标量运算」认成「已知算法」。最直接的线索是密码常量:寄存器流里出现 0x79cc4519(SM3 轮常量 Tj)、SHA1 的 0x5a827999、MD5 的 0x67452301——看到它们就知道这段在算哪个哈希。这一步是模式识别,trace 不会自动给你,得靠经验比对。
还原高级代码:本质是数据流回溯。 「还原成高级代码」不是把每条 VM 指令翻成一行 C,而是把百万条标量运算压回「field14 = SM3(query)[:6]」这种一句话。做法是数据流回溯:
- 从输出(某个签名字段)反向切片:它的值由哪条指令算出、那条指令的操作数又来自哪——值导向地一层层往回追。
- 追到一段满足 SM3 轮结构的运算 → 整段折叠成
SM3(消息)。 - 再找那个消息的来源(被哈希的输入)。
field14就是这么还原的:回溯到一个明文 query 串,一层到底。
举个具体的「前后对比」。下面是 VM 指令级 trace 里一轮 SM3 压缩的代表性片段(mnemonic、operand 编码、轮常量都是 trace 里实抓的):
OP=0x16 ROR r[t] = ror(r[A], 20) ; A <<< 12
OP=0x24 ADD r[t] = r[t] + r[E]
OP=0x24 ADD r[t] = r[t] + 0x79cc4519 ; ← SM3 轮常量 Tj(认出它=认出 SM3)
OP=0x16 ROR r[ss1] = ror(r[t], 25) ; <<< 7
OP=0x06 XOR r[ss2] = r[ss1] ^ r[a12]
OP=0x06 XOR ... (FF/GG 布尔函数 + TT1/TT2,每轮 ~70 条标量 op)
...
OP=0x06 XOR r[v0] = r[a'] ^ r[v0] ; ← feed-forward:V = ABCDEFGH ^ V_init
把这 64 轮、几千条标量 op 折叠回去,就是一个标准 SM3 压缩函数;再往上一层,就是一句话:
// 上面一整轮 ≈
SS1 = ROL(ROL(A,12) + E + ROL(Tj, j), 7);
SS2 = SS1 ^ ROL(A,12);
TT1 = FF(A,B,C) + D + SS2 + (Wj ^ Wj4);
TT2 = GG(E,F,G) + H + SS1 + Wj;
// 64 轮 + feed-forward 折叠完:
field14 = SM3("device_id=<did>&iid=<iid>&aid=<aid>")[:6];
左边几千条 ROR/ADD/XOR,右边一行 —— 这就是「还原成高级代码」要做的压缩。认出 0x79cc4519 和结尾的 feed-forward XOR,是把这堆标量运算锚定成 SM3 的关键。
还原的边界。 不是所有字段都能一句话还原。field14 运气好(消息是明文串);field13 的 SM3 消息在 vreg 里经指针间接拼装、和 OLLVM 谓词交织,反向切片追到的是 SM3 中间态而非干净消息(6.4)。这类要么继续做指针感知的符号执行把 vreg 间接解开,要么上 IDA + 微码去混淆(d810) 把那段 handler 反编译出来——后者是「还原成高级代码」的重武器,以后有时间再继续。
小结:VM 反汇编 = 动态抓已解密的执行路径(可靠、可读);还原高级代码 = 数据流回溯 + 算法识别(对干净输入能到「一句话」,对绑设备 / 指针间接的部分需符号执行或微码去混淆)。和「读懂一段加壳 native 函数」的差别在于:你面对的是一台自带指令集的虚拟机,得先摸清它的 ISA,再在它的指令层做数据流分析。
6.6 这轮深入分析印证了什么
- 签名器是「字节码 VM + 国密 + 设备指纹」三件套,比「OLLVM native 函数」深一层。当初判断「静态性价比极低」是对的,而且低估了。
- 同一套签名里,字段难度天差地别:
field14是 query 的闭式 SM3,一层到底;field13是设备指纹的键控 SM3,绑设备、藏在 VM 指针间接里,纯算法成本极高。 - 最关键的是:
field13即便复原出来也不可移植(它哈希的是真实设备指纹)。这说明对这类「键控 + 绑设备」的签名,纯算法还原的边际价值很低——逆清它的结构与性质(是什么、绑什么、为什么不可移植)才是分析的落点,而不是非要求出一份字节级闭式公式。
一句话收口:能闭式的(
field14)就闭式,绑设备 + VM 藏起来的(field13)就交给模拟器跑。知道哪些值得逆、哪些不值得逆,本身就是逆向的一部分。
七、设备风险检测与反 frida
签名只是 metasec 的一半活。另一半是给这台设备打风险分——它一边采指纹算签名,一边在背后探「是否调试、是不是越狱、有没有注入框架」,把结论编进签名里。这一节拆这套防御机制,也补上第三节「动态路全堵死」背后到底是怎么堵的。
7.1 它在采什么风险信号
用最朴素的办法看 metasec 探什么:hook 它会用到的底层 syscall——文件系统(stat/lstat/access/open/fopen)、进程(ptrace/sysctl)、环境(getenv),命中时打印调用方地址(__builtin_return_address(0),过滤出 metasec 区的调用)。一跑就看清它的探针面:
| 探测面 | 手段 | 探什么 |
|---|---|---|
| 调试器 | sysctl({CTL_KERN,KERN_PROC,KERN_PROC_PID,pid}) 查 p_flag & P_TRACED | 是否被 ptrace 附加(debugger / frida-server) |
| 越狱 | stat / access 一串路径 | 越狱文件、目录是否存在 |
| 注入 | 镜像枚举 / fopen | 是否加载了 Substrate / frida 等 |
| 环境 | getenv | DYLD_INSERT_LIBRARIES 等注入痕迹 |
| 完整性 | 自身代码/签名校验 | 代码是否被 patch(见第六节三层保护) |
这些结论不单独上报,而是揉进签名:X-Medusa 明文 protobuf 里有个 field24 是风险 JSON,形如 {"sts": ...}(status,风险态),init 期就开始采。环境一旦「脏」,这个值就变——服务端拿它做风控。
7.2 反 frida / 反 trace:为什么工具一碰就崩
第三节那张「动态路全堵死」的表,背后是一套组合拳。除了第六节的三层 VM 自毁保护(改代码就跳飞),还有专门针对插桩工具的:
① 反调试:上面 sysctl(KERN_PROC) 查 P_TRACED,frida-server 用 ptrace 附加就中招。
② 碰一下就杀:Interceptor hook 签名管理类的方法,甚至只是枚举一下这个类的方法列表,App 就 Process terminated——懒守卫,碰类即触发反 frida 检测,约 25s 杀进程。
③ Stalker 跟进必崩——而且不是「检测到 Stalker」:这是最有意思的一点。反编译实锤,崩因是 return-地址 gadget:
; 0x100109848(被调 257 次)
mov x3, x30 ; 取返回地址
add x3, x3, x0 ; 对它做算术
mov x30, x3 ; 写回 LR
ret ; 跳到「算出来的」地址
正常执行时 x30 是真实返回地址,加完跳对地方。但 Stalker 会把代码搬到 scratch 区执行、借用 x30 放自己的蹦床——gadget 对蹦床地址做算术,结果跳进 scratch 乱区,崩。compute 区有 60+ 个这种 ret、5 个 adr 假 ret。所以不是「检测到 frida 才崩」,是控制流本身依赖真实 LR,Stalker 一搬就散架。 硬件断点 / QBDI 原地执行、保留真 x30,反而免疫。
④ 没有固定 hook 点:这版 TTNet 是 Rust 直调 metasec native C,不走 ObjC 方法——按 selector 过滤 objc_msgSend 0 命中。签名计算又全在 VM 里 br x8 计算派发,没有 bl 某函数 这种固定调用点可下钩。
图 11:反 frida / 反 trace——三条动态路各自的崩因
唯一安全的姿势:hook 非 metasec 的 ttnet 胶水层(请求编排、MD5(body) 那些),从边界读 I/O、反推,但碰不到签名内部。
7.3 风险态怎么反馈进签名
这套检测不是摆设,会直接改签名输出。在被插桩的真机上,抓到的 X-Argus 解出来是退化的占位值(base64 的递增 khronos),风险态 sts 也高——metasec 发现环境脏,干脆不给真签名。
这恰好是模拟执行能成的一个隐藏原因:模拟环境是「干净」的——没有 ptrace、没有注入库、没有越狱文件、代码没被 patch。风险检测全部通过,metasec 给的是正常签名,而不是真机被插桩时那种退化值。换句话说,模拟执行不光绕过了反调试,还顺带骗过了风险画像。
7.4 对抗这套的思路
- 别 hook metasec 本体:碰类即死。要 trace 它,只有硬件断点(无痕、不改代码、保留真 LR)或 QBDI / Unicorn(原地执行)。
- 想读签名 I/O:hook 它外面的 ttnet 胶水层(参数进 / 结果出的边界)。
- 想要真签名:要么真机不插桩(但你就 trace 不了),要么用模拟执行——干净环境里原样跑函数,既绕反调试又过风险画像,一举两得。
把第六、七节连起来看,metasec 的设计哲学就是三道咬合的防御,而它们共享同一个盲点:
图 12:三道咬合防御 vs 模拟执行的干净环境
签名 = 设备指纹(绑死硬件)× 国密 VM(算法藏起来)× 风险画像(环境脏就退化),三者层层咬合,任何一环单独绕不够。但它最大的盲点是「假设自己跑在真实、脏的设备上」——模拟执行给它一台干净的虚拟设备、原样跑函数,反调试、反插桩、风险画像同时落空。
八、安全设计评析
把前面拆开的东西换个视角——站在防御方看,这套 metasec 哪些设计是真有效、哪些是纸老虎。
8.1 设计得好的地方
| 设计 | 为什么有效 |
|---|---|
| 纵深咬合 | 签名 = 设备指纹 × 国密 VM × 风险画像,三者层层依赖,单点绕过不够 |
| 绑硬件的指纹 | GF/dtrait = 真实系统文件时间戳 + Apple 签名收据,伪不出来;且「采哪些」服务端动态下发,不给固定靶子 |
| VM + 国密 + 三层自毁 | 静态反编译出空壳、动态插桩即自毁,把逆向成本抬到「另一个量级」 |
| 反插桩靠结构而非检测 | return-gadget 依赖真实 LR、自地址派生密钥——绕过某个「检测点」没用,得不破坏整条密钥链。比 if (frida) crash 难绕得多 |
| 风险检测内嵌进签名 | 调试器/越狱/注入的检测结论不单独上报,而是编进签名 protobuf 的 field24({"sts":..})——每个签名请求都附一份难伪造的设备风险自评。服务端不必自己探测:环境脏 → 字段变 + 签名退化,双重信号一起到后端 |
| 键控 + 绑设备字段 | field13 即便逆出算法也不可移植(哈希真实设备指纹),跨设备复用无门 |
8.2 被绕过 / 可改进的地方
| 弱点 | 说明 |
|---|---|
| 核心假设是单点盲区 | 所有自毁保护都假设「对手在真实设备上、会改代码 / 搬代码」。模拟执行——干净虚拟设备 + 原地不改码——一次性绕过反调试、反插桩、风险画像三层;连带那份内嵌签名的 field24 风险自评,也照常自报「干净」。最强的护甲,挡的是它预设的那种攻击 |
| 风险自评只是客户端自述 | field24 是设备给自己打的分——签名只保证它没被改,不保证它说的是真话。在干净模拟环境里,它如实报告一个干净环境,于是「难篡改」的风险遥测反而成了「难篡改的假报告」 |
| TTEncrypt 对称自包含 | 密钥派生常量内嵌(逆 S-box 两半异或)、R 内嵌密文、不与服务器协商 → 可完全离线复现 + 解密任意抓包,没有真正的「端密钥」 |
| 校验边界比预期小 | device_register 服务端不验签名,纯协议即可注册可用设备;签名实际拦截范围远小于它「看起来」覆盖的范围 |
| 端点强度不一致 | 同一份 X-Gorgon,chat 拦、注册 / 会话放行 → 弱端点成突破口 |
| 并非字段字段都绑死 | field14 = query 的闭式 SM3,可纯算法复现;防护强度在字段间并不均匀 |
8.3 给攻防双方的启示
- 分析侧:先用最小请求探出真实校验边界,往往比预期窄;算法逆不动就模拟执行、观测产物;判断「哪些值得逆」本身是关键。
- 防御侧:最大盲点是「假设对手一定在真实设备、且会动代码」。该补的是把信任根下沉到服务端——真正的密钥协商而非内嵌常量、收据 / 证明在服务端验签而非仅本地、关键校验扩到所有端点。对模拟执行环境做检测当然也行,但那是又一轮军备竞赛,治标不治本。
一句话:这套防御工程做得很扎实,强在「绑死硬件 + 抬高逆向成本 + 环境感知」;但它和绝大多数客户端防护一样,根子上的软肋是——安全逻辑跑在你不信任的设备上。一旦对手能在一个「干净、不改代码」的环境里把它原样执行,分层护甲就一起失效。真正的护城河,终究是服务端验证 + 绑服务器的密钥。
九、总结
| 阶段 | 手段 | 结论 |
|---|---|---|
| 字段采集 | 静态定位采集点 | IDFV+cdid 是稳定标识,决定新老设备;部分字段 base64-key 混淆采集 |
| body 组合 | BDInstall 序列化 | {magic_tag, header{...}} → 紧凑 JSON → gzip |
| 请求体加密 | 静态还原 TTEncrypt | SHA512×2 派生 + AES-128-CBC,纯 Python 复现、真机逐字节一致 |
| 动态指纹采集 | 服务端下发配置 | GF/dtrait:服务器下发“采哪些系统文件时间戳”+ App Store 收据,难伪造;device_register 不依赖 |
| metasec 签名 | 模拟执行 + 观测 | OLLVM 静态难以推进,在受控 Unicorn 环境执行签名函数、观测其产出 |
| 签名算法(深入分析) | trace + 数据流切片 | 签名器是字节码 VM(三层自毁保护);X-Medusa 拆出明文 protobuf;field14=SM3(query)[:6] 闭式,field13=键控 SM3(设备指纹) 绑设备不可移植 |
| 风险检测 / 反插桩 | ReconAF 探针 + 反编译 | 探调试器/越狱/注入/环境,结论编进 field24 风险 JSON;反 Stalker 是 return-gadget 依赖真实 LR(非检测);被插桩则签名退化。模拟执行的干净环境同时绕反调试 + 过风险画像 |
| device_register | 协议拼装 | 服务端不验签名,纯 body+stub+khronos 即可注册可用设备 |
| chat/completion | 协议拼装 + 签名 | body 明文 JSON,但验签名,靠模拟执行产出的 X-Gorgon 才放行 |
各接口 / 数据的加密、编码与签名一览:
| 接口 / 数据 | 编码·加密 | 签名 | 说明 |
|---|---|---|---|
device_register body | gzip → TTEncrypt(AES-128-CBC) | 不验 | 设备信息注册 |
chat/completion body | 明文 JSON | 验 X-Gorgon | AI 对话,SSE 流式响应 |
/im/conversation/* | 明文 JSON | 不验 | 会话管理(main 拿会话 / in_out 进会话) |
stats_collect 上报 | TTEncrypt → gzip → base64 → bd_pack(AES-256) | 不验 | GF/dtrait 指纹上报(三层套娃) |
stats_collect 下发配置 | 同上(bd_pack a7=1 常量) | — | 动态采集配置(采哪些文件) |
几点心得:
- 逆向不止“读懂算法”一条路。 当目标被 OLLVM / VMP / 国密堆到静态性价比极低时,“把那段代码放进受控环境原样执行、观测它的输入输出”往往更快。基于 Unicorn 这类 CPU 模拟器自建的执行环境,本质是把 App 的一个函数变成可观测、可复跑的对象——用来取样和验证,而不是替你读懂它。
- 先发一个最小请求探路,再决定逆什么。 没先用最小头试 device_register,我会一直以为必须先还原 X-Gorgon——结果它并不需要。服务器真实校验的范围,往往比你以为的小。
- 同一套签名,不同端点校验强度不同。 device_register 放行、chat 拦截,是同一份
X-Gorgon在不同后端的不同待遇。别用一个接口的结论套另一个。 - 采集这块最容易被忽视但最关键。 协议跑通后“能不能持续用、会不会被风控”,很大程度取决于设备字段采得真不真、组合得对不对。
- 知道哪些值得逆,本身就是逆向的一部分。 回头深入签名(第六节)才看清:它是字节码 VM + 国密 + 设备指纹键控。能闭式的字段(
field14 = SM3(query)[:6])就闭式还原;绑设备、藏在 VM 指针间接里的字段(field13= 键控 SM3(设备指纹))即便复原出来也不可移植——对这类签名,逆清结构与性质比求一份字节级公式更有价值。判断「不值得纯算法逆」和真把它逆出来,同样重要。 - 模拟执行顺带绕过了风险画像。 metasec 不只算签名,还探调试器/越狱/注入/完整性,环境不干净就让签名退化(第七节)。模拟执行给它一台干净虚拟设备、原样跑函数——反调试、反插桩、风险检测三道防御同时落空。逆向防御机制时,先想清楚它「假设自己跑在什么环境里」,往往就找到了那个盲点。
最终成果:从设备注册到 AI 对话,整条协议链逐层拆清,并以一次端到端跑通验证到位;签名头通过模拟执行观测取样。再回头拆开签名算法,逆出了能闭式的字段、证明了绑设备字段不可移植——协议链分析与签名算法逆向,两头都落到了实处。
本文仅用于安全研究与协议分析学习,相关标识已脱敏。
文档信息
- 本文作者:我是小三
- 本文链接:https://xiaosan-re.github.io/2026/06/11/ai-app-protocol/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
