リーフ関数を読ませたLLMが、もっともらしい結論を返してきたとする。「これはChaCha20で、ラウンド数だけが鍵由来の変数に変更されている」。ここまで言い切られると、そのまま書き直しを始めたくなるかもしれない。
だが、実行されるassertに落とし込むまでは、それは単なる文章にすぎない。モデルがどれほど多くの公開実装を学習していても、目の前の実装を正しく見抜けたかは検証できない。これは言語の問題ではなく事実の問題であり、両者の出力を並べてバイト単位で比較して初めて答えが出る。
その答えを出す装置が差分テストだ。本稿で扱うのは、4段階フローのStage 3であり、差分ポイントの探索から生まれた仮説の最終到達点でもある。モデルの役目は疑いを作ること。判定を下すのは差分テストだ。
「正しい実装ですか?」と聞いても検証にはならない
初心者がモデルに投げがちな質問がある。「この実装は正しいと確認できますか?」だ。
しかし、この問いは3つの意味で行き止まりになる。
第一に、モデルはユーザーの意図に同調しやすい。「この実装は正しい?」という聞き方には、正しくあってほしいという前提がすでに含まれている。モデルもその空気を拾い、「はい」と答えがちだ。
第二に、判断の根拠がない。正しさを決める根拠は、同じ入力群に対するブラックボックスの出力だけであり、それはモデルのコンテキストにはない。正解データと照合できない以上、コードが「それらしく読めるか」で推測するしかない。そして、難読化コードが最も得意とするのが、まさにそれらしく見せることだ。
第三に、判定役が間違っている。正しさは交渉可能な意見ではなく、等式が成立するかどうかという事実だ。モデルではなくassertに渡そう。モデルが正しいと言ってもテストが落ちれば不正解であり、モデルが誤りと言っても全テストが通るなら正解だ。判定をモデルに委ねた時点で、土台は幻覚になる。
差分テストで効く境界入力の作り方
差分テストの形そのものは単純だ。元の実装をブラックボックスとして扱い、同じ入力群を自分の書き直し実装に渡して、ケースごとに結果を比べる。重要なのはループではなく、そこへ投入する入力群である。
普通のランダム文字列を1万件投げてすべて通っても、ほとんど証明にはならない。通常入力は主経路しか通らず、差分ポイントはたいてい隅に潜んでいる。本当に情報量が高いのは境界だ。
# Boundary cases: derived from block size B
def boundary_cases(B: int) -> list[bytes]:
return [
b"", # empty: exposes initial state and padding logic
b"\x00", # single zero byte
b"\xff", # single high byte: checks sign-bit / unsigned handling
b"A" * (B - 1), # one below the block boundary
b"A" * B, # exactly one block
b"A" * (B + 1), # one above: checks carry and padding
bytes(range(256)), # full byte coverage: checks the alphabet map covers the whole domain
]
各ケースは特定の分岐を問い詰める。高位バイトの\xffは符号ビット処理を表に引きずり出す。JavaScriptの>>>と>>の違い、Pythonで& 0xffが必要かどうかは、この1ケースで露呈する。特に強力なのがB-1 / B / B+1の3点セットだ。ブロック境界のパディング処理は、ここでしか姿を現さない。全バイト網羅はカスタムアルファベット向けで、マッピングテーブルに文字が1つ多い、あるいは1つ足りないだけで確実に失敗する。
差分テスト本体は10行も要らない。
# Original as black box, rewrite compared case by case
def diff_test(blackbox, rewritten, B: int) -> None:
for case in boundary_cases(B):
got, want = rewritten(case), blackbox(case)
assert got == want, f"len={len(case)} hex={case.hex()}"
標準アルゴリズムに関する差分ポイント仮説なら、元のブラックボックスすら不要な場合もある。公開標準には権威あるテストベクターが付属していることが多いからだ。たとえばRFC 8439 §2.1.1には、ChaCha20のquarter roundに対する固定の入出力ペアがある。a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567を与えると、a=0xea2a92f4, …になる。まず書き直し実装を標準ベクターに通し、その後で対象ブラックボックスと比較すれば、「ChaCha実装そのものを書き間違えた」のか「対象がChaChaを変更している」のかを明確に切り分けられる。
assertのメッセージにlen(case)を含めている点にも注目したい。これはテストスイート全体で最も役に立つ診断情報の1行だ。7ケース中B+1だけが落ちるなら、問題はほぼパディングかキャリーにある。全バイト網羅だけが落ちるなら、アルファベットマップが怪しい。失敗ケースの長さが、誤っている層を推測なしで指し示してくれる。
同じ入力を必ず2回実行する
署名ロジックを書き直す際に最もよく詰まる原因は、アルゴリズムの誤認ではない。切り出せていないエントロピー源だ。
これを捕まえるには、同じ入力を2回実行するだけでよい。
# Same input twice: differing results mean a hidden RNG / timestamp
def assert_deterministic(fn, case: bytes) -> None:
assert fn(case) == fn(case), "unfixed entropy source or timestamp present"
結果が異なれば、実装にはtime.time()、nonce、自己増分カウンタ、あるいは呼び出しごとに変化する別の要素が混入している。その状態では差分比較すら成立しない。ブラックボックスが毎回違う答えを返すなら、何と比較すればよいのか。
対処はエントロピー源を削除することではない。削除すれば署名自体が変わってしまう。コード内部に埋め込まれた値を注入可能なパラメータとして外へ出し、差分テスト中だけ固定値にする。
# Lift the entropy source into an injectable parameter, pin it with a stub for diffing
class Rewritten:
def __init__(self, clock=time.time, rng=os.urandom):
self._clock = clock # formerly an inline time.time(), now injected
self._rng = rng
def __call__(self, data: bytes) -> bytes:
ts = int(self._clock()) # for diffing, clock=lambda: 0
nonce = self._rng(16) # for diffing, rng=lambda n: b"\x00" * n
...
対象のブラックボックス側にも同じことを行う。タイムスタンプや乱数が注入される箇所を見つけ、固定する方法を用意する。ページ内ならDate.nowをフックし、Nodeなら固定seedを渡す。両者のエントロピーを固定して初めて出力は決定的になり、差分に意味が生まれる。書き直し実装がエンドツーエンドで通ったら、clockとrngを実際の実装へ戻せばよい。
これも、モデルがコードを読んで助けられる種類の問題ではない。エントロピーがどこに隠れていて、どう注入されるかは実行時の振る舞いで決まる。コード読解ではなく、2回実行してあぶり出すものだ。
最終出力ではなく、内側の層から比較する
書き直し実装の最終出力がブラックボックスと一致しなかったとき、最後のバイト列だけを見続けてはいけない。それは複数の層が重なった結果であり、どこが壊れているのかはまだ分からない。
署名処理は通常、層になっている。最も内側にハッシュやブロック暗号があり、その外にエンコード層があり、さらに接頭辞の連結、フィールドの挿入、長さヘッダーの追加といった組み立て層がある。比較も内側から行い、現在の層が通ってから次の外側へ進む。
# Peel layer by layer: match the innermost first, then move outward
LAYERS = ["digest", "encode", "assemble"] # inner → outer
def first_divergent_layer(bb_dump, rw_dump, case: bytes) -> str | None:
for layer in LAYERS:
if bb_dump(case)[layer] != rw_dump(case)[layer]:
return layer # the first layer to diverge is the faulty one
return None
この方法には、書き直し実装が各層の中間状態をダンプできること、さらにブラックボックスから対応する中間値を取り出せることが必要になる。後者は多くの場合、実行時インストルメンテーションで実現する。手間は増えるが、最初に差が出る層こそ不具合のある層なので、調査対象を一気に絞り込める。
これは差分ポイントの4分類にもそのままつながる。digest層で差が出るなら、定数の改変やラウンド数の変更が疑わしい。encode層なら、アルファベットの並び替えが典型だ。assemble層なら、出力への何らかの埋め込みを疑う。差が出た層を見れば、フィンガープリンティングの記事でどの分類を追うべきかが分かる。
フィクスチャは時間とともに価値を増す
差分テストが緑になる瞬間は気持ちがいい。だが、それ自体は一度きりの成果だ。翌日にはアップストリームが新バージョンを出し、今日検証した実装が完全に外れているかもしれない。
長期的に価値を持つ成果物は、既知の入力と既知の出力を対応付けたテーブル、すなわちリポジトリにコミットするフィクスチャである。
# Fixture: known input → known output, committed to the repo
# Re-run on every upstream change; green yesterday, red today = upstream changed, not you
VECTORS = load_json("vectors.json") # [{"in": "<hex>", "out": "<hex>"}, ...]
for v in VECTORS:
got = rewritten(bytes.fromhex(v["in"])).hex()
assert got == v["out"], v["in"]
価値が現れるのは、アップストリームが変わったときだ。ある日CIが赤くなり、昨日は触れていない実装がフィクスチャに対して失敗したとする。この情報は非常に重要で、「自分がミスした」という可能性を外し、「アップストリームが変わった」と原因を一点に絞れる。フィクスチャがなければ、自分のコードが正しいのに、誰かがゴールポストを動かしただけの問題を半日かけてデバッグすることになる。
フィクスチャに入れるべき入力は、先ほどの境界ケースそのものだ。すでに手元で最もカバレッジの高いセットになっている。
この工程でモデルに任せる仕事は2つだけ
差分テストにおける役割分担をはっきりさせておこう。モデルの仕事は2つだけで、判定に対する発言権はない。
1つ目は、ケース生成。多数のプリミティブをまたいで境界入力をまとめて作る、疑わしい定数を調べるために1ビットだけ違う入力群を用意する。これは推論不要の列挙作業であり、安価で高並列なティアを使うのが最も経済的だ。
2つ目は、差分の説明。ケースが失敗したら、両者の層別ダンプをモデルに渡し、具体的なバイトを根拠に、最初の差分がどこで生じているか、4つの差分カテゴリのどれに属する可能性が高いかを説明させる。ここでは中程度の推論力と「バイトに基づいて説明する」能力が必要であり、本稿でモデルが実際に働くのはこの工程だ。
判定はあくまでassertに委ねる。この原則は変わらない。モデルの説明は手がかりであって結論ではない。手がかりが外れるのは普通のことであり、それを捕捉するのがassertだ。
この2つにはモデルに求める能力が異なる。全工程を1つのティアで回すと、コストか精度のどちらかを無駄にする。
差分テスト内の工程 | 必要な能力 | 選定モデル | model id |
|---|---|---|---|
境界ケース・対照ケースを大量生成する(多数のプリミティブ横断) | 低コスト・高並列。列挙に推論は不要 | Claude Sonnet 5 |
|
単一の失敗差分を読み、バイトに基づいて説明する | 中程度の推論力。特定の層へ原因を帰属させる力 | GPT-5.6 Sol |
|
原因帰属が収束しないとき、定数改変などの根本原因を深掘りする | 強い推論力。複数ラウンドの中間状態をまたいで推定する力 | Claude Opus 5 |
|
大量の層別ダンプやフィクスチャ一式を読み込み、差分を探す | 長いコンテキスト | Kimi K3 |
|
主力になるのは2番目のティアだ。差分の原因帰属にモデルを選ぶ価値があるかどうかは、自分で1ラウンド試せば分かる。手順は短い。
差分テストスイートから、実際に失敗しているケースを1つ選ぶ。ブラックボックスと書き直し実装、両方の層別ダンプも用意する。
同じ差分を
gpt-5.6-solとclaude-opus-5に別々に渡す。聞くのは、最初の差分がどの層にあるか、そして4つの差分カテゴリのうち何である可能性が高いか、この2点だけにする。見るべきなのは、原因帰属が具体的なバイトと具体的な層に着地するか、それとも「パディングの問題かもしれない」と曖昧に終わるかだけだ。
選定基準は原因帰属の精度である。その精度が、このケースを通すまでに何回修正を繰り返すかを直接左右する。
ベンチマークのリーダーボードよりも、1ラウンドの実テストのほうが違いを直接見せてくれる。
本当の障害は切り替えコスト
この4ティアは3つのベンダーにまたがる。SDKも認証方式もエラー形式も3種類だ。工程ごとにティアを切り替えるためだけにクライアント実装を3回用意するのは割に合わない。だから多くの人は結局1つのティアですべてを回し、差分の原因帰属では曖昧なことしか言わないモデルを使い、理由も分からないまま何度も修正を繰り返す。
AIReiterは、その差を吸収する。キーは1つ、インターフェースはOpenAI互換で1つ。その背後で4ティアを使え、切り替えはリクエストボディのmodelフィールドを変えるだけだ。
# Difference attribution: the mid-reasoning tier
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-sol",
"messages": [{"role": "user", "content": "<diff of the two layer dumps + locate the first divergent layer and deviation category>"}]
}'
# Attribution won't converge, escalate to dig the root cause: change one field
# "model": "claude-opus-5"
# Bulk-generate boundary cases:
# "model": "claude-sonnet-5"
すでにOpenAI SDKを使っているなら、base_urlをhttps://aireiter.com/api/v1へ向けるだけでよく、ほかは変更不要だ。Anthropic SDKなら、同じキーでPOST /api/v1/messagesを呼び出せる。
価格面では、Claudeモデルは定価から30%オフ、GPTモデルは半額で利用できる。このワークフローでは、割引が最も密度の高い工程に効く。差分の原因帰属は、差分テストで最も頻繁に呼び出される部分だ。失敗ケースごとに1ラウンド必要で、アップストリームが変わればフィクスチャを作り直し、新しい失敗ケース群を改めて原因帰属しなければならない。主力のgpt-5.6-solはGPTモデルなので半額となり、高頻度の工程をそのまま半分のコストにできる。根本原因の深掘りでclaude-opus-5へ上げる場面は呼び出し数こそ少ないが、こちらにもClaudeモデルの30%オフが適用される。
登録なしで試す — まず実際の差分を1つ、手作業で両ティアに渡してみよう。自分の目で原因帰属の精度を比べてから、どちらを組み込むか決めればよい。
まとめ
リバースエンジニアリングにおける書き直しは、結局2本の柱で成り立っている。仮説を出すのはモデル、判定を下すのはassertだ。
モデルは、あらゆる公開実装を見てきた疑い生成器である。「ここが変更されているかもしれない」と数秒で示せる一方で、今回それが正しいかどうかは知りようがない。差分テストは「かもしれない」を「一致する/しない」に変える装置だ。境界入力で分岐をあぶり出し、同一入力の2回実行でエントロピー源を表に出し、層ごとの切り分けで壊れた層を特定し、フィクスチャで「自分の実装ミス」と「相手側の変更」を分ける。
4段階フローと差分ポイント探索の記事から得たあらゆる判断は、最後にはこの関門を通さなければならない。モデルが何を言ったかは証拠にならない。意味を持つのは、assertが何を示したかだけだ。
