~/posts/mobile/flutter-ios-routing-tls-analysis.md

iOS Flutter:分开验证流量路由与 TLS

以 2020 年 ARM64 Flutter 样本为例,把 VPN 路由、透明代理、Mach-O 地址换算和证书校验语义分层验证;通过候选常量、32 字节函数前缀及双端请求记录说明每一步的证据边界。

date[31:24]
read[23:16]
7 分钟
cat[15:8]
移动端
目录
  1. 0x00先建立 HTTP 基线
  2. 0x01重定向与透明解析是两件事
  3. 0x02从 IPA 确认映像和坐标系
  4. 0x03常量只负责产生候选
  5. 0x04前缀匹配需要区间与唯一性
  6. 0x05用代理与设备两端闭合证据
  7. 0x06参考资料

代理里没有请求,首先是路由问题;连接已经到达但 HTTPS 失败,才需要继续追查 TLS 决策。这个 2020 年 iOS 测试应用用 Dart HttpClient 分别发送 HTTP 与 HTTPS 请求,适合把两道边界拆开验证。

分析针对随包携带的 ARM64 Flutter 引擎,不代表所有 Flutter 版本或插件都采用相同网络栈。请求中出现 Dart/2.7 (dart:io),但仅凭这一字段仍不足以确定完整引擎提交和构建参数。

先建立 HTTP 基线

最小证据链包含设备、网关和代理三个观察点。应用显示成功,只能说明它取得了某个响应;要证明请求走过指定路径,还需在入口连接与代理记录中找到对应操作。

分层验证矩阵4 行
观察点 需要看到什么 缺失时优先检查
网关入口 来自测试设备的连接 Wi-Fi、VPN、接口、路由和 DNS
HTTP 代理 主机、路径与响应状态 重定向、监听地址、透明解析
TLS 握手 连接到达并开始握手 目的地址恢复、SNI、服务端可达性
应用结果 响应与代理记录一致 证书、主机名及业务层检查

本例可通过分析机热点或 OpenVPN 把设备流量交给 Linux 网关。目的不是再填一次系统 HTTP 代理,而是让网关确实处于转发路径。Dart 的 findProxy 是独立代理选择接口,官方说明其未设置时使用直接连接;平台通道与其他客户端仍需分别检查。Dart HttpClient.findProxy

VPN 导入与连通性检查4 步
  1. 1

    准备专用配置

    只传递本次测试所需的配置文件,通过受控方式交给设备。不要将整个主目录作为 HTTP 文件目录,避免顺带暴露 shell 历史、密钥目录或其他文件。

  2. 2

    交给客户端

    打开配置文件,选择 OpenVPN。历史界面显示文件导入页,再通过 ADD 完成导入;实际界面名称随客户端版本变化。

  3. 3

    连接隧道

    确认配置已导入,并进入 CONNECTED 状态。连接计数变化证明隧道有活动,不直接证明某条业务请求已进入代理。

  4. 4

    先发送 HTTP

    触发 HTTP Request,在设备、网关和代理三处关联同一次请求,再进入 HTTPS 检查。

重定向与透明解析是两件事

网关示例将专用入口上的 TCP 80/443 送到本地 8080 监听器。tun0、wlan0、eth0 是环境相关的接口名;转发、NAT、防火墙后端和路由应按实际拓扑确认。只匹配测试设备或专用入口,避免把代理自身的出站请求再次送回代理。

代理设置里的关键状态可以归纳为下表。192.0.2.1 仅为说明地址,不是部署值。

透明代理监听配置5 行
设置 说明值或状态
Bind to port 8080
Bind to address 指定网关入口地址,例如 192.0.2.1
Support invisible proxying 开启
Force use of TLS 未勾选
Redirect to host / port 本例界面留空

普通显式代理期待带完整 URL 的 HTTP 请求,HTTPS 通常先用 CONNECT 指明目的地;透明重定向过来的客户端可能直接发送路径形式请求或 TLS 握手。Burp 的 invisible proxying 处理这类输入,HTTP 目的主机依赖 Host,握手阶段证书选择还会受到 SNI 影响。缺少相关信息时应明确配置目标,而不是假定 NAT 已替代理解决全部语义。PortSwigger:Invisible proxying

从 IPA 确认映像和坐标系

样本使用的框架路径是:

IPA 中的 Flutter 映像
  • Payload/
    • Runner.app/
      • Frameworks/
        • Flutter.framework/
          • Flutter
4 个目录,1 个文件

样本包含 ARMv7 与 ARM64 切片,这里分析后者。先确认实际加载的切片,以及代码是否处于可分析状态;若加密代码尚未恢复成可信指令,特征搜索的命中没有足够解释力。

文件偏移、映像虚拟地址、运行时地址是三个坐标系。对文件支持的某个 segment,换算关系是:

单个 segment 内的换算关系text
image_va = segment_vmaddr + file_offset - segment_fileoff
runtime_va = image_va + slide
segment_fileoff <= file_offset < segment_fileoff + segment_filesize

vmaddr、fileoff 和 filesize 的角色可与 Apple 的 Mach-O 结构定义核对。零填充区没有一一对应的文件字节;通用规则不是“把任意文件偏移直接加到模块基址”。Apple loader.h

常量只负责产生候选

对 0x186 的标量搜索出现两个候选位置,十进制值均为 390:

ARM64 静态搜索结果2 行
位置 指令 所在函数标签
0x0007c320 mov w3, #0x186 FUN_0007c178
0x00406a08 mov w3, #0x186 FUN_004068c8

