Android 应用正常访问后端,代理里却没有请求,先别急着改证书校验。路由决定连接经过哪里,TLS 验证决定是否接受对端身份;两条链要分开证明。
2019 年的 Android ARMv7 Flutter 测试应用使用 dart:io,原生 TLS 路径位于 libflutter.so。Jeroen Beckers 的实验区分了代理选择和证书链判断这两个环节;以下分析限定于该构建,不将其信任库行为推广到后续所有 Flutter 版本。

主题插画,仅表达工具关系,不作为网络拓扑或实验结果。
先证明请求进入代理
测试应用由计数器示例修改而成:按钮回调通过 HttpClient.getUrl() 创建请求,再用 request.close() 发送。计数变化只说明回调运行,请求完成还要看响应或日志。先测试 HTTP,再切换到同一目标的 HTTPS,可以减少同时变化的条件。

界面示意图。左侧为计数 0 的测试 App 与加号按钮;右侧代理开关为 ON,Auto Setting 未勾选,端口为 8888,类型为 HTTP。192.0.2.10 为文档示例地址。
有源码:明确指定 findProxy
历史记录中,应用日志显示请求成功,Burp 却没有对应流量。该客户端没有设置 findProxy 时采用直连,Android Wi-Fi 代理设置不会自动成为这条 Dart 路径的策略。Dart 的 findProxy 文档也明确区分直连与返回 PROXY host:port 的配置。
final client = HttpClient();
client.findProxy = (uri) => 'PROXY 192.0.2.10:8888';HTTP 请求记录如下,主机已脱敏为 target.example。这里展示请求字段,不包含响应:
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 的变化没有直接改变这条验证路径。
CERTIFICATE_VERIFY_FAILED: self signed certificate in certificate chain(handshake.cc:352)handshake.cc 是定位线索,352 不是固定二进制偏移。沿源码追踪到 ssl_verify_peer_cert,关键失败分支先记录错误,再发送致命告警:
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 点应沿控制流继续向前找。
区分布尔值与验证枚举
外层函数把证书链判断的布尔值映射为验证枚举:
ret = ssl->ctx->x509_method->session_verify_cert_chain(
hs->new_session.get(), hs, &alert)
? ssl_verify_ok
: ssl_verify_invalid;| 层次 | 成功值 | 选择位置时要检查 |
|---|---|---|
| 外层验证结果 | ssl_verify_ok = 0 |
返回之前是否已发送 fatal alert |
session_verify_cert_chain |
true = 1 |
是否仍位于外层失败处理之前 |
样本选择内层函数,是因为该构建的这条失败路径主要记录错误,外层那次告警尚未发送。这里的“副作用较少”需要源码和样本一起支撑,不应扩展成所有同名函数都适合在退出时修改。
错误宏还提供了剥离符号后可用的定位锚点:
#define OPENSSL_PUT_ERROR(library, reason) \
ERR_put_error(ERR_LIB_##library, 0, reason, __FILE__, __LINE__)__FILE__ 让源文件名进入二进制。问题由“整个共享库哪里处理 TLS”缩小为“哪些代码引用了 ssl_x509.cc”。
从字符串追到 Thumb 入口
| 字段 | 可见内容 |
|---|---|
| Filter | x509.cc |
| Location | 0x000815c2 |
| Len | 48 |
| String View | ../../third_party/boringssl/src/ssl/ssl_x509… |
字符串视图截断了路径,省略号表示未显示部分,不是文件名内容。同一位置有 4 个交叉引用(XREF):
| 序号 | 引用地址 |
|---|---|
| 1 | 0x002fd9c6 |
| 2 | 0x0034b3ec |
| 3 | 0x0034b546 |
| 4 | 0x0034b65a |
4 个引用地址不等于 4 个独立函数。逐一对照所属函数的参数、分支和错误报告后,历史分析将候选定位为 FUN_0034b330。这是 Ghidra 生成的地址标签,不是稳定导出符号。
入口的前 12 字节可作为候选搜索特征:
push0x34B330–0x34B333sub0x34B334–0x34B335mov0x34B336–0x34B337mov0x34B338–0x34B339strb0x34B33A–0x34B33B
唯一匹配只排除了本次扫描中的重名,不证明函数身份。 编译器优化、栈帧大小和寄存器分配都可能改变这段字节;身份仍要靠调用关系与语义核对。
安装观察点前检查地址语义
下面的示例包含候选扫描、ARM 架构检查和可执行映射检查。执行前应复核当前二进制的函数语义,并确认 libflutter.so 已实际加载;代码示例本身不代表一次设备验证结果。
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表示 Thumb 状态,不是跳过入口首字节;这也符合 Frida 的 Interceptor 地址约定。 - 此处对应
session_verify_cert_chain的布尔成功值,不是外层枚举的ssl_verify_ok = 0。
同步扫描遇到不可读页会抛出异常,应检查实际映射,而不是把扫描异常解释成“没有目标”。固定延时一秒也不等于模块已加载;AArch64 构建则需重新定位并采用对应的指令集语义。
用请求记录闭合证据链
处理路由与上述判断点后,历史记录的 Burp 列表中出现 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 对照 | http |
代理收到 GET 请求 |
| HTTPS 结果 | https |
代理展示解密后的 GET 请求 |
两段 raw request 看起来相同,因为 HTTP 请求行本身不携带 TLS 状态;协议区别来自 Burp 列表字段。这些请求记录没有完整响应或连续时间线,因此不代表全部业务请求都成功,也不构成严格单变量实验。
证书固定还可能在插件层
一种实现是把特定信任材料装入 SecurityContext:
final context = SecurityContext(withTrustedRoots: false);
context.setTrustedCertificatesBytes(certificateBytes);
final client = HttpClient(context: context);这里假定 certificateBytes 已从应用资源读取。它限制接受的信任材料,但是否等同于证书或公钥摘要固定,还要看证书类型、链构建行为与额外检查。该样本的测试仍依赖同一条原生链验证路径。
另一种实现是 ssl_pinning_plugin 的独立检查请求:Android 侧由 checkConnexion 返回结果,应用随后发起业务请求。检查成功与后续连接身份之间,需要明确的绑定关系;一次布尔值检查,不会自动把后续流量放入同一条已验证 TLS 会话。
定位时应分别确认检查请求和业务请求的连接对象、验证入口、信任材料及失败处理。插件层与 libflutter.so 层是不同切入点,命中其中一层不代表覆盖全部网络路径。
保留判断顺序,不依赖固定特征
- 1
确认实际客户端
区分 dart:io、平台插件与其他网络库,记录构建和处理器架构。
- 2
先验证路由
以 HTTP 对照确认请求进入代理,再引入 HTTPS。
- 3
追踪验证入口
从错误、源码和字符串引用定位函数,检查返回约定及告警副作用。
- 4
复核候选与结果
核对函数语义、映射权限和指令集,用请求记录与应用响应分别验证结果。
可复用的是这条证据链:代理选择 → TLS 实现 → 错误副作用 → 二进制定位 → 返回约定 → 请求结果。0x0034b330 和那 12 个字节只属于历史样本;面对新构建,应重新建立对应关系。