Kumbhakarna: The Sleepless Curse
By Team Cryptonite · 7 min read
About the CTF: Pinaka CTF
The gate (POST /lanka/awaken) wants a six-word summons and a 64-hex attestation:
**Pinaka CTF 2026** · Reverse engineering · Team Cryptonite · All 44 Pinaka write-ups
Category: reverse engineering
Flag: PINAKA{kumbhakarnasixdrumbeatspiercethe_sleep}
Overview
The gate (POST /lanka/awaken) wants a six-word summons and a 64-hex attestation:
{"summons": [b0,b1,b2,b3,b4,b5], "attestation": "<64 hex>"}
It opens only when the summons drives the engine's sleep-depth to zero and the attestation equals the tag the real machine emits. The tag is just whatever the emulator prints for a given summons, so only the awake condition has to be solved.
Files:
prahara_emu: stripped 18 KB x86-64 PIE that runs a ROM on a custom processorfirmware.rom: 3668 bytes (1834 x 16-bit words) of PRAHARA firmwarefirmware_debug.rom: same size, 30 bytes differdundubhi_cfg.bin,vectors.dat: extra hints the emulator never readsREADME.md
./prahara_emu firmware.rom <b0> <b1> <b2> <b3> <b4> <b5>
awake=0|1
sleep_depth=<n>
garjana=<hex trace>
tag=<sha256-ish 64 hex>
Analysis
Recon. Strings of interest: DUNDUBHI, GARJANA, PRANA, PRAHARA1, awake=%d, sleep_depth=%d, garjana=, tag=. .rodata has a SHA-256 K-table at 0x31c0 and the SHA-256 IV at 0x3330, so the tag is SHA-256 over a transcript. It also has four jump tables for the interpreter.
main starts at 0x1100 and:
- Reads the ROM into
[rsp+0xbf0](up to0x1000bytes). - Zero-extends each 16-bit ROM word into a 32-bit VM word at
[rsp+0x1bf0](2048-word instruction memory). - Parses
argv[2...]withstrtol, masks each to 12 bits, and stores them at[rsp+0xf0...]. - Zeroes a 256-word data RAM at
[rsp+0x7f0]. - Enters the VM fetch loop at
0x137c.

Fetch and dispatch at 0x137c:
eax = code[pc & 0x7ff]
esi = al ; operand (low byte)
op = (eax >> 8) & 0xf ; primary opcode (bits 8..11)
Primary opcodes 0..14 dispatch through the table at 0x3040, and opcode 15 is handled by a fall-through path. Three more tables hang off the extended instructions:
- main opcode:
0x3040, 15 entries -> index(word>>8)&0xf - extended A:
0x307c, 35 entries -> indexoperand - 0x10 - DUNDUBHI 9-op:
0x3108, 9 entries -> indexoperand - DUNDUBHI 35-op:
0x312c, 35 entries -> indexoperand - 0x10
The extended operand is the table index plus 0x10: EXT 0x20 is index 0x10, EXT 0x21 is 0x11, EXT 0x31 is 0x21, and so on. Misreading this cost a full debug cycle. Reading the jump tables directly (lea rdx,[rip+...] then base + s32(entry)) sorted it out.

