逆向某 AI App:设备注册与 AI 对话的完整协议分析

2026/06/11 iOS 共 21794 字,约 63 分钟
矛和盾的故事

一、背景 & 场景

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 的网络协议,要把整条链拆清楚,绕不开三道关:

  1. 设备指纹采集——一个请求 body 里塞了几十个设备字段,得厘清每个从哪来;
  2. 请求体加密(TTEncrypt)——body 是二进制密文;
  3. 请求签名(metasec / Argus 套件)——X-Gorgon 等一堆头,算法藏在重度混淆的 native 代码里。

本文围绕两个核心接口——device_register(设备注册)和 chat/completion(AI 对话)——逐层拆。前两关可以静态还原;签名因 OLLVM + 国密混淆,静态逆向性价比极低。这里有个分析上的取舍:签名算法静态难以推进时,与其耗在静态分析上,不如把真实二进制放进受控模拟环境执行、直接观测它产出的签名头——「读懂算法」暂时换成「动态观测产物」,先把协议这条链验证通,再回头深挖算法本身(第六节)。

1.3 研究动机与目标

  • 设备注册的 body 里那几十个字段,分别从哪采集、怎么组合、怎么加密?
  • 纯协议生成的 device_id,服务器认不认、能不能用?
  • 那一堆 X-Gorgon / X-Argus 签名头,哪些接口真校验?签名算法 OLLVM 混淆到读不动时,有没有不逆算法也能拿到正确签名的办法?

1.4 整体流程与框架

两条最终跑通的链路,整体架构如下:

整体架构:设备注册与 AI 对话两条链路 图 1:整体架构——设备注册与 AI 对话两条链路

一个反直觉的结论先抛出来:device_register 服务端不校验签名,chat/completion 校验。 这决定了模拟执行产出的签名,在前者“可选”、在后者“必需”。


二、设备注册:采集 → 组合 → 加密 → 请求

2.1 整体流程

设备注册不是“填个表发出去”,而是一条流水线。先看全貌,再逐段拆:

设备注册流水线:采集 → 组合 → gzip+加密 → 拼请求 图 2:设备注册流水线——采集 → 组合 → gzip+加密 → 拼请求

2.2 采集:设备字段从哪来

body 的 header{} 由 BDInstall(RangersAppLog)逐项采集。关键入口 +[BDInstallNetworkUtility commonURLParameters] / onTheFlyParameter,外加一层“混淆采集” +[BDInstallExtraParams extraParams](字段名用 base64 藏起来,搜索不到字符串)。

body 字段采集来源
device_modelsysctl hw.machine(如 iPhone14,7)
hardware_modelsysctl hw.model(如 D27AP)——base64 key 混淆采集
resolutionUIScreen.nativeBounds × scale(1170*2532)
vendor_idIDFV,UIDevice.identifierForVendor
idfaATT trackingIdentifier(未授权返回全 0)
cdid本地持久化的 UUID(首启生成、长期不变)
os_versionUIDevice.systemVersion
mcc_mncCoreTelephony 运营商
access / ac网络类型(WIFI…)
timezone / tz_nameNSTimeZone
mb_time / device_init_timeSDK 首启 / 设备初始化时戳——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

TTEncrypt 加密流程:key/iv 派生、inner 组装与输出帧布局 图 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 —— 纯协议成功注册出一台全新设备,后续验证是否可以正常使用。

