In this write-up
These notes document the October 2026 CTF challenge. Commands, addresses and outputs refer to the supplied challenge environment.
Challenge Overview#
We are given a tiny custom VM called MVM1. It reads a secret input, runs it through a bytecode program, and either prints unlocked or exits with locked.
Files provided:
bundle.zip
isa.txt
program.mvm
vm.py
The goal is to recover the accepted input, which has the flag format:
tibeb{...}
1. Inspect the ISA#
isa.txt gives the instruction set:
MVM1 + uint16_le input length.
Instructions: 10 LOAD, 20 XOR, 30 ADD, 40 ROL8, 50 CHECK; each followed by one unsigned byte.
FF HALT.
So the program format is:
4D 56 4D 31→ magicMVM1uint16_le→ expected input length- Then a sequence of 2-byte instructions:
0x10LOAD, arg = index into input0x20XOR, arg = immediate byte0x30ADD, arg = immediate byte0x40ROL8, arg = rotation amount0x50CHECK, arg = expected accumulator value0xFFHALT
2. Inspect the VM#
vm.py is short and clear:
import struct, sys
from pathlib import Path
p = Path(__file__).with_name("program.mvm").read_bytes()
n = struct.unpack_from("<H", p, 4)[0]
text = input("Secret: ").encode()
if len(text) != n:
raise SystemExit("locked")
i, acc = 6, 0
while p[i] != 255:
op, arg = p[i:i+2]
i += 2
if op == 0x10:
acc = text[arg]
elif op == 0x20:
acc ^= arg
elif op == 0x30:
acc = (acc + arg) & 255
elif op == 0x40:
acc = ((acc << arg) | (acc >> (8-arg))) & 255
elif op == 0x50:
if acc != arg:
raise SystemExit("locked")
else:
raise SystemExit("bad opcode")
print("unlocked")
Key observations:
- The input length is read from offset
4as a little-endianuint16. - The program starts at offset
6. - Each check is independent: it loads one byte of the input, transforms the accumulator, then compares it to a constant.
- If all checks pass, the program prints
unlocked.
3. Dump the Program#
We can inspect the raw bytes:
xxd -g1 program.mvm
Output includes the header:
00000000: 4d 56 4d 31 24 00 10 06 20 7d 30 c7 40 07 50 73 MVM1$... }0.@.Ps
- Magic:
MVM1 - Length:
0x0024= 36 - First instruction:
10 06→ LOAD index 6
So the secret is 36 bytes long.
4. Parse the Instructions#
We can write a small parser to print every instruction:
from pathlib import Path
import struct
p = Path("program.mvm").read_bytes()
n = struct.unpack_from("<H", p, 4)[0]
print("secret length =", n)
i = 6
ops = {0x10:"LOAD", 0x20:"XOR", 0x30:"ADD", 0x40:"ROL8", 0x50:"CHECK", 0xff:"HALT"}
while i < len(p):
op = p[i]
if op == 0xff:
print(f"{i:04x}: HALT")
break
arg = p[i+1]
print(f"{i:04x}: {op:02x} {ops.get(op, hex(op)):<5} {arg}")
i += 2
The parsed program shows a repeating pattern:
LOAD index
XOR xor_value
ADD add_value
ROL8 rol_value
CHECK check_value
This pattern repeats 36 times, once for each input byte.
5. Understand the Transformation#
For each input byte x = text[index], the VM computes:
acc = x
acc = acc ^ xor_value
acc = (acc + add_value) & 255
acc = ROL8(acc, rol_value)
Then it checks:
acc == check_value
To invert this, we reverse the operations in reverse order:
- Undo
ROL8by applyingROR8with the same rotation amount. - Undo
ADDby subtractingadd_valuemodulo 256. - Undo
XORby XORing withxor_value.
So the original byte is:
x = ((ROR8(check_value, rol_value) - add_value) & 0xff) ^ xor_value
Where:
def ror8(x, r):
r %= 8
return ((x >> r) | (x << (8 - r))) & 0xff
6. Solver Script#
Here is a complete Python solver that reads program.mvm and reconstructs the flag:
from pathlib import Path
import struct
p = Path("program.mvm").read_bytes()
assert p[:4] == b"MVM1"
n = struct.unpack_from("<H", p, 4)[0]
print("secret length =", n)
flag = [0] * n
def ror8(x, r):
r %= 8
return ((x >> r) | (x << (8 - r))) & 0xff
i = 6
while p[i] != 0xFF:
op, arg = p[i], p[i+1]
i += 2
if op == 0x10:
idx = arg
# Expect XOR, ADD, ROL8, CHECK
op_xor, xor_arg = p[i], p[i+1]; i += 2
op_add, add_arg = p[i], p[i+1]; i += 2
op_rol, rol_arg = p[i], p[i+1]; i += 2
op_chk, chk_arg = p[i], p[i+1]; i += 2
assert op_xor == 0x20
assert op_add == 0x30
assert op_rol == 0x40
assert op_chk == 0x50
# Invert the transformation
v = ror8(chk_arg, rol_arg)
v = (v - add_arg) & 0xff
v ^= xor_arg
flag[idx] = v
else:
raise SystemExit(f"unexpected opcode {op:02x} at offset {i-2}")
print(bytes(flag).decode())
Run it:
python3 solve.py
Output:
tibeb{bytecode_dc67f143d19b57109e1e}
7. Flag#
tibeb{bytecode_dc67f143d19b57109e1e}
Tricky / Interesting Points#
- The VM checks each input byte independently. There is no chaining between checks, so the program can be inverted block by block.
- The order of operations matters. The VM does
XOR, thenADD, thenROL8. To invert, you must undoROL8first, thenADD, thenXOR. ROL8is an 8-bit rotate, not a shift. Its inverse isROR8with the same rotation amount.- The input length is stored as a little-endian
uint16at offset4. Here it is36, so the flag is 36 bytes long. - The program starts at offset
6, not0. The first 6 bytes are the header:MVM1+ 2-byte length. - The
CHECKinstructions use immediates that are the expected accumulator values. Because the checks are independent, you can reconstruct each byte without brute force.