Originally published at blog.whynext.app.

証明書ピニングは一度アプリを殺した。2026年4月5日、リーフ証明書のSPKIをピニングした。ACMが証明書を自動更新し、リーフの公開鍵が変わった瞬間、全ユーザーのAPI呼び出しがブロックされた。リモートキルスイッチがなかったため、ストア経由でホットフィックスビルドを再申請せざるを得なかった。以降、リポジトリには「証明書ピニングを追加するな」というルールが残った。

2ヶ月後、再びピニングを実装した。今度はリーフではなく、4つのAmazonルートCAのSPKIをピニングした。ACM更新時にルートは変わらないので、同じ事故は二度と起きない。また、リモート設定から切り替え可能なキルスイッチも追加した。6月29日、レポート専用モードをリモートで有効にした。これは不一致を報告するだけで、何もブロックしない。

2日後、Sentryに問題が待っていた。

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

Enter fullscreen mode Exit fullscreen mode

送信したことのない2つのハッシュ

イベントを開くと、アプリが実際に受け取った証明書の2つの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を途中で終端し、独自の証明書で再署名してアプリに渡していた。

served_spkiが2エントリしかなかったのも手がかりだった。通常のパスでは、アプリが報告するチェーンはルートまで含む3つの証明書になる。2つ(リーフ+中間)だったということは、このレポートはコード内の別の分岐から来ていた。

ここで最初の結論を導いた。正確にはAIエージェントが導き、私はそれを妥当だと判断した。「これは修正すべきバグではない。レポート専用ピニングは設計通りに動作しており、他の人のネットワークで起きている傍受を検知している。企業や学校のプロキシ、アンチウイルスWeb保護、キャプティブポータルなどだ。これらを制御することはできないので、Sentryでミュートしてメトリクスとして見守るだけにしよう。」

ついでに提案もあった。バックアップピンを追加しよう。それを選んだ。

バックアップピンを追加しても、これらのイベントは1件も減らない

ピンを追加する作業を始めた直後、違和感を覚えて問い返した。

待って、不一致はそもそも不可能なはずでは?

その一言で、先ほど選んだ方向性が崩れた。エージェントは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

プローブには2つのステップがある。まずOSのトラストストアでチェーンを検証し、検証を通過したチェーンのみがピンリストと比較される。74件のイベントは最初のステップで既に失敗していた。verified == nullの場合、ピン比較を実行せずに即座にレポートする。バックアップピンは2番目のステップ、つまりチェーンは検証されるがピンが一致しない場合にのみ意味を持つ。

結論: Starfield G2をピンに追加しても、この問題のイベントは1件も減らない。

この時点で、通常のユーザーはこのレポートを生成できないことも明らかになった。ライブチェーンはピンに完全に一致するからだ。したがって、この9人は定義上、通常のユーザーではない。ここに修正すべきバグは存在しない。分からなければならなかったのは、この9人が誰かということだった。

無視すれば、決して有効にできない

「無視して監視する」という推奨も受け入れられなかった。

これはちゃんと修正しなければならない。後で実際に有効にする必要があるが、このままでは永遠に有効にできない。

このピニング作業の最終目標は強制モードだ。有効化を判断するゲートは設計ドキュメントに既に書かれている。実ユーザーでの不一致率が0.5%未満の状態が2週間続くことだ。不一致の理由を説明せずに放置すれば、この74件のイベントがずっとゲートを汚染し続ける。ミュートすればダッシュボードから74件は消えるが、ゲートは以前と全く変わらず汚染されたままになる。

そこでデータをさらに掘り下げた。タグ分布を取得した瞬間、奇妙なことが判明した。

74件のイベントはすべて同じデバイスから来ており、そのデバイスは存在しない

74件すべてのイベントのdeviceタグは100%同じ値だった。OnePlus8Pro。しかしそのデバイスのフィンガープリントは意味をなさなかった。

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コア、周波数0 8コア
simulator false -

一般向けARM端末がx86 ABIを広告することはない。世界に288x448、106dpiの画面を持つAndroid端末は存在しない。これはBuild.MODELだけを本物の端末名に偽装したx86仮想デバイスだった。さらにsimulator: falseを主張して物理ハードウェアを装っていた。

9つのユーザーIDも人間ではなかった。9つすべてが6月29日にのみ存在し、その1日で3つのバージョン(1.7.0、1.8.0、1.8.1)すべてを実行していた。それは人間ではなく、パイプラインだった。74件すべてのイベントのリージョンは米国だったが、都市はすべてnullだった。これはデータセンター範囲からのトラフィックに見られる特徴だ。

