vvm
vvm
Platform: HackTheBox | Category: Reversing | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-06-11 | Status: Solved Techniques: dispatch_table_recovery, dynamic_handler_dumping, length_gate_bruteforce, transform_inversion, vm_emulation_in_python, vm_opcode_reverse_engineering
Summary
Task: stripped x86-64 PIE ELF implementing a custom self-decrypting stack VM that validates a password client-side. Solution: reverse the dispatch table and lazily-decrypted opcode handlers via qemu-user/gdb, build a Python emulator, recover the PACK4/ROTL transforms and 8 magic constants, then algebraically invert (rotr + unpack + permutation) to recover the 32-byte flag.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
hackthebox| ID:20260611_hackthebox_vvm - Tags: pie, bytecode, custom_vm, permutation, stripped_binary, rotl, qemu_user, stack_machine, self_decrypting, ptrace_antidebug, pack, docker_amd64
- Indicators: binary named 'vvm' (= virtual machine), ptrace(PTRACE_TRACEME) anti-debug, handlers decrypted on-demand into RWX mmap pages, dispatch loop indexing table[opcode], client-side password validation claim
- Source:
20260611_hackthebox_vvm.md
Foothold
Vulnerability / Misconfiguration
- Dispatch_table_recovery
- Dynamic_handler_dumping
- Length_gate_bruteforce
- Transform_inversion
- Vm_emulation_in_python
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- dispatch_table_recovery
- dynamic_handler_dumping
- length_gate_bruteforce
- transform_inversion
- vm_emulation_in_python
- vm_opcode_reverse_engineering
- Tags: pie, bytecode, custom_vm, permutation, stripped_binary, rotl, qemu_user, stack_machine, self_decrypting, ptrace_antidebug, pack, docker_amd64
Original Writeup
<details><summary>Click to expand original content</summary>Description
A new startup claims to have developed an unbreakable client-side password validation system. VCs have invested millions, but I'm a bit skeptical of their claim. Can you prove them wrong?
A password-protected zip (password: hackthebox) contains an ELF named vvm. It
prints a banner vvm v0.0.3, asks What is the password:, and validates the
input. The goal is to recover the accepted password, which is the flag.
Analysis
Recon
rev_vvm/vvm is an ELF 64-bit LSB PIE executable, x86-64, dynamically linked,
stripped. Notable imports:
ptrace— anti-debuggetline— reads the passwordmalloc,memcpy,__printf_chk
The name "vvm" is the hint: it is a custom virtual machine. On Apple Silicon
(macOS/ARM) the x86-64 ELF cannot run natively, so analysis was done inside a
Docker --platform linux/amd64 (ubuntu:22.04) container, with
qemu-x86_64 -g <port> + gdb for dynamic analysis against the qemu gdbstub.
VM architecture (proven by static + dynamic analysis)
main flow:
- Banner setup.
- A setup routine that decrypts ~28 opcode handlers on-demand into RWX
mmappages (lazy-decrypted). The dispatch table in.bssat virtual address0x74e0is populated as opcodes are first used. - A dispatch loop.
Dispatch loop (.text 0x2870, call site 0x28d8): reads the current opcode
dword from the bytecode stream, indexes table[opcode], calls the handler. The
VM bytecode/program lives in .data at virtual address 0x5540 (IP starts
at 0x5544). The HALT opcode is 28 (0x1c).
Handler calling convention:
| reg | meaning |
|---|---|
| rdi | bytecode base |
| rsi | &IP |
| rdx | stack array (vaddr 0x6220) |
| rcx | &sp counter (vaddr 0x6200) |
| r8 | handler table |
Anti-debug: opcode 7 = ptrace(PTRACE_TRACEME). Under qemu it returns -1
and the program exits. Bypassed by NOP-ing the conditional jump in the binary
(producing vvm_patched) for clean tracing; the final flag was verified on the
original unpatched binary (which prints Correct!).
qemu-user note: deterministic load base = 0x4000000000, which makes gdb
software breakpoints reliable. The initial gdb stop is in ld.so
(~0x4001829290), not the program entry — you must use the fixed base
0x4000000000 for vvm addresses.
Recovered opcode set (stack VM)
top = stack[sp-1].
| op | semantics |
|---|---|
| 0 | strlen(top_string)+1 |
| 1 | SHL (32-bit) |
| 2 | MOD |
| 3 | READ INPUT via getline → push char* |
| 4 | DIV (signed, truncate toward zero) |
| 5 | ADD |
| 6 | MUL |
| 7 | ptrace anti-debug |
| 8 | OR |
| 9 | top = (top+17) % 3 |
| 10 | printf("%s", top); free(top) (PRINT) |
| 11 | JMP relative (IP = IP_at_operand + operand, dword units) |
| 12 | top = 6*top - 12 |
| 13 | strip trailing newline in string |
| 14 | pop/clear |
| 15 | PACK N: build a string from low byte of top N stack entries; push char* |
| 16 | COPY/pick: operand idx; push stack[sp-1-idx] |
| 17 | CALL subroutine: operands (target, nargs); runs dispatch at bytecode_base + target*4 until HALT on the SAME stack; result (top) moved down by nargs slots (sp -= nargs) |
| 18 | INDEX: operand idx; top = (signed char)((char*)top)[idx] |
| 19 | EQ |
| 20 | NEQ |
| 21 | top = 8*top + 24 |
| 22 | JNZ: operand off; pop cond; if cond != 0 then IP += off (dword units) |
| 23 | DUP |
| 24 | SUB |
| 25 | PUSH IMM (next dword) |
| 26 | SHR |
| 27 | JMP backward |
| 28 | HALT |
Validation logic
The full decrypted bytecode is 808 dwords. An initial gdb dump of only 1984 bytes / 496 words truncated it; re-dumping the whole region was essential — the real check lived past the first dump boundary.
-
Build & print the
What is the password:prompt (PACK 22, PRINT). -
READ INPUT, strip newline.
-
Length gate:
strlen+1is run through an affine transform; the computed value must equal76. Brute-forcing input length5..59shows only length 32 makes the equality hold (EQ → 1, JNZ taken over the failure branch). So the password is exactly 32 characters (8 packed words × 4 bytes). -
8 × 32-bit words: VM subroutine at offset
282=PACK4takes 4 bytes(b0,b1,b2,b3)and returnsb0 | b1<<8 | b2<<16 | b3<<24. Eight words are built from a permutation of all 32 input byte indices (0..31, each used exactly once). The permutation per word (each list = the 4 input indices feedingb0,b1,b2,b3):word indices [b0,b1,b2,b3]W0 [24,14,27,15]W1 [11,7,12,4]W2 [6,9,2,18]W3 [19,13,20,26]W4 [28,16,23,8]W5 [3,30,21,22]W6 [25,5,10,31]W7 [29,1,0,17] -
Rotate + permute: VM subroutine at offset
456=ROTL(value, n) = (value<<n)|(value>>(32-n))(32-bit). The 8 packed words are each rotated by a fixed amount and reordered, then each is subtracted from a magic 32-bit constant; all 8 differences must be zero:check computed = rotl(packed, rot)c0 rotl(W7, 15)c1 rotl(W6, 19)c2 rotl(W5, 7)c3 rotl(W4, 18)c4 rotl(W3, 12)c5 rotl(W2, 20)c6 rotl(W1, 14)c7 rotl(W0, 7)
Constants (signed int32, in check order):
[706975780, -1972114847, -1170424423, -574761715, -213630203, 1193747491, 353885596, -1237732311].
Solution
For each check i: packedWord = rotr(const_i, rot_i); unpack into 4 bytes
[b0,b1,b2,b3] (low→high); place those bytes at the corresponding permutation
indices. Reassembling all 32 positions yields the 32-character flag.
#!/usr/bin/env python3
# Invert the vvm password check to recover the flag.
MASK = 0xFFFFFFFF
def rotr(v, n):
v &= MASK
return ((v >> n) | (v << (32 - n))) & MASK
# Permutation per packed word: indices feeding [b0, b1, b2, b3].
perm = {
0: [24, 14, 27, 15],
1: [11, 7, 12, 4],
2: [ 6, 9, 2, 18],
3: [19, 13, 20, 26],
4: [28, 16, 23, 8],
5: [ 3, 30, 21, 22],
6: [25, 5, 10, 31],
7: [29, 1, 0, 17],
}
# Check order maps computed_i = rotl(W[word_i], rot_i) == const_i.
# (word index, rotation amount) in check order c0..c7:
checks = [
(7, 15),
(6, 19),
(5, 7),
(4, 18),
(3, 12),
(2, 20),
(1, 14),
(0, 7),
]
consts_signed = [706975780, -1972114847, -1170424423, -574761715,
-213630203, 1193747491, 353885596, -1237732311]
consts = [c & MASK for c in consts_signed]
flag_bytes = [0] * 32
for i, (word, rot) in enumerate(checks):
packed = rotr(consts[i], rot) # invert ROTL
b = [(packed >> (8 * k)) & 0xFF for k in range(4)] # unpack low->high
for k, idx in enumerate(perm[word]):
flag_bytes[idx] = b[k]
flag = bytes(flag_bytes).decode()
print(flag) # HTB{REDACTED}
assert len(flag) == 32
Running the recovered password against the original (unpatched) binary
prints Correct!, confirming the flag.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR