Originally published at blog.whynext.app.

证书固定曾经让我们的 App 崩溃过。2026 年 4 月 5 日,我们固定了叶证书的 SPKI。ACM 自动续期后,叶证书的公钥发生了变化,那一刻所有用户的 API 调用都被阻断。我们没有远程关闭开关,只能通过应用商店推送热修复版本。此后,代码库中加入了一条规则:“不要添加证书固定。”

两个月后,我们再次实现了固定。这次我们固定了四个 Amazon 根 CA 的 SPKI,而不是叶证书。根证书在 ACM 续期时不会改变,因此不会再次发生同样的事故。我们还添加了可通过远程配置切换的关闭开关。6 月 29 日,我们远程启用了 report-only 模式,该模式仅报告不匹配而不阻断任何内容。

两天后,Sentry 中出现了一个问题。

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

Enter fullscreen mode Exit fullscreen mode

两个我们从未发送过的哈希值

打开事件后可以看到 App 实际接收到的证书的两个 SPKI。

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

Enter fullscreen mode Exit fullscreen mode

我用 openssl s_client 获取了真实的服务器证书链进行比对。无论是我们服务器发送的叶证书、中间证书还是根证书,都与这两个哈希值不匹配。这 9 个人根本没有收到我们服务器的真实证书。有人在中间终止了 TLS,用自己的证书重新签名后再交给 App。

served_spki 只有两个条目也是一个线索。在正常路径下,App 上报的证书链包含三个证书,一直延伸到根证书。只有两个证书(叶证书加中间证书)意味着这份报告来自代码中的另一个分支。

至此我得出了第一个结论。准确地说,这个结论是由 AI 代理得出的,而我认为它合理。“这不是需要修复的 bug。report-only 固定正如设计那样工作,捕获了在其他人的网络上发生的拦截。企业或学校代理、防病毒软件的网页保护、强制门户等诸如此类的情况。我们无法控制这些,所以在 Sentry 中将其静音,只作为指标观察即可。”

既然已经在处理这个问题了,于是又提出了一个建议:添加一个备份固定。我采纳了这个建议。

备份固定不会消除其中任何一个事件

就在开始添加固定后不久,我感到有些不一致,于是回过头去问。

等等,不匹配从一开始不就应该是不可能的吗?

这一行推翻了我们刚刚选择的方向。代理重新阅读了 Android 探测代码并纠正了自己。

val verified = verifiedChainSpki(offered, host)
if (verified == null) {
    // Trust store validation failed = interception/forgery -> report without any pin comparison
    report(host, offered.mapNotNull(::spkiSha256))
    return
}
// Pin comparison only happens for a chain that passed validation
if (verified.none(pins::contains)) { report(host, verified) }

Enter fullscreen mode Exit fullscreen mode

该探测分为两个步骤。首先,它会根据操作系统信任库验证证书链,只有通过验证的链才会与固定列表进行比较。我们的 74 个事件已经在第一步失败了。当 verified == null 时,代码会立即上报,而不会执行固定比较。备份固定只在第二步(链通过验证但固定不匹配的情况)中才有意义。

结论:将 Starfield G2 添加到固定列表中不会减少该问题的一个事件。

那一刻也清楚了,普通用户无法产生这份报告,因为实时证书链与我们的固定完全匹配。因此,这些 9 个人从定义上就不是普通用户。这里没有需要修复的 bug。我们需要找出这 9 个人是谁。

忽略它,我们就永远无法开启

我也无法接受“忽略并监控”的建议。

我们必须正确地解决这个问题。我们以后需要真正开启它,如果一直这样,我们就永远无法开启。

这次固定工作的最终目标是 enforce 模式。决定我们是否可以开启 enforce 的门槛已在设计文档中写明:真实用户不匹配率低于 0.5% 持续两周。如果我们不解释这些不匹配,74 个事件将继续污染这个门槛。在 Sentry 中将其静音会让 74 个事件从仪表板消失,但门槛仍然会像之前一样被污染。

于是我深入挖掘了更多数据。当我拉出标签分布时,出现了一些奇怪的现象。

所有 74 个事件都来自同一台设备,而这台设备并不存在

所有 74 个事件上的 device 标签 100% 都是同一个值:OnePlus8Pro。但该设备的指纹毫无意义。

Field Event value Real OnePlus 8 Pro
archs includes x86_64, x86 arm64 only (Snapdragon 865)
Screen 288 x 448 px, 106 dpi 1440 x 3168, 513 dpi
CPU 2 cores, frequency 0 8 cores
simulator false -

消费级 ARM 手机不会宣传 x86 ABI。世界上不存在屏幕为 288x448、dpi 为 106 的 Android 手机。这是一个 x86 虚拟设备,仅将其 Build.MODEL 伪装成真实设备名称。它甚至声称 simulator: false 以伪装成物理硬件。

