
バイナリにセクションを追加しないx64 PE bin2bin難読化ツール
ビルド手順については、「4. ビルド」を参照してください。
この難読化ツールは bin2bin 方式であり、つまり、すでにコンパイルされた実行可能バイナリを受け取り、難読化パスを適用して再生成します。これにより、元のソースコードにアクセスしなくてもアプリケーションを保護できます。現時点では x64 PE(ポータブル実行可能)ファイルのみがサポートされていますが、将来的には他のバイナリ形式(ELF など)のサポートを追加する計画があります。
現在、よく知られている bin2bin 難読化ツールはすべて、バイナリの末尾にセクションを追加し、難読化されたコードやデータをその中に配置します。これは、既存のセクションの内容を変更する必要がないようにすることで、バイナリの元のレイアウトを保持するためです。この方法は、ほとんどの RVA(相対アドレス)が有効なまま維持されるため、管理がはるかに容易です。
このプロジェクトは bin2bin に対して独自のアプローチを採用しており、難読化されたコードやデータはバイナリの元のセクション内に挿入されます。そのためには、アプリケーション内のすべての RVA を追跡する必要があります。このアプローチの利点は次のとおりです。
このドキュメントでは、実行可能バイナリの書き換えと、実装された難読化手法の両方について説明します。以下の難読化手法が実装されています。
さらに、このプロジェクトは例外サポート(C++ 例外および SEH)も備えており、例外処理を含む関数を難読化できます。
バイナリ内のコードの発見と逆アセンブルを支援するために、シンボルファイル(PDB および MAP)がオプションで受け入れられます。シンボルファイルの提供は必須ではありませんが、複雑なバイナリでの逆アセンブルを支援します。例外サポートや制御フローの平坦化などの一部の機能では、シンボルファイルの提供が必要です。
バイナリリライターは、実行可能バイナリを受け取り、その中のコードやデータを変更して、変更が適用された出力バイナリを生成します。
難読化されたコードがバイナリの元のセクションに直接挿入されるため、プログラム内の相対アドレスを追跡し、それらへのすべての参照を調整できるようにする必要があります。これにより、コードやデータが挿入された後も参照が同じ場所を指すようになります。そうしないと、データやコードが間違った場所にアクセスされ、出力バイナリの動作が変わり、深刻な不安定性を引き起こします。
相対アドレスへの参照が見つかるたびに(例:rip 相対オペランドを含む命令や PE データディレクトリ)、追跡リストに追加され、書き換えの最後に更新されます。参照が発生する RVA(参照を更新する場所を特定するため)と、参照されている RVA(参照を更新する RVA を特定するため)の両方が追跡されます。
逆アセンブラが rip 相対命令を見つけるたびに、難読化の最後に更新される参照のリストに追加されます。これにより、それらすべての命令が元の場所を指し続けることが保証されます。ジャンプテーブルなどの他の相対命令のケースも、更新される参照として追加されます。
追跡されているすべての RVA は、バイナリにバイトが挿入または削除されるたびに調整する必要があります。例えば、次のバイト挿入ハンドラがあります。```cpp
void binwrite::binary_t::insert(const rva_t rva, const std::span data, const bool inclusive)
{
buffer_.insert_range(buffer_.begin() + rva.value(), data);
update_rvas(rva, static_cast<rva_t::size_type>(data.size()), inclusive);
}
`update_rvas` は、バイナリで発生した変更を反映するために、追跡中のすべてのRVAが更新される場所です。以下にこのプロセスの図を示します。

図1. 相対アドレストラッキング。
挿入されたデータ(青)は、現在のデータ(灰色)をシフトします。命令が参照するRVA(オレンジ)は、挿入されたデータ(青)を考慮して、同じメモリを指すように更新されます。
## 2.2. 逆アセンブル
すべての潜在的なコードエントリ(エクスポート、エントリポイント、コードセクションを指す再配置など)は、逆アセンブルキューに追加されます。シンボルファイルが存在する場合、そのシンボルファイルに記述されているすべての関数も逆アセンブルキューに追加されます。キュー内の各エントリは、個別の基本ブロックとして扱われます。
基本ブロックは、分岐のない命令のグループです。つまり、制御フロー命令(例:jump、ret、int)で終了します。基本ブロックは、呼び出し(call)では終了しません。ほとんどの場合、呼び出しは戻ることが期待されるためです。一部の関数は戻りません(例:_CxxThrowException)。これらは以降、'noreturn'呼び出しと呼ばれます。
逆アセンブルキューからの基本ブロックが処理されるとき、各命令は先頭から逆アセンブルされ、次のいずれかが発生するまで続けられます:
- 別の既に解析済みの基本ブロックに到達し、オーバーラップが発生する。「基本ブロックの分割」を参照。
- 終了命令が見つかる(jump、return、int)。
- 命令の逆アセンブルに失敗する。
- コードパディングが見つかる。
以下は、逆アセンブルと逆アセンブルキューへのエントリの図です(図ではコードパディングチェックは省略)。これは、逆アセンブルキューが空になるまで繰り返されます。

図2. 逆アセンブル処理。
### 2.2.1 基本ブロックの分割
2つの基本ブロックが重なっている場合、そのうちの1つを分割する必要があります。これにより、2つのブロックが同じ命令を記述するのを防ぎます。例:```asm
wcslen proc
or rax, 0FFFFFFFFFFFFFFFFh
loc_140001078:
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
retn
wcslen endp
これはワイド文字列の長さを取得する wcslen の実装です。
最初の命令 'or rax, FFFFFFFFFFFFFFFF' が基本ブロックの開始として逆アセンブルされると、'retn' まで逆アセンブルが続けられます。
'jnz loc_140001078' はループを形成するために後方にジャンプします。これは参照として追加され、jnz のターゲットとフォールスルーブランチ(次の命令)の両方が逆アセンブリキューに追加されます。
この命令はすでに分析されたブロックの途中にジャンプするため、新しいブロックを形成して 'retn' まで再び逆アセンブルするわけにはいきません。重複した表現になってしまうからです。
'jnz'(条件付きジャンプ)もフォールスルーブランチを取り、新しい基本ブロックを作成します。これで4つの基本ブロックが次のようになります:
Block A:```asm
or rax, 0FFFFFFFFFFFFFFFFh
loc_140001078:
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
ブロック B:```asm
loc_140001078:
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
ブロックC:```asm
retn
ブロックD:```asm
retn
これは基本ブロックの誤った表現であり、同じ命令を4つのブロックに重複して配置しています。これは、現在の逆アセンブル結果と重複する基本ブロックを分割することで修正できます。重複するたびに命令の複製表現を作成する代わりに、既存の命令が新しい基本ブロックに移されるため、重複した命令は存在しなくなります。分割を用いた正しい表現は次のとおりです。
Block A:```asm
or rax, 0FFFFFFFFFFFFFFFFh
ブロックB:```asm
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
ブロック C:```asm
retn
### 2.2.2. 間接制御フロー
#### 2.2.2.1. ジャンプテーブル
ジャンプテーブルは、switch文のハンドラのアドレスを格納するために使用されます。switch文の各caseに対して多数のif文や条件付きジャンプを使用する代わりに、case文ハンドラのアドレスのテーブルが保持されます。以下に例を示します(LLVM/CLANG バイナリ):```cpp
std::int32_t sub_140004080(const std::int32_t a1)
{
std::int32_t result;
switch ( a1 )
{
case 0:
result = 9;
break;
case 1:
result = 4;
break;
case 2:
result = 3;
break;
case 3:
result = 1;
break;
default:
result = 0;
break;
}
return result;
}
この switch 文は以下のアセンブリにコンパイルされます:```asm
; ecx = a1
cmp ecx, 3 ; check if above bounds, must be default case
ja short def_140004097 ; goto default if a1 above 3
mov ecx, ecx
mov eax, ecx
lea rcx, jpt_140004097
movsxd rax, ds:(jpt_140004097 - 140004100h)[rcx+rax*4] ; select correct index of jump table for a1
add rax, rcx
jmp rax ; goto index specified - the case handler
jpt_140004097:
dd offset loc_140004099 - 140004100h ; address of handler for first case
dd offset loc_1400040D5 - 140004100h ; address of handler for second case
dd offset loc_1400040BD - 140004100h ; address of handler for third case
dd offset loc_1400040C9 - 140004100h ; address of handler for fourth case
'a1' の値は case の最大値と比較され、それを超える場合は直接デフォルトハンドラへ移行します。a1 が case の範囲内であれば、ジャンプテーブルの該当エントリにアクセスし、計算されたハンドラアドレスへジャンプします。
ジャンプテーブルのエントリとジャンプテーブルへの参照は追跡され、そのまま保持されます。
##### 2.2.2.1.1. 境界付きジャンプテーブル
ジャンプテーブルがスイッチ文で使用可能なすべての値の範囲を網羅していない場合、境界付きテーブルを使用して制限をチェックします。これは、範囲外の場合にスイッチをデフォルト文にリダイレクトするためです。case 文の数を解析するために、比較命令をチェックしてエントリ数を見つけます。例えば、命令 'cmp ecx, 3' はジャンプテーブルのエントリ数が3であることを示します。
##### 2.2.2.1.2. 境界なしジャンプテーブル
ジャンプテーブルが case 文のすべての可能な値をカバーしている場合(例:UINT8 型の場合、UINT8 の最小値から最大値まで)、制限チェックのない境界なしテーブルを使用します。これは、コンパイラがジャンプテーブルがすべての可能な値をカバーしていることを知っているためです。ジャンプテーブルのエントリ数を示す比較命令がないため、エントリは総当たりで検出する必要があります。テーブルのベースからコードセクション内の有効な RVA を段階的にチェックし、有効なエントリをそれぞれ追跡します。この方法は、他のデータや命令をジャンプテーブルのエントリとして解析する可能性があるため安全ではなく、可能な場合は境界付きジャンプテーブルのチェックが使用されます。
##### 2.2.2.1.3. さまざまなタイプのジャンプテーブル
バイナリリライターは、MSVC(マルチレベルテーブルを含む)、LLVM/CLANG、GCC でビルドされたバイナリのジャンプテーブルをサポートしています。
MSVC のジャンプテーブルには2つの形式があります:通常とマルチレベルです。MSVC の通常のジャンプテーブルは、RVA の配列です。各 RVA は case 文のハンドラを指します。
MSVC のマルチレベルテーブルは、多数の case 文がありハンドラを共有するスイッチ文に使用されます。マルチレベル版には2つのテーブルがあります:1つはハンドラ RVA の配列用、もう1つは case 値を最初のテーブルのインデックスと一致させるためのものです。これにより、最初のテーブルでの RVA の繰り返しを防ぎます。各 case 値は4バイトの RVA ではなく1バイトのインデックスを記述するだけで済むからです。以下はマルチレベルジャンプテーブルの例です:```asm
lea rdx, cs:140000000h
movsxd rax, edi ; load value
movzx eax, ds:(byte_1400023D8 - 140000000h)[rdx+rax] ; get handler index by value
mov ecx, ds:(jpt_14000209D - 140000000h)[rdx+rax*4] ; get RVA of handler by handler index
add rcx, rdx
jmp rcx
jpt_14000209D dd offset loc_14000209F - 140000000h
dd offset loc_1400020AB - 140000000h
dd offset loc_1400020B7 - 140000000h
dd offset loc_1400020CF - 140000000h
dd offset loc_1400020E7 - 140000000h
dd offset loc_1400020F3 - 140000000h
dd offset loc_1400020FF - 140000000h
dd offset loc_14000210B - 140000000h
dd offset loc_140002117 - 140000000h
dd offset loc_140002123 - 140000000h
dd offset loc_1400020C3 - 140000000h
dd offset loc_14000213B - 140000000h
dd offset loc_140002147 - 140000000h
dd offset loc_140002153 - 140000000h
dd offset loc_14000216B - 140000000h
dd offset loc_140002177 - 140000000h
dd offset loc_140002183 - 140000000h
dd offset loc_14000219B - 140000000h
dd offset loc_1400021A7 - 140000000h
dd offset loc_1400021B3 - 140000000h
dd offset loc_1400021BF - 140000000h
dd offset loc_1400021CB - 140000000h
dd offset loc_1400021D7 - 140000000h
dd offset loc_1400021E3 - 140000000h
dd offset loc_1400021EF - 140000000h
dd offset loc_1400021FB - 140000000h
dd offset loc_140002207 - 140000000h
dd offset loc_140002213 - 140000000h
dd offset loc_14000221F - 140000000h
dd offset loc_14000222B - 140000000h
dd offset loc_140002237 - 140000000h
dd offset loc_140002243 - 140000000h
dd offset loc_14000224F - 140000000h
dd offset loc_14000225B - 140000000h
dd offset loc_140002267 - 140000000h
dd offset loc_140002273 - 140000000h
dd offset loc_14000227F - 140000000h
dd offset loc_14000228B - 140000000h
dd offset loc_140002294 - 140000000h
; ... more handler addresses
byte_1400023D8:
db 0, 2Bh, 1, 2, 2Bh, 3, 2Bh, 4, 2Bh, 5, 6, 7, 8, 9, 0Ah, 0Bh, 0Ch, 0Dh
db 2Bh, 0Eh, 0Fh, 10h, 2Bh, 11h, 12h, 13h, 14h, 15h, 2Bh, 16h, 2Bh, 17h
db 18h, 19h, 1Ah, 1Bh, 1Ch, 2Bh, 1Dh, 1Eh, 1Fh, 20h, 21h, 22h, 2Bh, 23h
db 24h, 25h, 26h, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh
; ... more indexes to handler table
LLVMは、テーブルのベースからのオフセットを含むテーブルを使用します。テーブルのベースアドレスと、エントリによって記述されたオフセットを加算することで、ハンドラアドレスを計算できます。
GCCのジャンプテーブルは、DIR64再配置の配列です。各再配置は、case文のアドレスを指します。再配置は既に追跡されているため、これらのジャンプテーブルエントリは既に修正されています。実行時には、これらの再配置エントリはベースアドレスによってオフセットされるため、各エントリはcase文のアドレスを取得するために間接参照できます。
サポートしなければならない他の形式の間接制御フローも存在します。例えば、FuncInfo3 C++例外を使用するCLANGバイナリでは、継続アドレスがレジスタraxにロードされ、その後呼び出し元に返されます。呼び出し元はその継続アドレスにジャンプします。```asm
lea rax, [rip+X]
retn
lea が指すアドレスがコードセクション内にあっても、それが実際のコードであるとは限りません。ジャンプテーブルや文字列は、キャッシュの局所性のためにバイナリのコードセクションに配置されることがあります。正しいコードパスを発見し逆アセンブルして、その中のRVA参照を追跡できるようにする(さらに難読化できるようにする)ためには、これらのケースを識別できることが重要です。これらは「リスクのある」参照です。
ジャンプテーブルが誤って逆アセンブリキューに追加されるのを修正するため、逆アセンブラはリスクのある参照をキューに追加する前に、まずそれがジャンプテーブルかどうかをチェックします。
文字列が lea されて誤って逆アセンブリキューに追加されるのを修正するため、逆アセンブラはそれを追加の健全性チェックを備えた基本ブロックとして逆アセンブルしようとします(例: リスクのある参照の場合はターミネータ命令が必要)。これらの健全性チェックのいずれかがリスクのある参照ブロックで失敗した場合、ブロック全体は無視され、データであるとみなされます。
シンボルファイルが提供されている場合、データシンボルはリスクのある参照として追加されません。
### 2.2.3. Functions
一部の難読化パスでは、どの基本ブロックがどの関数に属するかを把握する必要があります。そのため、すべての基本ブロックは、それを所有する対応する関数に割り当てられます。シンボルファイルを解析してすべての関数アドレスを検出し、リストに配置します。
各関数に対して、次の手順が実行されます。
- 関数のエントリ基本ブロックを取得します(関数開始アドレスの基本ブロック)。
- このエントリブロックを関数に割り当てます。
- 基本ブロックへのすべての出口(フォールスルーブランチ、ターゲットブランチ)を見つけます。
- すべての出口基本ブロックについて、それが別の関数のエントリブロックでない場合(基本ブロックRVA != 任意の関数RVA)は、そのブロックを関数に割り当てます。これらの最後の2つの手順は、発見されたすべての基本ブロックに対して繰り返されます。
- 発見されたブロック内のジャンプテーブルは解析され、そのターゲット基本ブロックが関数に割り当てられます。
### 2.2.4. 'Noreturn' call handling
noreturn関数は戻りません。noreturn呼び出しが発生すると、基本ブロックは逆アセンブルされ続けます。なぜなら、呼び出しは基本ブロックを終了させないからです。バイナリは呼び出し以降を実行することが想定されていないため、コンパイラは基本ブロックを終了させる適切なコードを挿入していません。前述の逆アセンブリチェックにより、これらのケースが捕捉され、基本ブロックが終了される可能性が高いです。```asm
sub_140006310 proc
sub rsp, 38h
mov rax, cs:__security_cookie
xor rax, rsp
mov [rsp+38h+var_8], rax
mov [rsp+38h+pExceptionObject], 2Ah
lea rdx, __TI1H
lea rcx, [rsp+38h+pExceptionObject]
call _CxxThrowException
db 0CCh
sub_140006310 endp
algn_14000633D:
align 20h
例えば、このnoreturn _CxxThrowException呼び出しでは、コンパイラがINT3命令の形式でパディングを挿入しており、これはパディングとしても終了命令としても検出されます。noreturn呼び出しの後にUD2命令が挿入されるなどの他のケースも処理されます。
これは検出の確実な方法ではありません。CodeDefenderチームも議論しているように、行き過ぎた逆アセンブルを修正するための対策が講じられています。基本ブロック内にジャンプテーブルのエントリが見つかった場合、基本ブロックは分割され、ジャンプテーブルが優先されます。これにより、noreturn呼び出し後にジャンプテーブルのエントリが命令として逆アセンブルされるのを防ぎます。次の命令が有効なコードである場合、次のアドレスの基本ブロックが逆アセンブル/処理されるときに、基本ブロックは結局分割されることになります。
難読化パスではスタック割り当てを利用します。これにより、使用するレジスタの値を保存し(例:push rax)、難読化パスの完了後に復元して、レジスタが破壊されないようにします。フレームポインタはスタック上の特定の位置を指すレジスタです。
すべてのスタック割り当ては、関数のアンワインドコードによって記述されるべきです(フレームポインタが存在しない場合)。これにより、OSの例外ハンドラがスタックを戻りアドレスまでバックトラックし、アプリケーションの例外ハンドラを検索できます。これらのスタック割り当ては、関数の先頭からの距離に制限があるため、プロローグ(関数の先頭)に配置する必要があります。プロローグの外部でスタック割り当てが行われると、アンワインドコードで記述できず、OSはアンワインドできなくなり、例外サポートが壊れます。
フレームポインタが使用される場合、OSはrspレジスタからスタックをバックトラックする必要はなく、代わりにフレームポインタからバックトラックできます。つまり、難読化プログラムは、アンワインドコードに記述しなくても、プロローグの外部でスタック割り当てを行うことができます。
リライターは、フレームポインタレジスタを持たないすべてのランタイム関数にフレームポインタレジスタを挿入します。これにより、難読化プログラムがプロローグの外部でスタック割り当てを行った後でも、OSがスタックをアンワインドできるようになります。
以下は、関数のプロローグとエピローグに加えられる変更の例です:
元の関数プロローグ:```asm
DriverEntry proc
sub rsp, 38h
修正された関数プロローグ:```asm
DriverEntry proc
push rbp
push rbp
sub rsp, 38h
lea rbp, [rsp]
lea rbp, [rsp]
元の関数エピローグ:```asm
add rsp, 38h
ren
DriverEntry endp
修正された関数のエピローグ:```asm
add rsp, 38h
pop rbp
pop rbp
retn
DriverEntry endp

