Originally published at blog.whynext.app.

Certificate pinning 曾讓我們的 App 壞掉一次。2026 年 4 月 5 日,我們在 App 中 pin 了 leaf certificate 的 SPKI。ACM 自動續發憑證後,leaf public key 改變,那一刻所有使用者的 API 呼叫全部被阻擋。我們沒有遠端 kill switch,只好再把 hotfix build 送回商店。之後 repo 裡多了一條規則:「不要再加 certificate pinning。」

兩個月後,我們再次實作 pinning。這次我們 pin 了四個 Amazon Root CA 的 SPKI,而不是 leaf。Roots 不會因為 ACM 續發而改變,所以同樣的意外不會再發生。我們也加了可以從 remote config 切換的 kill switch。6 月 29 日,我們從遠端啟用 report-only mode,遇到不符時只回報、不阻擋。

兩天後,Sentry 裡出現了一個 issue。

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

Enter fullscreen mode Exit fullscreen mode

兩個我們從未送出的 hash

打開事件後,顯示 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 拉出真實的伺服器憑證鏈比對。無論是 leaf、intermediate 還是 root,都沒有符合這兩個 hash。這 9 個人根本沒有收到我們伺服器真正的憑證。有人在中途終止 TLS,用自己的憑證重新簽署後再交給 App。

served_spki 只有兩筆資料也是線索。在正常路徑下,App 回報的鏈應該包含三張憑證,一直到 root。只有兩張(leaf 加 intermediate)代表這份報告來自程式碼中的另一個分支。

這時我得出第一個結論。更精確地說,是 AI agent 得出這個結論,而我覺得合理。「這不是要修的 bug。Report-only pinning 正如設計般運作,抓到其他人的網路正在進行攔截。企業或學校的 proxy、防毒軟體的網頁保護、captive portal 之類的。我們無法控制這些,所以在 Sentry 裡 mute 掉,只當作 metric 觀察。」

既然都在這裡了,agent 也順便建議:加一個 backup pin。我採納了這個建議。

加 backup pin 不會減少任何一筆事件

就在開始加入 backup pin 的工作後,我覺得有不一致的地方,於是回問。

等一下,不符在最一開始就應該不可能發生吧?

這一句話推翻了我們剛才的方向。agent 重新閱讀 Android probe 程式碼後自行修正。

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

probe 有兩個步驟。首先會用 OS trust store 驗證鏈,只有通過驗證的鏈才會拿去跟 pin list 比對。我們的 74 筆事件在第一步就失敗了。當 verified == null 時,程式碼會立刻回報,不會執行 pin 比對。Backup pin 只在第二步有意義,也就是鏈通過驗證但 pin 不符的情況。

結論:把 Starfield G2 加到 pins 裡,不會減少這次 issue 的任何一筆事件。

同時也清楚了:正常使用者不可能產生這種報告,因為真實的鏈完全符合我們的 pins。所以這 9 個人絕對不是正常使用者。這裡沒有 bug 要修。我們必須找出這 9 個人是誰。

忽略它,我們就永遠無法開啟

我也無法接受「忽略並監控」的建議。

我們必須好好解決這個問題。我們之後需要真正開啟 enforce,如果一直這樣就永遠辦不到。

這次 pinning 工作的最終目標是 enforce mode。決定是否能開啟 enforce 的門檻早已寫在設計文件裡:連續兩週,真實使用者的 mismatch rate 低於 0.5%。如果我們不找出這些 mismatch 的原因,這 74 筆事件就會持續污染門檻。把它們 mute 掉雖然能讓 dashboard 乾淨,但門檻仍然被污染。

因此我更深入分析資料。一拉出 tag 分佈,就出現了奇怪的現象。

全部 74 筆事件來自同一台裝置,而這台裝置根本不存在

這 74 筆事件中的 device tag 100% 都是同一個值:OnePlus8Pro。但這個裝置的 fingerprint 完全說不通。

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

消費級 ARM 手機不會宣告 x86 ABI。世界上沒有任何 Android 手機擁有 288x448、106 dpi 的螢幕。這是一台 x86 虛擬裝置,只把 Build.MODEL 偽裝成真實裝置名稱。它甚至聲稱 simulator: false 來假裝是實體硬體。

這 9 個 user ID 也不是真人。全部 9 個都只存在於 6 月 29 日,而且在同一天跑了三個版本:1.7.0、1.8.0 和 1.8.1。那不是真人,而是 pipeline。全部 74 筆事件的 region 都是 US,但 city 全部為 null。這就是資料中心 IP 範圍的流量特徵。