この時点で尋ねた。では誰がアプリを解析しているのか?エージェントはセキュリティベンダーの自動APK解析サンドボックスを1位に挙げ、続いてアプリインテリジェンス企業とAPKミラーサイトを挙げた。自動ストアレビューは4位だった。

4位だった理論が3つのIPで1位になった

4位の理論が気になった。そこで尋ねた。

Google Play ConsoleやApp Storeの可能性はあるか?

返ってきた答えは断定的だった。

Google Playのプレローンチレポート/Test Lab — ほぼ確実に違う。決定的な理由:GoogleはHTTPSをMITMしない。Playプレローンチは標準のトラストストアを使用し、実際の証明書をそのまま受け取る。ストアクローラーはトラフィックを傍受して再署名しない。それが解析サンドボックスの動作だ。

純粋な推論としては、反論しにくい主張だった。そしてそれは間違っていた。

欠けていたのはIPの証拠だった。SentryはIPをPIIとして削除するため、すべてのイベントでuser.ipはnullであり、過去のイベントの元のIPは復元できない。送信元を特定するにはIPが必要だったが、見ていたツールにはそれがなかった。

あったのは本番APIのアクセスログだった。すべてのリクエストでX-Forwarded-ForUser-Agentを記録しており、アプリのUser-Agentは一意だった。Dart/3.12 (dart:io)。そして6月29日はまだ7日間のログ保持期間内だった。

まずSentryから74件の不一致イベントのタイムスタンプを取得し、3つのクラスタにグループ化した。02:12-02:20、11:30-11:41、17:32-17:40(UTC)。次にCloudWatchで、これら3つの時間帯に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だ。3つの時間帯すべてにGoogleのIPがあった。3つのうち2つでは、その時間帯にあったアプリリクエストはGoogleのIPからのものだけだった。残りの時間帯にも本物のユーザーらしいIPがあったが、GoogleのIPも含まれていた。

3分後、エージェントは自ら訂正した。

訂正。先ほど「GoogleはMITMしないので、おそらく彼らではない」と言ったが、IPの証拠がその推測を覆す。これはGoogleだ。測定されたIPは推論よりも強い証拠である。

アプリを分解していたのは、Play上のすべてのアプリが受けるGoogleの日常的な自動スキャンだった。攻撃者でも、競合他社でも、セキュリティベンダーでもない。そのスキャン環境はトラフィックを計測する。それが、プローブにとってチェーンが検証不能に見えた理由だ。

結局、何を修正したのか

ピニングはそのままに、プローブが適用される場所を変更した。スキャナー環境ではピニングを一切有効にしないことにした。

残ったのはスキャナーをどう認識するかという問題だった。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フィンガープリントもチェックする。検出器は意図的に「これはエミュレータだ」と答える方向にバイアスをかけている。偽陽性は、その環境でのみピニングをスキップするだけだ。攻撃者がエミュレータでアプリを実行する場合、クライアント側ピニングを無効にするようアプリをパッチできるため、何も失わない。偽陰性の方がはるかに悪い。なぜなら、スキャナーをブロック画面の背後に閉じ込めてしまうからだ。

これが重要な理由だ。もし単に強制モードを有効にしていたら、こうなっていたはずだ。Googleのプレローンチクローラーが「安全でない接続」のブロック画面に到達する。それがPlay Consoleのプレローンチレポートで失敗として記録される。コードに問題はないのに、ストアレビュースクリーンに赤い線が表示される。

心に残ったこと

SentryはIPをPIIとして削除する。それは正しいデフォルトだ。しかしそのせいで、何が起きているかを特定できたかもしれない1つのフィールドがイベントから失われた。結局、アプリのUser-Agentとイベントのタイムスタンプという2つのものを相互参照し、Sentryが捨てたものをアクセスログから復元した。あるツールにないからといって、どこにもないわけではない。

「GoogleはHTTPSを傍受しない」という文は、滑らかな推論だった。ストアクローラーの目的、標準トラストストアの動作、サンドボックスとの違いを論じ、その理論を4位に押し下げた。ところが3つのIPが3分で結論を覆した。AIに原則を尋ねれば、原則が返ってくる。それらの原則が現実と一致するかどうかは、ログだけが教えてくれる。

もしこの74件のイベントをSentryでミュートしていたら、ダッシュボードはきれいになっていただろう。そして強制モードを有効にした日に、理由も分からぬままストアレビューで赤い線を受け取っていただろう。

まだ強制モードは有効にしていない。ゲートには、実ユーザーでの不一致率が0.5%未満の状態が2週間必要だ。スキャナーを除外した今、2週間のカウントを最初からやり直さなければならない。