プログラミング
HellGates、カスタムCPUのゲートレベルチャレンジ
HellGates, custom CPU gate-level challenge (blog.xutaxkamay.com)
要約
HellGatesは、カスタム32ビットCPUをVHDLで設計し、複数の難読化、アンチタンパー、タイミングチェック、アンチデバッグを施したゲートレベルのネットリストに合成した挑戦的なプロジェクトです。人間もLLMも長期間解けませんでしたが、GPT-6がサイドチャネル攻撃を用いてわずか20〜30分でこれを解決しました。この成功は、CPUの状態を保護する暗号化が不十分であったことと、GPT-6の高度な解析能力によるものです。
全文翻訳
はじめに
2025年の夏、私はHellGatesというクラックミーを公開しました。
そして、2025年11月にcrackmes.oneで公開しました。
基本的に、私はカスタム32ビットCPUをVHDLで設計し、暗号化およびビットアドレス可能(バイトアドレス可能ではない)にし、複数の難読化、アンチタンパー、タイミングチェック、アンチデバッグを施したゲートレベルのネットリストに合成しました。これは人間向けに設計されましたが、現在に至るまでLLMに対しても効果的でした。
1年間、誰もそれを解決できませんでした。
人間でさえ、数週間から数ヶ月かけて挑戦しましたが、諦めました。
LLMも、Claudeは失敗し、ChatGPTは失敗し、DeepSeekは、私が人々に与えたヒントに基づいて数日または数週間作業した後、モデルが「利用可能なリソースでは計算不可能」と宣言して失敗しました。
しかし、2026年9月にGPT-6がSRE-Benchで20〜30分以内にそれを解決しました。
Zhuo Zhang、これを可能にしてくれてありがとう!
要するに、CPUの状態を保護していた暗号化は怠慢に行われており、非常に弱かったということです。サイドチャネル/差分解析攻撃がGPT-6によって使用され、すべての難読化レイヤーが削除された後、実行を非常に簡単に再現できるようになりました。
これにより、使用された暗号化のヒントが得られ、レジスタが復号され、レジスタが再暗号化され、ネットリストが正しく実行され、復号された1GB(data.bin)のメモリ全体がダンプされました。
チャレンジの詳細はこちらです:
仮想CPUアーキテクチャ。
仮想CPUで使用したプログラム。
ホスト側の難読化。
ツールチェーン。
CPUアーキテクチャ
一般情報
アーキテクチャには16個の32ビット汎用レジスタと、いくつかの特殊レジスタが含まれています:
type special_registers is record overflow_flag : boolean;
condition_flag : boolean;
program_counter : cpu_address_type;
key_modifiers : special_key_modifiers_type;
index_for_key_modifier : cpu_word_index_type;
tea_pseudo_random_state : tea_integer_type;
end record special_registers;
type registers_record is record
general : register_array;
special : special_registers;
end record registers_record;
overflow_flag は、あらゆる種類の整数オーバーフローが発生した場合、またはゼロ除算が発生した場合に設定されます。
操作はすべて符号付き整数操作であり、基本的なALU操作(加算、減算、乗算など)が含まれていました。
condition_flag は、異なる場所間の分岐に使用されます。
例:
IsEqual R2, 0 // condition_flag=1
Branch @loc ...
instructions when condition_flag=0 ...
loc:
instructions when condition_flag=1 ...
さて、key_modifiers/index_for_key_modifierとは何かと疑問に思うかもしれません。
メモリアーキテクチャについては後ほど説明します。
すべてのオペコード:
-- Integer operations --
constant opcode_type_or : opcode_type := "00001";
constant opcode_type_and : opcode_type := "00010";
constant opcode_type_not : opcode_type := "00011";
constant opcode_type_add : opcode_type := "00100";
constant opcode_type_substract : opcode_type := "00101";
constant opcode_type_division : opcode_type := "00110";
constant opcode_type_multiply : opcode_type := "00111";
constant opcode_type_sla : opcode_type := "01000";
constant opcode_type_sra : opcode_type := "01001";
constant opcode_type_sll : opcode_type := "01010";
constant opcode_type_srl : opcode_type := "01011";
constant opcode_type_rol : opcode_type := "01100";
constant opcode_type_ror : opcode_type := "01101";
-- Memory operations --
constant opcode_type_read : opcode_type := "01110";
constant opcode_type_write : opcode_type := "01111";
-- Branch operations --
constant opcode_type_is_bigger : opcode_type := "10000";
constant opcode_type_is_lower : opcode_type := "10001";
constant opcode_type_is_equal : opcode_type := "10010";
constant opcode_type_had_integer_overflow : opcode_type := "10011";
-- Jumping, branches, set --
constant opcode_type_jump : opcode_type := "10100";
constant opcode_type_branch : opcode_type := "10101";
constant opcode_type_set : opcode_type := "10110";
-- Expanding instructions --
constant opcode_type_xor : opcode_type := "10111";
ここにはあまり難読化はありませんでした。連鎖命令暗号化、オペコードのシャッフル、マイクロコードエンジンを使用することもできましたが、当時はやりすぎだと思っていました。(それはそうでしたが、今はどうかわかりません)
メモリアーキテクチャ
暗号化されたメモリで動作するCPUを作成する場合、注意が必要です。ビットアドレスから単純にビットをフェッチすることはできません。整数や命令を読み書きしようとすると、ゴミを復号/暗号化してしまうため、CPUは役に立たなくなります。
これは、非暗号化メモリとは異なるところです。なぜなら、理論上は任意のビットアドレスから直接読み書きできるからです(実際には、ほとんどの一般的なCPUはそうしませんが)。
特定のインデックススロットにCPUが読み書きできるビット数(そしてそれだけ)をワードと呼びます。
私のアーキテクチャでは、ワードサイズは64ビットです。これは、使用した暗号化方式のブロックサイズと同じです。
しかし、プレーンテキストメモリと比較して、コードの書き込みは難しくなります。なぜなら、特定のインデックスのワードを単純に読み込んで命令をデコードすることはできないからです。
命令サイズ、整数サイズ、ワードサイズをすべて同じサイズにし、さらにコードが特定のビットアドレスにジャンプするのを不可能にし、設計上ワードサイズにアラインされたアドレスにのみジャンプできるようにした場合を除きます(整数をメモリに読み書きする場合も同様です)。
例えば、0x10000 は問題ありませんが、0x10001 の命令を読み込もうとしたらどうなるでしょうか?命令は2つの部分に分割されます(C++の例、ほとんどの人はこれに慣れているでしょう):
// 命令は 0x10001 から 0x10041 の間にあります
// 最初のワードは 0x10000、2番目は 0x10040 にあると想像してください
// さて、0x10040 まで読み込み始めましょう
static constexpr uint64_t WORD_SIZE_IN_BITS = 64;
uint64_t read_bits = 0;
uint64_t wanted_address = 0x10001;
uint64_t bit_offset = wanted_address - (wanted_address % WORD_SIZE_IN_BITS);
uint64_t word_index = (wanted_address - bit_offset) / WORD_SIZE_IN_BITS; // 0x400
bit_array_t word_bits = read_word_bits(word_index);
instruction_bit_array_t instruction;
for (uint64_t i = bit_offset; i < WORD_SIZE_IN_BITS; i++) {
if (read_bits < sizeof(instruction)) {
instruction[read_bits] = word_bits[i];
read_bits++;
}
}
// read_bits は現在 63 で、64 ではありません!
// そのため、1ビット足りません。次のワードを読み込んで取得する必要があります。
bit_offset = 0;
word_index++;
word_bits = read_word_bits(word_index);
for (uint64_t i = bit_offset; i < WORD_SIZE_IN_BITS; i++) {
if (read_bits < sizeof(instruction)) {
instruction[read_bits] = word_bits[i];
read_bits++;
}
}
// これで命令が完全にフェッチされました!安全に読み込めます。
VHDLコードはこれよりも少し複雑ですが、命令/整数操作がワードに分割されており、複数のワードインデックスが必要であることを覚えておく必要があります。
メモリ暗号化
さて、メモリ暗号化について説明しましょう。私が使用したアルゴリズムはTEAでした。
Cの参照実装から実装するのは非常に簡単でしたが、操作数とラウンド数が多いため、多くの論理ゲートが生成されました。
簡単に言うと、2つの暗号化レイヤーがあります。
1つはワードを暗号化するためのもので、各ワードはCPUの状態に基づいて動的に生成される独自のキーセットを持っています(これがkey modifiersの目的です)。
2つ目は、ワードキー自体が(KEK)暗号化されており、メモリを単純に読み取るだけでは取得できないようになっています。
各ワードのKEKも動的に生成されました(ただし決定論的)。ワードインデックスと静的キー間のノンスに基づいており、間違ったインデックスを選択して特定のワードを復号すると、ゴミが出力されます。
これはサイドチャネル攻撃から保護するためにも使用されました。そのため、ワードが同じ方法で暗号化されることは決してありませんでした。おそらくやりすぎですが、それが私がやったことです。
それに加えて、すべてのワードインデックスはランダムアクセスのように見えるように置換されました。そのため、ワードインデックス0は、0x2281FDのようなランダムな値になります。
これが1GBのdata.binがある理由です。仮想CPUのプログラムは、エントロピーデータと混ざって、1GBのデータ全体に散らばっています!
これはプログラムを難読化するための良いトリックでした。ネットリストで見つけられる単純な「静的キー」ではありませんでした。残念ながら