アプリのTLSを傍受していた9人を追跡したら、Googleだった
証明書ピンニングをreport-onlyで有効にした途端、SentryにSPKIミスマッチが74件たまった。MITM攻撃に見えたし、AIは「GoogleはHTTPSを傍受しない」としてストア審査の可能性をきっぱり退けた。本番APIのログから取り出した実際のIPが、その推定を覆した。
要点まとめ
証明書ピンニングをreport-onlyモードで有効にした最初の週、SentryにSPKIミスマッチが74件(ユーザー9人)上がってきた。アプリが受け取った証明書が我々のサーバーのものと一つも重ならないということは、誰かがTLSを傍受しているという意味だ。端末の指紋を見ると74件すべてが「OnePlus 8 Pro」なのに、x86アーキテクチャで288x448の画面だった。物理端末を装ったエミュレータファームである。正体を絞り込む途中、AIエージェントは「GoogleはあなたのHTTPSをMITMしない」としてストアの自動審査の可能性を退けたが、ミスマッチの時刻に本番APIへ入ってきた実際のIPはすべてGoogleの帯域だった。原理から出た推定が、実測の前で3分で崩れた。
目次
我々のアプリは一度、証明書ピンニングのせいで死んだことがある。2026年4月5日、leaf証明書のSPKIをピンとして埋め込んでいたのだが、ACMが証明書を自動更新した際にleafの公開鍵が変わり、その瞬間に全ユーザーのAPIが遮断された。リモートのキルスイッチもなく、ホットフィックス版のアプリをストアに上げ直すはめになった。それ以来、レポには「証明書ピンニング導入禁止」というルールが残った。
2か月後、我々はピンニングを作り直した。今度はleafではなくAmazon Root CA 4つのSPKIをピンとして埋め込んだ。ルートはACMが更新しても変わらないので、同じ事故は起きない。リモート設定でオン・オフできるキルスイッチも付けた。6月29日、遮断はせず報告だけするreport-onlyモードをリモートで有効にした。
2日後、Sentryにイシューが1件上がっていた。
[CertPinning] SPKI mismatch (report-only)
74 events · 9 users · 2026-06-29
我々が送った覚えのないハッシュ2つ
イベントを開くと、アプリが実際に受け取った証明書のSPKIが2つ記録されていた。
mode: "reportOnly"
host: "web.example.com"
expected_pin_count: 4
served_spki: [
"sha256/9hqPsoMiyQMwLCoRPk6FoCYmOsPiGqzQqUcpuZIfvgs=",
"sha256/r9mjYco6rQO8YkTqr/XXGsQlDUuQqz2mGr67S0imt7M="
]
openssl s_clientで実際のサーバーのチェーンを取り出して突き合わせた。我々のサーバーが送るleafも、中間証明書も、ルートも、この2つのハッシュと一つも重ならなかった。この9人は我々のサーバーの本物の証明書をそもそも受け取っていない。誰かが途中でTLSを切って、自分の証明書で署名し直してアプリに渡していたのだ。
served_spkiが2つしかないことも手がかりだった。正常な経路でアプリが報告するチェーンには、ルートまで含めて3つが入る。2つ(leaf + 中間)だけ記録されているということは、このレポートは別の分岐から出たものだ。
ここで私は最初の結論を出した。正確に言えばAIエージェントが出し、私はもっともらしいと思った。「これは直すべきバグではなく、report-onlyのピンニングが設計どおりに動作して、他人のネットワークで起きている傍受を捕まえたものだ。会社や学校のプロキシ、ウイルス対策ソフトのウェブ保護機能、キャプティブポータルといったものだ。我々には制御できないのでSentryではmuteして、指標としてだけ見よう。」
ついでにバックアップピンを1つ追加しようという提案も付いてきた。私はそれを選んだ。
バックアップピンはこのイシューを1件も減らせない
ピンを追加する作業が始まった直後、私はこれは辻褄が合わないと思って問い返した。
そもそもミスマッチになっちゃいけないんじゃないの?
この一行で、選んだばかりの方向が崩れた。エージェントがAndroidのプローブのコードを読み直して訂正した。
val verified = verifiedChainSpki(offered, host)
if (verified == null) {
// トラストストア検証の失敗 = 傍受/偽造 -> ピン比較なしで無条件に報告
report(host, offered.mapNotNull(::spkiSha256))
return
}
// ピン比較は検証を通過したチェーンに対してだけ行われる
if (verified.none(pins::contains)) { report(host, verified) }
プローブは2段階だ。まずOSの信頼ストアでチェーンを検証し、通ったチェーンだけをピンのリストと突き合わせる。我々の74件は最初の段階ですでに失敗していた。verified == nullなので、ピン比較そのものを経ずにただちに報告される。バックアップピンは2段階目、つまり「検証は通ったがピンが合わない」場合にだけ効く。
結論: Starfield G2をピンに追加しても、このイシューの件数は0件も減りません。
正常なユーザーにはこのレポートが出ようがない、ということもそのとき確認できた。ライブのチェーンは我々のピンと正確に一致するからだ。つまりこの9人は、定義上、正常なユーザーではない。直すべきバグはここにはなかった。この9人が誰なのかを明らかにする必要があった。
無視すれば永遠に有効にできない
「無視してモニタリングしよう」という勧めも、私は受け入れられなかった。
ちゃんと直さないと。これ後で実際に有効にしなきゃいけないのに、こうなってたら有効にできないじゃないか
このピンニングの最終目標はenforceだ。enforceを有効にしてよいかを判断するゲートは、設計文書にすでに書いてあった。2週間の実ユーザーのミスマッチ率が0.5%未満であること。ミスマッチの正体を分からないままにしておくと、この74件がずっとゲートを汚染し続ける。muteすれば74件はダッシュボードから消えるが、ゲートは汚染されたまま残る。
そこでデータをさらに掘った。タグの分布を出した途端に、おかしなものが出てきた。
74件すべてが同じ端末、しかしその端末は存在しない
74件のdeviceタグは100%が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のAndroidスマホもこの世に存在しない。Build.MODELだけを実在の端末名に偽装したx86の仮想端末だ。simulator: falseで物理端末のふりまでしていた。
9人のユーザーIDも人間ではなかった。9人全員が6月29日のその1日にだけ存在し、その1日で1.7.0、1.8.0、1.8.1の3バージョンをすべて動かしていた。人間ではなくパイプラインである。地域は74件すべて米国なのに、都市はすべてnullだった。データセンターの帯域から入ってくるとこう記録される。
ここで私は尋ねた。では誰が我々のアプリを解析しているのか、と。エージェントはセキュリティベンダーのAPK自動解析サンドボックスを1位に挙げ、アプリインテリジェンス企業とAPKミラーサイトがそれに続いた。ストアの自動審査は4位だった。
4位だった仮説が、IP3つで1位になった
私は4位が引っかかった。だから尋ねた。
Google Play ConsoleやApple Storeの場合もある?
返ってきた答えはきっぱりしていた。
Google Play pre-launch report / Test Lab - ほぼありません。決定的な理由: GoogleはあなたのHTTPSをMITMしません。 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件のイベントの時刻を取り出し、3つの塊にまとめた。02:12-02:20、11:30-11:41、17:32-17:40 (UTC)。次に、その3区間にDart/3.12で入ってきたリクエストのIPをCloudWatchで照会した。
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もGoogleだ。3区間すべてにGoogleのIPがあった。そのうち2区間では、その時間帯に入ってきたアプリのリクエストがGoogleのIPひとつだけだった。残りの1区間には実ユーザーと思われるIPも一緒にあったが、GoogleのIPはそこにもあった。
3分後、エージェントが自分を訂正した。
訂正します。先ほど「GoogleはMITMしないので違うだろう」と申し上げましたが、IPの証拠がその推定を覆します。これはGoogleで間違いありません。実測のIPは推定より強い証拠です。
我々のアプリを解剖していたのは、Playに上がっているアプリなら誰もが受ける、Googleの日常的な自動スキャンだった。攻撃者でも、競合他社でも、セキュリティベンダーでもなかった。そのスキャン環境はトラフィックを計測する。だから我々のプローブの目には、検証できないチェーンとして見えていたのだ。
それで何を直したのか
ピンニングはそのままにして、プローブをどこに適用するかを変えた。スキャナ環境ではピンニングをそもそも有効にしないことにした。
何をもってスキャナを見分けるかが残った。simulator: falseをそのまま信じられないことはすでに露呈しているので、スプーフィングされない信号が必要だった。CPUアーキテクチャは隠せない。
// 決定的なシグナル: コンシューマ向けARM端末がx86 ABIを名乗ることは絶対にない。
// GoogleのスキャナはBuild.MODELを実機名に偽装できる(観測: x86上の "OnePlus8Pro")が、
// x86_64であるという事実は隠せない。
if (supportedAbis.any((abi) => abi.contains('x86') || abi.contains('i686'))) {
return true;
}
これに加えて、Cuttlefish/GCE系のハードウェア名とtest-keysのフィンガープリントも検査する。判別器は意図的に「エミュレータだ」の側に偏らせてある。誤検知が出てもその環境ひとつでピンニングをスキップするだけだが、エミュレータでアプリを動かす攻撃者はどのみちアプリをパッチしてクライアント側のピンニングを無力化できるので、失うものはない。逆に見逃しはスキャナをブロック画面に閉じ込めることになるので、はるかに悪い。
これがなぜ重要かというと、enforceをそのまま有効にしていたら、こういうことが起きていたはずだからだ。Googleのpre-launchクローラーが我々のアプリの「安全でない接続」というブロック画面に引っかかる。それがそのままPlay Consoleのpre-launchレポートに失敗として記録される。コードには何の問題もないのに、ストアの審査画面に赤い線が引かれる。
残ったもの
SentryはIPをPIIとしてスクラブする。正しいデフォルトだ。しかしそのせいで、事件の正体を明かしてくれる唯一のフィールドがイベントから消えていた。結局、アプリのUser-Agentと事件の発生時刻という2つを突き合わせて、Sentryが捨てた情報をアクセスログから取り戻した。1つのツールにないからといって、どこにもないわけではなかった。
「GoogleはHTTPSを傍受しない」という文はロジックとしては滑らかだった。ストアのクローラーの目的、標準の信頼ストアの動作、サンドボックスとの振る舞いの違いまで押さえて4位へ押しやった。ところがIPを3つ突きつけると、3分で結論がひっくり返った。AIに原理を尋ねれば原理が返ってくる。その原理が現実と合っているかどうかは、ログを見なければ分からない。
この74件をSentryでmuteしていたら、ダッシュボードはきれいになっただろうし、enforceを有効にした日に我々は理由も分からないままストアの審査で赤い線を食らっていただろう。
我々はまだenforceを有効にしていない。ゲートは2週間の実ユーザーのミスマッチ率が0.5%未満であることを要求する。スキャナを取り除いたので、その2週間を最初から数え直すことになる。
よくある質問
証明書ピンニングをreport-onlyで有効にしたらミスマッチのレポートが上がってきます。バックアップピンを追加すれば減りますか?
レポートがどのコード経路から出たのかをまず見るべきです。ピンニングのプローブは通常2段階です。まずOSの信頼ストアで証明書チェーンを検証し、検証が通ってからそのチェーンをピンのリストと突き合わせます。チェーン検証そのものが失敗すると(傍受・偽造)、ピン比較を一切経ずにただちに報告します。我々のレポートはすべて後者で、だからバックアップピンをいくら追加してもレポートは1件も減りません。バックアップピンは「検証は通ったがピンが合わない」場合にだけ効きます。
SentryのイベントにIPがありません。それでも接続元は分かりますか?
SentryはIPをPIIとしてスクラブするためイベントに残らないことが多く、過去のイベントの元のIPは復元できません。代わりに他のログと時刻を突き合わせればよいです。我々はサーバーのアクセスログがリクエストごとにX-Forwarded-ForとUser-Agentを残していて、アプリのUser-Agentが「Dart/3.12 (dart:io)」で固有でした。Sentryでミスマッチが発生した正確な時刻区間を取り出し、その区間にそのUser-Agentで入ってきたリクエストのIPを照会したら、出どころが見えてきました。
証明書ピンニングをenforceで有効にするとGoogle Playの審査で問題になりますか?
なり得ます。Playの自動スキャン環境はトラフィックを計測しているように見え、その環境でアプリがピンニングのブロック画面を出すと、pre-launchレポートに失敗として記録されることがあります。スキャナやエミュレータ環境ではピンニングのプローブ自体をスキップするようにしておくのが安全です。どのみちエミュレータでアプリを動かす攻撃者はアプリをパッチしてクライアント側のピンニングを無力化できるので、その環境を例外にしても保護対象は減りません。
関連記事
- 💻 開発
DBが落ちたという通知が来た。DBは一度も落ちていなかった
1日のうちにCRITICALの通知が4つたまった。504、Prisma P2028、管理画面の500、そして「DATABASEサービスダウン」。RDSの指標をまず開いたら、21時間ずっとCPU最大19.7%で無事だった。遅かったのはDBではなく、大西洋を25回渡る往復であり、「ダウン」の通知はヘルスチェックが自分で作り出したものだった。
- 💻 開発
AIが直したコードを別のAIに反証させるパイプライン
同じモデルに自分のコードをレビューさせると、自分の出した結論をまた承認する。ほぼすべてのコードをAIと一緒に書く1人開発者が、一つのAIの仕事を別のAIに「反論してみろ」とぶつけるクロスチェックのループを常設パイプラインに固めた話。セルフレビューが見逃したバグを3ラウンド連続で捕まえた実例とプロンプト設計。
- 💻 開発
エージェントの「完了」を信じないことにした - verify.shゲート、prove-it
AIコーディングエージェントはテストを回していないのに「テスト通過」と報告する。嘘ではなく構造の問題だ。報告を検査に変えるオープンソースのゲートprove-itを作りながら学んだこと - そして自作の5行フックが静かに失敗する4つの形。