这 9 个用户 ID 也不是真实的人。所有 9 个 ID 仅存在于 6 月 29 日,且在这一天运行了全部三个版本:1.7.0、1.8.0 和 1.8.1。那不是一个人,而是一个流水线。所有 74 个事件的地区都是美国,但每个事件的城市都为空。那就是来自数据中心 IP 段的流量特征。

此时我问道:那么是谁在分析我们的 App?代理将安全厂商的自动化 APK 分析沙箱排在第一位,其次是 App 情报公司和 APK 镜像站点。自动化商店审核排在第四位。

排名第四的理论因三个 IP 而成为第一

第四名让我感到困扰。于是我问道。

会不会是 Google Play Console 或 App Store?

得到的回答很坚定。

Google Play 预发布报告 / Test Lab —— 几乎可以肯定不是。决定性理由是:Google 不会对你的 HTTPS 进行中间人攻击。 Play 预发布使用标准信任库,按原样接收你的真实证书。商店爬虫不会拦截并重新签名流量。那是分析沙箱才会做的事。

作为纯粹的推理,这个论点很难反驳。但它是错的。

缺失的是 IP 证据。Sentry 将 IP 作为 PII 进行清理,因此每个事件上的 user.ip 都是 null,且无法恢复过去事件的原始 IP。要识别来源,我们需要 IP,而我们正在查看的工具没有提供这些信息。

我们拥有的是生产 API 的访问日志。它记录了每个请求的 X-Forwarded-ForUser-Agent,而我们 App 的 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

Enter fullscreen mode Exit fullscreen mode

66.249.x.x 是 Googlebot 使用的 IP 段,而 66.102.x.x 也是 Google 的。三个时间窗口中都有 Google IP。在其中两个窗口中,该窗口内唯一的 App 请求来自 Google IP。剩余窗口中也存在看起来像真实用户的 IP,但 Google IP 也在其中。

三分钟后,代理纠正了自己。

更正。我之前说“Google 不会进行中间人攻击,所以这可能不是他们”,但 IP 证据推翻了这个猜测。这就是 Google。测得的 IP 比推理更有说服力。

正在拆解我们 App 的是 Google 例行的自动化扫描,每款上架 Play 的 App 都会经历这个过程。不是攻击者,不是竞争对手,也不是安全厂商。该扫描环境会对流量进行检测。这就是为什么,在我们的探测看来,证书链是不可验证的。

那么我们修复了什么?

我们保留了固定设置,并改变了探测应用的位置。我们决定不在扫描器环境中开启固定。

这留下了如何识别扫描器的问题。很明显我们不能信任 simulator: false,因此我们需要一个无法被伪造的信号。CPU 架构是无法隐藏的。

// Decisive signal: a consumer ARM phone never advertises an x86 ABI.
// Google's scanner can spoof Build.MODEL with a real device name (observed: "OnePlus8Pro" on x86),
// but it cannot hide the fact that it is x86_64.
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
  return true;
}

Enter fullscreen mode Exit fullscreen mode

此外,我们还会检查 Cuttlefish/GCE 硬件名称和 test-keys 指纹。该检测器故意偏向于回答“这是模拟器”。误报只意味着我们在该环境中跳过固定,而攻击者在模拟器上运行 App 时可以修改 App 以禁用客户端固定,因此我们不会损失任何东西。漏报则糟糕得多,因为它会将扫描器困在阻断屏幕后面。

这很重要。如果我们简单地开启 enforce,就会发生以下情况。Google 的预发布爬虫会遇到我们的“不安全连接”阻断屏幕。这会被记录为 Play Console 预发布报告中的失败。代码没有问题,但商店审核屏幕上却会出现一条红线。

给我留下的印象

Sentry 将 IP 作为 PII 进行清理。这是正确的默认做法。但正因如此,能够识别发生了什么的那个字段从事件中消失了。最终,我们交叉引用了 App 的 User-Agent 和事件的时间戳,从访问日志中恢复了 Sentry 丢弃的信息。一个工具中缺失的内容并不意味着在所有地方都缺失。

“Google 不会拦截 HTTPS”这句话是一段流畅的推理。它通过商店爬虫的目的、标准信任库的行为以及与沙箱的区别进行了推理,并将该理论排到了第四位。然后三个 IP 在三分钟内推翻了结论。向 AI 询问原则,你会得到原则作为回答。这些原则是否与现实相符,只有日志才能告诉你。

如果我们当时在 Sentry 中将这 74 个事件静音,仪表板会很干净,而在我们开启 enforce 的那天,我们会在不知情的情况下在商店审核中得到一条红线。

我们仍未开启 enforce。该门槛要求真实用户不匹配率低于 0.5% 持续两周。现在扫描器已被过滤,我们必须从头开始重新计算这两周。