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: The Impossible Bow

**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 <draw-hex>

file and nm output showing the ELF is not stripped and the pinaka_blob symbols

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

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.

vm_run dispatch loop and the jump table at 0x2004 in the disassembler

Opcode 0 is checked before the jump table at 0x2004:

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.

strings output showing the decoy flag in the binary

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.

emulator output showing the step count and opcode counter

The trace has this shape:

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:

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

solver output printing the draw hex and 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

Read this post on CyberHx →