図3: フレームポインタ挿入前のスタックレイアウト。
不揮発性レジスタrbpは、リライタによってフレームポインタとして使用されます。不揮発性レジスタは保存されなければならないため、rbpの値はプロローグでプッシュされ、アンワインドコードによって記述されます(OSのアンワインダーがrbpの元の値を復元できるようにするため)。rbpレジスタは、スタックを16バイトに再調整するために2回プッシュされます。2回目のプッシュは、再調整のみを目的としています。
rbpの値はプロローグで2回プッシュされるため、関数の終わりでも同様にポップして、スタックポインタを元の値に戻す必要があります。これは、リターン命令の際にリターンアドレスが[rsp]にあるようにするためです。
これらのプッシュ命令に対して、対応するアンワインドコードが挿入され、OSがプロローグにさらにスタック割り当てがあることを認識できるようにします(そこからフレームポインタをアンワインドするため)。そのため、エピローグには2つのポップが存在します。
これらの出口基本ブロック/エピローグを見つけるために、関数内のすべての基本ブロックから 'ret' または現在の関数の外部にジャンプするジャンプ命令が検索されます。間接ジャンプ(例: jmp rcx)も、ジャンプテーブルを除き、現在の関数の外部に出ると見なされます。すべての出口基本ブロックをグループ化した後、それらに2つのポップ命令を挿入して、関数を離れるときにプロローグでのプッシュの効果が元に戻されるようにします。
プロローグの最後にある 'lea rbp, [rsp]' は、OSに対してスタックの具体的な位置を示し、rspからのアンワインドではなく、その位置からアンワインドできるようにするためのものです。このフレームポインタ設定命令に対して、対応するアンワインド情報とコードが挿入されます。

