iOS 自研网络栈逆向分析:从 TNET/SPDY 到应用层协议降级
Ann945 2026-10-03 17:59 上海

看雪论坛作者ID:Ann945
工具:IDA Pro 9.0 + ida-pro-mcp、Frida

Tips:iOS 应用 ≠ 都用系统 TLS 栈。 国内大厂的自研网络库(阿里 EMAS、
字节 TTNet 这类)是整套静态编译进主二进制的。
问题描述很朴素:应用流量抓不到。
第一反应是"HTTP/2 或 SPDY 导致代理读不了"。这个思路不算离谱 —— 代理对h2 的二进制帧确实处理得不好,SPDY 更是基本不支持。
于是第一版脚本去 hook ALPN 设置函数,把 h2 / spdy/3.1 从协商列表里删掉:
// 第一版思路:hook 所有 ALPN 入口
sec_protocol_options_add_tls_application_protocol
nw_protocol_options_add_tls_application_protocol
SSL_CTX_set_alpn_protos
SSLSetALPNProtocols
_CFNetworkHTTPConnectionCacheSetUseHTTP22.1 第一步,读导入表
这是唯一能一锤定音的一步。把主二进制丢进 IDA,导出全部导入符号:
imports(offset=0, count=0) # 本例 5666 项然后搜候选 hook 点。本例结果:

