iOS 自研网络栈逆向分析:从 TNET/SPDY 到应用层协议降级

· 2026-10-03 17:59 · 38 阅读

Ann945 2026-10-03 17:59 上海

看雪论坛作者ID:Ann945

工具:IDA Pro 9.0 + ida-pro-mcp、Frida

00

TL;DR

Tips:iOS 应用 ≠ 都用系统 TLS 栈。 国内大厂的自研网络库(阿里 EMAS、
字节 TTNet 这类)是整套静态编译进主二进制的。

01

起点:一个错误的假设

问题描述很朴素:应用流量抓不到。

第一反应是"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
_CFNetworkHTTPConnectionCacheSetUseHTTP2


02

方法:三步确认一个陌生 App 的网络栈

2.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_ShortVideo

Flutter 混合工程。这一点很重要 —— 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

信息量极大:

  1. 源码路径泄漏

    /Users/yuanzhan/.emas/build/56857276/workspace/NetworkCore/
    —— EMAS 是阿里移动端中台,NetworkCore 是它的网络核心库。
    编译期没 strip 掉 __FILE__,这类字符串在国内 App 里极其常见,
    是最好的路标。

  2. 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"}])

这两条分别捞出了协议栈本身和降级开关。下面两节分别展开。

03

深入:NWSPDY 到底是什么协议

既然命名里带着 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.m

3.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_tnetRequest

primary / 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-keeplived

PING 帧 + 空闲探活 + 客户端超时,是长连接协议的标准三件套。

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:(统计采集),这是一套为高并发长连接做的工程实现,不是简单封装。

04

关键发现:降级开关在应用层

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
之前。也就是说这个开关优先级很高。

05

应用自带的开关:比硬改 flag 优雅得多

到这里很多人会直接开始 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: 清掉,否则不挂脚本也一直生效。

06

最后一步:验证降级是否生效

光看 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 力度。

07

附带产出:两条容易被忽略的链路

挖的过程中撞见两个东西,和降级无关但值得记:

一、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 行为,不涉及任何绕过平台
业务控制的内容。

文中所有地址均来自对本地样本的静态分析。

图片描述

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

火热售票中!1.25折门票即将售罄

图片

球分享

球点赞

球在看

点击阅读原文查看更多

阅读原文

跳转微信打开

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