追查拦截我们应用 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 |
|---|---|---|
| archs | 含 x86_64、x86 | 仅 arm64(Snapdragon 865) |
| 屏幕 | 288 x 448 px,106 dpi | 1440 x 3168,513 dpi |
| CPU | 2 核,频率为 0 | 8 核 |
| simulator | false | - |
消费级 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-For 和 User-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 报告就可能记为失败。稳妥的做法是让应用在扫描器或模拟器环境下干脆跳过固定探针。反正在模拟器里跑你应用的攻击者,本来就可以直接改包来废掉客户端固定,把这类环境排除在外并不会缩小真正的保护范围。
相关文章
- 💻 开发
关闭钩子跑了两次-SIGTERM 出现两个主人的那次排查
每次部署,Sentry 都会收到一条 Prisma P2028 错误。本想只是修一下关闭顺序,结果发现 NestJS 的 enableShutdownHooks 和 graceful shutdown 库同时抢占了 SIGTERM,导致关闭钩子每次都被执行了两遍。
- 💻 开发
收到了数据库挂了的告警。可数据库从来没挂过
一天之内堆了四条 CRITICAL 告警。504、Prisma P2028、后台 500,还有一条“DATABASE 服务宕机”。我先打开 RDS 指标,结果那 21 小时里 CPU 峰值只有 19.7%,好得很。慢的不是数据库,而是要横跨大西洋二十五次的往返;而那条“宕机”告警,是健康检查自己造出来的。
- 💻 开发
把 AI 修过的代码交给另一个 AI 证伪的流水线
让同一个模型评审自己的代码,它只会把自己得出的结论再批准一遍。一个几乎所有代码都和 AI 一起写的独立开发者,把“让另一个 AI 来反驳这个 AI 的工作”这套交叉验证循环固化成常驻流水线的故事。包含自我评审漏掉的 bug 被连续三轮抓出来的真实案例和提示词设计。