此時我問:那麼是誰在分析我們的 App?agent 把安全廠商的自動 APK 分析 sandbox 排在第一位,接著是 App 情報公司和 APK mirror 網站。自動商店審查排在第四。

原本排第四的理論,因為三個 IP 躍升第一

第四名讓我感到在意。於是我問:

會不會是 Google Play Console 或 App Store?

得到的回答很堅定。

Google Play pre-launch report / Test Lab - 幾乎不可能。決定性的理由是:Google 不會 MITM 你的 HTTPS。 Play pre-launch 使用標準 trust store,收到的是你真正的憑證。商店爬蟲不會攔截並重新簽署流量。那是分析 sandbox 才會做的事。

純粹從推理來看,這個論點很難反駁。但它是錯的。

缺少的是 IP 證據。Sentry 會把 IP 視為 PII 清除,所以每筆事件的 user.ip 都是 null,而且過去事件的原始 IP 無法恢復。要找出來源,我們需要 IP,而我們正在看的工具沒有這個資料。

我們有的是 production API 的 access log。它會記錄每筆請求的 X-Forwarded-ForUser-Agent,而我們 App 的 User-Agent 是獨一無二的:Dart/3.12 (dart:io)。而且 6 月 29 日仍在 7 天 log 保留期間內。

我先從 Sentry 拉出全部 74 筆 mismatch 的事件時間戳,分成三個群集:02:12-02:20、11:30-11:41、17:32-17:40(UTC)。然後在 CloudWatch 查詢這三個時間區間內、User-Agent 為 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 使用的範圍,66.102.x.x 也是 Google。這三個時間區間都有 Google IP。在其中兩個區間,該時段唯一來自 App 的請求就是 Google IP。剩下的那個區間雖然有看起來像真實使用者的 IP,但也有 Google IP 在內。

三分鐘後,agent 自行修正。

更正。先前我說「Google 不會 MITM,所以應該不是他們」,但 IP 證據推翻了這個猜測。這就是 Google。測量到的 IP 比推理更有力。

正在拆解我們 App 的是 Google 例行的自動化掃描,也就是每個在 Play 上架的 App 都會遇到的掃描。不是攻擊者、不是競爭對手、也不是安全廠商。這個掃描環境會對流量進行檢測。這就是為什麼在我們的 probe 看來,鏈是無法驗證的。

那麼我們修了什麼?

我們保留 pinning 不動,改成調整 probe 套用的位置。我們決定不在 scanner 環境中開啟 pinning。

接下來的問題是如何辨識 scanner。我們已經清楚不能相信 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 fingerprint。這個偵測器刻意偏向回答「這是模擬器」。誤判只會讓我們在該環境跳過 pinning,而攻擊者把 App 跑在模擬器上時,本來就可以 patch App 來停用 client-side pinning,所以我們不會損失什麼。誤判為否的後果更嚴重,因為那會讓 scanner 卡在阻擋畫面。

這很重要。如果我們直接開啟 enforce,會發生以下情況。Google 的 pre-launch crawler 會遇到我們的「不安全連線」阻擋畫面。這會被記錄為 Play Console pre-launch report 的失敗。程式碼沒有問題,但商店審查畫面卻會出現紅線。

留給我的啟示

Sentry 把 IP 視為 PII 清除。這是正確的預設值。但正因如此,能夠辨識發生什麼事的那個欄位從事件中消失了。最後我們交叉比對 App 的 User-Agent 和事件時間戳,從 access log 找回 Sentry 丟掉的資訊。一個工具缺少的,不代表到處都缺少。

「Google 不會攔截 HTTPS」是一段流暢的推理。它從商店爬蟲的目的、標準 trust store 的行為,以及與 sandbox 的差異進行推論,把這個理論排到第四名。然後三個 IP 在三分鐘內翻轉了結論。問 AI 關於原則的問題,得到的會是原則。這些原則是否符合現實,只有 log 能告訴你。

如果我們當時把這 74 筆事件在 Sentry 裡 mute,dashboard 會很乾淨,但在我們開啟 enforce 的那天,我們會在商店審查畫面看到紅線,卻不知道原因。

我們還沒有開啟 enforce。門檻要求連續兩週、真實使用者的 mismatch rate 低於 0.5%。現在 scanner 已被過濾,我們必須重新開始計算這兩週。