Why NextWhy Next
返回列表
💻 开发阅读约 7 分钟

追查拦截我们应用 TLS 的那 9 个用户,结果是谷歌

把证书固定(pinning)切到 report-only 之后,Sentry 里堆了 74 条 SPKI 不匹配。看着像是 MITM 攻击,而 AI 一口咬定“谷歌不会拦截你的 HTTPS”,直接把商店审核这个可能性排除掉了。可从生产 API 日志里捞出来的真实 IP,把这个推论掀翻了。

核心摘要

证书固定切到 report-only 模式的第一周,Sentry 上就出现了 74 条 SPKI 不匹配(涉及 9 个用户)。应用收到的证书和我们服务器的证书没有一条对得上,也就是说有人正在中间拦截 TLS。翻设备指纹时发现,74 条全是“OnePlus 8 Pro”,却跑在 x86 架构上、屏幕只有 288x448。这是伪装成物理设备的模拟器农场。在缩小嫌疑范围的过程中,AI 智能体以“谷歌不会 MITM 你的 HTTPS”为由排除了商店自动审核的可能;但在不匹配发生的那几个时间段里,打到生产 API 的真实 IP 全都落在谷歌的网段里。从原理推出来的判断,在实测面前 3 分钟就塌了。

目录

我们的应用曾经被证书固定搞挂过一次。2026 年 4 月 5 日,我们把 leaf 证书的 SPKI 钉成了 pin,结果 ACM 自动续期证书时 leaf 公钥变了,那一刻所有用户的 API 全都断了。当时连远程开关都没有,只能重新打个热修包传到商店。从那以后,仓库里就留下了一条规则:“禁止引入证书固定”。

两个月后,我们又把固定做回来了。这次钉的不是 leaf,而是 Amazon Root CA 四个根证书的 SPKI。根证书不会因为 ACM 续期而变,同样的事故不会重演。我们还加了一个可以远程开关的 kill switch。6 月 29 日,我们远程打开了只上报、不拦截的 report-only 模式。

两天后,Sentry 上多了一个 issue。

