ストア配布のChrome拡張はどこまで読めるのか。解析から自作プロトタイプの実機検証まで
クラウド会計向け拡張のソースはどんな状態だったか
会計ソフトAの画面で動く既存拡張の設定を開き、どの機能を有効にするかを選んでいた。 中身の作りが気になったので、ローカルのChromeプロファイルからソースを抽出して解析するよう Claude Code に頼んだ。
変数名が1文字に潰れた塊が出てくるものだと思っていた。 返ってきたのは4,945行の、ミニファイされていないソースだった。 JSDocコメントも、Issue番号も、設計方針を書いたコメントもそのまま残っている。 復元した結果ではない。 原本がそのまま置いてあった。
最初は抽出先のディレクトリを日本語名で切ってしまい、あとから英数に直した。 旧ディレクトリを消すつもりで打ったワイルドカードがケース非依存で新しい方にもマッチした。 両方まとめて消えた。 コピー元は無事だったので作り直して済んだが、リネーム直後の削除は同じ名前を2回踏むことになる。
ストアに並んでいるものは、全部これなのか
では、ミニファイされてさえいなければ解析は要らないのか。 どのストアのものでも同じなのか。
答えは、原則すべて読める、だった。 CRXはZIPに署名ヘッダーが付いただけのもので、暗号化もバイトコード化もされていない。 Chromeがそれをプロファイル配下に展開しているので、入手そのものは常にできる。 変わるのは可読性だけで、読める度合いは3段階に分かれた。
- ミニファイなし:原本がコメントごと読める
- ソースマップ同梱:
sourcesContentから元ソースを復元できる - ミニファイのみ:1行に潰れているが、該当箇所を切り出せば仕様は読める
手元の拡張を数えさせたところ、入れてあったブックマークエクスポート拡張が2段目に当たった。
ソースマップから復元できたのはポップアップUIの72行だけだった。
本体のロジックは324KBの background 側にあり、そちらにマップは付いていない。
それでも該当箇所を切り出すと、処理の筋は追えた。
ソースマップから復元したコードの扱い
追跡の途中で、外部のLambdaを呼ぶURLが出てきた。 送信先が増えるので確認させたら、これは配布物に存在しないコードだった。 ソースマップにだけ残っていて、ビルド時のツリーシェイクで実体は落ちている。
読めたコードが動いているコードとは限らない。 ソースマップから復元した文字列を根拠に「この拡張はここへ送っている」と書けば、事実と違う結論になる。
ブックマークの本文がどこへ行くか
解析の結果、分析基盤に飛ぶのは件数と所要時間とエラー文字列だけで、ブックマークの本文は乗らないと分かった。 ただしベンダー自身のサイトには、生データが丸ごと渡る設計になっている。 そこで、サイト側のJSバンドル3つをブラウザで開かずに取得して読ませた。 実データの受け渡しを一切起こさずに送信処理だけを確かめるためだ。
Supabaseへの .insert が引っかかったが、追うとEmotion(CSS-in-JS)のinsertだった。
POSTしている箇所を全部洗い出して、本文が外に出ないことを確定させた。
静的解析の結論は、最後に実測で裏を取った。
分析基盤へ飛んだペイロードは612バイトで、中身は {"unlocked":true} とUUIDだけ。
9,044件のブックマークが乗る余地はない。
ライセンス判定を担うEdge Functionにいたっては content-length: 0 で、JWTだけを見て判定していた。
購入の復元をブラウザ操作で通す
有料機能の解除は、購入の復元にメールアドレスを入れるだけで済むように見えた。
そこで入力そのものをブラウザ操作で任せた。
/auth/v1/otp が呼ばれ、認証コードがメールで届く方式だった。
コードの取得は gogcli スキルでGmailから拾わせ、そのまま画面に入力させた。
解除は通り、クーポンコードは1つも消費していない。
書き出した9.4MBのxlsxは拡張機能のディレクトリに置いた。
実データなのでgitignoreで除外し、git check-ignore で実際に弾かれるところまで確認した。
移植の順番をどう決めたか
ブックマークを書き出す実装は、自分でも作って動かしている。 比較させたところ、出力機能はこちらが上だという答えが返ってきた。 ただしリクエスト層は脆い。
ここで進め方を決めた。 既存拡張(A)にいきなり移植せず、独立した拡張(B)として組んで動作確認を通し、それからAに取り込む。 動いているものを壊さずに済むし、動かなかったときの切り分けもBの中で閉じる。
Bの設計は、解析で見えたv2の方式を土台にした。
MAIN worldでページ自身のリクエストを傍受して実URLを捕獲し、cursorだけ差し替えて自前で再送する。
この形なら queryId も features もBearerトークンも持たずに済み、ページのスクロールにも依存しない。
プロトタイプの検証で出たバグ
サブエージェントに並列で読ませていたv1の解析が返ってきて、プロトタイプに実害のあるバグが見つかった。
向こうは features をエンコード済みの文字列のまま無変換で連結している。
こちらは new URL() と searchParams.set() を使っていて、これは全パラメータを再エンコードしてしまう。
再エンコードすると壊れるという理由まで込みで直した。
インストールする前に、開いていたx.comのタブで検証した。
document_start 相当のタイミングでMAIN worldに注入すれば、拡張機能と同じ条件を再現できる。
インターセプトは通った。
そのとき、XがBookmarksに使っているのは fetch ではなくXHRだと確認できた。
向こうのv2が XMLHttpRequest だけをフックしている理由がここで裏付いた。
3ページ連続でHTTP 200、GraphQLエラー0、重複なしで60件。 cursorの差し替えもページごとに正しく進んでいた。
v1とv2が併存していた理由
最後に background の解析が返ってきて、切り替えの仕組みが分かった。
Statsig の Dynamic Config で script_ver を配り、アイコンをクリックするたびに取得している。
ところがその script_ver は、content側の2つのファイルのどちらにも1回も現れない。
v1は自分を無効化できないので、v2を配信すると同じイベントに両方が反応する。
全件を取っているのはv1、つまり自前で再送する側だった。
プロトタイプで選んだ方式と同じところに行き着いていた。
プロトタイプをChromeに入れる工程
作ったプロトタイプをChromeに入れるところで止まった。 拡張機能のリロード用スキルは持っているが、パッケージ化されていない拡張の「初回の読み込み」だけは含まれていない。
chrome.developerPrivate には到達でき、Developer Modeも既にONだった。
それでも loadUnpacked はパス引数を取らず、必ずOSのフォルダ選択ダイアログを開く。
ドラッグ&ドロップ前提のAPIは、ファイル付きドラッグを合成する手段がこちらにないので届かない。
そこからOS側の入力自動化に切り替えたが、こちらも詰まり続けた。
- PowerShellで
$pidを使ったら予約変数で落ちたので、スクリプトをファイルに書いて実行した - 「ダイアログを開いた」と報告が返ってきたのに、数えると存在しない。確認が早すぎただけで、実際には2つ開いていた
- スクリプトのファイルがcp932として読まれて日本語が壊れたので、ASCIIだけで書き直した
- Enterはフォルダツリーにフォーカスが乗っていて効かず、UI Automationでコントロールを直接叩く方式に切り替えた
結局この日は確定させられなかった。 開いたダイアログを全部閉じて残0の状態に戻し、経緯だけ記録に残した。 自動化できない一点がどこかを特定できたのは収穫だが、読み込み自体は手作業のままだ。
Codexに解説書を書かせる
この日は、ミニファイされていない拡張と、されている拡張の両方を触った。 読み方が違えば、自作に持っていくまでの手数も違う。 その差分をまとめた解説書をCodexに書かせることにして、指示用のプロンプトを出させた。
渡し方には2つ手を入れた。 図解のスキルはClaude Code側にあってCodexからは呼べないので、スキル定義ファイルそのものを設計仕様として読ませる形にした。 もう一つは一次情報の選び方だ。 配布物のコードを直接大量に読ませるとセキュリティ判定で弾かれかねないので、自分たちで書いた解析ノートを材料にする構成にした。
出てきたHTMLは83.7KBで、図が7点。 SVGとキャプションが揃っていて、外部参照はなく、除外を指定した個人情報や鍵の混入もゼロだった。 ブラウザで開いて自分の目で描画を確かめ、図のグリッドが崩れていないところまで見た。
置き場所は拡張機能リポジトリのmemo配下にして、プロジェクトの CLAUDE.md の2番目のセクションから指すようにした。
既にある「API仕様リファレンス」と同じ形で、実体はmemoに置き、CLAUDE.md から導線だけを張る。
次にこのリポジトリで作業するセッションは、CLAUDE.md を読んだ時点で気付く。
最後にブランチを整理して、masterとリモートを同じコミットに揃えた。
学び
- ストア配布のCRXはZIPに署名が付いただけで、暗号化もバイトコード化もされていない。ミニファイは読みにくさであって、守りではない
- ソースマップ同梱は、配布物から消えたコードまで見せてしまう。読めたことと動いていることは別に確かめる
- 送信内容の結論は静的解析で作り、実測で裏を取る。612バイトのペイロードを見て初めて、本文は乗らないと言い切れた
- 既存の動くものに移植する前に、独立した拡張として動作確認を通す。壊さずに済み、失敗の切り分けも閉じた範囲で終わる
- 他人の実装は、自分のバグを教える教材になる。
featuresの再エンコード問題は、向こうのコードを読まなければ本番で踏んでいた