webpackから吐き出された署名SDKを開くと、変数名は a や _0x3f2b ばかり。制御フローは平坦化され、文字列リテラルは配列の添字参照に隠れている。目標は明快だ。このコードを切り離し、元の実装と1バイトも違わない出力を返すスタンドアロン実装にする。
いまなら、多くの人がまずバンドル全体をLLMへ投げて「このコードは何をしている?」と聞くだろう。返ってくる説明はもっともらしい。実装もできる。なのにターゲットとの出力は、わずか1バイトすら一致しない。
モデルの能力不足が原因ではない。役割分担を間違えている。難読化解除では、LLMはパターン認識と仮説生成に強く、事実の検証には弱い。ビット演算の山に埋もれた暗号プリミティブの骨格は見つけられても、その判断が正しいかどうかは教えてくれない。そこを決めるのはテストだ。
以下では、この分担を前提にした4段階の手順と、私がモデルに任せない3つの作業を紹介する。
Stage 1:機械的な切り分けはモデルに渡さない
「コンテキストウィンドウは広いのだから、全部入れればいい」と考えがちだが、やめた方がいい。理由は2つある。
まず無駄が多い。難読化バンドルの大半は、ターゲットと無関係なポリフィル、ランタイムshim、業務モジュールだ。それらをコンテキストに詰め込むコストを払い、薄まった注意しか得られない。
さらに重要なのは、コンテキストが大きいほど、幻覚が紛れ込める場所も増えることだ。モデルは無関係な2モジュールの特徴をつなぎ合わせ、内部では筋の通った、しかし実在しない結論を返す。この種の誤りは、明白なナンセンスよりずっと見つけにくい。
分割は決定的に処理できる作業だ。スクリプトで済ませよう。
ASTツール(
@babel/parser、acorn)でバンドルをモジュールと関数へ分解し、スコープごとにインデックス化する。数値リテラルと文字列定数をすべて抽出し、出現頻度とビット幅でグループ化する。
コールグラフを作り、入次数0のノード(エントリポイント)と出次数0のノード(末端プリミティブ)に印を付ける。
ビット演算の密度が異様に高い関数を探す。
^、>>>、<<、&が1関数に集中していれば、そこがアルゴリズムの核であることが多い。
モデルに見せるべきなのは、この末端プリミティブだ。通常は数十行程度で、上位レイヤーの状態に依存せず、入力と出力の境界も明確になっている。40行の末端関数と、それが参照する定数。この粒度ならモデルは安定して扱える。
この段階を終えたら、「候補プリミティブ一覧」ができているはずだ。各エントリには関数本体、参照定数、呼び出し元を持たせる。以降のモデル呼び出しは、この一覧の1エントリだけを、一度に1つずつ対象にする。
Stage 2:アルゴリズム系統の特定こそLLMの仕事
ここはモデルを置き換えにくい工程だ。
暗号やエンコードのアルゴリズムには、強い指紋がある。固有の定数、特定のシフト量の組み合わせ、特徴的なループ構造だ。人間は経験の蓄積でそれを見分けるが、モデルは公開実装を幅広く見ているため、この仕事を自然にこなせる。
「指紋」とは何か、公開情報からいくつか例を挙げる。
0x811c9dc5と0x01000193の組み合わせは、32ビットFNV-1aハッシュのオフセット基底値と素数だ。共同作者のLandon Curt Nollが管理する FNV reference page には、標準値として10進数の2166136261と16777619で掲載されている。0x61707865, 0x3320646e, 0x79622d32, 0x6b206574は、ASCII文字列"expand 32-byte k"をリトルエンディアンのワードにしたもの。RFC 8439 §2.3 に記載されているChaCha20の初期状態定数だ。a += b; d ^= a; d = rotl(d, 16); c += d; b ^= c; b = rotl(b, 12);のような構造に、16/12/8/7という4つのローテーション量が並べば、ChaCha20のquarter roundの指紋である。兄弟のSalsa20は7/9/13/18を使うため、この4つの数値だけで両者を判別できる。0xd76aa478で始まる64エントリの定数テーブルは、MD5のTテーブルだ(RFC 1321 §3.4)。0x63, 0x7c, 0x77, 0x7bで始まる256バイトのテーブルは、AES S-boxである(FIPS 197、Table 4)。0xEDB88320は反射CRC-32の多項式であり、gzip spec, RFC 1952 でも使われている値だ。
勝負は質問の設計で決まる。「このコードは何をする?」と聞けば、文章による説明しか返らない。必要なのは検証可能で構造化された判断だ。そのため、候補・根拠・反証の3部構成を強制する。
以下は難読化バンドルから抽出した末端関数と、
その関数が参照するすべての数値定数です。
<function>
{{function body}}
</function>
<constants>
{{constant list, with locations}}
</constants>
以下の構成で回答してください。文章形式の説明は書かないでください。
1. 候補となるアルゴリズム系統(可能性順に最大3件)
各候補について、名称と分類を示してください
(hash / stream cipher / block cipher / encoding / compression / checksum)
2. 裏付けとなる根拠
すべての根拠は、上記の特定の定数値または特定の行を指すこと。
「構造が似ている」など、検証できない表現は禁止です。
3. 反証と標準実装からの差分
これがそのアルゴリズムの標準実装なら、ここにあるべきなのに存在しないものは?
標準実装には通常あり得ないのに、ここに存在するものは?
その差分は「variant」なのか、それとも「I got it wrong」なのか?
4. 仮説を判定する最小テスト
具体的な入力を3つ挙げ、仮説が正しい場合に各入力から得られる出力の形を示してください。
境界ケースも含めること。
このプロンプトの価値は、Part 3にある。標準実装とのズレこそ、このコードが変更されている箇所だ。独自アルファベット、差し替えられた定数、変更されたラウンド数などである。書き直すときに実際に苦労するのもそこだけで、それ以外は公開実装からコピーできる。モデルにこうした差分を漏れなく出させる方法は、the algorithm-fingerprinting piece で詳しく扱っている。
Part 4はモデルの判断を、そのまま次に実行するテストへ落とし込む。往復を1回減らせる。
なお、この工程では非標準エンコードに出会うことも多い。判定は機械的だ。アルファベット長が65(64文字とパディング記号1つ)、6ビット単位でグループ化され、出力長が ceil(n/3)*4 ならBase64系である。文字の並びが違えば独自テーブルだ。同様に、辞書が256から始まり、辞書の成長に応じて出力コード幅が増すならLZWである。いずれもコードを1行も読まず、入出力長の関係だけで検証できる。
Stage 3:仮説を差分テストに変える
モデルから得たのは仮説だ。テストを書くまでは、ただの一文にすぎない。
ここに近道はない。そしてワークフロー全体で幻覚を止められる関門はこれだけだ(なぜこれだけなのかは、the differential-testing piece で説明している)。元実装をブラックボックスとして扱い、書き直した実装とケースごとに比較する。
# 差分テストの骨格:元実装をブラックボックスとし、書き直し版をケースごとに比較する
CASES = [
b"", # 空入力:初期状態とパディング処理を露出させる
b"\x00", # ゼロバイト1つ
b"\xff", # 上位ビット付きの1バイト:符号ビット処理を確認
b"a" * 63, # ブロック境界の1つ手前
b"a" * 64, # ちょうど1ブロック
b"a" * 65, # 1つ超過:パディングとキャリーを確認
bytes(range(256)), # 全バイトを網羅:アルファベットのマッピングを確認
]
for case in CASES:
assert rewritten(case) == blackbox(case), case.hex()
実務では、次の原則を押さえておくとよい。
境界をまたぐケースが最も多くの情報を持つ。 ブロックアルゴリズムでは、パディング処理は 64n と 64n±1 で最も表れやすい。全ケース中で失敗が1件だけなら、その入力長からどの層が壊れているかを直接絞り込める。
同じ入力を2回実行する。 結果が異なるなら、乱数またはタイムスタンプが混入している。注入箇所を見つけて外部から上書きできるようにしない限り、差分比較そのものができない。署名ロジックの書き直しで最もよく詰まるポイントだ。アルゴリズムが誤っているのではなく、エントロピー源を切り出せていないだけである。
全体を比べず、層を1つずつ剥がす。 まず最内層のハッシュを一致させる。それが通ってからエンコード層、最後に組み立て層を検証する。出力全体が不一致では故障箇所が分からないが、分解していれば最初に失敗した層が問題の層になる。
固定ベクターはfixtureとしてコミットする。 既知の入力 → 既知の出力の表があれば、上流の更新後に「自分の実装が間違っているのか、それとも相手が変えたのか」をすぐ判断できる。このfixtureの価値は時間とともに増す。
この段階でモデルに任せるのは、テストケースの生成と差分の説明までだ。正誤を裁くのはモデルではない。判定するのは assert である。
Stage 4:実装を着地させ、転送レイヤーごとに後退させる
すべての仮説が通ったら、長期運用できるコードへ落とし込む。優先順位があり、上位の層ほど粘って守るべきだ。
ターゲット言語によるネイティブな書き直し。 元ランタイムから完全に切り離し、標準ライブラリだけに依存させる。追加プロセスも追加依存もなく、CIへ素直に組み込める唯一の形式だ。
最小断片をローカルJSエンジンで実行する。 短期的には純化コストが高すぎるロジックもある。その場合は元JSの小さな断片だけを残し、ローカルのNode/V8で動かす。ただしJSエンジンのコンテキストはスレッドセーフではない。コンパイル済みコンテキストを複数スレッドから呼ぶならロックが必要になる。
class Signer:
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)
受動的なブラウザブリッジ。 実ページのランタイムからしか取得できない状態があるなら、当面の選択肢はブラウザしかない。これは暫定形態だ。インターフェースの注記で明示し、決してデフォルト実装にしてはいけない。
この後退順序は、プロジェクトの規約に書いておく価値がある。1段下がるたびに、依存関係の面積、障害モード、デプロイコストはいずれも桁違いに増える。tier 1は純粋関数だが、tier 3は人がセッションを維持する必要のある外部プロセスだ。標準ではtier 1を守るようにすれば、「まず動かそう」が静かに積み上げる長期コストの大半を防げる。3つのtierをどう区切るか、受動的ブリッジをどう暴走させないかは、the purification-ladder piece にまとめている。
参考値を1つ挙げる。難読化された署名SDKを4段階すべてに通すと、ランタイム内蔵の crypto だけに依存する、600行未満のスタンドアロン実装に収まる。これはモデルが元ファイルを「理解」したからではない。アルゴリズム系統を正しく特定できれば、コードの大半は公開実装からそのまま持ってこられるからだ。
工程ごとに選ぶべきモデル
4つの工程が求める能力はまったく異なる。最初から最後まで1つのモデルで済ませると、コストか精度のどちらかを無駄にする。
工程 | 実際に必要な能力 | 私の選択 | model id |
|---|---|---|---|
分割後の構造マッピング | 長いコンテキストで、モジュール全体のコールグラフを一度に読む力 | Kimi K3 |
|
アルゴリズム系統の特定と反証 | 強い推論力。差分を見つけ、自説に反論できること | Claude Opus 5 |
|
大量のシンボル改名、コメント補完 | 低コストで、数百回の呼び出しを高並行で回せること | Claude Sonnet 5 |
|
差分の原因帰属(テスト失敗時にdiffを読む) | 中程度の推論力。特定のバイトを根拠に説明できること | GPT-5.6 Sol |
|
特に考えるべきはStage 2だ。モデルを替えて目に見えて結果が変わるのは、アルゴリズム系統の特定だけである。ここで試しているのは、まさに「どれだけ多くの公開実装を見てきたか」と「自分の仮説に反論する気があるか」だからだ。同じ末端関数を渡しても、弱いモデルは自信満々に誤答を返し、強いモデルは反証セクションで自らの候補を取り下げる。
違いは自分で試すのが早い。手順はこうだ。
自分の難読化バンドルから末端関数を3つ選ぶ。そのうち少なくとも1つは、答えをすでに把握しているものをコントロールとして含める。
Stage 2の3部構成プロンプトを使い、同じ入力を
claude-opus-5とgpt-5.6-solに別々に渡す。見るのは2点だけ。候補の系統を当てたか。そして反証セクションで本当に自説へ反論しているか、それとも候補を言い換えているだけか。
反証セクションの質を選定基準にする。ここが、Stage 3で無駄なテストを何本書くことになるかを直接左右する。
Stage 3とStage 4では、モデル選定はほとんど重要ではない。動くものなら何でもよい。Stage 1に必要なのは、自前で分割検索を実装する手間を省くための長コンテキストモデルだけだ。
問題はモデル選びではなく、切り替えのコスト
3ベンダーにまたがる4モデルなら、SDKも認証方式もエラー形式も3種類になる。わずかな節約のためにクライアントを書き直す価値は薄い。だからこそ、多くの人は結局1モデルをすべてに使う。
AIReiter はその層を取り除く。キーは1つ、インターフェースはOpenAI互換で1つ、その背後で4モデルを使え、切り替えはリクエストボディの model フィールドを変えるだけだ。
# アルゴリズム系統の特定:推論tier
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": "<Stage 2 prompt + leaf function + constants>"}]
}'
# 大量のシンボル改名:modelフィールドだけを変更し、他はそのまま
# "model": "claude-sonnet-5"
すでにOpenAI SDKを使っているなら、base_url を https://aireiter.com/api/v1 に向けるだけで、ほかは変えなくてよい。Anthropic SDKなら、同じキーで POST /api/v1/messages を呼び出す。
価格面では、Claudeモデルはリスト価格から30%引き、GPTモデルは半額だ。このワークフローでは、見た目以上に効いてくる。Stage 3の大量シンボル改名は簡単に数百回の呼び出しになり、Stage 1の長コンテキスト処理は単一入力あたり数十万トークンに達する。この2つがコストの大半を占めるため、割引は最も高価な部分にそのまま効く。
登録せずに試す — まずは手作業でいくつかプロンプトを実行し、2モデルの反証セクションを比べる。採用モデルを決めてからスクリプト化すればよい。
モデルに任せてはいけない3つのこと
1つ目:最終実装を直接書かせない。 モデルに「完全な書き直し」を頼むと、一見完成して動くものの、微妙な差分を含んだコードが返ってくる。そして自分で層ごとに検証していないため、その差分を特定できない。正しい使い方は、プリミティブごとに仮説を出させ、それを1つずつ検証し、自分で組み立てることだ。遅くはなるが、各行がなぜその形なのかを把握できる。
2つ目:結果の正誤判定をさせない。「この実装が正しいと確認できる?」は行き止まりの質問だ。モデルはあなたに同意しがちである。正誤を決める唯一の権威は差分テストだ。モデルが正しいと言ってもテストが落ちれば、勝つのはテスト。モデルが間違いと言っても全テストが通れば、やはり勝つのはテストだ。
3つ目:コンプライアンス判断をさせない。 対象に手を加えてよいか、結論を公開してよいか、取得したデータをどう使えるか。これらは管轄、対象の利用規約、あなたの具体的な目的によって決まる。モデルにはそのどれについても事実に基づく判断材料がない。返答は、見てきた免責文の口調をまねているだけだ。その判断は自分で下すか、実際の法律顧問に委ねるべきである。
まとめ
この種の作業におけるモデルの役割は明確だ。公開アルゴリズムの実装を幅広く見てきたパターン認識器として、候補仮説を数秒で出す。答えそのものではないし、検証者でもない。
ワークフローの骨格は、AIを使うかどうかとは関係がない。機械的な分割、仮説、検証、純化。この4段階はモデルが登場する前から存在した。モデルが圧縮するのは「仮説」の工程だけであり、リファレンスを掘るのに何日もかかっていた作業を数分に縮める。他の3工程にかかるコストは、昔から変わらない。
この4工程のモデル呼び出しをスクリプトに固定すれば、ワークフロー全体は速く回る。そこで残る摩擦はモデル切り替えだけだ。これはインフラの問題であり、統一インターフェース上でモデルを選ぶことで解決できる。
