Skip to content

SECURE_CONNECTION//PRESS[CTRL+J]FOR ROOT ACCESS

BACK TO INTEL
ReverseMedium

Ghost In The Machine

CTF writeup for Ghost In The Machine from niteCTF

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

bash
$ 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:

bash
$ 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):

bash
$ 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 + 0x38 holds 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:

  1. Run vm under GDB.
  2. Break every time it fetches an opcode (at 0x40144b).
  3. 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:

bash
$ ./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 bytes in[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 on in[j] and a constant Cj

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 on in[24] as out[0] = in[24] XOR C24
  • out[1] depends on in[25] as out[1] = in[25] XOR C25

So we solve those two bytes with the same trick:

  • set the byte to 0, read C from the oracle, then in = 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:

bash
$ 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).