Reverse Engineering / Pinaka CTF 2026
Pinaka: The Impossible Bow
Decode an encrypted VM instruction stream and invert its bijective checks to recover the accepted 24-byte draw.
Challenge
M03_player.zip contains two files:
| File | Size | Notes |
|---|---|---|
pinaka_bow | 8,142,904 B | ELF x86-64 PIE, not stripped |
bow.vmx | 8,126,152 B | byte-identical to the blob embedded in the ELF |
The task is to find the one 24-byte "draw" the checker accepts. The flag-shaped string inside the binary,
PINAKA{the_bow_answers_only_to_dharma_not_to_brute_force}is a decoy.
The validator accepts a 24-byte draw as 48 hex characters:
POST /bow/string {"draw":"<48 hex chars>"}1. The wrapper (main)
main is small and does only four things:
- Requires
argv[1]to be a hex string of even length<= 0x2000. - Decodes pairs of hex digits into a byte buffer on the stack.
- Checks the 8-byte magic
PNKBOW01atpinaka_blob_start. - Calls
vm_run.part.0(blob, blob_len, input, input_len).
It treats the return value as:
1→puts("ACCEPT"), exit 00→puts("REJECT"), exit 1- anything else → malformed-image error
The blob is embedded at file offset 0x2083 and ends at 0x7c1f4b. Extracting
it gives exactly bow.vmx (8,126,152 bytes).
2. Blob header
The first 0x28 bytes are a little-endian header:
| Offset | Value | Meaning |
|---|---|---|
0x00 | PNKBOW01 | magic |
0x08 | 1 | version |
0x0c | 24 | required input length |
0x10 | 32 | VM memory size |
0x14 | 0xba645b85 | seed / initial decoder state |
0x18 | 0x002f2418 | entry PC |
0x1c | 1,015,724 | instruction budget |
0x20 | 24 | "accumulator" byte checked at halt |
0x24 | 0 | reserved |
0x28 | … | 256-byte encrypted substitution table |
The VM allocates 32 zeroed bytes, copies the 24-byte input into mem[0..23],
and leaves mem[24..31] zero.
3. The substitution table
vm_run.part.0 generates a 256-byte xorshift32 keystream from the seed at
+0x14:
uint32_t s = seed;
for (int i = 0; i < 256; i++) {
uint32_t e = s;
e = (e << 13) ^ s;
e ^= e >> 17;
s = (e << 5) ^ e;
key[i] = s & 0xff;
}Then it XOR-decrypts the 256 bytes at blob offset 0x28 with that keystream:
S[i] = blob[0x28 + i] ^ key[i];A common trap is to use the raw keystream as the S-box. The decrypted table
S is what opcode 1 indexes, and crucially S is a permutation (all 256
outputs distinct). This makes the whole checker bijective on the 24 input
bytes, which is what makes inversion possible.
4. Instruction encoding
Each instruction is 8 bytes and is decoded with a rolling 32-bit state:
op = blob[pc] ^ (state & 0xff);
a = blob[pc + 1] ^ ((state >> 8) & 0xff);
b = blob[pc + 2] ^ ((state >> 16) & 0xff);
c = blob[pc + 3];
d = u32(blob + pc + 4) ^ state;After execution the state is updated from the decoded fields:
state = (op
+ state * 0x1000193
+ 0x9E3779B1
+ a
+ b
+ (((state >> 24) ^ c) & 0xff)
+ (d & 0xff)) & 0xffffffff;The next PC is pc + 8, except for opcode 7 (see below). Because all fields
come from the blob and the current state, decoding is input-independent.
5. Opcode table
Reverse engineering vm_run.part.0 gives eight opcodes:
| Op | Semantics |
|---|---|
| 0 | HALT: return 1 iff mem[24] == 0, else 0 |
| 1 | mem[a] = S[mem[a]] |
| 2 | mem[a] += d & 0xff |
| 3 | mem[a] ^= mem[b] |
| 4 | no-op (only state/PC advance) |
| 5 | mem[a] = d & 0xff |
| 6 | `mem[24] |
| 7 | unconditional jump to PC d |
Opcode 7 is the control-flow flattening: the dispatch path skips the
rax = pc + 8 assignment and jumps to d instead. Opcodes 4 and 7 do not
touch memory.
Following these jumps from 0x2f2418 produces a single straight-line trace of
1,015,724 instructions (including the final HALT), with this distribution:
| Op | Count |
|---|---|
| 1 (S-box) | 401,065 |
| 2 (add) | 299,840 |
| 3 (xor) | 229,276 |
| 4 (nop) | 69,891 |
| 7 (jump) | 15,626 |
| 6 (check) | 24 |
| 5 (set) | 1 |
| 0 (halt) | 1 |
There are no conditional branches: control flow is fixed and depends only on the blob.
6. What the program computes
The trace has a clear shape:
Prologue: for each of the 24 bytes, apply the S-box and add a per-byte constant (
op1/op2pairs), then a short XOR diffusion chain.Body: roughly a million mixed S-box / add / XOR instructions — a byte-oriented permutation network over the 24-byte state.
Epilogue: an XOR diffusion chain,
mem[24] = 0(op5), then 24 checks of the formcmem[24] |= mem[i] ^ target[i]; // i = 0..23and finally HALT, which accepts iff
mem[24] == 0.
Since | can only clear the accumulator if every term is zero, acceptance is
exactly:
F(input)[i] == target[i] for all i = 0..23The constants are read directly from the 24 op6 instructions:
2a 45 56 1f c6 8a 1a db 9f 3e c8 b9 de f0 9c 4f fa f0 61 0d 66 9c a8 df7. Inverting the checker
Every memory operation used on mem[0..23] is invertible:
| Op | Forward | Inverse |
|---|---|---|
| 1 | x = S[x] | x = S^-1[x] |
| 2 | x += k | x -= k |
| 3 | x ^= y | x ^= y |
Opcodes 4, 5, 6, 7 and 0 do not modify mem[0..23], so they are skipped.
Starting from the required final bytes, walk the instruction trace backwards:
mem[:24] = targets
for op, a, b, d in reversed(ops):
if op == 1: mem[a] = invS[mem[a]]
elif op == 2: mem[a] = (mem[a] - (d & 0xff)) & 0xff
elif op == 3: mem[a] ^= mem[b]Because S is a permutation, the inverse is unique, so this recovers exactly
one 24-byte input:
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8It is not ASCII — the validator expects it as 48 hex characters.
8. Verification
Local checker:
$ ./pinaka_bow 7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8
ACCEPT
$ echo $?
0
A deliberately wrong byte is rejected:
$ ./pinaka_bow 0056e835fc372462249b2c08fe6349ce8f8699d3599a02f8
REJECT
$ echo $?
1
Validator:
$ curl -sS -X POST 'https://pinaka-m03-cyber-xo-x.vercel.app/bow/string' \
-H 'Content-Type: application/json' \
--data '{"draw":"7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8"}'
{"flag":"PINAKA{one_true_draw_bends_shivas_bow_and_rama_wins_mithila}",
"proclamation":"The bow bends, the string sings, Pinaka is strung.",
"result":"accepted"}
9. Answer
Draw:
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8Flag:
PINAKA{one_true_draw_bends_shivas_bow_and_rama_wins_mithila}Appendix: standalone solver
Save as solve.py next to bow.vmx and run it.
#!/usr/bin/env python3
"""Recover the 24-byte input accepted by pinaka_bow."""
import struct
blob = open("bow.vmx", "rb").read()
u32 = lambda o: struct.unpack_from("<I", blob, o)[0]
seed = u32(0x14) # PRNG seed / initial decode state
in_len = u32(0x0c) # 24
mem_size = u32(0x10) # 32
pc = u32(0x18) # entry point
max_cnt = u32(0x1c) # instruction budget
check_i = u32(0x20) # 24 (accumulator byte)
# --- 1. Recover the substitution table S ---
K = bytearray(256)
st = seed
for i in range(256):
e = st
e = ((e << 13) & 0xffffffff) ^ st
e ^= e >> 17
st = (((e << 5) & 0xffffffff) ^ e) & 0xffffffff
K[i] = st & 0xff
S = bytes(blob[0x28 + i] ^ K[i] for i in range(256)) # a permutation
invS = [0] * 256
for i, v in enumerate(S):
invS[v] = i
# --- 2. Decode the instruction stream, following jumps ---
ops = []
state = seed
while len(ops) < max_cnt:
op = blob[pc] ^ (state & 0xff)
a = blob[pc + 1] ^ ((state >> 8) & 0xff)
b = blob[pc + 2] ^ ((state >> 16) & 0xff)
c = blob[pc + 3]
d = u32(pc + 4) ^ state
ops.append((op, a, b, d))
if op == 0: # HALT: accept iff mem[check_i] == 0
break
state = (op + state * 0x1000193 + 0x9E3779B1 +
a + b + (((state >> 24) ^ c) & 0xff) + (d & 0xff)) & 0xffffffff
pc = d if op == 7 else pc + 8 # opcode 7 = unconditional jump
# --- 3. Required final bytes, read from the 24 opcode-6 checks ---
expected = [0] * in_len
for op, a, b, d in ops:
if op == 6 and a < in_len:
expected[a] = d & 0xff
# --- 4. Invert the (bijective) pipeline ---
mem = bytearray(32)
mem[:in_len] = expected
for op, a, b, d in reversed(ops):
if op == 1:
mem[a] = invS[mem[a]]
elif op == 2:
mem[a] = (mem[a] - (d & 0xff)) & 0xff
elif op == 3:
mem[a] ^= mem[b]
print(bytes(mem[:in_len]).hex())Output:
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8