Pinaka: The Impossible Bow
By Team Cryptonite · 4 min read
About the CTF: Pinaka CTF
pinakabow is a custom VM host with a 1M-instruction program embedded in it. The validator wants a 24-byte "draw" as 48 hex chars. The decoding is state-dependent and the control flow is flattened, but the program…
**Pinaka CTF 2026** · Reverse engineering · Team Cryptonite · All 44 Pinaka write-ups
Category: reverse engineering
Flag: PINAKA{onetruedrawbendsshivasbowandramawins_mithila}
Overview
pinaka_bow is a custom VM host with a 1M-instruction program embedded in it. The validator wants a 24-byte "draw" as 48 hex chars. The decoding is state-dependent and the control flow is flattened, but the program never branches on the input, so every step can be run in reverse.
Files:
pinaka_bow: x86-64 PIE, not strippedbow.vmx: the embedded program, byte-for-byte identical to the blob betweenpinakablobstartandpinakablobend
./pinaka_bow <draw-hex>

Analysis
main hex-decodes argv[1], checks the blob starts with PNKBOW01, and calls vmrun.part.0(blob, bloblen, input, input_len). Return 1 prints ACCEPT, 0 prints REJECT, anything else prints ERROR: malformed image or execution fault.
The ELF couldn't be run in my setup, so everything below comes from objdump -d -M intel and a Python re-implementation.
vmx header (little-endian u32s after the magic):
0x08: version (1)0x0c: input length (0x18= 24), must match exactly0x10: VM memory size (0x20= 32 bytes)0x14: seed (0xba645b85, falls back to0x1234567if zero)0x18: bytecode start (0x2f2418)0x1c: instruction budget (0xf7fac, loop runsbudget + 0x10times)0x20: index of the result byte in memory (0x18= 24)0x28: 256-byte encrypted S-box
The input is copied to mem[0..23]. mem[24..31] starts at zero.
S-box. A xorshift32 stream seeded with the header seed gives 256 bytes, which are XORed with the 256 bytes at 0x28:
s ^= s << 13; s ^= s >> 17; s ^= s << 5; out.append(s & 0xff)
The result is a permutation of 0..255.
Dispatch loop. Each instruction is 8 bytes and a 32-bit rolling key (starts as the seed) decodes it:
op = byte0 ^ (key & 0xff)
a = byte1 ^ (key >> 8)
b = byte2 ^ (key >> 16)
c = byte3 (raw, only used in the key update)
imm = dword4 ^ key
key = key*0x1000193 + op - 0x61c8864f + a + b + ((c ^ (key>>24)) & 0xff) + (imm & 0xff)
The key update uses values just decoded, so instruction N can't be decoded without executing everything before it. A linear sweep fails, an emulator works.

Opcode 0 is checked before the jump table at 0x2004:
- 0:
0x1693-> halt, returnmem[res] == 0 - 1:
0x1571->mem[a] = S[mem[a]] - 2:
0x1633->mem[a] += imm(low byte) - 3:
0x160b->mem[a] ^= mem[b] - 4:
0x1590-> nop - 5:
0x1677->mem[a] = imm - 6:
0x1652->mem[res] |= mem[a] ^ imm(the check) - 7:
0x164b->pc = imm(unconditional jump)
Opcode 7 is the only control flow, and it is unconditional. The flattening just stitches fragments together, so every input runs the same operation sequence.
Decoy. PINAKA{thebowanswersonlytodharmanottobrute_force} sits in .rodata and at offset ~0x128 in the vmx. It is padding, not the flag.