一眼就能看出来:Network.framework 和 CFNetwork 的 ALPN 入口全都没有,只有SecureTransport 的 SSLSetALPNProtocols 一个。这意味着 ALPN 这条路即使存在,也不是主路径。
同时导入表里有一堆值得注意的东西:
/System/Library/Frameworks/Network.framework/Network (给 Flutter/其它组件用)
@rpath/Flutter.framework/Flutter
@rpath/flutter_boost.framework/flutter_boost
@rpath/AliVCSDK_ShortVideo.framework/AliVCSDK_ShortVideoFlutter 混合工程。这一点很重要 —— Flutter 的 Dart 侧网络走自己的栈,,和原生侧是两套东西,后面排查时要分开看。
2.2 第二步,搜字符串,尤其搜源码路径
find_regex(pattern="spdy")结果里有:
0x10570267b networkDemoteSPDY
0x105702693 unDemoteSPDY
0x10592a848 /Users/yuanzhan/.emas/build/56857276/workspace/NetworkCore/Protocol/NWSPDYCallBack.m
0x10592a95b .../NetworkCore/Protocol/NWSPDYOrigin.m
0x10592b00e -[NWSPDYSession createTnetSocket:forceQuic1RTT:isTunnelAvailable:isMultipathEnabled:scene:]
0x105916bb1 MTOPSDK_Enable_Network_SPDY信息量极大:
- 源码路径泄漏
/Users/yuanzhan/.emas/build/56857276/workspace/NetworkCore/
—— EMAS 是阿里移动端中台,NetworkCore 是它的网络核心库。
编译期没 strip 掉__FILE__,这类字符串在国内 App 里极其常见,
是最好的路标。 NWSPDYSession挂在自己的 TNET socket 上(
createTnetSocket:),
而且带forceQuic1RTT、isMultipathEnabled这些参数。
再确认一下到底有没有协商:
find_regex(pattern="spdy/3") → n=0
find_regex(pattern="spdy/2") → n=0零命中。 到这里可以下结论了:这个 App 根本不通过 ALPN/NPN 协商 SPDY。
第一版的整个思路从前提上就是错的。
顺带,alpn 的搜索结果也很有意思:
0x1054b7b49 ALPN_MISMATCH_ON_EARLY_DATA
0x1054b81dc INVALID_ALPN_PROTOCOL
0x1054b837b NEGOTIATED_ALPS_WITHOUT_ALPN
0x1054b8398 NEGOTIATED_BOTH_NPN_AND_ALPN
0x1056eac65 xqc_engine_add_alpn
0x1056f0c8a xqc_tls_ctx_register_alpn前四条是 BoringSSL 的报错常量 —— 说明 BoringSSL 被静态链接进来了
(所以 SSL_CTX_* 符号找不到,Module.findExportByName 也抓不到)。
后两条 xqc_* 是 XQUIC(阿里的 QUIC 实现),说明 HTTP/3 能力也在里面。
2.3 第三步,列类方法
list_funcs(queries=[{"filter":"NWSPDY"}])
list_funcs(queries=[{"filter":"NetworkDemote"}])这两条分别捞出了协议栈本身和降级开关。下面两节分别展开。
既然命名里带着 SPDY,就值得单独搞清楚 —— 因为它决定了"降级"这个动作
到底在降什么。
3.1 类族结构
NWSPDYOrigin —— 连接标识(schema / host / port + hash + cindex)
NWSPDYSession —— 会话 / 传输层
NWSPDYRequest —— 请求层
NWSPDYProxy —— 代理支持(proxyHost / proxyPort / userName / passwd)
DataChannel —— 数据通道
NWSPDYCallBack —— 回调源码路径(编译期未 strip):
/Users/yuanzhan/.emas/build/56857276/workspace/NetworkCore/Protocol/
NWSPDYCallBack.m
NWSPDYOrigin.m
NWSPDYRequest.m
NWSPDYSession.m3.2 Session 层:多路径(multipath)
ObjC 的 ivar 类型编码字符串会原样留在二进制里(T@"..." 那一批),它泄漏了实例的字段结构:
T@"NWSPDYSession",W,N,V_session
T@"NWSPDYSession",&,V_underlaySock ← 底层 socket
T@"NWSPDYSession",&,V_primary ← 主路径
T@"NWSPDYSession",&,V_secondary ← 备路径 ←← 双路
T@"NWSPDYSession",&,V_active
T@"NWSPDYSession",W,V_waitingSession ← 待命会话
T@"NWSPDYOrigin",R,N,V_origin
T@"NWSPDYRequest",&,N,V_tnetRequestprimary / secondary / active / waitingSession 这个组合,是典型的
多路径结构:同一条逻辑连接同时维持主备两条物理路径,一条断了切另一条。
三条独立证据互相印证:
-[NWSPDYSession createTnetSocket:forceQuic1RTT:isTunnelAvailable:isMultipathEnabled:scene:]
↑ isMultipathEnabled
-[NWSPDYSession startMultipathTask] / executeMultipath: / cancel4multipath
NWNetworkConfiguration 埋点字符串: network_tnet_multipath可以确认这是设计内的能力,不是历史残留。
对抓包分析的实际影响:同一条业务请求可能出现在两条路径上。
只看一条会漏包 —— 这是"某些请求莫名缺失"的一个常见来源。
3.3 传输层:可以是 TCP,也可以是 QUIC
-[NWSPDYSession createTnetSocket:forceQuic1RTT:isTunnelAvailable:isMultipathEnabled:scene:]
-[NWSPDYSession setSocketPublickey:isPlainMode:forSocket:]
-[NWSPDYSession updateMSS:domain:ip:]
-[NWSPDYSession asynConnect]
-[NWSPDYSession didConnected:]
-[NWSPDYSession close:errorcode:]
-[NWSPDYSession setStatus:]createTnetSocket:—— 底层是 TNET(阿里的长连接传输层)
forceQuic1RTT—— 可以强制走 QUIC 的 1-RTT 握手
updateMSS:domain:ip:—— 连 MSS(Maximum Segment Size)都在按域名/IP 调,
说明它对 TCP 层有细粒度控制setSocketPublickey:isPlainMode:forSocket:—— 每个 socket 单独设公钥,
还有一个isPlainMode
所以它不是"跑在 TLS 上的一层应用协议",而是自带传输层的协议栈。这一点直接决定了:任何针对 TLS 层的 hook 都对它无效。
3.4 帧层:确实有 SPDY 血统
-[NWSPDYSession sendCustomFrameType:withFlags:datachannel:qos:withData:sslIndex:]
-[NWSPDYSession _sendCustomFrameType:withFlags:datachannel:qos:withData:sslIndex:]
-[NWSPDYSession recvCustomFrameType:withFlags:withData:]
-[NWSPDYSession sendRequest:]frameType + flags 这个参数组合,正是 SPDY / HTTP-2 帧头的形状
(控制位 + 版本 + 类型 + 标志 + 长度)。所以"SPDY"这个命名不是乱起的 ——
帧的抽象确实沿用了 SPDY 的模型。
但多出来的参数全是自研扩展:
额外参数 | 含义 |
|---|---|
datachannel | 数据通道 |
qos | 优先级 / 服务质量 |
sslIndex | 多个 TLS 会话的索引 |
标准 SPDY 里没有这些东西。所以准确定位是:
"SPDY 帧模型 + 自研传输层 + 私有扩展字段"。
3.5 保活机制
-[NWSPDYSession sendPing:]
-[NWSPDYSession recvPingReply:]
-[NWSPDYSession clientPingTimeout]
-[NWSPDYSession handleIdleEvent]
-[NWSPDYSession idleValue]
字符串: spdy-keeplivedPING 帧 + 空闲探活 + 客户端超时,是长连接协议的标准三件套。
SPDY 的 PING 帧类型(6)在这里被沿用。
3.6 错误信息里的实现线索
parse spdy protocol error
spdy internal error
spdy error
spdy ping callback error
no exists spdy session
url=%@,spdy=%@url=%@,spdy=%@ 这条尤其好 —— 说明协议选择是按 URL 打日志的,即"哪些 URL 走 SPDY、哪些不走",是运行时可观测的。排查抓包时会很有用。
3.7 性能工程的痕迹
-[NWSPDYRequest nalRequestCreate:context:]
-[NWSPDYRequest fixArena:]
-[NWSPDYRequest copyStringToBuffer:]
-[NWSPDYRequest prepareRequestHeaders:context:]
+[NWSPDYRequest readStreamRun:]
+[NWSPDYRequest readStreamThread]
-[NWSPDYSession dataCollection:]fixArena: + copyStringToBuffer: 是 arena 分配器的做法 ——
把一次请求的所有小对象塞进一块连续内存,减少 malloc 次数。
配合 + readStreamThread(独立读线程)、dataCollection:(统计采集),这是一套为高并发长连接做的工程实现,不是简单封装。
NetworkDemote 这个类的方法列表:
+[NetworkDemote shareInstance]0x103bd81f0 dispatch_once 单例
-[NetworkDemote isDemoteSPDY]0x103bd8678
-[NetworkDemote unDemoteSPDY]0x103bd8688
-[NetworkDemote networkDemoteSPDY]0x103bd86e4
-[NetworkDemote isHTTPSDemote]0x103bd8774
-[NetworkDemote httpsDemote]0x103bd877c
-[NetworkDemote unHTTPSDemote]0x103bd8788
-[NetworkDemote isWhiteHost:]0x103bd8454
-[NetworkDemote isBlackHost:]0x103bd8530
-[NetworkDemote canInitWithRequest]0x103bd87fc名字已经把用途写脸上了。注意 canInitWithRequest / isWhiteHost: ——
这些是 NSURLProtocol 的方法名,说明这个类可能同时是个 NSURLProtocol 子类,只是降级相关的接口被切出来当开关用。
4.1 读实现,拿 ivar 偏移
直接 decompile 最省事,但要拿准确偏移还得看反汇编:
-[NetworkDemote isDemoteSPDY]:
103bd8678LDRW8, [X0,#0xC] ; demote2Http @ +0xC, int
103bd867cCMPW8, #0
103bd8680CSETW0, NE
103bd8684RET和 - init 对得上:
*((_DWORD *)v2 + 3) = 0; // +0xC demote2Http
v2[16] = 0; // +0x10 httpsDemote顺带一个好玩的发现 —— 有几个方法是空壳:
-[NetworkDemote demote2HTTP] { ; } // 空函数体
-[NetworkDemote isDemote2HTTP] { return0; }
-[NetworkDemote alwaysSpdyDemote] { ; }
-[NetworkDemote unAlwaysSpdyDemote] { ; }别被方法名骗了。 真正生效的只有 demote2Http 和 httpsDemote 两个 flag。
命名残留是逆向里最常见的陷阱之一。
4.2 从 selref 反查调用点
知道有 isDemoteSPDY 还不够,要知道谁在读它。方法是先找selRef_isDemoteSPDY 符号,再顺着 data 引用找到代码:
selRef_isDemoteSPDY @ 0x106c35900
└─ data 引用 @ 0x1053f6d68
└─ lookup_funcs("0x1053f6d68")
└─ 落在 -[NWURLConnection dispatch:] @ 0x103c6a88c这一步很关键 —— 它把"有个开关"变成了"这个开关控制什么"。
4.3 读决策逻辑
-[NWURLConnection dispatch:] 的反编译结果(精简):
if (option == 0) goto 用_NWHttpConnection;
if (option == 1) goto 用_NWTnetConnection;
if (option.connectionType == 2) goto 用_NWTnetConnection;
// ★★★ 降级判断,0x103c6a940 ★★★
if ([NetworkDemote shareInstance].isDemoteSPDY)
goto 用_NWHttpConnection;
if (originRequest[@"networkRedirectCount"]) goto 用_NWHttpConnection;
if (currentThread.name == "WebThread") goto 用_NWHttpConnection;
if ([session hasValidSession:withScheme:]) goto 用_NWTnetConnection;
if (![self hasTnetPolicy:withScheme:]) goto 用_NWHttpConnection;
// 落到这里才是 NWSPDY 长连接整个架构一下就清楚了:
NWTnetConnection= TNET 长连接(§3 那套自研栈),代理看不见
NWHttpConnection= 标准
NSURLSession,走 CFNetwork,系统代理可见isDemoteSPDY返回 YES 时,请求被从前者路由到后者
而且注意判断顺序:isDemoteSPDY 排在 hasValidSession / hasTnetPolicy
之前。也就是说这个开关优先级很高。
到这里很多人会直接开始 hook isDemoteSPDY 让它恒返回 YES。我也这么干了,
结果把 App 搞断网了。
5.1 踩坑记录
hook 全部安装成功:
[demote] ✓ NetworkDemote.isDemoteSPDY
[demote] demote2Http @+0xc = 1
[demote] demote2Http=1 httpsDemote=0 isDemoteSPDY=true一切看起来都对。但业务请求一条都没发出去 —— 我在NWHttpConnection / NWTnetConnection 的 initWithRequest: 上都挂了日志,
一次都没触发。App 界面能开,但没有任何网络请求。
原因:在 App 自己的状态机跑起来之前(t=0),就把它的决策依据改了。
SDK 引导阶段的请求也被强行降级,引导链路直接崩。
这是个很典型的教训:hook 一个"配置读取函数"和 hook 一个"运行时状态函数"
是两回事。 前者要在正确的时机介入,后者才适合无条件强制。
5.2 正确的位置:App 自己就有这个开关
继续挖源码路径里的 MtopCore,在+[DMMtopSDKInitializer initMTopSDK] @ 0x1016dd384 里找到:
v15 = [NSUserDefaults standardUserDefaults];
v16 = [v15 valueForKey:@"dm_openMtopDemote"];
v17 = [v16 boolValue];
if (v17) {
v19 = [[NSUserDefaults standardUserDefaults] valueForKey:@"dm_mtopDemoteType"];
v20 = [v19 intValue];
if (v20 == 0) v21 = &selRef_httpsDemote;
elseif (v20 == 1) v21 = &selRef_dnsDemote;
elsegoto 跳过; // type >= 2 什么都不做
v22 = [NetworkDemote shareInstance];
[v22 networkDemoteSPDY]; // ★ 和上面硬调的是同一句
v23 = [NetworkDemote shareInstance];
[v23 performSelector:*v21];
}App 自己会调networkDemoteSPDY只是被 NSUserDefaults 里的开关挡住了。
这是给内部调试用的代码路径。
两个 key 的语义:

用 Frida 在 initMTopSDK 的 onEnter 上写入这两个 key,时机无竞态 ——
比轮询重试去抢可靠得多:
Interceptor.attach(ObjC.classes.DMMtopSDKInitializer['+ initMTopSDK'].implementation, {
onEnter() {
const d = ObjC.classes.NSUserDefaults.standardUserDefaults();
d.setBool_forKey_(1, 'dm_openMtopDemote');
d.setInteger_forKey_(1, 'dm_mtopDemoteType');
}
});注意 setBool:forKey: 是持久化的,会落到 App 的 plist。
调试完记得 removeObjectForKey: 清掉,否则不挂脚本也一直生效。
光看 hook 装上了不算数,要看到路由结果。
观测点选在-[NWURLConnection startWithOption:] —— 用 xrefs 确认它是 dispatch: 的唯一调用者,所以它是这套栈的必经入口:
xrefs_to("0x103c6a88c")
from0x103c6a0a0 code -[NWURLConnection startWithOption:]
from0x1053f59f4 data None在这里读 option.connectionType / httpFrameworkType 和当前 isDemoteSPDY
的值,再在各 connection 类的 initWithRequest: 上打路由日志:
[demote] 路由 [HTTP(可抓)] mtop.domains.tld/app/...
[demote] 路由 [TNET(抓不到)] guide.domains.tld/..."startWithOption 一次都没打" 本身就是结论 —— 说明业务请求压根没走这套栈,要继续往别处找,而不是继续加大 hook 力度。
挖的过程中撞见两个东西,和降级无关但值得记:
一、ACCS 长连接可以走 HTTP/3
+[NWNetworkConfiguration setAccsH3Hosts:debugHost:dailyHost:]
-[NWNetworkConfiguration disableH3:withDomain:]0x103c0fdfc ← 空函数体disableH3: 是空壳,关不掉。配合 §2.2 里搜到的 xqc_*(XQUIC),
以及 -[NWQUICDetector loadSerialized:currentNetpath:],说明 QUIC 支持是完整的。
ACCS 是推送/心跳通道,不走业务请求,但如果发现后台流量缺失,源头在这里。
二、SDK 自己维护了一套 SSL 公钥校验
-[NWNetworkConfiguration setHost:withSlightSslPubkeySeq:]
-[NWNetworkConfiguration slightSslPubkeySeqWithHost:]
-[NWNetworkConfiguration setIsUseSecurityGuard:]
-[NWSPDYSession setSocketPublickey:isPlainMode:forSocket:]这解释了为什么常规的 X509_* hook 一直挂不上 —— 它的校验不走 OpenSSL
的 X509 API,是 socket 层自己的公钥机制(和 §3.3 的 setSocketPublickey:
是同一套)。这类自研校验只能具体分析。
以详情接口为例,返回体里有一堆看起来像"开关"的字段:
skuSalable "false" 该档是否可售
salableQuantity "0" 剩余张数
frontEndStatus "2" 前端状态(1=可售 2=缺货)
performSalable "false" 场次是否可售
itemBuyBtn.btnStatus "91" 整页按钮状态本文仅讨论客户端网络栈的静态分析方法与公开 API 行为,不涉及任何绕过平台
业务控制的内容。
文中所有地址均来自对本地样本的静态分析。

看雪ID:Ann945
*本文为看雪论坛精华文章,由 Ann945原创,转载请注明来自看雪社区


球分享

球点赞

球在看

点击阅读原文查看更多
本文转载自微信公众号「看雪学院」,查看原文。版权归原作者所有,如有侵权请联系删除。

