2026年8月13日 · 约 16 分钟读完
从抓包到 Dart AOT:我如何拆解一个 Flutter iOS App 的 A/B 分流
记录我怎样从一个可疑的页面差异出发,通过 USB 抓包、配置还原和 Dart AOT 分析,一步步找到 Flutter App 的 A/B 分流逻辑。
一开始,我只是觉得它有点不对劲
最近我在测试一个 Flutter iOS App 时,发现同一个版本换了用户以后,看到的内容和新用户权益并不完全一样。再结合它启动时会拉取远程配置这件事,我开始觉得,这可能不只是普通的个性化推荐。
我当时想到了一种可能:App 在审核期间先展示相对安全、功能有限的一面,等审核通过后,再由服务端让普通用户进入另一面,看到审核时没有出现的敏感内容,甚至是疑似色情或其他不符合商店政策的内容。
当然,这时候还只是怀疑,远远谈不上结论。我需要先弄清几件更具体的事:客户端里到底有没有分流入口?它会看哪些信息?算出来的结果又是怎样发给服务端的?
这类问题特别容易被表面现象带偏。两个用户余额不同、一个请求走了代理、设备语言或时区不一样,看起来都很可疑。但只要没有控制变量,它们就只能算线索,不能当成答案。
我最后把问题拆成了四层:
网络是否可观测
→ 请求到底发送了什么
→ 客户端怎样计算分流参数
→ 服务端是否还保存了独立的用户状态
下面记录的是我这次排查的过程。所有测试都在我控制的设备和样本上完成。文中不会放动态凭据、真实设备身份和可以直接复现生产请求的细节,也不会讨论怎样绕过服务端控制。
为什么我会继续追下去
远程配置、feature flag 和灰度发布本身都很常见。我们平时会拿它们逐步开放新功能、按地区调整业务,或者在出问题时快速关掉某个模块。单看“动态下发”这件事,并没有什么不正常。
真正值得追的是:它会不会让审核人员和普通用户看到两套没有公开说明的核心体验。
我当时脑子里的可能流程,大概是这样:
Apple 的 App Review Guidelines 对这件事说得很清楚:App 不应该藏着没有向审核说明的功能,重要功能和产品变化都应该让审核人员看到;露骨色情内容也属于明确不接受的内容。指南还提醒,试图欺骗审核流程,可能导致 App 下架,甚至失去开发者资格。
不过,能远程切页面,不等于它就在规避审核。绝大多数使用远程配置的 App 都是在做正常业务。真要把怀疑坐实,还得证明分流确实和审核环境有关,而且另一面真的出现了审核时没有披露的违规内容。
先说清楚,什么才算证据
为了不让几个可疑字段把自己带跑偏,我先把不同线索能说明什么列了出来:
| 看到什么 | 可以说明什么 | 还不能说明什么 |
|---|---|---|
| 存在远程开关和两条页面路径 | App 具备动态切换能力 | 开关一定用于规避审核 |
| 不同身份得到不同配置或内容 | 服务端确实执行用户分流 | 分流依据就是 App Store 审核 |
| 审核相关环境稳定进入安全面,普通环境进入敏感面 | 与审核规避假设高度一致 | 内容是否违法仍需结合地区法律和实际样本判断 |
如果要把这件事说明白,我认为至少要串起下面几步:
- 同一个版本确实能看到两套不同的内容;
- 客户端里能找到明确的分流变量和调用关系;
- 远程配置或用户接口能够改变这个变量;
- 控制语言、时区、网络和新旧身份后,差异仍能稳定复现;
- 敏感内容确实来自另一条分流,而不是用户主动上传、缓存污染或偶发推荐;
- 请求和响应的时间线能够说明,切换确实发生在审核前后。
这也决定了我后面的做法:先不改判断条件,也不主动制造另一面的结果,而是尽量把 App 原本怎么判断、服务端原本怎么响应记录下来。相比“我能不能把它改出来”,原始行为更能说明问题。
先把 Flutter 的流量抓出来
我没有一上来就反编译,第一步是先让网络流量变得可见。
普通 iOS App 大多使用系统网络栈,设好 HTTP 代理以后,Proxyman、Charles 一般就能看到流量。但 Flutter App 可能通过 Dart 自己的网络实现建立连接,不一定理会系统代理。实际现象就是:其他 App 都抓得到,目标 App 却像根本没联网。
测试设备通过线缆连接电脑,所以我把抓包链路设计成下面这样:
这里真正重要的不是工具名,而是把每一层都确认一遍:
- App 是否真的解析了目标域名;
- socket 最终连向哪个 IP 和端口;
- USB 转发端口是否可达;
- TLS 信任处理后,请求能否完整到达抓包工具;
- 改变网络路径以后,业务返回是否发生变化。
链路跑通以后,我终于可以稳定看到目标域名的 HTTPS 请求。后面的请求头、请求体、调用顺序和响应内容,也就不用再靠猜了。
请求头看着复杂,其实只是混淆
业务请求里带着一个自定义请求头。还原以后,里面其实就是一段 JSON。为了避免通过字段和值反向识别具体产品,下面统一用了意思相同的别名:
{
"locale": "en",
"channel": "Default",
"packageName": "SampleAppIOS",
"appId": "<device-scoped-id>",
"token": "<user-token>",
"deviceType": "2",
"version": "x.y.z",
"timeOffset": "0",
"useVpn": ""
}
它并没有用什么复杂的加密,流程很简单:
JSON 紧凑序列化
→ Base64
→ 在固定位置插入两段随机字符
解析时再把这两段随机字符删掉,然后做 Base64 解码。这种做法能让内容不至于直接出现在日志里,也能挡住最简单的字符串搜索,但并不能真正保密。
这也提醒了我:以后再看到一串不可读的字符,不要马上往 AES、RSA 上想。先看看长度、字符集、结尾填充和多次请求中的固定部分,通常更容易判断它到底是加密、编码,还是单纯做了点混淆。
远程配置里面还套了一层 JSON
App 启动后会先请求一份远程配置,外层响应大概长这样:
{
"message": "Operation success.",
"success": true,
"encryptStatus": 0,
"data": {
"content": "<obfuscated-base64>"
}
}
这里很容易看错。encryptStatus 虽然是 0,但 content 仍然不能直接读。它用了和请求头相似的插入式混淆:先删掉两段随机字符,再做 Base64 解码。第一次 jsonDecode 之后,里面的 customJson 还是字符串,所以还得再解析一次。
简化后的 Dart 表达如下:
Map<String, dynamic> decodeConfigContent(String content) {
final cleanBase64 =
content.substring(0, 10) +
content.substring(20, content.length - 20) +
content.substring(content.length - 10);
final decodedText = utf8.decode(base64Decode(cleanBase64));
final result = jsonDecode(decodedText) as Map<String, dynamic>;
result['customJson'] = jsonDecode(result['customJson'] as String);
return result;
}
当时我拿到的关键配置是:
{
"featureGateA": "0",
"controller": "sample_1",
"customJson": {
"variantOpenMode": 1,
"variantAppName": "sample",
"variantChannel": "Default",
"selfiePrice": 10,
"adWaitSeconds": 180,
"safetyThreshold": 0.5,
"rewardAdLimit": 3
}
}
完整结果里还有文件域名、服务端时间、分辨率列表、视频价格、裁剪尺寸、更新地址,以及一些暂时为空的 H5 和推送配置。token、key、时间戳每次都可能变化,这里没有保留,也不能把它们当成固定配置。
先排除几个最像答案的因素
抓到请求以后,我没有急着改客户端,而是先做了几组对照。
测试维度包括:
- 新旧
appId; - 语言和时区;
- 直连与不同出口网络;
- User-Agent 和普通 HTTP 请求头;
- 审核表现用户与普通表现用户请求远程配置的结果。
结果是:新生成的身份换了几组语言、时区和网络,拿到的新用户权益都一样;原来的旧身份也一直保留自己的历史状态。把动态 token、key 和时间戳去掉以后,两个身份拿到的远程配置完全一致。
从这几组结果里,至少可以先得到四点:
- 当时的远程配置接口没有针对这两个身份下发不同配置;
- 语言、时区和出口 IP 不能单独解释观察到的差异;
- 余额或新用户奖励的差别更像账户历史,不能直接当成审核标志;
- 已存在的
appId可能关联了服务端保存的历史状态。
但这不代表语言、IP 和设备身份永远没用。它只能说明,在这轮测试里,单独改变这些输入并没有改变结果。服务端仍然可能把它们和账户历史、设备记录或其他风控信号放在一起判断。
接下来才轮到 Dart AOT
抓包只能告诉我客户端发了什么,却解释不了这些参数是怎么算出来的。要继续往下追,就得回到 Flutter 的 AOT 产物。
这个样本使用了比较新的 Dart 版本,业务代码主要藏在 App.framework 的 Dart snapshot 里,不像普通 Objective-C/Swift 程序那样能直接看到一批清楚的符号。搜索 Mach-O 字符串可以找到接口路径和字段名,但很难知道它们是在哪里、按什么顺序被用到的。
我用 Blutter 解析 Dart snapshot,又针对 iOS Mach-O 的 snapshot 区段做了一点临时适配。完整分析因为这个样本使用无压缩指针,没有一次跑通;好在 --no-analysis 模式还是恢复出了不少类名、函数名、字符串池和相对地址。再把这些结果和 ARM64 反汇编放在一起看,调用关系就慢慢清楚了。
这一步真正有用的不是一份看起来很像源码的伪代码,而是三类可以互相对照的信息:
| 手里的信息 | 能回答什么 |
|---|---|
| Dart 字符串池 | 二进制里实际存在什么字段和接口名 |
| 恢复的类与函数名 | 某段代码属于配置、用户还是网络模块 |
| ARM64 调用与分支 | 字段以什么顺序读取,返回值怎样影响请求 |
沿着这三类信息继续追,我最后定位到了用户对象里的一个布尔 getter。为了匿名,下面把它叫作 variantLevel。
真正的分流入口是 variantLevel
这个 getter 的原始名字看起来像某种“等级”,实际返回的却是 true/false。它会读取下面几项:
localChannel 本地保存的渠道
variantChannel 远程配置要求的渠道
userChannel 当前用户渠道,缺省为 Default
rechargeStatus 用户是否充值
variantOpenMode 分流模式
hiddenGate 配置或服务端控制的隐藏开关
把汇编整理成容易读的 Dart,大致是这样:
bool calculateVariantLevel({
required String savedChannel,
required String typeChannel,
required String musicType,
required int rechargeStatus,
required int openStatus,
required bool hiddenGate,
}) {
final channelCondition =
!containsIgnoreCase(typeChannel, musicType) ||
!containsIgnoreCase(typeChannel, savedChannel) ||
rechargeStatus == 1;
switch (openStatus) {
case 0:
return channelCondition;
case 1:
return channelCondition && hiddenGate;
case 2:
return hiddenGate;
default:
return false;
}
}
这里的 containsIgnoreCase 是忽略大小写的包含判断,不是简单的字符串相等。这个细节看着小,实际会影响某些渠道值的结果。
配置解析阶段还会算出一个隐藏开关:
final hiddenGate =
featureGateA != '1' &&
appName == controller;
用户初始化请求里的分流参数,刚好和 variantLevel 反着来。下面把它叫作 variantType:
final variantType = variantLevel ? 0 : 1;
代入这次样本的配置:
openStatus = 1
featureGateA = 0
appName = sample
controller = sample_1
appName 和 controller 对不上,所以第一次解析配置时,hiddenGate 会是 false。而 openStatus=1 又要求渠道条件和隐藏开关同时成立,于是 variantLevel=false,最后发出去的 variantType 就是 1。
服务端还能让客户端重新算一次
继续看用户初始化的返回处理时,我又发现了一个挺关键的分支:如果服务端返回一个同步标志,客户端会把隐藏开关改成 true,然后重新请求一次用户接口。下面把这个标志叫作 syncFlag。
把前面的配置判断和这里的服务端反馈放在一起,完整流程会更清楚:
这也解释了为什么只看第一条请求不够。服务端不只是被动接收 variantType,它还可以借下一次响应让客户端改掉开关,再重新计算和请求一遍。
有些看起来很可疑的字段,其实没用在这里
用户接口返回了很多看起来很像“身份标签”的字段,比如账户类型、聊天状态、资料状态、余额和订阅状态。只看 JSON 的话,很容易挑其中一个,当成所谓的审核用户标记。
但从静态检索和用户绑定函数来看,客户端明确取出来保存的主要是:
认证令牌
昵称
头像
余额
AOT 字符串池里也没有 blacklist、isReview、reviewStatus 这类直接字段。用户类型、聊天状态和资料状态等返回值,同样没有进入 variantLevel。
“登录 IP 国家”也是一个很容易让人多想的字段。它确实在 App 里,但引用位置属于网页支付和地区选择,并不在首页分流或用户初始化这条链路上。所以至少在客户端这一侧,不能拿它来证明 App 会按 IP 国家切换 A/B 面。至于服务端会不会直接看请求来源 IP,单靠客户端二进制还回答不了。
到这里,能确定什么
查到这里,客户端这一侧已经比较清楚了。
我已经从客户端里确认:
- App 先解析远程配置,再进入用户登录或绑定;
variantLevel是本地 A/B 分流的核心布尔值;variantLevel由渠道、充值状态、远程模式和隐藏开关共同决定;variantType与variantLevel反向映射;syncFlag=1可以触发隐藏开关变化和自动重试;- 用户返回中的常见类型字段没有直接参与这段判断。
但下面这些问题,客户端里看不到答案:
- 服务端数据库怎样记录某个既有
appId; - 服务端是否把 IP、语言、时区、设备历史或风控结果组合使用;
- 相同
variantType下,服务端是否仍会按身份返回不同内容; - 账户历史状态何时以及由哪个后台任务写入。
所以,与其说“客户端在某一行把用户拉黑了”,更准确的说法是:
客户端通过
variantLevel产生一个分流提示variantType,服务端仍然拥有按身份保存状态和反向要求客户端重试的能力。特定旧身份长期停留在某一面,更可能是服务端状态,而不是客户端读取了一个显式黑名单字段。
这套机制能不能用来切换审核面
从结构上看,审核前后切换需要的几个零件,它基本都有了:
| 需要的部分 | 现在查清了吗 | 能做什么 |
|---|---|---|
| 启动时远程拉取配置 | 已确认 | 服务端可以改变客户端运行参数 |
| 客户端分流函数 | 已确认 | 把渠道、账户和隐藏开关合成一个结果 |
| 请求中的分流参数 | 已确认 | 让服务端知道客户端当前处于哪种状态 |
| 服务端反向同步标志 | 已确认 | 要求客户端改变开关并重新请求 |
| 按既有身份保留历史状态 | 与实验结果一致 | 使同一设备身份长期停留在某一面 |
| 自动识别 App Store 审核人员 | 尚未确认 | 需要更多审核期样本和服务端证据 |
| 普通面稳定下发违规内容 | 尚未形成完整证据链 | 需要保存内容接口响应和可重复时间线 |
这不是一个简单写死在页面里的 if。即使 App Store 审核前后安装的都是同一个 IPA,后端也可以靠远程配置、账户历史和同步标志,改变客户端最后去请求什么内容。整个过程不需要重新发版。
这也是我觉得它值得研究的地方。审核人员看到的,只是某个时间、某个网络、某个身份下的运行结果;服务端却可以在二进制完全不变的情况下,改变之后的体验。静态分析或许能看到两条路径都存在,却很难直接知道后端什么时候会打开哪一条。
但查到这里,仍然不能直接说“服务端认出了 Apple 审核人员”。两个测试身份拿到的远程配置完全一样;语言、时区和一次出口 IP 的变化,也没有单独改变新身份的结果。现在更像是服务端给旧身份保存了某种状态。这个状态最初来自审核 IP、设备特征、注册时间、人工标记,还是其他风控模型,客户端样本里看不到答案。
如果以后继续查,最值得做的不是再猜更多参数,而是把审核期的时间线保存下来:同一个身份、同一份二进制,在审核前、审核中和审核后分别拿到了什么配置、同步标志、分流参数和内容列表。只有这条时间线能够稳定复现,事情才能从“这套架构可以这么做”走到“它确实这么做了”。
中间也走了几段弯路
把余额差异当成审核差异
一个用户余额为零,另一个新用户有奖励,第一眼真的很像 A/B 面差异。但余额还会受到注册时间、活动、消费和账户历史影响。没有把内容列表、配置和请求参数一起对照,它最多只能算一条弱线索。
过早相信语言、时区和 IP
这些的确经常被用在审核和风控里,但“经常”不代表这个样本也一定这么做。还是得用全新身份逐项对照,才能知道某个变量有没有单独改变结果。
只做字符串搜索
字符串搜索很适合先画一张地图,却证明不了调用关系。看到“IP 国家”字段,不代表它参与了 A/B 判断;看到一个分流参数,也不知道它为什么会是 0 或 1。最后还是得把函数归属、对象池引用和分支指令放在一起看。
把去混淆叫作解密
请求头和配置 content 都只是 Base64 加固定位置的随机字符。口头上说“解密”很方便,但很容易让人误以为里面有真正的密码学算法,然后跑去找根本不存在的密钥。
下次再遇到,我会这样查
以后再碰到 Flutter App 的远程配置和 A/B 分流,我大概还会按这个顺序来:
- 先解决网络可见性,确认 App 是否绕过系统代理;
- 记录完整启动调用顺序,而不是只截取某一个接口;
- 把外层响应、编码内容和字符串形式的嵌套 JSON 分开处理;
- 针对每个可疑因素逐项做对照;
- 对比结果时先剔除 token、key、时间戳等动态字段;
- 从请求中的分流字段反向追踪生成函数;
- 用 Dart 字符串池、函数名和 ARM64 分支交叉验证;
- 分清哪些已经确认、哪些只是推断、哪些在服务端暂时看不到;
- 只做观察性验证,不把修改身份或绕过控制当成分析结论。
工具用多少其实没那么重要。更重要的是,每一个结论都能说清楚:它从哪里来,怎样复现,还有没有别的解释。
最后回头看
最开始,我只想知道这个 App 会不会通过动态下发区分审核用户,以及判断代码藏在哪里。查完以后,我发现答案并不是某一个函数地址就能概括的。
客户端确实有 A/B 分流。关键不在页面代码,而在启动时的配置解析、variantLevel 和 variantType。服务端还可以按身份保留历史状态,再通过 syncFlag 影响客户端下一次请求。两边加在一起,确实足以支撑“审核时展示安全面、审核后切到普通面”这样的动态变化。
但“足以做到”和“已经这么做了”之间,仍然差着关键证据。目前能确认的是分流机制和远程控制方式;还不能确认的是,服务端到底怎样识别 App Store 审核身份,以及另一面的内容是否真的会在审核前后稳定切换。
这也是这次分析给我最深的提醒:看到客户端代码,不等于看到了整个系统;抓到服务端响应,也不等于知道这个状态最初是怎么来的。
真正站得住的结论,不是“我找到了一个很可疑的字段”,而是静态代码、动态流量和对照实验都指向了同一条调用链。至于还不知道的部分,就先老老实实留在那里。