図4: フレームポインタ挿入後の誤ったスタックレイアウト。
もう一つの考慮点は、スタックポインタの前に配置されるスタック引数です。関数内で、ローカル割り当ての後(リターンアドレスのスロット以降)にスタックがアクセスされる場合、それらの参照は更新されなければなりません。これは、プッシュによってスタックが16バイト調整されるため、その後のすべての参照データも16バイトシフトする必要があるからです。上の図は、スタック参照がどのようにアライメントを崩し、修正が必要になるかを示しています。
例えば、'mov rax, [rsp+0x90]' には0x10(10進数で16)が加算され、プッシュが実行された後でも同じスタックスロットにアクセスできるようになります。修正後の命令は: 'mov rax, [rsp+0xA0]' となります。
命令がローカルスタック割り当てを超えてアクセスする場合、16だけ調整され、スタック上で行われたプッシュをスキップします。スタックポインタが別のレジスタに移動され、別のレジスタを通じてアクセスされることがあります。その場合、そのレジスタは監視され、スタックポインタと同様に調整されます。
もう一つのケースは、例外のcatchハンドラです。rdxにはEstablisherFrameのアドレス(例外発生時のフレームポインタレジスタの値に等しい)が渡されます。catchハンドラはrdxを通じて例外関数のスタックにアクセスするため、rdxも追跡して調整する必要があります。
以下は、スタック参照調整によって修正された後のスタックレイアウトの図です。