The PRAHARA ISA. The main CPU is a stack machine:
0:0x13d0-> NOP (also the common trace-append path)1:0x1f4e->PUSH imm, push next word& 0xfff, skip it2:0x1efc->LOADCODE,top = code[top & 0x7ff]3:0x1eae->LOADDATA,top = data[top & 0xff]4:0x20df->STORE,data[addr & 0xff] = val(pop addr, val)5:0x209c->ADD = (a + b) % 0xffd6:0x2059->SUB = (a - b) % 0xffd7:0x2013->MUL = (a * b) % 0xffd8:0x21b0-> stack ops selected by operand (0mask/dup,1drop,2swap,3...)9:0x2126->READPORT, push a special register/portA:0x217a->EXT operand, pop one value, dispatch via0x307cB:0x1e80-> conditional branch, iftop == 0jump tocode[pc+1], else skipD:0x1db0-> indirect jump,pc = code[pc+1]E:0x1f90-> frame return / restore (used by the drum interrupt)F:0x21fb->PUSH data[operand]
Data RAM is 256 words, and the arithmetic modulus is 0xffd = 4093, which is prime.
READPORT values:
0x00: next summons word (sequential)0x01: remaining summons count0x11: drum PRNG value[rsp+0x34]0x22: last DUNDUBHI result[0x150b0]0x33: awake flag[rsp+0x28]- other: 0
Extended ops (operand = index + 0x10):
EXT 0x10: index0x00-> set drum period / enable timer (0x25f5)EXT 0x20: index0x10-> set coprocessor multiplierK= popped value (0x2451)EXT 0x21: index0x11-> emit one byte to the GARJANA buffer and the coprocessor mailbox (0x23f3)EXT 0x30: index0x20-> append popped value to the0x3bf0trace buffer (0x23bf)EXT 0x31: index0x21-> sleep tick, if popped value== 1thensleep_depth -= 30(0x2390)EXT 0x32: index0x22-> awake check,awake |= (val == 0xace && sleep_depth == 0)(0x2472)
The 0x312c table is the same family but ignores the operand (uses 0), and is used when the stack is empty.
The DUNDUBHI coprocessor. The main CPU talks to it through a mailbox in .data:
[0x5060]: command register (initialised to-1, meaning idle)[0x5064]: parameter/accumulatorD[0x150b8]: 12-bit accumulatoracc[0x150b0]: last result
When the coprocessor is idle, an emitted byte is a coprocessor opcode (table 0x3108). When a command is pending, the byte is data, and after two bytes a 12-bit word (byte1 << 8) | byte0 is formed and the command runs.
Coprocessor opcodes:
0:0x2797-> reset,D = 1,acc = 01:0x25df-> set command 1 (next 2 bytes)2:0x25df-> set command 23:0x27c4->acc = acc^2 % 0xffd4:0x2730->acc = rotl12(acc)(12-bit rotate left by 1)5:0x25df-> set command 56:0x25df-> set command 67:0x26ff->acc = (acc + D*K) % 0xffd8:0x27ae-> publishresult = acc & 0xfff
Mailbox commands:
1:acc = (acc + word) % 0xffd2:acc = (acc * word) % 0xffd5:D = word6:D = (D * word) % 0xffd
Each summons word K is therefore pushed through:
acc = 0
acc = (acc + A) % p
acc = (acc * B) % p
acc = (acc * acc) % p
acc = (acc + C) % p
acc = rotl12(acc)
acc = (acc + D*K) % p # K = summons word
result = acc & 0xfff
where A, B, C, D are fixed per-slot constants emitted by the firmware.

The drum timer. At boot the firmware does EXT 0x10 with value 0xd, enabling a periodic interrupt with period 13. The interpreter counts qualifying instructions, and when the counter reaches the period it saves the current PC/stack frame and runs the handler at VM address 0x600:
0x600: READPORT 0x11 ; drum PRNG value
PUSH 0
STORE ; data[0] = drum_value
RETURN
The drum PRNG is a small state machine on [rsp+0x34] and [rsp+0x38]: x = (x^2 + 0x3fd) % 0xffd, y = (y + x) % 0xffd.
The interrupt is driven by instruction count, so it perturbs data[0], which the firmware reads later. But both arms of the per-slot branch execute the same number of instructions, so the drum schedule doesn't depend on the branch outcome. The data[0] used by each slot's comparison is therefore fixed and input-independent.
Validation logic. The firmware copies six constants from code[0x700..0x705] into data[0x10..0x15]:
data[0x10..0x15] = [1234, 2345, 3456, 400, 1500, 2600]
For slot i the VM executes (PCs shown for slot 0):
0x5c: READPORT 0x22 ; push result_i
0x5d: PUSH data[0] ; drum value
0x5e: ADD
0x5f: PUSH 0x10+i
0x61: LOADDATA ; data[0x10+i]
0x62: SUB
0x63: B -> if top == 0, jump to the tick path (0x6a)
The tick is taken exactly when (resulti + data[0]) % 0xffd == data[0x10+i], i.e. resulti == data[0x10+i] - data[0] (mod 4093). The tick path at 0x6a does PUSH 1; EXT 0x31, lowering sleepdepth by 30. Six satisfied slots give 6 x 30 = 180, taking sleepdepth from 180 to 0. Finally the firmware pushes 0xace and runs EXT 0x32. That value is hard-coded, so the only real condition is sleep_depth == 0.

Solution
Each slot resets the coprocessor (opcode 0), so resulti depends only on its own Ki. With base_i as the accumulator before the D*K term:
base_i = rotl12( ((A_i*B_i % p)^2 % p + C_i) % p )
K_i = ((data[0x10+i] - data[0] - base_i) mod p) * D_i^-1 mod p
Per-slot constants (taken from the PUSH imm; EXT 0x21 byte stream in the ROM), as (A, B, C, D) -> target:
- slot 0: (11, 5, 100, 7) -> 1234
- slot 1: (23, 9, 250, 31) -> 2345
- slot 2: (37, 13, 500, 101) -> 3456
- slot 3: (51, 17, 750, 211) -> 400
- slot 4: (67, 21, 1000, 1009) -> 1500
- slot 5: (83, 25, 1500, 2003) -> 2600
The drum value data[0] at each comparison was read live with gdb, breaking at the PUSH data[0] VM PCs 93, 165, 237, 309, 381, 453:
data[0] at comparison = [2858, 2899, 1307, 4065, 1670, 2712]
A short gdb script does this by breaking on the fetch loop (main+0x27c, i.e. 0x137c) and, when the VM PC is one of those six, reading data[0] from [rsp+0x7f0].
![gdb session breaking on the fetch loop and printing data[0] at each of the six comparison PCs](./11.png)
Since p = 4093 is prime, every D_i^-1 exists:
p = 0xffd # 4093, prime
consts = [1234, 2345, 3456, 400, 1500, 2600] # data[0x10+i]
data0 = [2858, 2899, 1307, 4065, 1670, 2712] # data[0] at each comparison
ABCD = [(11,5,100,7), (23,9,250,31), (37,13,500,101),
(51,17,750,211), (67,21,1000,1009), (83,25,1500,2003)]
def rotl12(x):
return ((x << 1) | (x >> 11)) & 0xfff
sols = []
for i, (A, B, C, D) in enumerate(ABCD):
acc = (A * B) % p
acc = (acc * acc) % p
acc = (acc + C) % p
base = rotl12(acc)
target = (consts[i] - data0[i]) % p
K = ((target - base) % p * pow(D, -1, p)) % p
sols.append(K)
print(sols) # [1799, 2879, 1995, 183, 3623, 2273]
Verifying with the emulator, which is deterministic, so its tag is the machine's own attestation:
$ ./prahara_emu firmware.rom 1799 2879 1995 183 3623 2273
awake=1
sleep_depth=0
garjana=
tag=a5f66ca0adfe80c20447b6bfa7a4b509e9b7ac5132d7acbda89d96b3a337f887
Submission
POST /lanka/awaken
{"summons":[1799,2879,1995,183,3623,2273],
"attestation":"a5f66ca0adfe80c20447b6bfa7a4b509e9b7ac5132d7acbda89d96b3a337f887"}
{"flag":"PINAKA{kumbhakarna_six_drumbeats_pierce_the_sleep}",
"proclamation":"Kumbhakarna's eyes open; the sleepless curse is broken.",
"status":"awake"}
Notes
- The extended-operand offset (
index = operand - 0x10) was the biggest trap. Read the jump tables directly. firmware_debug.romdiffers in 30 bytes and turns on extra GARJANA tracing (and one tick). It's a debug build, not the target. The releasefirmware.romis the one whose tag the gate expects.- Because both branch arms cost the same number of instructions, the drum schedule is input-independent, which turns a chicken-and-egg coupling into six independent modular equations over the prime 4093.
- The tag is SHA-256 over a structured transcript (
"PRAHARA1"prefix, per-instruction(pc, opcode)trace,"DUNDUBHI","GARJANA", and the emitted bytes). Reimplementing it is unnecessary: the emulator is the oracle, and only theawakepredicate had to be reversed.