~/posts/mobile/flutter-armv7-proxy-tls-analysis.md

Flutter ARMv7 流量分析:路由与 TLS 分开看

以 2019 年 Android ARMv7 Flutter 构建为例,分开验证代理路由与 TLS 信任,通过握手错误、字符串交叉引用和 Thumb 入口定位证书链判断点,并说明返回值、副作用与插件层检查的边界。

date[31:24]
read[23:16]
8 分钟
cat[15:8]
移动端
目录
  1. 0x00先证明请求进入代理
  2. ·有源码:明确指定 findProxy
  3. ·无源码:检查转发模式
  4. 0x01沿握手错误确认 TLS 实现
  5. 0x02区分布尔值与验证枚举
  6. 0x03从字符串追到 Thumb 入口
  7. 0x04安装观察点前检查地址语义
  8. 0x05用请求记录闭合证据链
  9. 0x06证书固定还可能在插件层
  10. 0x07保留判断顺序,不依赖固定特征

Android 应用正常访问后端,代理里却没有请求,先别急着改证书校验。路由决定连接经过哪里,TLS 验证决定是否接受对端身份;两条链要分开证明。

2019 年的 Android ARMv7 Flutter 测试应用使用 dart:io,原生 TLS 路径位于 libflutter.so。Jeroen Beckers 的实验区分了代理选择和证书链判断这两个环节;以下分析限定于该构建,不将其信任库行为推广到后续所有 Flutter 版本。

Flutter 与 Burp 流量分析主题插画

主题插画,仅表达工具关系,不作为网络拓扑或实验结果。

先证明请求进入代理

测试应用由计数器示例修改而成:按钮回调通过 HttpClient.getUrl() 创建请求,再用 request.close() 发送。计数变化只说明回调运行,请求完成还要看响应或日志。先测试 HTTP,再切换到同一目标的 HTTPS,可以减少同时变化的条件。

界面示意图:计数为 0 的 Flutter App,以及代理开关已开启的 ProxyDroid 设置

界面示意图。左侧为计数 0 的测试 App 与加号按钮;右侧代理开关为 ON,Auto Setting 未勾选,端口为 8888,类型为 HTTP。192.0.2.10 为文档示例地址。

有源码:明确指定 findProxy

历史记录中,应用日志显示请求成功,Burp 却没有对应流量。该客户端没有设置 findProxy 时采用直连,Android Wi-Fi 代理设置不会自动成为这条 Dart 路径的策略。Dart 的 findProxy 文档也明确区分直连与返回 PROXY host:port 的配置。

显式代理配置(示例地址)dart
final client = HttpClient();
client.findProxy = (uri) => 'PROXY 192.0.2.10:8888';

HTTP 请求记录如下,主机已脱敏为 target.example。这里展示请求字段,不包含响应:

历史 HTTP 请求 · 主机已替换
请求
GET / HTTP/1.1
user-agent: Dart/2.4 (dart:io)
Accept-Encoding: gzip, deflate
content-length: 0
host: target.example
Connection: close

这个结果只证明代理选择已生效。findProxyFromEnvironment 则依赖应用进程实际持有的环境变量;在桌面终端设置 http_proxy,并不意味着 Android zygote 启动的应用继承了它。

无源码:检查转发模式

历史测试使用带 root 权限的 ProxyDroid,通过 iptables 调整连接路径。这里还要区分显式 HTTP 代理和透明转发:TCP 重定向本身不会补出 CONNECT 协商,代理监听模式、目的地址恢复及转发规则要彼此匹配。

到这里验证的是“按钮 → Dart 客户端 → TCP 路由 → 代理”。HTTPS 的证书链验证尚未进入结论。

沿握手错误确认 TLS 实现

把 URL 切换为 HTTPS 后,代理已观察到连接,握手却失败。历史设备安装了代理 CA,但该样本的 Dart/Flutter 构建使用独立根证书集合,并通过 BoringSSL 验证证书;系统 CA 的变化没有直接改变这条验证路径。

历史错误信息节选log
CERTIFICATE_VERIFY_FAILED: self signed certificate in certificate chain(handshake.cc:352)