図5: フレームポインタ挿入後の修正されたスタックレイアウト。
例外のサポートには、難読化ツールにシンボルファイルを提供する必要があります。これは、バイナリのシンボルに関する可能な限り多くの情報を必要とするためです。
リライタはバイナリのアンワインド情報を解析して、例外ハンドラ情報を見つけます。見つかったRVAも追跡されます。サポートされる例外ハンドラ情報の種類は以下の通りです。
この形式は、以下のテーブルエントリの配列です。```cpp
struct c_scope_table_entry_t
{
std::uint32_t begin_rva; // where exception-throwing range begins
std::uint32_t end_rva; // where exception-throwing range ends
std::uint32_t handler_rva; // the handler type/rva (normally 1)
std::uint32_t target_rva; // the catch handler rva
};
struct c_scope_table_t
{
std::uint32_t entry_count;
c_scope_table_entry_t table[1];
};
開始/終了RVAは、例外をスローできるコードの範囲を記述します。ターゲットRVAは、例外発生時にそれを処理するcatchハンドラを記述します。
#### 2.3.2.2. FuncInfo3 と FuncInfo4
C++例外に使用され、FH3とFH4の主な違いは、FH4がメモリ節約のために圧縮形式を使用することです。これらは以下の記述子を共有します。
- アンワインドマップ - 破棄する必要があるC++オブジェクトのリストと、フレームからのオブジェクトのオフセット。
- トライブロックマップ - catchハンドラのリストと、各ハンドラがキャッチできる型(例:std::runtime_error)。
- IP2Stateマップ - 現在の命令ポインタ/関数内のオフセットに応じたオブジェクトの状態を記述。
FH3の詳細:
- トライブロックマップ内の継続アドレスはコード内に保持され、catchハンドラによってraxで返されます(例:lea rax, continuation_address)。
FH4の詳細:
- マップ情報を圧縮整数形式で格納し、スペースを節約します。
- トライブロックマップには、FH4情報構造体にエンコードされた継続アドレスが含まれます。
C++例外がサポートされているのはMSVCとCLANG/LLVMのみです。GCCはFH3/FH4とは異なる形式を使用するため、C++例外はサポートされていません。
### 2.3.3. RTTI と ThrowInfo の解析
実行時型情報(RTTI)とスロー情報は、例外のスローを含む、実行時にC++型をイントロスペクトするために使用される構造体です。これらの構造体には多くのRVAが含まれているため、安定性の目的で追跡する必要があります。
#### 2.3.3.1. RTTI
C++例外記述子の「トライブロック」マップには、スローされた型をキャッチできるかどうかを知るための型情報があります。この型情報はRTTIと呼ばれ、型に関する他の情報も記述します。例えば以下のようなものです。
- 仮想関数テーブル。
- 型名。
- 継承クラス。
仮想関数を持たないクラスの場合、生成されるのは型記述子のみです。```cpp
struct type_descriptor_t
{
std::uint64_t vftable_address; // this is a DIR64 relocation
std::uint64_t unk;
char name[1];
};
これは、データセクションをスキャンして、メンバフィールド vftable_address にある DIR64 リロケーションを見つけることで検出されます。このフィールドは、それが実際の仮想関数テーブルであるかどうかをチェックされます。
仮想関数を持つクラスに対しては、complete object locator と class hierarchy descriptor が生成されます。class hierarchy descriptor には、基底クラスの配列が含まれています。```cpp
struct complete_object_locator_t
{
std::uint32_t signature;
std::uint32_t offset;
std::uint32_t constructor_offset;
std::uint32_t type_rva;
std::uint32_t hierarchy_rva;
std::uint32_t self_rva;
};
struct hierarchy_descriptor_t
{
std::uint32_t signature;
std::uint32_t attributes;
std::uint32_t base_class_count;
std::uint32_t base_class_list_rva;
};
struct base_class_array_t
{
std::uint32_t class_rvas[1];
};
struct base_class_descriptor_t
{
std::uint32_t type_rva;
std::uint32_t element_count;
std::uint32_t member_displacement;
std::uint32_t unk;
std::uint32_t unk1;
std::uint32_t attributes;
std::uint32_t hierarchy_rva;
};
To find these, data sections are scanned for DIR64 relocations that point to a complete object locator. Checks are done on the target of the DIR64 relocation to ensure it points to a complete object locator (e.g. does self_rva point to the RVA of the base of the class, do the type descriptors and hierarchy descriptors parse properly).
#### 2.3.3.2. ThrowInfo
ThrowInfo is used to describe how to destroy the exception object once it is processed, as well as the thrown type. The inherited classes of the thrown exception type are described in the catchable type array (contains RTTI references) to ensure the exception handler can match it to the catch statements.
The ThrowInfo is scanned in data sections by checking the catchable type array's contents with the previously discovered RTTI information. All the RVAs of the catchable types and the ThrowInfo are added to the tracking list.```cpp
struct throw_info_t
{
std::uint32_t attributes;
std::uint32_t pmfn_unwind; // address of exception object destructor
std::uint32_t forward_compat;
std::uint32_t catchable_type_array;
};
struct catchable_type_array_t
{
std::uint32_t count;
std::uint32_t type_rvas[1];
};
struct catchable_type_t
{
std::uint32_t attributes;
std::uint32_t rva_type;
std::uint32_t mdisp;
std::uint32_t pdisp;
std::uint32_t vdisp;
std::uint32_t size_of_thrown_object;
std::uint32_t optional_copy_constructor_rva;
};
この手法は、x86-64 アーキテクチャの命令を取得し、それらを仮想 CPU アーキテクチャに変換します。これにより、リバースエンジニアは元の命令が何をするかを分析する前に、まず仮想 CPU アーキテクチャを理解する必要があるため、分析が格段に困難になります。
この実装では、仮想マシンハンドラを生成するための汎用的なアプローチを使用しており、各 x86-64 命令に対してハンドラをハードコードすることなく、幅広い命令を難読化できます。



