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:

Kumbhakarna: The Sleepless Curse

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

  1. Reads the ROM into [rsp+0xbf0] (up to 0x1000 bytes).
  2. Zero-extends each 16-bit ROM word into a 32-bit VM word at [rsp+0x1bf0] (2048-word instruction memory).
  3. Parses argv[2...] with strtol, masks each to 12 bits, and stores them at [rsp+0xf0...].
  4. Zeroes a 256-word data RAM at [rsp+0x7f0].
  5. Enters the VM fetch loop at 0x137c.

main() in the disassembler showing the ROM load and argv parsing

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:

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 jump table at 0x307c and the lea/base+s32 pattern used to resolve it

The PRAHARA ISA. The main CPU is a stack machine:

Data RAM is 256 words, and the arithmetic modulus is 0xffd = 4093, which is prime.

READPORT values:

Extended ops (operand = index + 0x10):

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:

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:

Mailbox commands:

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 DUNDUBHI opcode handlers and mailbox command table in the disassembler

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.

the six-slot compare/branch sequence at VM pc 0x5c-0x6a

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:

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

Read this post on CyberHx →