webpackバンドルを丸ごとダンプし、ASTを切り分け、末端のプリミティブを洗い出す。既知の手順でフィンガープリントも読めるようになった。ところが、リクエストヘッダーにある署名値だけが毎回変わり、それを生成する関数が見つからない。怪しいビット演算やハッシュらしい断片を静的アセット全体から探しても、何も出てこない。
検索が足りないわけではない。そのロジックは、ダンプしたコードの中に存在しない。
静的解析では必ず空振りする署名がある。アルゴリズム自体が実行時になって初めて現れるからだ。この種の対象で、存在しない関数を探し続けても意味はない。方針を切り替えよう。アルゴリズムをリバースするのではなく、可能な限り小さな形で実行するか、値を読み取る。
静的解析の対象ではないと判断する2つのサイン
まず、本当にこのタイプの対象なのか、それとも単に何かを見落としているだけなのかを切り分けたい。分かりやすい兆候は2つある。
1つ目は、値がセッションごと、あるいはリクエストごとに変わるのに、静的アセット内には生成処理がないケースだ。ネットワークリクエストには毎回違う値が見える。全 .js を取得して全文検索しても、それを組み立てる場所はない。その処理は実行時に配信されている。
2つ目は、バンドル内にリテラルとして見つかるものの、昨日控えた値が今日は使えないケースだ。ビルド出力には単なる文字列定数として埋め込まれており、その日にコピーすれば使える。ところが数日後にはエンドポイントがエラーになり、見直すと、その「定数」は別の新しいリテラルに入れ替わっている。ビルド時にはハードコードされているが、フロントエンドのリリースごとに更新される値だ。
どちらにも共通するのは、静的な観測結果がスナップショットにすぎず、本当の値は時間やセッションに応じて流動することだ。コードを探しても見つからないか、見つかっても生きている。いずれにせよ、「一度静的にダンプしてコピーする」という方法が通用しなくなっている。これは「アルゴリズムの系統を判別できない」問題とは別物だ。フィンガープリント照合の精度が低いわけではない。手元にあるコードのコピーには、照合すべき対象そのものが入っていないだけだ。
タイプ1:サーバーが実行すべきアルゴリズムを配信してくるチャレンジ
1つ目のタイプでは、あるエンドポイントに到達する前に、サーバーがワンタイムのJSを配信してくる。そのスクリプトがブラウザ内で実行され、cookieやtokenを生成する。それを持たなければ先へ進めない。スクリプトの内容は通常セッションごと、場合によってはリクエストごとに異なる。
静的解析で必ず空振りする理由は明白だ。ロジックは実行時に配信され、静的バンドルにはそもそも含まれない。たまたま捕捉したスクリプトも、その時点の1インスタンスにすぎない。今日ダンプしたものと明日配信されるものは別のコードかもしれず、リバースしている間にも標的は動き続ける。
正しい戦略は、ブラックボックスとして実行することだ。何を計算しているかを理解する必要はない。結果が出るまで動かせる程度に本物らしい実行環境を与え、生成された結果だけを取り出せばよい。実務では次のように進める。
最小限のブラウザ環境シムを作る。
document、location、navigator、cookie、いくつかのObserverとタイマーに空のスタブを用意し、グローバルオブジェクト不足で例外が出なくなるところまで必要に応じて埋めていく。配信されたソースを、実行タイムアウト付きのローカルNode/V8サンドボックスで動かす。
node:vm、またはexecjsで起動したプロセスを使う。スクリプトは実行中にcookieを書き込む、あるいは何らかのグローバルへ値を格納する。その書き込みをフックし、必要なtokenを抜き出す。
Zhihuの訪問者チェックはこの形だ。配信されたスクリプトが訪問者識別子をブラウザcookieへ書き込み、こちらはDOM環境をシムして実ページ上で動いていると信じ込ませ、完了後に値を取得する。ここでリバースしたアルゴリズムの行数はゼロだ。スクリプトが自分の処理を演じられるだけの舞台を用意したにすぎない。したがってコストがかかるのは「アルゴリズムを理解すること」ではなく、「環境を十分に本物らしく保つこと」になる。スクリプトは navigator.webdriver を見たり、特定ノードの存在を確認したり、APIからの戻り値を期待したりする。シムはそれを正確に欺きつつ、保守不能なほど大きくしてはならない。このトレードオフが、後述する「最小実行面」のテーマだ。
タイプ2:コードにはあるがリリースごとに変わる動的ID
2つ目は逆のパターンだ。値は静的バンドル内にリテラルとして確かに存在する。ただしビルド時に生成され、フロントエンドのリリースごとに差し替わる。
典型例は、プレーンテキストのGraphQLではなく事前登録済みクエリを使うモダンなフロントエンドだ。タイムライン取得や検索結果取得といった各操作には、ビルド時にoperation idまたはquery idが対応付けられ、エンドポイントパスに載せられる。このidはビルド出力から検索できる。しかしフロントエンドがリリースされると、同じ操作のidも新しい値へ変わる。
このタイプで静的解析は、「見つけた」という誤った安心感を生む。値を発見し、コピーして、その日は動いたのでハードコードする。2週間後にエンドポイントが400を返し、そこで初めて、その「定数」が生き物だったと気づく。見つけたつもりになっている分、タイプ1より厄介だ。
正しい戦略はリバースではない。アルゴリズムなど存在せず、ビルド定数だからだ。リクエスト時点で現行ページから現在の値を読み取り、セッション中はキャッシュする。取得方法には安価なものから高価なものまで段階があり、前の手段から試すべきだ。
まず、ページがすでに読み込んだリソースを見る。ページ自身がこの識別子を載せたリクエストを送っているため、現在の値はURLに存在する。リソース記録から抜き出せばよい。既存の事実を読むだけで、アルゴリズムに一切触れない最安の方法だ。
そこから取得できなければ、スクリプトソースをパターンで抽出する。現在のバンドル内で「この操作名はこのidに対応する」と宣言している箇所を見つけ、その値を取る。
それでも失敗したときだけ、バンドラーのモジュールテーブルを掘るか、手がかりを基に関連バンドルを取得して解析する。最もコストが高いので最後の手段にする。
Twitterのタイムラインや検索のoperation idも、まさにこの方法で取得できる。ページがすでに送信したGraphQLリクエストのURLからidを抜き出し、取得後はセッション中キャッシュして使い続ける。
事前登録済みクエリを得る方法の体系、それぞれのコスト、選び分けは、persisted operationに関する記事で詳しく扱っている。ここでは、これも実行時由来の値という大きな分類に属する、とだけ押さえておけばよい。
2タイプの違いを整理する
並べてみると、それぞれで何をすべきかは明確になる。
タイプ1:チャレンジ | タイプ2:動的ID | |
|---|---|---|
正解がある場所 | 実行時に配信される。静的コードには存在しない | 静的コードにあるが、リリースごとに変わる |
取るべき行動 | 実行する:本物のアルゴリズムを動かし、副作用を取得する | 読み取る:定数の位置を特定して取得し、アルゴリズムは実行しない |
失敗の現れ方 | 環境シムが足りず、コードが完走しない | バンドル構成の変更で取得処理が外れ、古い値または空の値を取る |
変更の主体 | サーバー。いつでも変更可能 | フロントエンドのリリース。デプロイ頻度に従う |
まとめるならこうだ。どちらも「一度静的にダンプしてコピーする」手法を壊す。違いは、実際にコードを動かす必要があるかどうかだけだ。自分がどちらにいるかを見極めれば、次に必要なのがサンドボックスか抽出器かは直ちに決まる。
この対象で無理にピュリファイしない理由
ここで反論はあるだろう。配信スクリプトのアルゴリズムを完全にリバースしたり、動的IDの生成規則を完全に再構成したりして、元のランタイムから切り離したネイティブ実装にできないのか、と。4段階ワークフローのステージ4にあるピュリファイだ。最もクリーンな形であり、一度の投資で長期的に依存をゼロにでき、CIにも組み込める。
ただし、この2タイプではたいてい採算が合わない。ざっくり使える回収モデルは次のとおりだ。
ピュリファイには一度だけCのコストがかかる。アルゴリズムのリバースと、差分テストによる検証のコストだ。ピュリファイ後は、「毎回実行または読み取りを行う」方法と比べ、単位時間あたりsを節約できる。回収期間はおよそ C / s となる。
決定要因はCでもsでもない。対象がどれだけの頻度で変わるか、変更サイクルTだ。
T < C/s:回収前に対象が変わる。リリースから数日でピュリファイ済み実装が一致しなくなり、作り直しになる。投資対効果はマイナスだ。
T が C/s より十分に長い:ピュリファイは確実に得になる。一度の投資が長く持つため、ピュリファイの梯子を上り、ネイティブ再実装の層へ進むべきだ。
2タイプは性質上、異なる位置に落ち着く。
タイプ1のTはサーバー側に支配され、いくらでも短くなり得る。配信スクリプトのロジックはいつでも変えられ、こちらから予測できない。そのため、ほぼ常に「実行」側に寄る。明日には変わるかもしれないアルゴリズムを無理にピュリファイすると、相手のリリース周期に自分の運命を預けることになる。
タイプ2のTはフロントエンドのリリース周期であり、数日から数か月だ。リーダーを堅牢にするほど、つまりフォールバックを少し増やし、アンカーを安定させるほどC/sは下がる。どちらも合理的になり得るので、実際に計算して決めることになる。
これがタイトルの意味するところだ。コード内に見つからないからといって、見落としとは限らない。そこにはピュリファイする価値のある安定したアルゴリズムが、そもそも存在しないのかもしれない。
最小実行面をどう設計するか
「ピュリファイ」ではなく「実行」を選んだら、エンジニアリング上の目標も変わる。目指すのはクリーンさではない。実行面を最小かつ制御可能で、問題の帰属先が分かる状態まで絞り込むことだ。重要なのは3点ある。
必要な断片だけを動かす。 ページのランタイム全体を持ち込んではいけない。アルゴリズムが実際に依存するコードと、最小のシムだけを渡す。シムのサイズには適正値がある。例外を出さずコードを動かすだけの、十分に小さいサイズだ。スタブを1つ増やすごとに保守対象が増える。相手が検査を変えれば追従が必要になる一方、1つでも足りなければ即座にクラッシュする。手間を省くためにブラウザ環境全体を移植してはいけない。それでは署名器ではなく、ブラウザ半分を保守することになる。
コンテキストを固定する。 コンパイル済みのV8/Nodeコンテキストはスレッドセーフではない。ワンタイムチャレンジなら、都度新しいサンドボックスを開くため互いに干渉しない。しかし起動コストを抑えるためコンパイル済みコンテキストを再利用したり、動的IDパーサーをキャッシュしてリクエスト間で共有したりするなら、並行呼び出しを直列化する必要がある。
class RuntimeSigner:
def __init__(self, source: Path) -> None:
self._context = execjs.get("Node").compile(source.read_text())
self._lock = threading.Lock() # V8 context is not thread-safe
def call(self, fn: str, *args):
with self._lock:
return self._context.call(fn, *args)
失敗の原因を帰属可能にする。 実行型の値取得で最も見落とされやすく、最も時間を節約できる部分だ。失敗時には、どの層で問題が起きたか分からなければならない。
環境が足りない:コードが
ReferenceErrorを投げる、またはタイムアウトまでハングする。シムに、スクリプトが必要とする何かが欠けている。プロトコルが変わった:コードは完走して出力も得られるが、形式が違う、JSONとしてパースできない。出力フォーマットが変更された。
セマンティクスを満たさない:値は取得でき、パースもできるが、不完全であるか、実際に使うと検証に失敗する。アルゴリズム自体が変わった。
この3つに必要な対応はまったく異なる。シムを修正する、プロトコルに追従する、アルゴリズムを確認する、の3通りだ。「失敗した」とだけ報告する実行器では、毎回ゼロから調査することになる。成熟したチャレンジ実行器なら、実行失敗、パース不能な出力、不完全な結果、タイムアウトをそれぞれ別のエラーとして扱う。
工程ごとに使うべきモデル
このフローにおけるモデルの役割は、フィンガープリント作業とは異なる。フィンガープリントではアルゴリズム系統を識別する。ここでは識別すべきアルゴリズムがなく、主な仕事は「ピュリファイか実行か」というアーキテクチャ判断を支援すること、そして実行失敗の原因を帰属することだ。4つの小工程でモデルに求めるものは大きく違う。
小工程 | 必要な能力 | 選択 | model id |
|---|---|---|---|
ピュリファイか実行かを決める(両論を検討する) | 強い推論力。自らの結論に反論できること | Claude Opus 5 |
|
動的IDの隠れ場所を特定する:定数注入ポイントを探し、バンドル全体で候補を読む | 長いコンテキスト。バンドル全体を一度に読めること | Kimi K3 |
|
対象の操作を含みそうな候補スクリプトを一括フィルタリングする | 低コスト。高並行で数百回呼び出せること | Claude Sonnet 5 |
|
実行失敗時にログやスタックを読み、どの層で失敗したか判定する | 中程度の推論力。特定のエラーに沿って説明できること | GPT-5.6 Sol |
|
特に注目したいのは1行目だ。この工程はモデルを変えると結果の差が目に見えて出る。求められるのは、両方の立場を論じ、自分自身に反論する能力であり、フィンガープリント作業における反証セクションと同じ能力でもある。弱いモデルは一方の道を選び、その結論を支持する理由だけを積み上げ、もう一方を真剣に検討しない。強い推論モデルは「ピュリファイ」と「実行」をそれぞれ最後まで論じ、各案で最も強い根拠と失敗モードを書き出したうえで、比較によって結論を出す。
違いは、実際に試せば分かる。
すでに判断を下した実在の対象を、コントロールとして使う。実行すべきかピュリファイすべきか、感覚的にも分かっている対象がよい。
観測済みの事実、つまりロジックが実行時に配信されるのかリリース定数なのか、変更頻度、環境依存の深さを
claude-opus-5とgpt-5.6-solに渡し、それぞれに「ピュリファイ vs 実行」の判断メモを書かせる。見るべき点は2つある。「変更サイクル」を決定変数として認識したか。実装難易度だけを比較しているなら失格だ。もう1つは、結論を覆す条件が観測可能な形で示されているか。「次のリリースで識別子が変わらなければピュリファイへ戻す」のように、トリガーシグナルまで含まれていれば使える。
1ラウンド試せば、判断しているモデルと、ただ結論を代行しているだけのモデルを見分けられる。
本当の障害はモデルの切り替えコスト
3ベンダーの4モデルを使うとなると、SDKは3種類、認証方式も3種類、エラー形式も3種類になる。小工程ごとにモデルを変えるだけでクライアントを書き直すのは割に合わない。そのため多くの人は最初から最後まで1つのモデルで済ませ、「ピュリファイか実行か」で自分に反論できないモデルを使い、相手が明日変える対象のピュリファイへ突っ込んでしまう。そして、そもそもの方向が間違っていたと気づくまで遠回りする。
AIReiterはその層を取り除く。キーは1つ、インターフェースはOpenAI互換で統一され、背後に4つの層がある。リクエストボディの model フィールドを変えるだけで切り替えられる。
# Purify or execute: the reasoning tier arguing both sides
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<observed facts + argue the strongest case for both purify and execute>"}]
}'
# Locate the dynamic identifier across the bundle: change the model field, leave the rest
# "model": "kimi-k3"
# Bulk-filter candidate scripts:
# "model": "claude-sonnet-5"
# Attribute an execution failure:
# "model": "gpt-5.6-sol"
すでにOpenAI SDKを使っているなら、base_url を https://aireiter.com/api/v1 に向けるだけでよく、それ以外は変えなくてよい。Anthropic SDKでは、同じキーで POST /api/v1/messages を呼び出せる。
料金面では、このフローのトークン負荷は偏っている。Kimi K3は動的IDを探すためにフロントエンドバンドル全体を読み、入力ごとに数十万トークンになる。Claude Sonnet 5は候補スクリプトを一括フィルタリングし、数百回の呼び出しになりやすい。この2つが請求額の大半を決める。Claudeの30%オフは一括フィルタリング(Sonnet)と判断の論証(Opus)に効き、GPTの半額は失敗原因の帰属(GPT-5.6 Sol)に効く。長いコンテキストでバンドル全体を読むK3も同じキーで呼び出せる。割引が適用されるのは、汎用的な安価モデルではなく、最もトークンを消費するバッチと最も高価な推論層だ。
登録なしで試す:まず手作業で「ピュリファイ vs 実行」の判断メモをいくつか実行し、変更サイクルの認識という点で2モデルを比較してから、組み込むか決める。
まとめ
静的解析で何も見つからないからといって、必ずしもスキル不足とは限らない。対象の性質による場合もある。署名がコード内にないのは、実行時に配信されるからか、リリースごとに変わるからだ。
この2タイプでは、存在しない関数を探すのをやめよう。チャレンジ型なら、結果が出る程度にだけ本物らしいサンドボックスを用意し、最小限に実行する。動的IDなら、実行時に読み取り、セッション中キャッシュする。どちらの道でも最初に必要なのは、「静的スナップショット」という見方自体がもう通用しないと認めることだ。
そして「ピュリファイか実行か」は、純粋なアーキテクチャ判断だ。決める変数は1つ、対象の変更サイクルが一度きりの投資回収期間を上回るかどうかである。短周期の対象、特にサーバーがいつでも配信内容を変えられるタイプでは、ピュリファイを強行すると投資対効果はマイナスになる。両論を検討する作業は、自分自身に反論できる推論層に任せよう。思いつきで決めず、モデルにピュリファイを決めさせてもいけない。ピュリファイは決定論的なエンジニアリングであり、その検証は差分テストに属する。これは4段階ワークフローのステージ3と4で扱う領域だ。ここでモデルに助けてもらうのは1点だけ。この対象を、そもそもリバースする価値があるのかを考えることである。