図 6. 仮想マシンのアーキテクチャ。
仮想化される元の命令シーケンスは、仮想マシンのエントリブロックへの呼び出しに置き換えられます。
仮想マシンに入ると、すべての汎用レジスタ(rsp を除く)がスタックにプッシュされます。rflags レジスタもスタックにプッシュされます。これらのスタックスロットは仮想レジスタとして使用され、それぞれが元のレジスタに対応します(したがって、rax のスロットは rax の代わりに使用されます)。これらの仮想レジスタのスタック内での順序はランダム化されるため、各仮想マシンハンドラのレジスタレイアウトは変化します。
汎用レジスタを仮想スタックレイアウトに配置するためのプッシュ命令もランダム化され、「sub rsp, 8; mov [rsp] reg」または「push reg」のいずれかになります。これは、仮想マシンエントリのシグネチャを構築することを困難にするために行われます。
ハードウェアレジスタは、x86-64 アーキテクチャの汎用レジスタです。ハードウェアレジスタがスタック上の仮想 CPU 状態に保存されたため、これらは自由に上書き可能になりました。仮想マシンの状態は、仮想マシンスタブが使用できる現在利用可能なハードウェアレジスタのリストを追跡します。スタブが完了すると、それらのハードウェアレジスタはリストに戻され、再利用可能になります。
これでアプリケーションは仮想マシンに入り、ターゲット命令のハンドラに実行を渡す段階になりました。ターゲット命令とは、この CPU アーキテクチャに仮想化されている x86-64 命令のことです。
まず、ターゲット命令のオペランドをスタックにロードする必要があります。オペランドの値は、空いているハードウェアレジスタにロードされます。その後、オペランドの値は難読化され、スタックにプッシュされます。これは、命令ハンドラの前のブロック(前のハンドラ、または最初のハンドラの場合は仮想マシンエントリブロック)から行われます。
オペランドの値に適用される難読化は以下の通りです。
即値オペランドの場合、値が既知であるため、この難読化は難読化時に行うことができ、実行時には計算されないため、リバースがより困難になります。
特定のレジスタを必要とする隠しオペランド(例:「rep movsb」の rsi と rdi)は、ランダムなハードウェアレジスタではなく、それらの特定のレジスタにロードされます。
命令ハンドラブロックでは、オペランドがスタックからポップされ、難読化が解除されます。逆の演算を実行して、オペランドの元の値を取得します。
元の命令が rflags レジスタを読み取る場合、命令を実行する前に rflags がスタックコンテキストからロードされます。
元の命令が rflags レジスタに書き込む場合、命令を実行する前に rflags がスタックコンテキストからロードされます。元の命令の実行後、更新された rflags がスタックコンテキストに書き戻されます。
これにより、仮想化された命令が元の命令とまったく同じフラグ動作を持つことが保証されます。
命令ハンドラブロック内で、元の x86-64 命令は、難読化が解除されたオペランドを使用するようにエンコードされます。元の命令が実行されると、結果のオペランドは難読化され、スタックにプッシュされます。
これが最後の命令ハンドラである場合、次の基本ブロックは仮想マシン終了ブロックになります。そうでない場合、次のブロックは次のハンドラからになります。
次の基本ブロックは、難読化された結果のオペランドをスタックからポップし、同じ難読化解除プロセスを適用します。結果の値は、元の宛先に書き込まれます。これは、スタックコンテキスト上の仮想レジスタ、または元の命令で記述されたメモリ内の特定の場所のいずれかになります。
このプロセスは、基本ブロック内の命令を仮想化できなくなるまで(例:rsp レジスタを使用する命令)繰り返されます。
その後、仮想マシンコンテキストをアンロードして、非仮想化コードに戻る必要があります。すべての仮想レジスタがスタックからポップされ、対応する汎用レジスタに格納されます。変更された rflags レジスタもスタックからポップされて復元されます。これで、仮想マシン終了ブロックは呼び出し元に戻ります。
仮想化された命令が例外をスローした場合、OS は仮想マシンコンテキストからアンワインドして、呼び出し元で適切な例外ハンドラを見つけられる必要があります。
これを可能にするために、仮想マシン関数のスタックレイアウトが OS に認識されるように、バイナリにアンワインド情報を追加する必要があります。
フレームポインタが rbp にロードされます。これは、仮想マシンハンドラがプロローグ外でスタック割り当てを使用するためです。つまり、rbp は仮想マシンハンドラによって「利用可能な」ハードウェアレジスタとして使用することはできません。
使用される仮想マシンのすべてのハードウェアレジスタはスタックコンテキストにプッシュされるため、それらのプッシュに対応するアンワインドコードが挿入されます。
仮想マシン関数のランタイム関数が例外ディレクトリに挿入されます。これで、仮想マシンはアンワインド可能になりました。
この手法は、データフローが誤った偽の基本ブロックへの分岐を作成し、リバースエンジニアを混乱させます。
不透明述語とは、常に true または false と評価されるステートメントです。
基本ブロックは複製され、不透明述語の if 文でラップされます。一方のブロックはデータフローが歪められ、類似しているが正しくないものになります。```cpp
if (opaque_statement_always_true)
{
… original basic block
}
else
{
… incorrect but similar basic block
}
さらにリバースエンジニアを混乱させるため、すべての命令オペランドが収集され、ランダム化されます。各命令は収集されたリストからランダムなオペランドで再コンパイルされます。これにより、複製されたブロックの動作が元のブロックとは異なることが保証されます。
ブロックの位置もランダムにシャッフルされるため、メモリ内の物理的な位置から正しいブランチがどれかが分かることはありません。
ブランチ選択に必要な条件もランダムに選択されます。例えば、ある反復では条件付きジャンプのフォールスルーブランチが正しいブロックに繋がり、別の反復では条件付きジャンプのターゲットブランチが正しいブロックに繋がります。これにより、正しいブランチを見つけることが難しくなります。
不透明述語式自体も非常に重要であり、簡単に評価できるようでは効果がありません。そのため、不透明式として[フェルマーの最終定理](https://en.wikipedia.org/wiki/Fermat's_Last_Theorem)が選ばれています。フェルマーの最終定理は「任意の2より大きい整数nに対して、a^n + b^n = c^nを満たす3つの正の整数a, b, cは存在しない」と述べています(^はべき乗を意味します)。この式は、与えられた条件下では常に偽であることが証明されています。3つの数a, b, cが選ばれ、それらは3から7の間でランダムに選ばれたべき乗に引き上げられます。この式は新しく作成された基本ブロックで実行され、その後条件付きブランチが正しいブロックへと進みます。
もしパラメータa, b, c, nが既知であれば、攻撃者は式を解いて正しいブランチを見つけることができます。これを防ぐため、パラメータは実行時に決定される値から選択されます。スタックポインタ(rsp)と命令ポインタ(ASLRによりripは実行時に再配置されます)です。例えば:```cpp
if ((rsp^n) + (rip^n) != (c^n))
{
… incorrect but similar basic block
}
else
{
… original basic block
}
制御フローとは、プログラムがたどる実行経路(例:'if文')のことです。制御フローの変更は、ある基本ブロックが別のブロックにジャンプするときに発生します。これらの制御フローの変更は、単一のディスパッチャスタブにまとめてシャッフルすることで、理解しにくくすることができます。ディスパッチャスタブは、直接ジャンプする代わりに、次の基本ブロックへの制御フローの変更を担当します。