[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29

两个我们从没发过的哈希

打开事件一看,里面记着应用实际收到的证书的两个 SPKI。

mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
  "sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
  "sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]

我用 openssl s_client 把服务器真实的证书链拉出来对了一遍。我们服务器发出去的 leaf、中间证书、根证书,和这两个哈希没有一个对得上。这 9 个用户压根就没收到过我们服务器的真证书。有人在中间掐断了 TLS,用自己的证书重新签名后再递给应用。

served_spki 只有两个,这本身也是线索。正常路径下应用上报的链里会一直到根证书,共三个。只出现两个(leaf + 中间证书),说明这条报告是从另一个分支出来的。

到这里我下了第一个结论。准确地说,是 AI 智能体下的,我觉得挺有道理。“这不是需要修的 bug,而是 report-only 固定按设计正常工作,抓到了发生在别人网络里的拦截。比如公司或学校的代理、杀毒软件的网页防护、强制门户之类。既然不在我们的掌控范围内,那就在 Sentry 里 mute 掉,只当指标看。”

顺手还附了个建议:既然都动了,不如再加一个备用 pin。我选了这条路。

备用 pin 一条都减不掉

刚开始加 pin,我就觉得这事前后对不上,于是反问了一句。

这不匹配本来就不该发生吧?

就这一句,把刚选好的方向掀翻了。智能体重新读了一遍 Android 探针代码,然后更正了自己。

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // 信任库校验失败 = 被拦截/伪造 -> 不做 pin 比对,直接上报
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// 只有通过校验的证书链才会进入 pin 比对
if (verified.none(pins::contains)) { report(host, verified) }

探针分两步。先用操作系统信任库校验链,只有通过校验的链才拿去和 pin 列表比对。我们那 74 条在第一步就已经失败了。verified == null,所以根本不经过 pin 比对就直接上报。备用 pin 只对第二步,也就是“校验通过了但 pin 对不上”的情况有用。

结论:即使把 Starfield G2 加进 pin,这个 issue 的条数一条也不会减少。

也正是那时才确认:正常用户身上不可能出现这种报告。因为线上链和我们的 pin 是精确匹配的。所以按定义,这 9 个就不是正常用户。这里没有要修的 bug。要弄清的是,这 9 个到底是谁。

忽略掉,就永远别想打开了

“先忽略、只做监控”这个建议,我也没法接受。

得好好修啊,这个以后是真要打开的,照这样根本打不开

这套固定的最终目标是 enforce。能不能开 enforce,判断闸门在设计文档里早就写好了:连续两周真实用户的不匹配率低于 0.5%。如果放着不匹配的真面目不管,这 74 条就会一直污染这道闸门。Mute 掉,74 条会从看板上消失,但闸门照样是脏的。

于是我继续往数据里挖。标签分布一拉出来,怪事就出现了。

74 条全来自同一台设备,可这台设备并不存在

74 条的 device 标签百分之百是 OnePlus8Pro 一个值。但这台设备的指纹很不对劲。

字段事件里的值真正的 OnePlus 8 Pro
archsx86_64、x86仅 arm64(Snapdragon 865)
屏幕288 x 448 px,106 dpi1440 x 3168,513 dpi
CPU2 核,频率为 08 核
simulatorfalse-

消费级 ARM 手机不会宣告 x86 ABI。世上也不存在 288x448、106dpi 的安卓手机。这是一台只把 Build.MODEL 伪装成真机名字的 x86 虚拟设备,还把 simulator: false 也挂上,装成物理设备。

那 9 个用户 ID 也不是人。9 个全都只在 6 月 29 日这一天存在过,而且这一天里把 1.7.0、1.8.0、1.8.1 三个版本都跑了一遍。这不是人,是流水线。地区上 74 条全是美国,但城市字段全是 null。从数据中心网段进来的请求,就是这个样子。

我在这里追问:那到底是谁在分析我们的应用。智能体把安全厂商的 APK 自动分析沙箱排在第一,应用情报公司和 APK 镜像站紧随其后。商店自动审核排在第四。

排第四的假设,被三个 IP 顶到了第一

我卡在了第四这一条上,于是又问。

会不会是 Google Play Console 或者 App Store?

回答斩钉截铁。

Google Play pre-launch report / Test Lab - 几乎不可能。决定性理由:谷歌不会 MITM 你的 HTTPS。 Play pre-launch 用标准信任库,收到的就是你的真证书。商店爬虫不会拦截流量再重新签名。那是分析沙箱干的事。

单看原理,这个论证无懈可击。而它是错的。

缺的是 IP 证据。Sentry 把 IP 当 PII 清洗掉了,事件里的 user.ip 全是 null,而且过去事件的原始 IP 无法恢复。要查清身份就得有 IP,可手上没有一个握着 IP 的工具。

不过生产 API 的访问日志还在。每条请求都记了 X-Forwarded-ForUser-Agent,而我们应用的 User-Agent 是独一无二的 Dart/3.12 (dart:io)。而且 6 月 29 日还在日志保留期(7 天)之内。

我先从 Sentry 里把 74 条事件的时间取出来,归成三簇:02:12-02:20、11:30-11:41、17:32-17:40(UTC)。然后在 CloudWatch 里查这三个时间段内以 Dart/3.12 打进来的请求 IP。

02:12-02:20  ...  66.249.84.132, 66.249.84.141
11:30-11:41  ...  66.102.7.69
17:32-17:40  ...  66.249.84.141

66.249.x.x 是 Googlebot 用的网段,66.102.x.x 也是谷歌。三个时间段里都有谷歌 IP。其中两个时间段里,那段时间打进来的应用请求只有谷歌 IP 一个。剩下那个时间段里还夹着看起来像真实用户的 IP,但谷歌 IP 在那里同样存在。

3 分钟后,智能体自己更正了。

更正一下。前面我说“谷歌不会 MITM,所以应该不是它”,但 IP 证据把这个推论推翻了。这就是谷歌。实测到的 IP 是比推论更强的证据。

在拆我们应用的,是谷歌对上架 Play 的应用一视同仁的日常自动扫描。不是攻击者,不是竞品,也不是安全厂商。那个扫描环境会对流量做计测,所以在我们的探针眼里,它就是一条校验不过的证书链。

那到底改了什么

固定本身没动,改的是探针应用在哪些环境上。我们决定在扫描器环境里干脆不开固定。

剩下的问题是:凭什么认出扫描器。simulator: false 靠不住这件事已经摆在眼前了,所以得找一个伪装不了的信号。CPU 架构藏不住。

// 决定性信号:消费级 ARM 手机绝不会声明 x86 ABI。
// 谷歌的扫描器即使把 Build.MODEL 伪装成真实机型(实测:x86 上的 "OnePlus8Pro"),
// 也藏不住自己是 x86_64 这个事实。
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

在此之上,还额外检查 Cuttlefish/GCE 系列的硬件名和 test-keys 指纹。这个判别器是故意偏向“判成模拟器”那一边的。就算误判,也不过是在那一个环境里跳过固定;而在模拟器里跑应用的攻击者反正可以改包废掉客户端固定,所以我们没有损失。反过来,漏判会把扫描器困在拦截页面上,那要糟糕得多。

这件事之所以重要,是因为如果当初直接开了 enforce,接下来会是这样:谷歌的 pre-launch 爬虫撞上我们应用的“连接不安全”拦截页面,原样记进 Play Console 的 pre-launch 报告,判为失败。代码一点毛病都没有,商店审核页面上却划了一道红线。

剩下的

Sentry 把 IP 当 PII 清洗掉。这是个正确的默认值。但也正因如此,唯一能揭开真相的字段从事件里消失了。最后我们靠应用的 User-Agent 和事件发生时间这两样交叉比对,从访问日志里把 Sentry 丢掉的信息捡了回来。一个工具里没有,不等于哪里都没有。

“谷歌不会拦截 HTTPS”这句话,作为论证是顺畅的。它一路点到商店爬虫的目的、标准信任库的行为、以及和沙箱在行为上的差别,把这个可能性挤到了第四位。可三个 IP 一摆到面前,结论 3 分钟就翻了。向 AI 问原理,回来的就是原理。这个原理和现实对不对得上,只能靠看日志才知道。

要是当初把这 74 条在 Sentry 里 mute 掉,看板确实会变干净;而在打开 enforce 的那一天,我们会莫名其妙地在商店审核里吃上一道红线。

我们还没有打开 enforce。闸门要求连续两周真实用户的不匹配率低于 0.5%。既然把扫描器过滤掉了,这两周就得从头重新数起。

常见问题

把证书固定切到 report-only 之后开始收到不匹配报告。加几个备用 pin 能减少吗?

先看这些报告是从哪条代码路径出来的。固定探针通常分两步:先用操作系统信任库校验证书链,只有校验通过之后,才拿这条链去和 pin 列表比对。如果链校验本身就失败了(被拦截、被伪造),根本走不到比对 pin 这一步,会直接上报。我们那 74 条全属于后者,所以无论加多少备用 pin,报告一条都不会少。备用 pin 只在“链校验通过了但 pin 对不上”的情况下才有用。

Sentry 事件里没有 IP。这样还能查出访问来源吗?

Sentry 会把 IP 当作 PII 清洗掉,所以事件里往往留不下 IP,而且过去事件的原始 IP 也无法恢复。但可以用别的日志按时间交叉比对。我们的服务器访问日志每条请求都记录了 X-Forwarded-For 和 User-Agent,而应用的 User-Agent 是独一无二的“Dart/3.12 (dart:io)”。先从 Sentry 里取出不匹配发生的准确时间段,再查这些时间段内带着这个 User-Agent 打进来的请求 IP,来源就浮出来了。

把证书固定切到 enforce,会不会影响 Google Play 审核?

有可能。Play 的自动扫描环境看起来会对流量做计测,一旦应用在那个环境里弹出固定拦截页面,pre-launch 报告就可能记为失败。稳妥的做法是让应用在扫描器或模拟器环境下干脆跳过固定探针。反正在模拟器里跑你应用的攻击者,本来就可以直接改包来废掉客户端固定,把这类环境排除在外并不会缩小真正的保护范围。

相关文章

0 条评论