← Back to Writeups
HTBN/AReversing

vvm

XESXOR8/23/20268 min read
#reversing#htb#n/a

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
PortServiceVersionNotes
<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

  1. Dispatch_table_recovery
  2. Dynamic_handler_dumping
  3. Length_gate_bruteforce
  4. Transform_inversion
  5. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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-debug
  • getline — reads the password
  • malloc, 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:

  1. Banner setup.
  2. A setup routine that decrypts ~28 opcode handlers on-demand into RWX mmap pages (lazy-decrypted). The dispatch table in .bss at virtual address 0x74e0 is populated as opcodes are first used.
  3. 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:

regmeaning
rdibytecode base
rsi&IP
rdxstack array (vaddr 0x6220)
rcx&sp counter (vaddr 0x6200)
r8handler 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].

opsemantics
0strlen(top_string)+1
1SHL (32-bit)
2MOD
3READ INPUT via getline → push char*
4DIV (signed, truncate toward zero)
5ADD
6MUL
7ptrace anti-debug
8OR
9top = (top+17) % 3
10printf("%s", top); free(top) (PRINT)
11JMP relative (IP = IP_at_operand + operand, dword units)
12top = 6*top - 12
13strip trailing newline in string
14pop/clear
15PACK N: build a string from low byte of top N stack entries; push char*
16COPY/pick: operand idx; push stack[sp-1-idx]
17CALL 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)
18INDEX: operand idx; top = (signed char)((char*)top)[idx]
19EQ
20NEQ
21top = 8*top + 24
22JNZ: operand off; pop cond; if cond != 0 then IP += off (dword units)
23DUP
24SUB
25PUSH IMM (next dword)
26SHR
27JMP backward
28HALT

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.

  1. Build & print the What is the password: prompt (PACK 22, PRINT).

  2. READ INPUT, strip newline.

  3. Length gate: strlen+1 is run through an affine transform; the computed value must equal 76. Brute-forcing input length 5..59 shows 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).

  4. 8 × 32-bit words: VM subroutine at offset 282 = PACK4 takes 4 bytes (b0,b1,b2,b3) and returns b0 | 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 feeding b0,b1,b2,b3):

    wordindices [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]
  5. 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:

    checkcomputed = rotl(packed, rot)
    c0rotl(W7, 15)
    c1rotl(W6, 19)
    c2rotl(W5, 7)
    c3rotl(W4, 18)
    c4rotl(W3, 12)
    c5rotl(W2, 20)
    c6rotl(W1, 14)
    c7rotl(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.

</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR