Reverse Engineering / Pinaka CTF 2026

Pinaka: The Impossible Bow

02 Nov 2026 · 7 min read

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:

FileSizeNotes
pinaka_bow8,142,904 BELF x86-64 PIE, not stripped
bow.vmx8,126,152 Bbyte-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,

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

text
POST /bow/string   {"draw":"<48 hex chars>"}

1. The wrapper (main)

main is small and does only four things:

  1. Requires argv[1] to be a hex string of even length <= 0x2000.
  2. Decodes pairs of hex digits into a byte buffer on the stack.
  3. Checks the 8-byte magic PNKBOW01 at pinaka_blob_start.
  4. Calls vm_run.part.0(blob, blob_len, input, input_len).

It treats the return value as:

  • 1 → puts("ACCEPT"), exit 0
  • 0 → 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:

OffsetValueMeaning
0x00PNKBOW01magic
0x081version
0x0c24required input length
0x1032VM memory size
0x140xba645b85seed / initial decoder state
0x180x002f2418entry PC
0x1c1,015,724instruction budget
0x2024"accumulator" byte checked at halt
0x240reserved
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:

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

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

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

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

OpSemantics
0HALT: return 1 iff mem[24] == 0, else 0
1mem[a] = S[mem[a]]
2mem[a] += d & 0xff
3mem[a] ^= mem[b]
4no-op (only state/PC advance)
5mem[a] = d & 0xff
6`mem[24]
7unconditional 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:

OpCount
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/op2 pairs), 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 form

    c
    mem[24] |= mem[i] ^ target[i];   // i = 0..23
    

    and finally HALT, which accepts iff mem[24] == 0.

Since | can only clear the accumulator if every term is zero, acceptance is exactly:

text
F(input)[i] == target[i]   for all i = 0..23

The constants are read directly from the 24 op6 instructions:

text
2a 45 56 1f c6 8a 1a db 9f 3e c8 b9 de f0 9c 4f fa f0 61 0d 66 9c a8 df

7. Inverting the checker

Every memory operation used on mem[0..23] is invertible:

OpForwardInverse
1x = S[x]x = S^-1[x]
2x += kx -= k
3x ^= yx ^= 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:

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

text
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8

It is not ASCII — the validator expects it as 48 hex characters.

8. Verification

Local checker:

console
$ ./pinaka_bow 7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8
ACCEPT
$ echo $?
0

A deliberately wrong byte is rejected:

console
$ ./pinaka_bow 0056e835fc372462249b2c08fe6349ce8f8699d3599a02f8
REJECT
$ echo $?
1

Validator:

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

text
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8

Flag:

text
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.

python
#!/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:

text
7a56e835fc372462249b2c08fe6349ce8f8699d3599a02f8