Kumbhakarna: The Machine Beneath Lanka
By Team Cryptonite · 4 min read
About the CTF: Pinaka CTF
kumbha is a stripped x86-64 binary that turns out to be a tiny 24-bit virtual machine with its ROM embedded in the ELF. The validator wants four 24-bit input words (the "invocation") and the resulting TAG. Rather…
**Pinaka CTF 2026** · Reverse engineering · Team Cryptonite · All 44 Pinaka write-ups
Category: reverse engineering
Flag: PINAKA{kumbhakarnawakeswheny0umodelthemachinenotbrute_it}
Overview
kumbha is a stripped x86-64 binary that turns out to be a tiny 24-bit virtual machine with its ROM embedded in the ELF. The validator wants four 24-bit input words (the "invocation") and the resulting TAG. Rather than brute-forcing, the check can be run backwards.
Files:
kumbha: stripped x86-64 PIE, the VM hostrom.bin: 780 bytes, 260 24-bit big-endian ROM wordstraces/trace0.txt...trace2.txt: reference PC/R0 traces for three test invocations
Each trace lists the invocation, the result (DORMANT), the final tag, and the PC/R0 pair for every step. They act as an oracle: any VM model that doesn't reproduce them is wrong.
Analysis
Finding the ROM. The binary copies 0x410 bytes from .rodata offset 0x2200 into a VM struct in BSS at 0x4080. That's 260 little-endian dwords, one per ROM word:
import struct
b = open("kumbha", "rb").read()
rom = list(struct.unpack("<260I", b[0x2200:0x2200+0x410]))
rom.bin is the same data as 3-byte big-endian values. The interesting words:
ROM[0x000] = 0x3c0400
ROM[0x001] = 0x00000e
ROM[0x008] = 0x088400 # interrupt service routine
ROM[0x100] = 0xc3b014 # encrypted multiplier 0
ROM[0x101] = 0xa0b682
ROM[0x102] = 0x16ea82
ROM[0x103] = 0xfea221
ROM[0x048] = 0x6e1a93 # comparison constant 0
ROM[0x05e] = 0x69ad85 # comparison constant 1
ROM[0x074] = 0x387eda # comparison constant 2
ROM[0x08a] = 0xa30740 # comparison constant 3

VM architecture:
- 24-bit words and registers, 8 registers
R0toR7. - 8 memory banks of 4 KiB, with memory-mapped I/O in bank 0 at
0xF00and above. - A single interrupt line, driven by DMA completion and the timer.
- A cycle hash over the machine state, printed as
TAG.
I/O ports used by this ROM:
0x00: timer reload / countdown0x01: interrupt enable0x10: DMA source0x11: DMA destination0x12: DMA length0x13: DMA seed0x14: DMA bank0x15: DMA start0x20: interrupt mask0x30: LCG state0x31: step the LCG0x40: read the next input word0x41: input available0x50: signal / output value
Instruction encoding. A 24-bit word w decodes as:
op = (w >> 18) & 0x3f # 6-bit opcode
A = (w >> 15) & 7 # destination / first register
B = (w >> 12) & 7 # source / second register
f = (w >> 10) & 3 # immediate mode
r9 = w & 0x3ff # 10-bit immediate or small offset
If f != 0, the next ROM word is a 24-bit immediate. The next-PC rule that matches the binary is nxt = pc + 2 + (1 if (w & 0x800) else 0).
Opcodes that matter:
5: add with end-around carry6: subtract9: XOR26/27: port read / port write28: halt31: XOR the LCG state32: multiply in GF(2^24)33: done / report
Two of these aren't what they look like:
- Opcode 32 is not XOR. It's a 24-round shift-and-reduce multiply in GF(2^24) with polynomial
0x100001B. The shift loop around0x15C0gives it away. - Opcode 5 is not plain addition. It's end-around carry, i.e. addition modulo
2^24 - 1:
s = a + b
carry = s >> 24
r = ((s & 0xffffff) + carry) & 0xffffff