这些是分析数据库中的地址,不是设备运行时地址。第二个候选的上下文出现 ssl_x509.cc、ssl_server 与 ssl_client 字符串,并含证书处理分支。把常量、字符串引用和调用关系放在一起,才支持将其识别为证书链校验外层决策函数。

FUN_004068c8 的 32 字节序言
0x004068c8ff 03 05 d1subsp, sp, #0x140
0x004068ccfc 6f 0f a9stpx28, x27, [sp, #0xf0]
0x004068d0f8 5f 10 a9stpx24, x23, [sp, #0x100]
0x004068d4f6 57 11 a9stpx22, x21, [sp, #0x110]
0x004068d8f4 4f 12 a9stpx20, x19, [sp, #0x120]
0x004068dcfd 7b 13 a9stpx29, x30, [sp, #0x130]
0x004068e0fd c3 04 91addx29, sp, #0x130
0x004068e408 0a 80 52movw8, #0x50

该函数的可见控制流最终返回 uVar5:失败路径置为 0,成功路径置为 1。但其他 BoringSSL 接口可能返回枚举;例如 ssl_verify_result_t 的首项 ssl_verify_ok 对应 0。所以“把校验返回值统一改成 1”并不是跨函数的正确规则。BoringSSL ssl.h

前缀匹配需要区间与唯一性

五个 Flutter 映像的对照结果为四个命中、一个未命中;部分命中地址相同,部分已经移动。完整版本与映像摘要未给出,这组结果只支持有限构建间的复用观察,不支持稳定跨版本匹配。

缩短前缀可能增加召回,也会增加误命中。静态候选应依次满足:位于正确映像的可执行区域、确为函数入口或已理解的调用点、出现在当前 HTTPS 路径上、返回约定已经确认。唯一命中只是其中一项。

下面的离线检查覆盖 segment 范围、空特征、零命中、多命中和 32 字节长度。地址换算使用示例数值;它不是完整 Mach-O 搜索器,也没有连接设备。

flutter_ios_models.pypython
def fileoff_to_runtime(fileoff, segment_fileoff, segment_filesize,
                       segment_vmaddr, slide):
    if not segment_fileoff <= fileoff < segment_fileoff + segment_filesize:
        raise ValueError("offset outside file-backed segment")
    return segment_vmaddr + fileoff - segment_fileoff + slide

def unique_signature_offset(blob, signature):
    if not signature:
        raise ValueError("empty signature")
    hits = [i for i in range(len(blob) - len(signature) + 1)
            if blob[i:i + len(signature)] == signature]
    if len(hits) != 1:
        raise ValueError(f"expected one match, got {len(hits)}")
    return hits[0]

assert fileoff_to_runtime(0x2340, 0x2000, 0x1000,
                          0x100004000, 0x200000) == 0x100204340
for offset in (0x1fff, 0x3000):
    try:
        fileoff_to_runtime(offset, 0x2000, 0x1000, 0x100004000, 0)
    except ValueError:
        pass
    else:
        raise AssertionError("out-of-range offset accepted")
assert unique_signature_offset(b"ABCDWXYZ", b"WXYZ") == 4
for blob, sig in ((b"ABAB", b"AB"), (b"AB", b"X"), (b"AB", b"")):
    try:
        unique_signature_offset(blob, sig)
    except ValueError:
        pass
    else:
        raise AssertionError("invalid signature match accepted")
prefix = bytes.fromhex(
    "ff 03 05 d1 fc 6f 0f a9 f8 5f 10 a9 f6 57 11 a9 "
    "f4 4f 12 a9 fd 7b 13 a9 fd c3 04 91 08 0a 80 52")
assert len(prefix) == 32
assert 0x004068c8 + len(prefix) == 0x004068e8
print("PASS: segment bounds; unique/zero/multiple/empty matches; 32-byte prefix")
离线检查结果
$ python flutter_ios_models.py
PASS: segment bounds; unique/zero/multiple/empty matches; 32-byte prefix

在函数返回处调整结果会保留函数主体的副作用,这与直接替换整个函数不同。即使外层返回成功,内部会话、错误状态或后续校验仍可能不一致。动态验证需要继续追到应用实际收到响应,而不是以 Hook 被调用作为完成标志。

用代理与设备两端闭合证据

代理记录来自 Burp Suite Community Edition 2020.2.1。两条请求均为 GET,路径为 /,状态码为 200;以下主机使用说明域名,长度保留观察值,表示代理列表中的 Length 列而非臆定的正文长度。

请求列表节选2 行
协议 主机 方法与路径 状态码 Length MIME
HTTP example.com GET / 200 1833 HTML
HTTPS secure.example.com GET / 200 43137 HTML

HTTPS 请求的可见头部如下,Host 已替换,未展示的响应内容不作补齐:

请求
GET / HTTP/1.1
user-agent: Dart/2.7 (dart:io)
Accept-Encoding: gzip, deflate
content-length: 0
host: secure.example.com
Connection: close

界面示意图:HTTP 与 HTTPS 请求按钮及各自成功状态

界面示意图:并列展示 HTTP 基线和 HTTPS 检查的状态,不表示两次操作发生在同一时刻。

设备界面分别显示 HTTP 与 HTTPS 成功,代理侧同时具备可读请求记录。这支持对本例路由与证书决策两道边界的解释,但不是系统信任库设置成功的证明,也不证明业务层二次签名、固定证书或响应校验已被一并处理。

复核新样本时,应重新记录引擎版本、架构、映像摘要、候选位置、返回语义和前后请求状态。可复用的是验证顺序,不是这个构建的绝对地址和函数前缀。

参考资料

NORMAL~/posts/mobile/flutter-ios-routing-tls-analysis.md§--
0%zh-CN