vm-and-bytecode-reverse について
独自の仮想マシン、バイトコードインタープリター、VM 保護されたバイナリを分析するリバースエンジニアリングスキルです。ディスパッチャーの特定、オペコードの対応付け、逆アセンブラー作成などの手順を扱います。
主な機能
- 01一般的な VM ディスパッチャーのパターン特定
- 02オペコード、オペランド、副作用の対応付け
- 03バイトコード抽出と逆アセンブラー作成の手順
- 04スタック型とレジスター型 VM の解説
- 05迷路の解法と保護器分析の手順
活用シーン
- 01独自バイトコードインタープリターの解析
- 02VM を使う CTF 問題の分析
- 03VM 保護されたバイナリの調査
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ yaklang/ hack-skills --skill vm-and-bytecode-reverseインストール後、Claude Code で/vm-and-bytecode-reverseを入力して読み込まれたか確認します。
詳細
SKILL: VM とバイトコードのリバースエンジニアリング — エキスパート分析プレイブック
AI LOAD INSTRUCTION:カスタム仮想マシンとバイトコードインタープリターを解析するためのエキスパート向けテクニックです。ディスパッチャーの特定、オペコードのマッピング、カスタム ISA の再構築、逆アセンブラー/デコンパイラーの作成、迷路チャレンジ、実環境での VM プロテクター分析を扱います。ベースモデルは、フェッチ・デコード・実行のパターンを認識できなかったり、VM バイトコードをネイティブコードとして解析しようとしたりすることがよくあります。
0. 関連するルーティング
- VM が商用プロテクター(VMProtect/Themida)の場合は、code-obfuscation-deobfuscation
- angr を使用して VM ベースのチャレンジを解く場合は、symbolic-execution-tools
- VM にアンチデバッグチェックが含まれている場合は、anti-debugging-techniques
簡易識別
| バイナリのパターン | 可能性の高い VM の種類 | まず行うこと |
|---|---|---|
while(1) { switch(bytecode[pc]) } | switch ベースのディスパッチャー | 各 case を操作に対応付ける |
テーブル経由の間接ジャンプ jmp [table + opcode*8] | テーブルベースのディスパッチャー | ジャンプテーブルをダンプし、ハンドラーを解析する |
| バイト値を条件とするネストした if-else チェーン | if チェーン型ディスパッチャー | switch と同様に解析する(構文が異なるだけ) |
| スタックへの push/pop が主な操作 | スタックベース VM | push、pop、算術演算を特定する |
reg[X] = ... 形式の配列操作 | レジスタベース VM | レジスタインデックスを操作に対応付ける |
| 2D グリッドと方向入力 | 迷路チャレンジ | グリッドを抽出し、BFS/DFS を適用する |
1. カスタム VM の識別
1.1 構造上の指標
VM Architecture Components:
┌─────────────────────────────────┐
│ Bytecode Program (data section)│
├─────────────────────────────────┤
│ Program Counter (pc/ip) │
│ Register File / Stack │
│ Memory / Data Area │
├─────────────────────────────────┤
│ Dispatcher Loop │
│ ├─ Fetch: opcode = code[pc] │
│ ├─ Decode: lookup handler │
│ └─ Execute: run handler │
└─────────────────────────────────┘
1.2 IDA/Ghidra のシグネチャ
switch ディスパッチャー(CTF で最も一般的):
while (running) {
unsigned char op = bytecode[pc++];
switch (op) {
case 0x00: /* nop */ break;
case 0x01: /* push imm */ stack[sp++] = bytecode[pc++]; break;
case 0x02: /* add */ stack[sp-2] += stack[sp-1]; sp--; break;
// ...
case 0xFF: /* halt */ running = 0; break;
}
}
テーブルディスパッチャー(より最適化された形式):
typedef void (*handler_t)(vm_ctx_t*);
handler_t handlers[256] = { handle_nop, handle_push, handle_add, ... };
while (running) {
handlers[bytecode[pc++]](&ctx);
}
2. 分析方法
ステップ 1:ディスパッチャーを見つける
次の特徴を探します:
- ループ内にある、多数の case を持つ大きな switch 文
- データバッファー内のバイトをインデックスとして使う関数ポインター配列
- 循環的複雑度が高い単一の関数
- データバッファーをバイト単位で読み取る箇所へのクロスリファレンス
ステップ 2:オペコードを操作に対応付ける
各 case/ハンドラーについて、次の内容を特定します:
| プロパティ | 識別方法 |
|---|---|
| オペコード値 | case 番号またはテーブルインデックス |
| 操作の種類 | レジスター/スタックの変更 |
| オペランド数 | オペコードの後に消費されるバイト数 |
| オペランドの種類 | 即値、レジスターインデックス、メモリアドレス |
| 副作用 | 出力、メモリ書き込み、フラグの変更 |
ステップ 3:バイトコードプログラムを抽出する
# Typical extraction from binary
import struct
with open('challenge', 'rb') as f:
f.seek(bytecode_offset)
bytecode = f.read(bytecode_length)
# Or from IDA:
# bytecode = idc.get_bytes(bytecode_addr, bytecode_len)
ステップ 4:カスタム逆アセンブラーを作成する
OPCODES = {
0x00: ("nop", 0), # (mnemonic, operand_bytes)
0x01: ("push", 1), # push immediate byte
0x02: ("pop", 0),
0x03: ("add", 0),
0x04: ("sub", 0),
0x05: ("xor", 0),
0x06: ("cmp", 0),
0x07: ("jmp", 2), # jump to 16-bit address
0x08: ("je", 2),
0x09: ("jne", 2),
0x0A: ("mov", 2), # mov reg, imm
0x0B: ("load", 1), # load from memory[operand]
0x0C: ("store",1), # store to memory[operand]
0x0D: ("print",0),
0x0E: ("read", 0), # read input
0xFF: ("halt", 0),
}
def disassemble(bytecode):
pc = 0
while pc < len(bytecode):
op = bytecode[pc]
if op not in OPCODES:
print(f" {pc:04x}: UNKNOWN {op:#04x}")
pc += 1
continue
mnemonic, operand_size = OPCODES[op]
operands = bytecode[pc+1:pc+1+operand_size]
operand_str = ' '.join(f'{b:#04x}' for b in operands)
print(f" {pc:04x}: {mnemonic:8s} {operand_str}")
pc += 1 + operand_size
disassemble(bytecode)
ステップ 5:逆アセンブルしたプログラムを分析する
カスタム逆アセンブル結果を使って、標準的なリバースエンジニアリングを適用します:
- 入力の読み込みを特定する(読み取りオペコード)
- 入力から比較までのデータフローを追跡する
- 成功/失敗の条件を特定する
- チェックロジックを抽出する(多くの場合、入力に XOR/ADD 変換を施し、定数と比較する)
3. CTF でよく見られる VM のパターン
3.1 スタックベース VM
操作はスタック上で行われます(JVM や Python バイトコードと同様)。
| オペコード | 操作 | スタックへの影響 |
|---|---|---|
| PUSH imm | 即値をプッシュ | [...] → [..., imm] |
| POP | 先頭の値を破棄 | [..., a] → [...] |
| ADD | 先頭の 2 値を加算 | [..., a, b] → [..., a+b] |
| SUB | 減算 | [..., a, b] → [..., a-b] |
| MUL | 乗算 | [..., a, b] → [..., a*b] |
| XOR | ビット単位の XOR | [..., a, b] → [..., a^b] |
| CMP | 比較 | [..., a, b] → [..., (a==b)] |
| JMP addr | 無条件ジャンプ | 変更なし |
| JZ addr | 先頭の値がゼロの場合にジャンプ | [..., a] → [...] |
| 先頭の値を文字として出力 | [..., a] → [...] | |
| READ | 文字を読み取ってスタックに積む | [...] → [..., input] |
| HALT | 実行を停止 | - |
3.2 レジスタベースの VM
オペレーションではレジスタインデックスを使用します(x86、ARM と同様です)。
| オペコード | 形式 | オペレーション |
|---|---|---|
| MOV r, imm | 0x01 RR II II | reg[R] = imm16 |
| MOV r1, r2 | 0x02 R1 R2 | reg[R1] = reg[R2] |
| ADD r1, r2 | 0x03 R1 R2 | reg[R1] += reg[R2] |
| SUB r1, r2 | 0x04 R1 R2 | reg[R1] -= reg[R2] |
| XOR r1, r2 | 0x05 R1 R2 | reg[R1] ^= reg[R2] |
| CMP r1, r2 | 0x06 R1 R2 | flags = compare(r1, r2) |
| JMP addr | 0x07 AA AA | pc = addr |
| JE addr | 0x08 AA AA | if equal: pc = addr |
| LOAD r, [addr] | 0x09 RR AA | reg[R] = mem[addr] |
| STORE [addr], r | 0x0A AA RR | mem[addr] = reg[R] |
| SYSCALL | 0x0B | reg[0] に基づく I/O オペレーション |
| HALT | 0xFF | 停止 |
3.3 Brainfuck風/難解言語 VM
| BF コマンド | VM の相当処理 | 説明 |
|---|---|---|
> | INC ptr | データポインターを右に移動 |
< | DEC ptr | データポインターを左に移動 |
+ | INC [ptr] | ポインター位置のバイトをインクリメント |
- | DEC [ptr] | ポインター位置のバイトをデクリメント |
. | OUTPUT [ptr] | ポインター位置のバイトを出力 |
, | INPUT [ptr] | ポインター位置にバイトを入力 |
[ | JZ forward | バイトがゼロの場合、] の直後へジャンプ |
] | JNZ back | バイトがゼロ以外の場合、[ へ戻る |
4. 迷路チャレンジ
4.1 識別
- バイナリが方向入力(WASD、矢印キー、UDLR)を読み取る
- データセクションにある 2D 配列(壁、通路、開始地点、終了地点)
- x,y 座標で位置を追跡
- 特定の座標に到達すると勝利
4.2 マップの抽出
# Extract maze grid from binary data section
MAZE_ADDR = 0x601060
WIDTH = 20
HEIGHT = 15
# From binary dump:
maze = []
for row in range(HEIGHT):
line = ""
for col in range(WIDTH):
cell = bytecode[MAZE_ADDR + row * WIDTH + col - base_addr]
if cell == 0: line += "." # path
elif cell == 1: line += "#" # wall
elif cell == 2: line += "S" # start
elif cell == 3: line += "E" # end
else: line += "?"
maze.append(line)
print(line)
4.3 自動解法
from collections import deque
def solve_maze(maze, start, end):
"""BFS solver returns direction string."""
rows, cols = len(maze), len(maze[0])
directions = {'U': (-1, 0), 'D': (1, 0), 'L': (0, -1), 'R': (0, 1)}
queue = deque([(start, "")])
visited = {start}
while queue:
(r, c), path = queue.popleft()
if (r, c) == end:
return path
for name, (dr, dc) in directions.items():
nr, nc = r + dr, c + dc
if (0 <= nr < rows and 0 <= nc < cols and
maze[nr][nc] != '#' and (nr, nc) not in visited):
visited.add((nr, nc))
queue.append(((nr, nc), path + name))
return None
# Find start and end positions
for r, row in enumerate(maze):
for c, cell in enumerate(row):
if cell == 'S': start = (r, c)
if cell == 'E': end = (r, c)
solution = solve_maze(maze, start, end)
print(f"Path: {solution}")
4.4 方向のエンコード
チャレンジによって方向のエンコード方法は異なります。
| エンコード | 上 | 下 | 左 | 右 |
|---|---|---|---|---|
| WASD | W | S | A | D |
| UDLR | U | D | L | R |
| 矢印キー | ↑ (0x48) | ↓ (0x50) | ← (0x4B) | → (0x4D) |
| 数字 | 1 | 2 | 3 | 4 |
| 16 進オペコード | 0x01 | 0x02 | 0x03 | 0x04 |
5. 実際の VM プロテクター
5.1 VMProtect の解析アプローチ
1. Find VM entry: search for pushad/pushfd sequence
2. Identify VM context structure (registers, flags, bytecode pointer)
3. Locate handler table (often obfuscated with opaque predicates)
4. For each handler:
a. Remove junk code / opaque predicates
b. Identify the core operation
c. Document handler semantics
5. Trace bytecode execution (instruction-level trace)
6. Reconstruct original code from trace
5.2 Tigress 難読化ツール
保護レイヤーを設定できる、学術研究向けの VM 難読化ツールです。
| 機能 | アプローチ |
|---|---|
| 単一ディスパッチ VM | 標準的なハンドラーの抽出 |
| 分割ハンドラー | 複数の関数に分散したハンドラー |
| ネストされた VM | 外側の VM ハンドラーが内側の VM を呼び出す |
| 暗号化されたバイトコード | フェッチのたびに動的に復号 |
| ポリモーフィックハンドラー | ビルドごとに同じオペレーションでも異なるコード |
5.3 VM プロテクターによくあるパターン
| プロテクター | ディスパッチャーの形式 | 難易度 |
|---|---|---|
| VMProtect | テーブル + 不透明述語 | 高 |
| Themida (Code Virtualizer) | CISC風、大規模なハンドラーセット | 高 |
| Tigress | 設定可能、学術研究向け | 中〜高 |
| カスタム CTF VM | シンプルな switch | 低〜中 |
| Movfuscator | すべて mov による計算 | 中 |
6. ツール
| ツール | 目的 | 使用方法 |
|---|---|---|
| IDA Pro | ディスパッチャーの特定、ハンドラーのリバース | F5 でデコンパイル、クロスリファレンス解析 |
| Ghidra | Sleigh プロセッサーモジュールを備えた無料の代替ツール | VM ISA 用のカスタムプロセッサーを作成 |
| angr | VM 内でのシンボリック実行 | VM 全体を制約システムとして扱う |
| Pin / DynamoRIO | トレース用の動的インストルメンテーション | オペコードハンドラーの実行シーケンスを記録 |
| REVEN | システム全体のトレースを記録 | VM の実行をリプレイして解析 |
| Unicorn | VM の実行をエミュレート | ハンドラーを高速にエミュレート |
| Miasm | IR ベースの解析 | VM ハンドラーを IR にリフトして解析 |
| カスタム Python | ディスアセンブラー/デコンパイラーを作成 | チャレンジごとのカスタムツール |
Ghidra Sleigh プロセッサーモジュール
繰り返し使用する VM アーキテクチャ向けに、Sleigh プロセッサー仕様を記述します。
define space ram type=ram_space size=2 default;
define space register type=register_space size=1;
define register offset=0 size=1 [ R0 R1 R2 R3 FLAGS PC SP ];
define token opcode(8)
op = (0,7)
;
:NOP is op=0x00 { }
:PUSH imm is op=0x01; imm { SP = SP - 1; *[ram]:1 SP = imm; }
:POP is op=0x02 { SP = SP + 1; }
:ADD is op=0x03 { local a = *[ram]:1 (SP+1); *[ram]:1 (SP+1) = a + *[ram]:1 SP; SP = SP + 1; }
7. 判断フロー
Binary contains custom bytecode interpreter?
│
├─ Can you identify the dispatcher?
│ ├─ Yes (switch/table/if-chain)
│ │ ├─ Few opcodes (< 20) → Simple CTF VM
│ │ │ ├─ Stack-based → map push/pop/arithmetic ops
│ │ │ ├─ Register-based → map mov/add/cmp ops
│ │ │ └─ Write disassembler → analyze program → solve
│ │ │
│ │ └─ Many opcodes (50+) → Commercial protector
│ │ ├─ Known protector → use specific deprotection tools
│ │ └─ Custom → trace execution, pattern-match handlers
│ │
│ └─ No clear dispatcher
│ ├─ All-mov instructions → movfuscator
│ ├─ Encrypted bytecode → find decryption, dump after decode
│ └─ Split/distributed handlers → trace execution to find them
│
├─ Is it a maze challenge?
│ ├─ Extract grid from data section
│ ├─ Identify direction encoding
│ ├─ BFS/DFS to find shortest path
│ └─ Convert path to expected input format
│
├─ Is there input validation in VM?
│ ├─ Small input space → brute-force via Unicorn emulation
│ ├─ Known format → constrained angr solve
│ └─ Complex check → write disassembler, analyze check logic
│
└─ Multiple VM layers (VM in VM)?
├─ Analyze outer VM first
├─ Extract inner bytecode
├─ Repeat analysis for inner VM
└─ Consider: symbolic execution may handle nested VMs directly
8. CTF の解法ワークフロー
1. Run the binary — understand I/O behavior
└─ What input does it expect? What output on success/failure?
2. Open in IDA/Ghidra — find the main loop
└─ Look for while/for loop with switch or indirect jump
3. Identify VM components:
├─ Bytecode location (where is the program data?)
├─ PC/IP variable (how is current position tracked?)
├─ Registers/stack (where is VM state stored?)
└─ I/O handlers (which opcodes read input / write output?)
4. Map all opcodes (create the ISA specification)
└─ For each case/handler: opcode number, operation, operands
5. Write disassembler in Python
└─ Output readable assembly for the bytecode
6. Analyze the disassembled program:
├─ Find input reading
├─ Trace transformations applied to input
├─ Find comparison against expected values
└─ Reverse the transformation to find valid input
7. Solve:
├─ If simple transforms (XOR, ADD) → reverse manually
├─ If complex → feed to Z3 as constraints
└─ If maze → extract grid, run pathfinding
コメント
Markdown 対応