device_register 实跑:凭空注册一台全新设备 图 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_m1SDK 首启时戳
d_b0系统启动时间
d_r2App Store 收据(Apple WWDR 签名的 PKCS#7,内含 bundleid,约 4.8KB DER)
d_r1空桩(collectDR1 直接返回空——代码即空,非采集失败)

这套为什么难伪造:它不是客户端随手能编的字段,而是一组真实系统文件的时间戳 + Apple 签名的收据。Carrier Bundles 的时间随设备激活/运营商更新固定、跨重装稳定,是强设备指纹;d_r2 那张收据是 Apple 私钥签的,伪不出来。再加上“采哪些文件”由服务端动态下发,连个固定靶子都不给你。

GF/dtrait 动态指纹采集:服务端下发配置 + 三层套娃上报 图 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 整体流程

对话流程:注册 → 拿会话 → 进会话 → chat → SSE 回复 图 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 边收边解析:

chat/completion 实跑:交互式对话的流式回复 图 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 一碰就崩:它们要搬代码/打补丁,而 ①③ 让任何代码改动都破坏密钥链。模拟环境原地执行、保留真实寄存器,反而免疫。

把签名器内部画出来,就是一台带三层护甲的虚拟机:

签名器内部:字节码 VM + 三层自毁保护 图 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 是另一个量级的工程。

field13 反虚拟化:从定位到四法证伪,最终只剩 d810 图 9:field13 反虚拟化——从定位到四法证伪,最终只剩 d810

6.5 VM 字节码反汇编与还原高级代码

前面几节都在用 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]」这种一句话。做法是数据流回溯:

  1. 从输出(某个签名字段)反向切片:它的值由哪条指令算出、那条指令的操作数又来自哪——值导向地一层层往回追。
  2. 追到一段满足 SM3 轮结构的运算 → 整段折叠成 SM3(消息)。
  3. 再找那个消息的来源(被哈希的输入)。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 等
环境getenvDYLD_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 某函数 这种固定调用点可下钩。

反 frida / 反 trace:三条动态路各自的崩因 图 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 的设计哲学就是三道咬合的防御,而它们共享同一个盲点:

三道咬合防御 vs 模拟执行的干净环境 图 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
请求体加密静态还原 TTEncryptSHA512×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 bodygzip → TTEncrypt(AES-128-CBC)不验设备信息注册
chat/completion body明文 JSON验 X-GorgonAI 对话,SSE 流式响应
/im/conversation/*明文 JSON不验会话管理(main 拿会话 / in_out 进会话)
stats_collect 上报TTEncrypt → gzip → base64 → bd_pack(AES-256)不验GF/dtrait 指纹上报(三层套娃)
stats_collect 下发配置同上(bd_pack a7=1 常量)—动态采集配置(采哪些文件)

几点心得:

  1. 逆向不止“读懂算法”一条路。 当目标被 OLLVM / VMP / 国密堆到静态性价比极低时,“把那段代码放进受控环境原样执行、观测它的输入输出”往往更快。基于 Unicorn 这类 CPU 模拟器自建的执行环境,本质是把 App 的一个函数变成可观测、可复跑的对象——用来取样和验证,而不是替你读懂它。
  2. 先发一个最小请求探路,再决定逆什么。 没先用最小头试 device_register,我会一直以为必须先还原 X-Gorgon——结果它并不需要。服务器真实校验的范围,往往比你以为的小。
  3. 同一套签名,不同端点校验强度不同。 device_register 放行、chat 拦截,是同一份 X-Gorgon 在不同后端的不同待遇。别用一个接口的结论套另一个。
  4. 采集这块最容易被忽视但最关键。 协议跑通后“能不能持续用、会不会被风控”,很大程度取决于设备字段采得真不真、组合得对不对。
  5. 知道哪些值得逆,本身就是逆向的一部分。 回头深入签名(第六节)才看清:它是字节码 VM + 国密 + 设备指纹键控。能闭式的字段(field14 = SM3(query)[:6])就闭式还原;绑设备、藏在 VM 指针间接里的字段(field13 = 键控 SM3(设备指纹))即便复原出来也不可移植——对这类签名,逆清结构与性质比求一份字节级公式更有价值。判断「不值得纯算法逆」和真把它逆出来,同样重要。
  6. 模拟执行顺带绕过了风险画像。 metasec 不只算签名,还探调试器/越狱/注入/完整性,环境不干净就让签名退化(第七节)。模拟执行给它一台干净虚拟设备、原样跑函数——反调试、反插桩、风险检测三道防御同时落空。逆向防御机制时,先想清楚它「假设自己跑在什么环境里」,往往就找到了那个盲点。

最终成果:从设备注册到 AI 对话,整条协议链逐层拆清,并以一次端到端跑通验证到位;签名头通过模拟执行观测取样。再回头拆开签名算法,逆出了能闭式的字段、证明了绑设备字段不可移植——协议链分析与签名算法逆向,两头都落到了实处。

本文仅用于安全研究与协议分析学习,相关标识已脱敏。

文档信息

Search

    订阅

    新文章邮件提醒,输入邮箱即可:

    或使用 RSS 订阅

    Table of Contents