図7. 制御フロー平坦化。
関数のすべての基本ブロック(プロローグを除く)をリストにまとめ、そのすべての分岐(条件付き、無条件)を収集します。各基本ブロックには一意のID/識別子が与えられます。ディスパッチャスタブは、すべての可能性のある分岐を受け取り、すべてのIDをケースとするswitch文を構築します。case文は対象の基本ブロックにジャンプします。元の基本ブロック/元の制御フローへのすべてのジャンプは、正しいID(対象ブロックのID)を指定してswitch文へのジャンプに置き換えられます。
基本ブロックの物理的なレイアウトはシャッフルされるため、そのメモリ位置から元の制御フローに関する手がかりを得ることはできません。
この手法は、命令にエンコードされた数値(メモリオペランドの変位、即値オペランド)を取り、実行時に計算することでその真の値を隠します。
難読化時に、元の数値と同じビット幅の乱数値が生成されます。これが元の数値に加算され、未使用のレジスタに即値オペランドとしてロードされます。
実行時には、乱数がレジスタから減算され、結果として元の数値が得られます。
Rを乱数、Nを元の数値とすると、式は実質的に ((R+N) - R) となります。
メモリオペランドを再エンコードするために、ベースオペランドに対してレジスタ挿入が行われます。既にベースオペランドがある場合は、その値が追加で加算されます。
置換されるスタックメモリオペランドの場合、プッシュによって引き起こされるスタック変位を考慮して、値に変位が追加されます。プッシュはrflagsレジスタと未使用レジスタの保存に使用されます。
以下は、'mov [rsp+24h], 0' におけるメモリオペランドの置換の例です。```asm
push r11
pushfq ; save flags for now
mov r11, rsp
add r11, 1D4D9F71h
sub r11, 1D4D9F3Dh
popfq ; restore flags, the original instruction will now execute and populate flags with the correct values
mov dword ptr [r11], 0 ; original instruction substituted (r11 == rsp+24h)
pop r11
'add rbx, 8' における即値オペランドの置換の別の例を示します:```asm
push r10
pushfq
mov r10, 0FFFFFFFFE3F78112h
sub r10, 0FFFFFFFFE3F7810Ah
popfq
add rbx, r10
pop r10
この手法は、通常の算術式(例:x+y)を受け取り、同じ結果を生成するより複雑な式に変換します。これは、式を等価な線形恒等式に置き換えることで行います。
例えば、(x+y) は等価な恒等式 ((x & y) + (x | y)) を持ちます。難読化ツールが (x+y) を処理するとき、それをリバースエンジニアリングがより困難な恒等式の1つに置き換えます。
次の算術命令に対して恒等式のリストがあります: 'add', 'sub', 'and', 'or', 'xor'。つまり、これらの各命令に対して、より複雑でランダムな置換が選択されて置き換えられます。リストからランダムに恒等式を選択することで、各難読化出力が異なることが保証されます。
これをさらに難読化しにくくするために、再帰的に適用されます。その結果、置換された各恒等式が複数回再置換され、複雑さが指数関数的に増大します。以下は、この技術を2回適用した後の (x+y) の変換例です。

図8. 混合ブール算術。
考慮すべき点は、命令のフラグ計算結果です。元の命令はフラグレジスタに特定のフラグ動作を適用していましたが、式が異なる操作/命令に分割されるため、同じではなくなります。これを解決するために、難読化ツールは正しいフラグを計算して適用するスタブを挿入することでフラグ動作をエミュレートします。
'and', 'or', 'xor' のフラグ動作は 'test' 命令と同じです。同じオペランドを持つ test 命令を挿入することで、これら3つの置換命令に対してフラグが適切にエミュレートされます。
'sub' の場合、'cmp' 命令は同じフラグ動作を持ち、フラグ計算のために同じオペランドで挿入できます。
'add' の場合、SF、ZF、PF は 'test' で計算できますが、CF、AF、OF は手動で計算する必要があります。その後、add 命令の CF、AF、OF を手動で計算するスタブが挿入されます。
これらのフラグエミュレーションスタブは元の命令が何であったかを示唆するため、絶対に必要な場合にのみ行われます。基本ブロック全体が、フラグを読み取る命令とフラグを書き込む命令についてスキャンされます。フラグエミュレーションを追加する条件は次のとおりです。
これにより、基本ブロックの終わりに達したときやフラグが読み取られたときに、フラグエミュレーションスタブによってフラグが最新に保たれます。これにより、条件文(例:if文)で誤った分岐が取られるのを防ぎます。
以下は、CMakeを使用してプロジェクトをビルドするためのコマンド例です。これらのコマンドはプロジェクトのルートディレクトリから実行を開始してください。```
cmake -B build
cmake --build build
Visual StudioがインストールされているWindowsシステムでは、生成されたVisual Studioソリューションファイル(.sln)を使用してプロジェクトをビルドできるため、最後のコマンドをスキップできます。Visual Studioソリューションファイルは'build/'フォルダにあります。
# 5. 使い方
難読化ツールはコマンドライン設定システムを備えています。以下はコマンドライン引数で設定できる項目です。
- 使用する難読化パス。
- 入力バイナリファイルのパス。
- 入力シンボルファイルのパス(オプション)。
- 出力バイナリファイルのパス(オプション)。
以下はコマンドライン引数の概要です。
Usage: binprotect binary-path symbol-path [--out-binary-path VAR] [--control-flow-flattening VAR] [--virtual-machine VAR] [--opaque-predicates VAR] [--linear-substitution VAR] [--mixed-boolean-arithmetic VAR]
位置引数:
binary-path 入力バイナリのファイルパス [必須]
symbol-path 入力バイナリのシンボルのファイルパス [オプション]
--out, --out-path, --out-binary-path 出力バイナリの希望ファイルパス
--cff, --control-flow-flattening 制御フロー平坦化パスを有効にする [default: 1]
--vm, --virtual-machine 仮想マシンパスを有効にする [default: 1]
--opa, --opaque, --opaque-predicate, --opaque-predicates 難読述語パスを有効にする [default: 1]
--lin, --linear-substitution 線形置換パスを有効にする [default: 1]
--mba, --mixed-boolean-arithmetic 混合ブール算術パスの数を指定する [default: 2]
# 6. 頭字語
- bin2bin - バイナリからバイナリへ。
- RVA - 相対アドレス。
- MBA - 混合ブール算術。
- [SEH - 構造化例外処理](https://learn.microsoft.com/en-us/cpp/cpp/structured-exception-handling-c-cpp)。
- FH3 - FuncInfo3。
- FH4 - FuncInfo4。
- [MSVC - Microsoft Visual C++](https://en.wikipedia.org/wiki/Microsoft_Visual_C%2B%2B)。
- [LLVM - 低水準仮想マシン](https://en.wikipedia.org/wiki/LLVM)。
- [CLANG - LLVM用のC/C++言語フロントエンド](https://clang.llvm.org/)。
- [GCC - GNUコンパイラコレクション](https://en.wikipedia.org/wiki/GNU_Compiler_Collection)。
- SF - 符号フラグ。
- ZF - ゼロフラグ。
- PF - パリティフラグ。
- CF - キャリーフラグ。
- AF - 補助キャリーフラグ。
- OF - オーバーフローフラグ。
# 7. 謝辞
以下の方々はプロジェクトの開発中に貴重な助言を提供しました。
- Aita.
- Papstuc.
- Eriktion.
- IDontCode.
- Abdulla.
- Brit.
- Phage.