難読化された署名SDKを開くと、どの行も得体の知れない処理に見える。暗号の専門家でなければ追えないような、複雑なロジックの塊だと感じるかもしれない。
ただ、ひととおりリバースエンジニアリングしてみると、実態はたいてい逆だ。コードの90%は標準アルゴリズムでできている。ハッシュは公開仕様のもの、エンコードも公開仕様、ストリーム暗号も公開仕様である。その部分を完全に理解する必要はない。何のアルゴリズムかを特定し、RFCやリファレンス実装を写せば、1バイトもずれない実装を再現できる。
厄介なのは残りの10%だ。標準アルゴリズムにほんのわずかな変更が入っている箇所である。本来は固定値のラウンド数が変数になっていたり、基準定数が近い別の値に置き換えられていたり、あるラウンドだけワードの添字が入れ替わっていたり、鍵が暗号文の中に埋め込まれていたりする。どれも小さな変更だが、1つでも見逃せば再実装は対象と一致しない。しかも残り90%が正しいだけに、どこが原因なのか分からなくなる。
つまり、難読化署名の解析で本当にやるべきことは、90%のコードを読むことではない。標準から外れた10%の地点を突き止めることだ。この記事では、その探索を大規模モデルに担わせる方法と、なぜこの工程でモデルの代替が利きにくいのかを扱う。
(前提となるアルゴリズムファミリーの特定、つまり定数や構造から「これはChaChaだ」「これはFNVだ」と見抜く手順は、前回の記事で説明した。ここでは骨格の特定は済んでいるものとして、改変点の探索に進む。)
標準らしく見える箇所ほど、人は見落とす
人間のパターン認識には、強い癖がある。全体像をつかんだ時点で、細部を確認しなくなることだ。
コード片を見て、ChaCha特有の定数があり、構造もそれらしいと分かる。そこで頭の中では「ChaCha20」と判定して次へ進んでしまう。各ラウンド数、添字、定数の下位1ビットまで逐一確かめることはない。「もう分かった」という感覚が、注意を止めてしまうからだ。
難読化の作者が狙っているのは、まさにそこだ。アルゴリズムを丸ごと作り直すのはコストも高く、バグも入りやすい。そこで標準実装に最小限の変更だけを加える。数値を1つ変える、添字を入れ替える、1ステップ追加する。人のパターン認識なら通り過ぎる程度に小さい一方、標準どおりに書き直した実装を完全に不一致にできる程度には大きい。
これは注意力の非対称戦だ。作者は改変点を1つ隠せばよいが、解析側はすべて見つけなければならない。そして人間は、生まれつき「正しく見えるものを疑い続ける」ことが得意ではない。
ここでモデルには意外な強みがある。「もう特定できた」という満足感によって注意が切れることがない。プロンプトで改変点を探すよう明示すれば、途中で雑にならず、項目ごとに確認を続けられる。もちろん、適切なプロンプトが必要であり、それは第5節で扱う。先に、代表的な4種類の改変を見ていこう。
よくある4つの改変パターン
以下では公開標準のアルゴリズムを例に、標準実装がどう見え、そこからどのように外されるのかを説明する。実案件では、同じ署名処理の中にこの4種類が重なっていることも珍しくない。
1. 固定値の変数化:定数であるべき値を実行時に決める
標準的なストリーム暗号では、ラウンド数は固定されている。ChaCha20なら20ラウンドで、不変だ。標準実装では必ずハードコードされており、RFC 8439の序文でも、扱うのは20ラウンドのChaChaだけであると明記されている(8ラウンド版と12ラウンド版は別途定義されている)。ラウンド数は仕様に記された定数である。
改変では、その固定ラウンド数を鍵から動的に計算する値に変える。quarter roundのロジックは同じでも、何ラウンド繰り返すかが鍵の特定バイトに依存する。鍵が変われば、ラウンド数も変わる。
人が見逃す理由は単純だ。quarter roundの構造を認識し、σ定数も認識した時点で、「これはChaCha20」と結論づけて20ラウンドで写経してしまう。ループ回数を決める変数が定数なのか式なのか、そもそも確認しない。これまで見てきたChaChaでは、常に定数だったからだ。
このタイプの兆候は、定数があるはずの位置に、入力依存の式があることだ。反証を意識した分析では、「標準実装ならここは固定値だが、対象では計算されていないか」を重点的に確認する。
差分テストでの確認方法は、1バイトだけ異なる2つの鍵で意図的に実行することだ。出力差が、単に1バイトの影響とは思えないほど大きければ、そのバイトはキーストリームへのXORだけでなく、ラウンド数のようなグローバルなパラメータに使われている可能性が高い。
2. 定数の微調整:基準定数を近い値にすり替える
FNV-1aハッシュには、offset basisとprimeという2つの公開マジックナンバーがある。正しいFNV-1a実装は必ずこの値を使う。共同著者のLandon Curt Nollが管理するFNV reference pageには、32ビットのoffset basisは2166136261、primeは16777619と、桁単位で掲載されている。
改変は、この基準定数の一方を、標準値との差がごく小さい近傍の値に差し替えるものだ。ハッシュの全体構造は同じで、XOR、乗算、ループも正しい。違うのは初期値だけで、ほとんど気付かない程度に書き換えられている。
4種類の中でも、これは特に厄介だ。FNVらしい構造があり、offset basisに見える大きな定数を見れば、標準のFNV-1aだと判断してしまう。「認識済み」のマジックナンバーを、仕様値とビット単位で照合する人はほとんどいない。
この種の改変は、大きな数値を目視で比較することが極めて不確実なので、ほぼ差分テストでしか捕まえられない。既知の入力を標準実装と対象ブラックボックスの両方に通して比較する。構造は同じなのに出力が違うなら、原因は基準定数にある可能性が高い。そこで疑わしい定数をモデルに渡し、標準値との差分を取らせる。これは人より機械のほうがはるかに確実だ。
この場面でのモデルの価値は具体的である。モデルは標準FNV-1aのoffset basisを末尾ビットまで覚えているが、人間はそうではない。「この定数は標準FNV-1aのoffset basisと完全に一致するか」と聞けば、差を即座に示せる。
3. 構造の局所変更:標準アルゴリズムの1ステップだけを書き換える
ChaChaのdouble roundは8つのquarter roundからなる。最初の4つは列に対して、次の4つは対角線に対して実行される。各quarter roundがどのワードを操作するかは仕様で固定されており、RFC 8439 §2.3には、ブロック関数と各ラウンドが触るstate wordが明確に定義されている。
改変では、どこか1つのラウンドだけ、ワード添字を1つか2つ入れ替える。大半のラウンドは標準のままで、途中の一箇所だけ、本来操作すべきワードが別のワードに置き換わる。見た目はChaChaのままでエラーも出ないが、生成するキーストリームは標準ChaChaとは完全に異なる。
人が見落とすのは、quarter roundの添字が長い数列だからだ。(0,4,8,12)(1,5,9,13)…のような8グループを目で追っても、「列ラウンドと対角ラウンドになっている」とだけ確認して流してしまう。各グループの4つの数値が、すべて標準どおりの位置にあるかまでは見ない。すでに目が滑りやすい添字列の中は、変更を隠すには格好の場所だ。
モデルに任せる場合も、単に見せるだけでは足りない。ラウンドごとに添字を列挙し、標準ChaChaとの差分を取るよう指示する必要がある。これは機械的な照合作業であり、人間が集中を切らしやすく、モデルが処理しやすい領域だ。「標準の添字と実際の添字」の表を出力させれば、改変箇所は自然に浮かび上がる。
差分テストでは、最初の2種類を除外しても出力が一致しないなら、構造変更を疑う。ラウンドごとの中間stateをダンプし、標準ChaChaとの差が最初に出るラウンドを調べれば、変更された箇所が分かる。
4. マテリアルの埋め込み:鍵データを出力の中に隠す
最初の3つがアルゴリズムを変える手法だったのに対し、これはデータの配置方法を変える。
よくある手法は、暗号鍵を別チャネルで送らず、分割して暗号文そのものに挿入するものだ。受信側は同じ規則で鍵を取り出す。さらに巧妙な例では、挿入位置を固定せず、データ内容から計算する。暗号文が変われば、鍵が隠される位置も変わる。外側の層では、カスタムアルファベットのBase64とマーカー用プレフィックスバイトで全体を包むこともある。
人が見逃す理由は、暗号アルゴリズムの解析に集中するあまり、鍵を解読する必要自体がないことに気付かないからだ。鍵は手元の暗号文の中にすでにあり、どの断片に隠れているかが分からないだけである。初学者はここでよく詰まり、平文が目の前にあるのに「解読」しようとしてしまう。
このタイプの兆候は、データブロック内に周辺と異なる統計的特性を持つ領域があることだ。鍵は通常、高エントロピーなランダムバイトであり、暗号文の中央に挿入されると、識別できる「異物の区間」になる。モデルには、「この出力のどの区間が他と異なるバイト分布を持つか」を分析させ、埋め込まれたマテリアルの境界を探せる。
検証では、挿入規則を見つけられた場合、たとえばバイト和に対する剰余のような式を、複数の既知の入力・出力ペアから逆算する。その後、順方向にも計算して一致を確認する。モデルは少数サンプルから規則を推定する助けになるが、最終的に正しいかを決めるのはやはりassertだ。
反証フェーズこそ、推論モデルが真価を問われる場所
ここまでの4種類には共通点がある。アルゴリズムファミリーを認識して候補を出すのは簡単で、標準との差異という反証を見つけるほうが難しいという点だ。
候補の特定は、ほとんどのモデルにできる。ChaChaのσ定数やFNVの構造は、学習データに含まれており、見れば認識できる。モデルの差が本当に出るのは反証フェーズだ。いったん認識したアルゴリズムをなお掘り下げ、「ただし、この部分は標準と一致しない」と言えるかどうかである。
弱いモデルはここで止まる。「これはChaCha20だ」と認識すると、反証フェーズが候補の言い換えに劣化しがちだ。「この実装は古典的なquarter roundを使った標準ChaCha20構造に従っている」といった説明を繰り返すだけで、改変を確認する言葉が1つもない。そのような反証では、次の手を決められない。
強い推論モデルの反証は別物だ。「候補はChaCha20だが、標準実装から3点外れている。第一に、標準ChaCha20は20ラウンド固定なのに、ラウンド数が鍵依存の式で制御されている。第二に、ラウンドNのワード添字が標準の対角ラウンドと一致しない。第三に……」という形で、検証可能な改変点を具体的に指す。この反証リスト自体が、Stage 3で書く差分テストのチェックリストになる。
だからこそ、アルゴリズムファミリーの特定には推論ティアを割く価値があり、実運用に入る前に自分で試すべきなのだ。モデル間の反証品質の差は、無駄なテストをどれだけ書くか、どれだけ遠回りするかを直接左右する。
このワークフローで使い分けている4つのティアは次のとおり。
Stage | 必要な能力 | 選定モデル | model id |
|---|---|---|---|
分割後の構造マッピング | 長いコンテキストを扱い、モジュール全体を一度に読めること | Kimi K3 |
|
アルゴリズムファミリーの特定と反証 | 強い推論力。自分の結論に反論できること | Claude Opus 5 |
|
シンボル名の一括リネーム | 低コストかつ高並列 | Claude Sonnet 5 |
|
差分の原因帰属 | 中程度の推論力。特定バイトに基づいて説明できること | GPT-5.6 Sol |
|
この記事の中心は2番目のティアだ。ただし、私の評価をそのまま信じる必要はない。実際に試せばよい。手順は簡単だ。
自分の難読化バンドルからleaf functionを2〜3個選ぶ。このうち少なくとも1つは、答えがすでに分かっているものを対照として含める。
前回の記事で紹介した「候補 / 根拠 / 反証」の3部構成プロンプトを使い、同じ入力を
claude-opus-5とgpt-5.6-solにそれぞれ渡す。見るのは反証フェーズだけだ。改変を本当に1つずつ検査しているか。それとも候補を別の表現で繰り返しているだけか。対照ケースでは、各モデルが改変をいくつ見つけたか。
検出した改変の数と質を、選定基準にする。
ベンチマークのランキングを見るより直接的に、1回の実行で差が分かるはずだ。
本当の障壁はモデル切り替えのコスト
3社にまたがる4モデルを使うとなると、素直にやれば3つのSDK、3種類の認証、3系統のエラーハンドリングをつなぐことになる。多くの人はそこで割に合わないと判断し、結局は1つのモデルですべてを済ませる。その結果、反証が弱いティアでアルゴリズムファミリーを特定し、理由も分からないまま遠回りを重ねることになる。
AIReiterは、この接続層をまとめてくれる。キーは1つ、インターフェースはOpenAI互換で、4つのモデルをすべて利用可能だ。切り替えはリクエストボディのmodelフィールドを書き換えるだけでよい。
# Algorithm-family ID + counter-evidence: the reasoning 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": "<three-part prompt + leaf function + constants>"}]
}'
# Bulk symbol renaming: change one field
# "model": "claude-sonnet-5"
# Difference attribution:
# "model": "gpt-5.6-sol"
すでにOpenAI SDKを使っているなら、base_urlをhttps://aireiter.com/api/v1に向けるだけで、ほかは変更不要だ。Anthropic SDKを使っている場合は、同じキーでPOST /api/v1/messagesを呼び出せる。
料金面では、Claudeモデルは定価から30%オフ、GPTモデルは半額だ。このワークフローでは、割引が最も効く箇所に当たる。アルゴリズムファミリーの特定では、同じ関数に対して何度もプロンプトを調整しながら問い合わせるため、フロー中で最も呼び出し密度が高い。シンボル名の一括リネームも数百回単位の呼び出しから始まる。この2つがコストの大部分を占める。
登録なしで試す — 手作業で数回実行し、2つのモデルの反証フェーズを並べて確認してから判断すればよい。
まとめ
難読化された署名を解析する際の現実はこうだ。コードの大半はそのまま写せる標準アルゴリズムであり、本当に重要なのは標準から静かに変えられた箇所を探すことにある。
固定値の変数化、定数の微調整、構造の局所変更、マテリアルの埋め込み。この4種類の改変には共通点がある。人間のパターン認識が見過ごすほど小さく、それでいて再実装全体を不一致にするほど大きいことだ。正しく見えるものを疑い続けるのは人間の苦手分野であり、適切にプロンプトしたモデルが力を発揮するのもまさにそこにある。
ただし、モデルが生み出すのは疑いであって、確定ではない。改変点の仮説はすべて、最終的に差分テストへ落とし込む必要がある。これは差分テストの記事で扱うテーマだ。モデルは「変更されているかもしれない場所」の一覧を渡してくれる。実際に変更されていたかを判定するのは、assertである。
