VM 기반 문제는 보통 bytecode + dispatcher로 구성된다. 핵심은 opcode 의미를 복원하고, bytecode를 사람이 읽을 수 있는 형태로 끌어내는 것이다.
1. VM Skeleton
아래처럼 스택 기반 VM이 많다. 구조체 안에 pc, sp, flag만 있어도 충분하다.
typedef struct { const uint8_t *code; const uint8_t *input; uint32_t pc; uint8_t sp; uint8_t flag; uint8_t stack[64];} VM;
static uint8_t rol8(uint8_t v, unsigned r) { return (uint8_t)((v << r) | (v >> (8 - r)));}
static int vm_run(VM *vm) { for (;;) { uint8_t op = vm->code[vm->pc++]; switch (op) { case 0x01: { // PUSH imm8 vm->stack[vm->sp++] = vm->code[vm->pc++]; break; } case 0x02: { // LOAD idx uint8_t idx = vm->code[vm->pc++]; vm->stack[vm->sp++] = vm->input[idx]; break; } case 0x10: { // XOR uint8_t b = vm->stack[--vm->sp]; uint8_t a = vm->stack[vm->sp - 1]; vm->stack[vm->sp - 1] = a ^ b; break; } case 0x11: { // ADD uint8_t b = vm->stack[--vm->sp]; uint8_t a = vm->stack[vm->sp - 1]; vm->stack[vm->sp - 1] = (uint8_t)(a + b); break; } case 0x12: { // ROL imm uint8_t r = vm->code[vm->pc++]; vm->stack[vm->sp - 1] = rol8(vm->stack[vm->sp - 1], r); break; } case 0x20: { // CMP imm uint8_t v = vm->stack[--vm->sp]; uint8_t imm = vm->code[vm->pc++]; vm->flag = (v == imm); break; } case 0x30: { // JNZ rel8 (flag == 0이면 jump) int8_t off = (int8_t)vm->code[vm->pc++]; if (!vm->flag) { vm->pc = (uint32_t)(vm->pc + off); } break; } case 0xFF: { // HALT imm uint8_t ret = vm->code[vm->pc++]; return ret; } default: return 0; } }}리버서 입장에서는 아래를 먼저 체크한다.
pc가 opcode/operand를 읽은 뒤 증가하는지 여부sp가 push/pop 이전인지 이후인지flag의 의미가 true/false로 그대로 쓰이는지, 반전되는지JNZ오프셋이 부호 있는 상대 이동인지, 절대 이동인지
Opcode Table
스택 효과까지 같이 보면 식을 만들기가 쉬워진다. S를 스택이라고 하면,
| Opcode | Mnemonic | Stack Effect | Desc |
|---|---|---|---|
0x01 | PUSH imm8 | S -> S, imm | 즉시값 push |
0x02 | LOAD idx | S -> S, input[idx] | 입력 push |
0x10 | XOR | S, a, b -> S, (a ^ b) | XOR |
0x11 | ADD | S, a, b -> S, (a + b) | mod 256 |
0x12 | ROL imm | S, a -> S, rol8(a, imm) | rotate |
0x20 | CMP imm | S, a -> S | flag = (a == imm) |
0x30 | JNZ rel8 | S -> S | flag == 0이면 PC += off |
0xFF | HALT imm | S -> S | 즉시값 반환 |
스택 효과를 먼저 정리하면 식 세우기가 훨씬 깔끔하다. 특히
CMP가 값을 pop하는지, 남겨두는지 파악을 해야한다.
Dispatcher
바이너리에서 VM을 찾을 때는 다음 순서로 좁혀 들어간다.
- opcode fetch 패턴:
op = code[pc++]같은 형태가 반복되는 루프를 찾자 - switch/jumptable:
switch (op)또는jmp [table + op*8]같은 분기 테이블 체크 - operand fetch: 특정 case에서
code[pc++]를 추가로 읽는지 확인한다. 이게 곧 opcode 길이. - stack 업데이트:
sp++,--sp의 위치를 보고 push/pop 고정 - flag 사용:
CMP나TEST결과가JZ/JNZ로 이어지는지 추적
VM이 난독화되어 있으면 opcode를 XOR/ADD로 디코딩하거나, 바이트코드를 런타임에 복호화한다. 이런 경우는 dispatcher 이전에 작은 복호화 루틴이 붙는 경우가 많다.
Bytecode 추출 + 디스어셈블
바이트코드를 덤프했으면 디스어셈블을 만들어야 한다. opcode 길이만 알면 간단히 파싱 가능하다.
op_info = { 0x01: ("PUSH", 1), 0x02: ("LOAD", 1), 0x10: ("XOR", 0), 0x11: ("ADD", 0), 0x12: ("ROL", 1), 0x20: ("CMP", 1), 0x30: ("JNZ", 1), 0xFF: ("HALT", 1),}
def disasm(code): pc = 0 out = [] while pc < len(code): op = code[pc] name, imm_len = op_info.get(op, ("DB", 0)) if name == "DB": out.append(f"{pc:02X}: DB 0x{op:02X}") pc += 1 continue imm = None if imm_len: imm = code[pc + 1] if name == "JNZ": off = imm if imm < 0x80 else imm - 0x100 tgt = (pc + 2 + off) & 0xFFFFFFFF out.append(f"{pc:02X}: JNZ {off:+#x} ; -> {tgt:02X}") elif imm_len: out.append(f"{pc:02X}: {name} 0x{imm:02X}") else: out.append(f"{pc:02X}: {name}") pc += 1 + imm_len return outJNZ rel8는 pc가 operand를 읽은 뒤를 기준으로 상대 이동한다. 이 기준이 틀리면 분기 타겟이 전부 어긋난다.
2. Bytecode Example
해당 bytecode는 8바이트 입력을 검증한다. 고정 패턴이 반복되므로 디스어셈블만 제대로 되면 바로 식이 나온다.
static const uint8_t program[] = { 0x02, 0x00, 0x01, 0x13, 0x10, 0x01, 0x05, 0x11, 0x12, 0x01, 0x20, 0xEE, 0x30, 0x64, 0x02, 0x01, 0x01, 0x57, 0x10, 0x01, 0x11, 0x11, 0x12, 0x01, 0x20, 0x94, 0x30, 0x56, 0x02, 0x02, 0x01, 0x9B, 0x10, 0x01, 0x1F, 0x11, 0x12, 0x01, 0x20, 0x3C, 0x30, 0x48, 0x02, 0x03, 0x01, 0xDF, 0x10, 0x01, 0x2A, 0x11, 0x12, 0x01, 0x20, 0xAD, 0x30, 0x3A, 0x02, 0x04, 0x01, 0x22, 0x10, 0x01, 0x07, 0x11, 0x12, 0x01, 0x20, 0xA8, 0x30, 0x2C, 0x02, 0x05, 0x01, 0x68, 0x10, 0x01, 0x19, 0x11, 0x12, 0x01, 0x20, 0x62, 0x30, 0x1E, 0x02, 0x06, 0x01, 0xA1, 0x10, 0x01, 0x2D, 0x11, 0x12, 0x01, 0x20, 0x06, 0x30, 0x10, 0x02, 0x07, 0x01, 0x3C, 0x10, 0x01, 0x3B, 0x11, 0x12, 0x01, 0x20, 0x1B, 0x30, 0x02, 0xFF, 0x01, 0xFF, 0x00};디스어셈블하면 이런 식으로 반복된다.
00: LOAD 002: PUSH 0x1304: XOR05: PUSH 0x0507: ADD08: ROL 10A: CMP 0xEE0C: JNZ +0x64(반복)70: HALT 172: HALT 0이 구조에서 블록 하나의 길이는 14바이트다.
- 첫 블록
JNZ의 오프셋은0x72 - 0x0E = 0x64 - 실패 주소는
HALT 0가 시작되는0x72 - 성공 주소는
HALT 1가 시작되는0x70
수식화
패턴이 똑같으니 한 블록만 보면 된다. 스택의 변화까지 적으면 식이 나온다.
LOAD i -> [x_i]PUSH k[i] -> [x_i, k_i]XOR -> [x_i ^ k_i]PUSH c[i] -> [x_i ^ k_i, c_i]ADD -> [(x_i ^ k_i) + c_i]ROL 1 -> [rol8((x_i ^ k_i) + c_i, 1)]CMP t[i] -> flag = (top == t_i), stack popJNZ fail -> if flag == 0, jump to fail여기서 는 입력 바이트, 는 key, 는 add, 는 CMP 상수다. 모든 연산은 mod 256이다.
역연산도 바로 나온다.
아래는 VM 상수
k = [13, 57, 9B, DF, 22, 68, A1, 3C]c = [05, 11, 1F, 2A, 07, 19, 2D, 3B]t = [EE, 94, 3C, AD, A8, 62, 06, 1B]실제 문제는
ROL대신ROR이 섞이거나,ADD위치가 바뀌거나, 스택/레지스터가 꼬여 있다. 어렵지만 패턴을 찾고, 수식화를 차근차근 한다면 당신도 할 수 있다.
3. Solving
Direct
def ror8(v, r): return ((v >> r) | (v << (8 - r))) & 0xFF
keys = [0x13, 0x57, 0x9B, 0xDF, 0x22, 0x68, 0xA1, 0x3C]adds = [0x05, 0x11, 0x1F, 0x2A, 0x07, 0x19, 0x2D, 0x3B]targets = [0xEE, 0x94, 0x3C, 0xAD, 0xA8, 0x62, 0x06, 0x1B]
out = []for i in range(8): v = ror8(targets[i], 1) v = (v - adds[i]) & 0xFF v = v ^ keys[i] out.append(v)
print(bytes(out))SMT
오퍼랜드가 꼬여 있거나 제약이 섞이면 SMT도 고려해볼 수 있다.
from z3 import *
def rol8(v, r): return ((v << r) | LShR(v, 8 - r)) & 0xFF
keys = [0x13, 0x57, 0x9B, 0xDF, 0x22, 0x68, 0xA1, 0x3C]adds = [0x05, 0x11, 0x1F, 0x2A, 0x07, 0x19, 0x2D, 0x3B]targets = [0xEE, 0x94, 0x3C, 0xAD, 0xA8, 0x62, 0x06, 0x1B]
x = [BitVec(f"x{i}", 8) for i in range(8)]s = Solver()
for i in range(8): expr = rol8((x[i] ^ keys[i]) + adds[i], 1) s.add(expr == targets[i])
if s.check() == sat: m = s.model() ans = bytes([m[x[i]].as_long() for i in range(8)]) print(ans)
VM 문제는 작은 차이로 풀이 방향이 바뀐다. 아래는 참고하면 좋을 거다.
JNZ rel8는 부호 있는 오프셋일 가능성이 높다.int8_t캐스팅을 먼저 의심한다.pc기준을 확정하자. 오프셋 계산은 operand 읽은 뒤 pc 기준인 경우가 많다.- 스택/레지스터 폭을 확정하자.
uint8_tvsuint32_t차이로 결과가 깨진다. CMP가 값을 pop하는지 확인하자. pop 유무에 따라 식이 달라진다.- VM은 종종 opcode를 XOR/ADD로 디코딩한다. Dispatcher 이전 복호화 루틴을 찾는다.
- 반복 패턴이 보이면, 한 블록만 수식화해서 전체에 적용하자.
4. 실습
#include <stdint.h>#include <stdio.h>
static uint8_t ror8(uint8_t v, unsigned r) { return (uint8_t)((v >> r) | (v << (8 - r)));}
static int run_vm(const uint8_t *code, size_t code_len, uint8_t *out, size_t out_cap, size_t *out_len) { uint8_t stack[64]; size_t sp = 0; size_t pc = 0; size_t out_idx = 0;
while (pc < code_len) { uint8_t op = code[pc++]; switch (op) { case 0xA1: { // PUSH imm8 if (pc >= code_len || sp >= sizeof(stack)) { return 0; } stack[sp++] = code[pc++]; break; } case 0xB2: { // ADD imm8 if (pc >= code_len || sp == 0) { return 0; } uint8_t imm = code[pc++]; stack[sp - 1] = (uint8_t)(stack[sp - 1] + imm); break; } case 0xC3: { // XOR imm8 if (pc >= code_len || sp == 0) { return 0; } uint8_t imm = code[pc++]; stack[sp - 1] ^= imm; break; } case 0xD4: { // ROR imm8 if (pc >= code_len || sp == 0) { return 0; } uint8_t imm = code[pc++]; stack[sp - 1] = ror8(stack[sp - 1], imm & 7u); break; } case 0xE5: { // OUT if (sp == 0 || out_idx >= out_cap) { return 0; } out[out_idx++] = stack[--sp]; break; } case 0xF6: { // HALT *out_len = out_idx; return 1; } default: return 0; } }
return 0;}
int main(int argc, char **argv) { (void)argc; (void)argv; static const uint8_t program[] = { 0xA1, 0xA7, 0xB2, 0x3A, 0xC3, 0x05, 0xD4, 0x01, 0xE5, 0xA1, 0x02, 0xB2, 0x7F, 0xC3, 0x2D, 0xD4, 0x05, 0xE5, 0xA1, 0xB0, 0xB2, 0x12, 0xC3, 0x71, 0xD4, 0x03, 0xE5, 0xA1, 0x3D, 0xB2, 0xC6, 0xC3, 0x13, 0xD4, 0x07, 0xE5, 0xA1, 0xDB, 0xB2, 0x9D, 0xC3, 0xA9, 0xD4, 0x02, 0xE5, 0xA1, 0x96, 0xB2, 0x21, 0xC3, 0x6C, 0xD4, 0x06, 0xE5, 0xA1, 0x55, 0xB2, 0xB8, 0xC3, 0x0F, 0xD4, 0x04, 0xE5, 0xA1, 0xE8, 0xB2, 0x4E, 0xC3, 0xD2, 0xD4, 0x01, 0xE5, 0xA1, 0x95, 0xB2, 0x3A, 0xC3, 0x05, 0xD4, 0x01, 0xE5, 0xA1, 0xC2, 0xB2, 0x7F, 0xC3, 0x2D, 0xD4, 0x05, 0xE5, 0xA1, 0xF8, 0xB2, 0x12, 0xC3, 0x71, 0xD4, 0x03, 0xE5, 0xA1, 0x62, 0xB2, 0xC6, 0xC3, 0x13, 0xD4, 0x07, 0xE5, 0xA1, 0x9F, 0xB2, 0x9D, 0xC3, 0xA9, 0xD4, 0x02, 0xE5, 0xA1, 0xCF, 0xB2, 0x21, 0xC3, 0x6C, 0xD4, 0x06, 0xE5, 0xA1, 0x55, 0xB2, 0xB8, 0xC3, 0x0F, 0xD4, 0x04, 0xE5, 0xA1, 0xD0, 0xB2, 0x4E, 0xC3, 0xD2, 0xD4, 0x01, 0xE5, 0xA1, 0xA3, 0xB2, 0x3A, 0xC3, 0x05, 0xD4, 0x01, 0xE5, 0xA1, 0x82, 0xB2, 0x7F, 0xC3, 0x2D, 0xD4, 0x05, 0xE5, 0xA1, 0x38, 0xB2, 0x12, 0xC3, 0x71, 0xD4, 0x03, 0xE5, 0xA1, 0xBD, 0xB2, 0xC6, 0xC3, 0x13, 0xD4, 0x07, 0xE5, 0xF6 };
uint8_t out[256]; size_t out_len = 0; int ok = run_vm(program, sizeof(program), out, sizeof(out), &out_len);
if (!ok) { return 1; }
fwrite(out, 1, out_len, stdout); fputc('\n', stdout); return 0;}이 VM의 opcode는 아래처럼 단순하다.
| Opcode | Mnemonic | Stack Effect | Desc |
|---|---|---|---|
0xA1 | PUSH imm8 | S -> S, imm | 즉시값 push |
0xB2 | ADD imm8 | S, a -> S, (a + imm) | mod 256 |
0xC3 | XOR imm8 | S, a -> S, (a ^ imm) | XOR |
0xD4 | ROR imm8 | S, a -> S, ror8(a, imm) | rotate |
0xE5 | OUT | S, a -> S | 출력 버퍼에 append |
0xF6 | HALT | S -> S | 종료 |
bytecode는 다음 패턴을 반복한다.
PUSH e_iADD k_iXOR a_iROR r_iOUT가 힌트 문자열이 되도록 를 미리 만들어 둔 형태다. 역으로 계산하면
flag 파일에는 이 들이 opcode와 같이 들어간 bytecode가 있다.
A1 8D B2 3A C3 05 D4 01 E5 A1 61 B2 7F C3 2D D405 E5 A1 40 B2 12 C3 71 D4 03 E5 A1 E4 B2 C6 C313 D4 07 E5 A1 77 B2 9D C3 A9 D4 02 E5 A1 91 B221 C3 6C D4 06 E5 A1 11 B2 B8 C3 0F D4 04 E5 A1C2 B2 4E C3 D2 D4 01 E5 A1 89 B2 3A C3 05 D4 01E5 A1 E4 B2 7F C3 2D D4 05 E5 A1 F8 B2 12 C3 71D4 03 E5 A1 E1 B2 C6 C3 13 D4 07 E5 A1 AB B2 9DC3 A9 D4 02 E5 A1 9A B2 21 C3 6C D4 06 E5 A1 B1B2 B8 C3 0F D4 04 E5 A1 6C B2 4E C3 D2 D4 01 E5A1 2D B2 3A C3 05 D4 01 E5 A1 8C B2 7F C3 2D D405 E5 A1 C6 B2 12 C3 71 D4 03 E5 A1 F6 B2 C6 C313 D4 07 E5 A1 DC B2 9D C3 A9 D4 02 E5 A1 8F B221 C3 6C D4 06 E5 A1 11 B2 B8 C3 0F D4 04 E5 A166 B2 4E C3 D2 D4 01 E5 A1 29 B2 3A C3 05 D4 01E5 A1 A4 B2 7F C3 2D D4 05 E5 A1 79 B2 12 C3 71D4 03 E5 A1 E2 B2 C6 C3 13 D4 07 E5 A1 D0 B2 9DC3 A9 D4 02 E5 A1 80 B2 21 C3 6C D4 06 E5 A1 D1B2 B8 C3 0F D4 04 E5 A1 1E B2 4E C3 D2 D4 01 E5A1 3D B2 3A C3 05 D4 01 E5 A1 A1 B2 7F C3 2D D405 E5 A1 DE B2 12 C3 71 D4 03 E5 A1 E4 B2 C6 C313 D4 07 E5 A1 DB B2 9D C3 A9 D4 02 E5 A1 9A B221 C3 6C D4 06 E5 A1 C0 B2 B8 C3 0F D4 04 E5 A1B4 B2 4E C3 D2 D4 01 E5 A1 2D B2 3A C3 05 D4 01E5 A1 0C B2 7F C3 2D D4 05 E5 A1 E0 B2 12 C3 71D4 03 E5 A1 C4 B2 C6 C3 13 D4 07 E5 A1 9F B2 9DC3 A9 D4 02 E5 A1 CF B2 21 C3 6C D4 06 E5 A1 42B2 B8 C3 0F D4 04 E5 A1 F0 B2 4E C3 D2 D4 01 E5A1 A5 B2 3A C3 05 D4 01 E5 A1 03 B2 7F C3 2D D405 E5 F6bytecode가 고정 패턴으로 반복된다는 점을 이용해 를 직접 뽑아내자.
from pathlib import Path
def ror8(v, r): return ((v >> r) | (v << (8 - r))) & 0xFF
def load_bytecode(path): data = path.read_text().strip().split() if not data: return [] return [int(tok, 16) for tok in data]
def parse_blocks(code): blocks = [] pc = 0
while pc < len(code): op = code[pc] if op == 0xF6: return blocks
if pc + 8 >= len(code): raise ValueError("truncated block")
if code[pc] != 0xA1 or code[pc + 2] != 0xB2 or code[pc + 4] != 0xC3: raise ValueError(f"unexpected opcode sequence at {pc:04X}") if code[pc + 6] != 0xD4 or code[pc + 8] != 0xE5: raise ValueError(f"unexpected opcode sequence at {pc:04X}")
e = code[pc + 1] k = code[pc + 3] a = code[pc + 5] r = code[pc + 7] blocks.append((e, k, a, r)) pc += 9
raise ValueError("missing HALT")
def recover_output(blocks): out = [] for e, k, a, r in blocks: v = (e + k) & 0xFF v ^= a v = ror8(v, r) out.append(v) return bytes(out)
def main(): bytecode_path = Path(__file__).with_name("flag") code = load_bytecode(bytecode_path) blocks = parse_blocks(code) output = recover_output(blocks) print(output.decode("ascii"))
if __name__ == "__main__": main()- Top 3 Solver (Discord @badabodaa)
@lacroix RubiyaLab 2025.12.31@Gunter RubiyaLab 2025.12.31@minzu RubiyaLab 2025.12.31