Boot and DMA. Boot code programs a four-word DMA (source = ROM[0x100], destination = 0x10, length = 4, seed = 0xC0DE). The DMA decrypts each word with the same LCG constants used elsewhere:
seed = (seed * 0x19660D + 0x3C6EF3) & 0xffffff
word = ROM[src + i] ^ seed
Decrypting ROM[0x100..0x103] gives the four multipliers 0x0F1E2D, 0x3C4B5A, 0x678869, 0xA5B4C3. DMA completion raises the interrupt, which runs the ISR at ROM[0x08] and starts the main check.
The four-block check. The main routine reads four 24-bit input words from port 0x40 and starts with R0 = 0x4B554D ("KUM"). Then it runs four identical blocks. For block i:
R0 = R0 + input[i]with end-around carry.R0 = R0 * mem[0x10 + i]in GF(2^24), polynomial0x100001B.R0 = R0 ^ lcg_i.R7 = R7 | (R0 ^ const_i).
The LCG starts at 0x9A1337 and steps with state = state * 0x19660D + 0x3C6EF3 mod 2^24, giving the per-block values 0x1652BE, 0xE45699, 0x46CAB8, 0x920A4B. The comparison constants are 0x6E1A93, 0x69AD85, 0x387EDA, 0xA30740.
The ROM signals AWAKE only when R7 == 0, so every block must finish with R0 == const_i. That makes the four equations independent once R0 is carried forward.

Solution
Work each block backwards:
target = const_i ^ lcg_i
q = target * inverse(mem[i]) in GF(2^24)
input = q - R0 modulo 2^24 - 1
R0 for the next block is const_i. The GF inverse is a^(2^24 - 2), and subtraction undoes end-around-carry addition. Because the addition is mod 2^24 - 1, b = (q - R0) mod (2^24 - 1) is unique in range, with the usual edge case at 0 / 0xFFFFFF.
Worked values as target -> q -> input:
- block 0:
0x78482D->0xA2A07E->0x574B31 - block 1:
0x8DFB1C->0xBC81E5->0x4E6752 - block 2:
0x7EB462->0xAB00BF->0x41533A - block 3:
0x310D0B->0xA7EBFB->0x6F6D21
The solver doesn't need the emulator:
M = 0xffffff
POLY = 0x100001b
def gfmul(a, b):
a &= M
b &= M
r = 0
for _ in range(24):
if b & 1:
r ^= a
b >>= 1
a <<= 1
if a & 0x1000000:
a ^= POLY
return r & M
def gfpow(a, e):
r = 1
while e:
if e & 1:
r = gfmul(r, a)
a = gfmul(a, a)
e >>= 1
return r
def gfinv(a):
return gfpow(a, (1 << 24) - 2)
def add(a, b):
s = a + b
carry = s >> 24
return ((s & M) + carry) & M
def sub_inv(r, a):
# find b with add(a, b) == r
for b in ((r - a) % M, (r - a - 1) % M, (r - a + 1) % M):
b &= M
if add(a, b) == r:
return b
return None
mem = [0x0f1e2d, 0x3c4b5a, 0x678869, 0xa5b4c3]
lcg = []
s = 0x9a1337
for _ in range(4):
s = (s * 0x19660d + 0x3c6ef3) & M
lcg.append(s)
const = [0x6e1a93, 0x69ad85, 0x387eda, 0xa30740]
R0 = 0x4b554d
inputs = []
for i in range(4):
target = const[i] ^ lcg[i]
q = gfmul(target, gfinv(mem[i]))
inp = sub_inv(q, R0)
inputs.append(inp)
print(i, hex(inp))
R0 = const[i]
print(" ".join("%06X" % x for x in inputs))
0x574b31
0x4e6752
0x41533a
0x6f6d21
574B31 4E6752 41533A 6F6D21
The TAG is a cycle hash over the machine state, so rather than reproduce it in a custom emulator, run the real binary on the recovered input (on macOS, via Docker):
docker run --rm --platform linux/amd64 \
-v "$PWD":/w -w /w ubuntu:24.04 \
sh -c './kumbha 574B31 4E6752 41533A 6F6D21'
AWAKE
TAG d1ddfd42
Submission
curl -s -X POST https://pinaka-kumbhakarna-validator.onrender.com/verify \
-H 'Content-Type: application/json' \
-d '{"invocation":"574B31 4E6752 41533A 6F6D21","tag":"d1ddfd42"}'
{"flag":"PINAKA{kumbhakarna_wakes_when_y0u_model_the_machine_not_brute_it}","message":"Kumbhakarna stirs. The machine beneath Lanka awakens.","ok":true}
Notes
- Opcode 32 is a GF(2^24) multiply, not XOR. Treating it as XOR gives wrong inputs.
- Addition is end-around carry, not plain mod 2^24. The carry out of bit 23 is added back at bit 0.
- The cycle hash isn't just over
R0. The loop at0x127Bhashes((instruction_word << 8) ^ R0). This only matters if you reproduceTAGin your own emulator; theAWAKEdecision doesn't depend on it. - The invocation is unique, so there's no second solution to search for.
- The ASCII is meaningless:
574B31 4E6752 41533A 6F6D21reads asWK1NgRAS:om!. The validator checks the four words and the tag.