//Ghost in the Machine
Flag format: nite{...}
This challenge ships a small custom VM interpreter (vm) and a bytecode program (chall.bin). The goal is to provide the correct 48-byte access code.
>Files
$ file vm chall.bin
vm: ELF 64-bit LSB executable, x86-64, ... stripped
chall.bin: data
Key takeaway: the logic isn’t in chall.bin as native code; it’s interpreted by vm.
>Quick recon
Run it once to see behavior:
$ echo -n 'AAAA' | ./vm chall.bin
Enter access code: Loaded 1304 instructions from chall.bin.
It prompts, reads stdin, and exits quietly if the code is wrong.
chall.bin is small (1304 bytes):
$ python3 - <<'PY'
from pathlib import Path
b=Path('chall.bin').read_bytes()
print('chall.bin size',len(b))
print('first 64 bytes',b[:64].hex())
PY
chall.bin size 1304
first 64 bytes 0c000a2e02000014000030000a1d0202001400001934006b4500795500d3700044000a7200060074000ad102010044000074000a5404021c0c0001000005000a
>VM structure (what to reverse)
The VM executes a bytecode instruction stream.
A very useful anchor is the opcode fetch site inside the interpreter loop. Disassembling around it shows:
- The VM keeps a 16-bit instruction pointer (IP).
- Each loop reads
opcode = read8(state, ip + 0x2000).
In this binary, the opcode fetch is at address 0x40144b.
Important state layout
From the helper that reads a byte from VM memory (read8), we can recover the state structure field that points to VM memory:
state + 0x38holds a pointer to the VM’s memory buffer.
That lets us dump VM memory cleanly in GDB.
>Find the last check
The bytecode performs a final validation and then does a conditional jump. We previously identified the last-check moment as when the VM bytecode IP reaches:
IP == 0x0490
At that point, the VM’s data memory contains:
- the prompt + user input
- a computed 48-byte block
- an expected 48-byte constant block
The comparison is effectively computed == expected.
>Build a reliable “oracle” (GDB memory dump)
Instead of guessing the flag, we turn the VM into an oracle:
- Run
vmunder GDB. - Break every time it fetches an opcode (at
0x40144b). - When the VM IP equals
0x0490, dump the VM data-memory region.
The dumped region we care about is:
mem + 0x1000 .. mem + 0x1800(2KB)
Inside that dump:
- input lives at offset
0x14(48 bytes) - computed output lives at offset
0x44(48 bytes) - expected constant lives at offset
0x74(48 bytes)
I automated that into a helper script: oracle.py
Example usage:
$ ./oracle.py --ascii A --show all
in 414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141414141
out 362017e8a42624c4dfc4e0b6c6bfd08b699f134dec1f125415352c1a606dabe45986db610703d85c461052aff5f4f886
exp 28583e99d0503ab2aedac6c4f3a1a6a21b81657ed8693b683a1d67f92ff9f7e098eef2f59159f96ca9407d76af4941a4
prompt Enter access code:
That single dump gives us everything needed to solve the input.
>Solve strategy
We want an input in[0..47] such that out[0..47] == exp[0..47].
From differential testing (change one input byte and watch what output bytes change), the transform splits into clean parts:
Part A: last 22 bytes are XOR-masked (already known)
For indices:
- output bytes
out[2..23]depend only on input bytesin[26..47]
Empirically (and consistently across runs), the relation is:
$$out[i] = in[i+24] \oplus K[i-2] \quad \text{for } i=2..23$$
with constant 22-byte mask:
K = 56a9e56765859e85a1f787fe91ca28de520cad5e5315
So we solve:
$$in[i+24] = exp[i] \oplus K[i-2]$$
That yields the known tail:
in[26..47] = h057_70_g3t_7h3_7ru7h}
Part B: first 24 bytes map directly to out[24..47]
For the remaining mismatching region, the transform has a very nice “first-touch” property:
out[24+j]depends onin[j]and a constantCj
Specifically:
$$out[24+j] = in[j] \oplus C_j$$
You can measure Cj by setting in[j]=0x00 and reading out[24+j] from the oracle.
Then solve:
$$in[j] = exp[24+j] \oplus C_j \quad \text{for } j=0..23$$
This recovers the first 24 bytes deterministically.
Part C: bytes 24..25 map to out[0..1]
Finally:
out[0]depends onin[24]asout[0] = in[24] XOR C24out[1]depends onin[25]asout[1] = in[25] XOR C25
So we solve those two bytes with the same trick:
- set the byte to 0, read
Cfrom the oracle, thenin = exp XOR C.
>Final result (verified)
Putting the three parts together yields the full 48-byte access code:
nite{cr4ck_7h3_5h311_4nd_9h057_70_g3t_7h3_7ru7h}
Run the original VM with the solved input:
$ python3 - <<'PY'
import subprocess
flag=b'nite{cr4ck_7h3_5h311_4nd_9h057_70_g3t_7h3_7ru7h}'
p=subprocess.run(['./vm','chall.bin'],input=flag,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
print(p.stdout.decode(errors='replace'))
PY
Enter access code: Access granted
Loaded 1304 instructions from chall.bin.
>Notes on offsets used
These offsets are from the 2KB dump of VM memory [mem+0x1000 .. mem+0x1800) at bytecode IP 0x0490:
- input:
dump[0x14:0x14+48] - computed:
dump[0x44:0x44+48] - expected:
dump[0x74:0x74+48]
If your local environment differs, re-confirm the break condition (IP 0x0490) and these three offsets with a quick dump and a known input (like 'A'*48).