Solution
Lift. Emulate the VM without any input, following pc and the key, and log each real operation. Nops and jumps are dropped.
import struct, pickle
v = open('bow.vmx', 'rb').read()
ver, inlen, msz, seed, code, cnt, res, _, _ = struct.unpack('<9I', v[8:0x2c])
# sbox = blob[0x28:0x128] ^ xorshift32 stream
s = seed or 0x1234567
st = []
for i in range(256):
s ^= (s << 13) & 0xffffffff
s ^= s >> 17
s ^= (s << 5) & 0xffffffff
st.append(s & 0xff)
S = [v[0x28 + i] ^ st[i] for i in range(256)]
pc, key, trace = code, seed, []
for step in range(cnt + 0x10):
op = (v[pc] ^ key) & 0xff
if op == 0:
trace.append(('H',)); break
a = (v[pc+1] ^ (key >> 8)) & 0xff
b = (v[pc+2] ^ (key >> 16)) & 0xff
c = v[pc+3]
imm = (struct.unpack('<I', v[pc+4:pc+8])[0] ^ key) & 0xffffffff
npc = pc + 8
if op == 1: trace.append(('S', a))
elif op == 2: trace.append(('A', a, imm & 0xff))
elif op == 3: trace.append(('X', a, b))
elif op == 4: pass
elif op == 5: trace.append(('M', a, imm & 0xff))
elif op == 6: trace.append(('C', a, imm & 0xff))
elif op == 7: npc = imm
else: raise SystemExit(f'badop {op} at step {step}')
key = (key * 0x1000193 + op - 0x61c8864f + a + b
+ ((c ^ (key >> 24)) & 0xff) + (imm & 0xff)) & 0xffffffff
pc = npc
pickle.dump((S, trace, res), open('trace.pkl', 'wb'))
1015723 steps, 930207 real ops
Counter({'S': 401065, 'A': 299840, 'X': 229276, 'C': 24, 'M': 1, 'H': 1})
It halts cleanly with no bad opcodes and no bounds errors, so the decoding is right.

The trace has this shape:
- Only
mem[0..23]is ever touched. - At index
930181there is oneM(mem[24] = 0) that clears the result byte, then 24Cchecks, one permem[0]..mem[23]. - Every cell depends on all 24 input bytes by then, so it can't be solved byte by byte.
The checks are:
mem[0]==42 mem[1]==69 mem[2]==86 mem[3]==31 mem[4]==198 mem[5]==138
mem[6]==26 mem[7]==219 mem[8]==159 mem[9]==62 mem[10]==200 mem[11]==185
mem[12]==222 mem[13]==240 mem[14]==156 mem[15]==79 mem[16]==250 mem[17]==240
mem[18]==97 mem[19]==13 mem[20]==102 mem[21]==156 mem[22]==168 mem[23]==223
Invert. Every op before the checks is reversible:
S: apply the inverse S-box (it's a permutation)A: subtract the constant mod 256X:mem[a] ^= mem[b]undoes itself as long asa != b(asserted, never fires)
The body is a bijection on 24 bytes, so there is exactly one valid input. Start from the 24 expected values and apply the inverse ops in reverse order, then run the result forward to confirm the accumulator stays 0.
import pickle
S, tr, res = pickle.load(open('trace.pkl', 'rb'))
Si = [0] * 256
for i, x in enumerate(S):
Si[x] = i
body = tr[:930181] # everything before the M / checks
assert all(t[1] != t[2] for t in body if t[0] == 'X')
m = [0] * 24
for t in tr[930182:930206]: # the 24 checks give the target state
m[t[1]] = t[2]
for t in reversed(body):
if t[0] == 'S': m[t[1]] = Si[m[t[1]]]
elif t[0] == 'A': m[t[1]] = (m[t[1]] - t[2]) & 255
elif t[0] == 'X': m[t[1]] ^= m[t[2]]
x = bytes(m)
print(x.hex())
# forward check
f = list(x) + [0] * 8
for t in tr:
if t[0] == 'S': f[t[1]] = S[f[t[1]]]
elif t[0] == 'A': f[t[1]] = (f[t[1]] + t[2]) & 255
elif t[0] == 'X': f[t[1]] ^= f[t[2]]
elif t[0] == 'M': f[t[1]] = t[2]
elif t[0] == 'C': f[res] |= f[t[1]] ^ t[2]
print('acc', f[res])
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8
acc 0

Submission
curl -s -X POST https://pinaka-m03-cyber-xo-x.vercel.app/bow/string \
-H 'Content-Type: application/json' \
-d '{"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"}
Notes
- The rolling key blocks static disassembly but not emulation, because it never depends on the input.
- The flattening uses only unconditional jumps, so the whole VM is one fixed sequence of ops.
- Bijective S-box, add-constant and XOR between two different cells are all invertible. Check whether the ops can be inverted before reaching for z3.
- The answer is raw binary, not printable text.