返回列表

2026年8月13日 · 约 16 分钟读完

从抓包到 Dart AOT:我如何拆解一个 Flutter iOS App 的 A/B 分流

记录我怎样从一个可疑的页面差异出发,通过 USB 抓包、配置还原和 Dart AOT 分析,一步步找到 Flutter App 的 A/B 分流逻辑。

一开始,我只是觉得它有点不对劲

最近我在测试一个 Flutter iOS App 时,发现同一个版本换了用户以后,看到的内容和新用户权益并不完全一样。再结合它启动时会拉取远程配置这件事,我开始觉得,这可能不只是普通的个性化推荐。

我当时想到了一种可能:App 在审核期间先展示相对安全、功能有限的一面,等审核通过后,再由服务端让普通用户进入另一面,看到审核时没有出现的敏感内容,甚至是疑似色情或其他不符合商店政策的内容。

当然,这时候还只是怀疑,远远谈不上结论。我需要先弄清几件更具体的事:客户端里到底有没有分流入口?它会看哪些信息?算出来的结果又是怎样发给服务端的?

这类问题特别容易被表面现象带偏。两个用户余额不同、一个请求走了代理、设备语言或时区不一样,看起来都很可疑。但只要没有控制变量,它们就只能算线索,不能当成答案。

我最后把问题拆成了四层:

网络是否可观测
  → 请求到底发送了什么
  → 客户端怎样计算分流参数
  → 服务端是否还保存了独立的用户状态

下面记录的是我这次排查的过程。所有测试都在我控制的设备和样本上完成。文中不会放动态凭据、真实设备身份和可以直接复现生产请求的细节,也不会讨论怎样绕过服务端控制。

为什么我会继续追下去

远程配置、feature flag 和灰度发布本身都很常见。我们平时会拿它们逐步开放新功能、按地区调整业务,或者在出问题时快速关掉某个模块。单看“动态下发”这件事,并没有什么不正常。

真正值得追的是:它会不会让审核人员和普通用户看到两套没有公开说明的核心体验。

我当时脑子里的可能流程,大概是这样:

同一份 IPA 在审核环境和普通用户环境下可能展示两种体验

Apple 的 App Review Guidelines 对这件事说得很清楚:App 不应该藏着没有向审核说明的功能,重要功能和产品变化都应该让审核人员看到;露骨色情内容也属于明确不接受的内容。指南还提醒,试图欺骗审核流程,可能导致 App 下架,甚至失去开发者资格。

不过,能远程切页面,不等于它就在规避审核。绝大多数使用远程配置的 App 都是在做正常业务。真要把怀疑坐实,还得证明分流确实和审核环境有关,而且另一面真的出现了审核时没有披露的违规内容。

先说清楚,什么才算证据

为了不让几个可疑字段把自己带跑偏,我先把不同线索能说明什么列了出来:

看到什么可以说明什么还不能说明什么
存在远程开关和两条页面路径App 具备动态切换能力开关一定用于规避审核
不同身份得到不同配置或内容服务端确实执行用户分流分流依据就是 App Store 审核
审核相关环境稳定进入安全面,普通环境进入敏感面与审核规避假设高度一致内容是否违法仍需结合地区法律和实际样本判断

如果要把这件事说明白,我认为至少要串起下面几步:

  1. 同一个版本确实能看到两套不同的内容;
  2. 客户端里能找到明确的分流变量和调用关系;
  3. 远程配置或用户接口能够改变这个变量;
  4. 控制语言、时区、网络和新旧身份后,差异仍能稳定复现;
  5. 敏感内容确实来自另一条分流,而不是用户主动上传、缓存污染或偶发推荐;
  6. 请求和响应的时间线能够说明,切换确实发生在审核前后。

这也决定了我后面的做法:先不改判断条件,也不主动制造另一面的结果,而是尽量把 App 原本怎么判断、服务端原本怎么响应记录下来。相比“我能不能把它改出来”,原始行为更能说明问题。

先把 Flutter 的流量抓出来

我没有一上来就反编译,第一步是先让网络流量变得可见。

普通 iOS App 大多使用系统网络栈,设好 HTTP 代理以后,Proxyman、Charles 一般就能看到流量。但 Flutter App 可能通过 Dart 自己的网络实现建立连接,不一定理会系统代理。实际现象就是:其他 App 都抓得到,目标 App 却像根本没联网。

测试设备通过线缆连接电脑,所以我把抓包链路设计成下面这样:

Flutter iOS App 通过 Frida 和 USB 端口转发进入 Proxyman 的抓包链路

这里真正重要的不是工具名,而是把每一层都确认一遍:

  1. App 是否真的解析了目标域名;
  2. socket 最终连向哪个 IP 和端口;
  3. USB 转发端口是否可达;
  4. TLS 信任处理后,请求能否完整到达抓包工具;
  5. 改变网络路径以后,业务返回是否发生变化。

链路跑通以后,我终于可以稳定看到目标域名的 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 和时间戳去掉以后,两个身份拿到的远程配置完全一致。

从这几组结果里,至少可以先得到四点:

  1. 当时的远程配置接口没有针对这两个身份下发不同配置;
  2. 语言、时区和出口 IP 不能单独解释观察到的差异;
  3. 余额或新用户奖励的差别更像账户历史,不能直接当成审核标志;
  4. 已存在的 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 分流,我大概还会按这个顺序来:

  1. 先解决网络可见性,确认 App 是否绕过系统代理;
  2. 记录完整启动调用顺序,而不是只截取某一个接口;
  3. 把外层响应、编码内容和字符串形式的嵌套 JSON 分开处理;
  4. 针对每个可疑因素逐项做对照;
  5. 对比结果时先剔除 token、key、时间戳等动态字段;
  6. 从请求中的分流字段反向追踪生成函数;
  7. 用 Dart 字符串池、函数名和 ARM64 分支交叉验证;
  8. 分清哪些已经确认、哪些只是推断、哪些在服务端暂时看不到;
  9. 只做观察性验证,不把修改身份或绕过控制当成分析结论。

工具用多少其实没那么重要。更重要的是,每一个结论都能说清楚:它从哪里来,怎样复现,还有没有别的解释。

最后回头看

最开始,我只想知道这个 App 会不会通过动态下发区分审核用户,以及判断代码藏在哪里。查完以后,我发现答案并不是某一个函数地址就能概括的。

客户端确实有 A/B 分流。关键不在页面代码,而在启动时的配置解析、variantLevel 和 variantType。服务端还可以按身份保留历史状态,再通过 syncFlag 影响客户端下一次请求。两边加在一起,确实足以支撑“审核时展示安全面、审核后切到普通面”这样的动态变化。

但“足以做到”和“已经这么做了”之间,仍然差着关键证据。目前能确认的是分流机制和远程控制方式;还不能确认的是,服务端到底怎样识别 App Store 审核身份,以及另一面的内容是否真的会在审核前后稳定切换。

这也是这次分析给我最深的提醒:看到客户端代码,不等于看到了整个系统;抓到服务端响应,也不等于知道这个状态最初是怎么来的。

真正站得住的结论,不是“我找到了一个很可疑的字段”,而是静态代码、动态流量和对照实验都指向了同一条调用链。至于还不知道的部分,就先老老实实留在那里。