handshake.cc 是定位线索,352 不是固定二进制偏移。沿源码追踪到 ssl_verify_peer_cert,关键失败分支先记录错误,再发送致命告警:

ssl_verify_peer_cert 的失败分支cpp
if (ret == ssl_verify_invalid) {
    OPENSSL_PUT_ERROR(SSL, SSL_R_CERTIFICATE_VERIFY_FAILED);
    ssl_send_alert(ssl, SSL3_AL_FATAL, alert);
}

如果等外层函数退出才覆盖返回值,先前发出的 fatal alert 仍然存在。改返回值不等于撤销函数副作用,因此 Hook 点应沿控制流继续向前找。

区分布尔值与验证枚举

外层函数把证书链判断的布尔值映射为验证枚举:

链验证结果到验证枚举的映射cpp
ret = ssl->ctx->x509_method->session_verify_cert_chain(
          hs->new_session.get(), hs, &alert)
          ? ssl_verify_ok
          : ssl_verify_invalid;
同一调用链中的两套返回约定2 行
层次 成功值 选择位置时要检查
外层验证结果 ssl_verify_ok = 0 返回之前是否已发送 fatal alert
session_verify_cert_chain true = 1 是否仍位于外层失败处理之前

样本选择内层函数,是因为该构建的这条失败路径主要记录错误,外层那次告警尚未发送。这里的“副作用较少”需要源码和样本一起支撑,不应扩展成所有同名函数都适合在退出时修改。

错误宏还提供了剥离符号后可用的定位锚点:

错误报告保留源文件名cpp
#define OPENSSL_PUT_ERROR(library, reason) \
  ERR_put_error(ERR_LIB_##library, 0, reason, __FILE__, __LINE__)

__FILE__ 让源文件名进入二进制。问题由“整个共享库哪里处理 TLS”缩小为“哪些代码引用了 ssl_x509.cc”。

从字符串追到 Thumb 入口

Ghidra 字符串搜索结果4 行
字段 可见内容
Filter x509.cc
Location 0x000815c2
Len 48
String View ../../third_party/boringssl/src/ssl/ssl_x509…

字符串视图截断了路径,省略号表示未显示部分,不是文件名内容。同一位置有 4 个交叉引用(XREF):

指向 0x000815c2 的交叉引用4 行
序号 引用地址
1 0x002fd9c6
2 0x0034b3ec
3 0x0034b546
4 0x0034b65a

4 个引用地址不等于 4 个独立函数。逐一对照所属函数的参数、分支和错误报告后,历史分析将候选定位为 FUN_0034b330。这是 Ghidra 生成的地址标签,不是稳定导出符号。

ARMv7 Thumb · FUN_0034b330 入口节选
0x0034b3302d e9 f0 4fpush{r4,r5,r6,r7,r8,r9,r10,r11,lr}
0x0034b334a3 b0subsp,#0x8c
0x0034b33682 46movr10,r0
0x0034b33850 20movr0,#0x50
0x0034b33a10 70strbr0,[r2,#0x0]
0x0034b33cda f8 98 70ldr.wr7,[r10,#0x98]
0x0034b34000 2fcmpr7,#0x0
0x0034b3424c d0beqLAB_0034b3de
0x0034b34438 68ldrr0,[r7,#0x0]
0x0034b34600 28cmpr0,#0x0

入口的前 12 字节可作为候选搜索特征:

历史样本的入口特征(12 字节)
0034B3302DE9F04FA3B0824650201070
  1. push0x34B330–0x34B333
  2. sub0x34B334–0x34B335
  3. mov0x34B336–0x34B337
  4. mov0x34B338–0x34B339
  5. strb0x34B33A–0x34B33B

唯一匹配只排除了本次扫描中的重名,不证明函数身份。 编译器优化、栈帧大小和寄存器分配都可能改变这段字节;身份仍要靠调用关系与语义核对。

安装观察点前检查地址语义

下面的示例包含候选扫描、ARM 架构检查和可执行映射检查。执行前应复核当前二进制的函数语义,并确认 libflutter.so 已实际加载;代码示例本身不代表一次设备验证结果。

历史 ARMv7 样本的 Hook 逻辑javascript
if (Process.arch !== 'arm') throw new Error('Expected 32-bit ARM');
const mod = Process.findModuleByName('libflutter.so');
if (mod === null) throw new Error('libflutter.so is not loaded');

const pattern = '2d e9 f0 4f a3 b0 82 46 50 20 10 70';
const matches = Memory.scanSync(mod.base, mod.size, pattern);
if (matches.length !== 1) {
  throw new Error('Expected exactly one reviewed candidate');
}

const candidate = matches[0].address;
const range = Process.findRangeByAddress(candidate);
if (range === null || !range.protection.includes('x')) {
  throw new Error('Candidate is not in executable memory');
}

const entry = candidate.or(1);
Interceptor.attach(entry, {
  onLeave(retval) {
    retval.replace(1);
  }
});
  1. 低位 1 表示 Thumb 状态,不是跳过入口首字节;这也符合 Frida 的 Interceptor 地址约定。
  2. 此处对应 session_verify_cert_chain 的布尔成功值,不是外层枚举的 ssl_verify_ok = 0。

同步扫描遇到不可读页会抛出异常,应检查实际映射,而不是把扫描异常解释成“没有目标”。固定延时一秒也不等于模块已加载;AArch64 构建则需重新定位并采用对应的指令集语义。

用请求记录闭合证据链

处理路由与上述判断点后,历史记录的 Burp 列表中出现 HTTPS 请求。其可见请求内容如下,主机仍使用同一占位值:

历史 HTTPS 请求 · 主机已替换
请求
GET / HTTP/1.1
user-agent: Dart/2.4 (dart:io)
Accept-Encoding: gzip, deflate
content-length: 0
host: target.example
Connection: close
HTTP 与 HTTPS 请求记录的结论范围2 行
观测阶段 列表中的传输协议 直接观察到的内容
HTTP 对照 http 代理收到 GET 请求
HTTPS 结果 https 代理展示解密后的 GET 请求

两段 raw request 看起来相同,因为 HTTP 请求行本身不携带 TLS 状态;协议区别来自 Burp 列表字段。这些请求记录没有完整响应或连续时间线,因此不代表全部业务请求都成功,也不构成严格单变量实验。

证书固定还可能在插件层

一种实现是把特定信任材料装入 SecurityContext:

限制 HttpClient 的信任材料dart
final context = SecurityContext(withTrustedRoots: false);
context.setTrustedCertificatesBytes(certificateBytes);
final client = HttpClient(context: context);

这里假定 certificateBytes 已从应用资源读取。它限制接受的信任材料,但是否等同于证书或公钥摘要固定,还要看证书类型、链构建行为与额外检查。该样本的测试仍依赖同一条原生链验证路径。

另一种实现是 ssl_pinning_plugin 的独立检查请求:Android 侧由 checkConnexion 返回结果,应用随后发起业务请求。检查成功与后续连接身份之间,需要明确的绑定关系;一次布尔值检查,不会自动把后续流量放入同一条已验证 TLS 会话。

定位时应分别确认检查请求和业务请求的连接对象、验证入口、信任材料及失败处理。插件层与 libflutter.so 层是不同切入点,命中其中一层不代表覆盖全部网络路径。

保留判断顺序,不依赖固定特征

换一个构建时重新验证4 步
  1. 1

    确认实际客户端

    区分 dart:io、平台插件与其他网络库,记录构建和处理器架构。

  2. 2

    先验证路由

    以 HTTP 对照确认请求进入代理,再引入 HTTPS。

  3. 3

    追踪验证入口

    从错误、源码和字符串引用定位函数,检查返回约定及告警副作用。

  4. 4

    复核候选与结果

    核对函数语义、映射权限和指令集,用请求记录与应用响应分别验证结果。

可复用的是这条证据链:代理选择 → TLS 实现 → 错误副作用 → 二进制定位 → 返回约定 → 请求结果。0x0034b330 和那 12 个字节只属于历史样本;面对新构建,应重新建立对应关系。

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