Miscellaneous / Pinaka CTF 2026
DASHA-MUKHA — Ravana’s Ten Heads
Exploit a representation mismatch between recovery state and modular proofs to make locally valid authorities accept an impossible transaction.
Category: Final Boss
Target: http://137.23.48.54:8080
Flag: PINAKA{ten_heads_agreed_on_a_truth_that_never_was}
0. Executive summary
The platform is a ten-authority distributed vault. A release is supposed to require that the same
transaction T is simultaneously:
- Authorized by Niyama (
amount ≤ LIMIT), - Committed by Smriti (state reconstructed from the Kala journal), and
- Verified by Pramana (a Pedersen opening certifying
value ≤ LIMIT).
Every authority is locally correct. The break is a representation mismatch created by the recovery path:
Smriti stores a state value as a 128-bit pair
(lo, hi)interpreted aslo + hi·2^64.POST /storage/recovery_apply(reachable only after driving the Punarjanma firmware into recovery mode) lets us choosehiarbitrarily.Pramana commits with
G^x · H^r mod Pwhere the exponent is reduced moduloQ = (P-1)/2. Since2^64is invertible moduloQ, the valuetextC = lo + (k·Q)·2^64is congruent to
lo (mod Q). The same proof transcript that validatesloalso opens the commitment forC, even thoughC ≫ LIMIT.
So the vault sees an id that is authorized for 5, verified for 5, and committed — but the
committed value is astronomically larger than the envoy limit. Every head individually agrees, and
together they swear to a truth that never was. Rajakosha returns the flag.
Recovery mode itself is gated by a small custom VM. The legal input sequence was derived by reversing
binaries/recovery-emulator; it is:
[0x5241, 0x5641, 0x4e41, 0x2121] # "RAVANA!!" as four 16-bit words1. The artifact
12-Dashanana.zip contains:
| Path | Purpose |
|---|---|
CHALLENGE.txt | Story / target |
docs/architecture.md | The ten authorities, the global invariant |
source/main.py | The FastAPI surface (the core dasha.py is withheld) |
source/crypto.py | Pedersen parameters and commit(), hid() |
binaries/recovery-emulator | Stripped ELF implementing the Punarjanma VM |
binaries/firmware.rom | The 24-bit instruction ROM |
tools/dasha_client.py | Minimal client (no solution) |
evidence/*.json | Sample authorization/journal/transcript entries |
The important thing to note early: dasha.py is not shipped. The vault's exact composition check
is a black box. Everything below about Smriti/Rajakosha behavior was established by black-box probing
of the live service, while the firmware was reversed from the binary.
2. API surface
All requests need the header X-Team: team_... obtained from POST /gate/session.
| Endpoint | Authority | Body |
|---|---|---|
POST /gate/session | Dvara | — |
POST /identity/authorize | Pehchan/Niyama | {subject, amount} |
POST /sequencer/event | Kala | {subject, amount} |
POST /firmware/recover | Punarjanma | {sequence: [w0,w1,w2,w3]} |
POST /storage/alloc | Smriti | — |
POST /storage/free | Smriti | {handle} |
POST /storage/snapshot | Smriti | — |
POST /storage/recovery_apply | Smriti | {target_id, handle, hi} |
POST /proof/verify | Pramana | {subject, commitment, x, r} |
GET /audit | Sakshi | — |
POST /vault/release | Rajakosha | {id} |
2.1 Cryptography (source/crypto.py)
P = 0xFFFFFFFF...FFFFFFFF # 2048-bit safe prime
Q = (P - 1) // 2
G = 2
H = pow(G, int(sha256(b"ravana-dasha-pedersen-h").digest(), "big") % Q, P)
def commit(x, r):
return pow(G, x % Q, P) * pow(H, r % Q, P) % P
def hid(subject, a_norm):
return sha256(f"{subject}|{a_norm}".encode()).hexdigest()Note x % Q inside commit. Values that are congruent modulo Q are indistinguishable to Pramana.
This is the seed of the final step.
2.2 Identity
id = sha256(f"{subject}|{amount}"), confirmed against every entry in evidence/audit-ledger.json
(e.g. angada|5 → 60c96c63…).
2.3 Measured policy limits
Binary search against the live service:
| Operation | Accepted | Rejected |
|---|---|---|
authorize | amount ≤ 1000 | amount ≥ 1001 (Niyama: amount exceeds envoy limit) |
verify | 0 ≤ x ≤ 1000 | x ≥ 1001 (Pramana: certified value out of range) |
authorize also accepts negative integers (-1 was accepted), but verify rejects them, so a
negative transfer cannot be fully composed.
A normal three-way transaction releases but is not sovereign:
POST /vault/release {"id": id(angada,5)}
→ 200 {"sovereign": false, "amount": "5",
"message": "ordinary dispatch acknowledged"}3. Reversing Punarjanma (VIII) — the recovery emulator
binaries/recovery-emulator is a stripped x86-64 PIE implementing a small VM. Running it prints
either AWAKE or DORMANT plus a CRC-like TAG.
$ ./recovery-emulator 0 0 0 0
DORMANT
TAG 05f19d9eInput is four 24-bit words taken from argv[1..4] (or from stdin if there are fewer than four
arguments).
3.1 VM state layout
The VM keeps all state in one .bss block (rbx, 0x3018c bytes). The layout recovered from the
disassembly:
| Offset | Meaning |
|---|---|
+0x00 … +0x1c | 8 × 24-bit general registers r0…r7 |
+0x20 | PC (14-bit index into code memory) |
+0x24 | SP (24-bit, data-address space) |
+0x28 | bank (3 bits) |
+0x2c | Z flag |
+0x30 | sign flag (bit 23 of result) |
+0x34 | carry flag |
+0x38 | overflow flag |
+0x3c | halted / trap marker |
+0x40 | code memory (260 words copied from .rodata at 0x2200) |
+0x10040 … | 8 banks × 4096 words of data memory |
+0x2c040 | stack (addressed via SP) |
+0x30040+ | MMIO / control registers |
+0x3007c | the four input words |
+0x30044 | "exit" flag checked by the main loop |
Data addresses are (bank << 12) | ((reg + simm10) & 0xFFF). In bank 0, addresses ≥ 0xF00 are
MMIO ports instead of memory.
3.2 Instruction encoding
A 24-bit instruction word:
bits 23..18 opcode (6 bits)
bits 17..15 S (destination / source register depending on op)
bits 14..12 D (other register)
bits 11..10 mode
bits 9..0 imm10 (two's complement)Instruction length:
mode == 0→ 1 word,imm10is a signed 10-bit immediate.mode == 1→ 2 words, word 2 is a 24-bit immediate / absolute address.mode == 2,3→ 3 words (word 2 is used; the third word is padding).
3.3 Recovered ISA
| Op | Mnemonic | Semantics |
|---|---|---|
0x00 | nop | — |
0x01 | mov S,D | r[S] = r[D] |
0x02 | ldi S,imm | r[S] = imm (sign-extended imm10 or 24-bit word 2) |
0x03 | ld S,[D+imm] | r[S] = mem[bank:((r[D]+imm)&0xFFF)]; bank-0 ≥0xF00 → MMIO read |
0x04 | st [D+imm],S | mem[...] = r[S]; bank-0 ≥0xF00 → MMIO write |
0x05 | add S,D | end-around-carry add, i.e. addition mod 2^24-1 |
0x06 | sub S,D | S + ~D |
0x07 | and S,D | bitwise |
0x08 | or S,D | bitwise |
0x09 | xor S,D | bitwise |
0x0a | shl S,imm8 | shift left by low 8 bits of the word |
0x0b | shr S,imm8 | shift right |
0x0c | not S | one's complement, 24-bit |
0x0d | sbc S,D | subtract with complement, flag variants |
0x0e | addi S,imm10 | add signed immediate |
0x0f | jmp | relative (mode 0) or absolute word 2 |
0x10 | jz abs | jump if Z |
0x11 | jnz abs | jump if !Z |
0x12 | jc abs | jump if carry |
0x13 | js abs | jump if sign |
0x14 | call abs | push PC, jump |
0x15 | ret | pop PC |
0x16 | push D | push register |
0x17 | pop S | pop register |
0x18 | setbank imm3 | select bank |
0x19 | getbank S | read bank |
0x1a | in S,port8 | MMIO read from 0xF00+port8 |
0x1b | out port8,D | MMIO write to 0xF00+port8 |
0x1c | hlt | set halted/trap marker (yield) |
0x1d | clrhlt | clear halt marker |
0x1e | reti | restore flags+bank and PC from stack |
0x1f | xor S,rng | XOR register with RNG state |
0x20 | mul S,D | GF(2²⁴) multiply, reduction polynomial 0x100001b |
0x21 | exit | stop the run |
0x22 | nop | — |
Flags: Z is set when a result is 0 or 0xFFFFFF (consistent with mod 2^24-1
arithmetic), sign is bit 23, carry is the end-around carry, overflow is the usual signed overflow.
3.4 MMIO map
| Port | Read | Write |
|---|---|---|
0xF00 | timer value | timer reload |
0xF01 | IRQ enable | IRQ enable |
0xF10 | 0 | recovery source start |
0xF11 | 0 | recovery destination delta |
0xF12 | 0 | recovery word count |
0xF13 | 0 | recovery PRNG seed |
0xF14 | 0 | recovery target bank |
0xF15 | 0 | trigger recovery replay |
0xF20 | IRQ mask | IRQ mask |
0xF21 | IRQ status | — |
0xF22 | — | clear IRQ bits |
0xF30 | RNG state | — |
0xF31 | — | advance RNG |
0xF40 | next input word | — |
0xF41 | inputs remaining | — |
0xF50 | verdict marker/value | sets verdict |
The final verdict logic is:
AWAKE ⟺ [0x34204] != 0 ∧ [0x34208] == 0A write of value 0 to 0xF50 is what makes the coordinator AWAKE. The ROM writes an OR of
mismatch terms there, so AWAKE means "all checks passed".
3.5 The ROM
Disassembling code words 0x100..0x103 reveals four data constants:
0x00dd8c0a 0x00d8a8be 0x0006ea72 0x00f1ad2eThe ROM's reset path (pc = 0 jumps to 0x00e) configures the replay unit and triggers it:
000: jmp 00e
008: ldi r1, 0xf22
00a: ldi r4, 3
00c: st [r1], r4 ; clear IRQ bits 0..1
00d: reti ; (harmless context restore)
00e: setbank 0
00f: ldi r1, 0xf10
011: ldi r4, 0x100 ; source = code word 0x100
013: st [r1], r4
014: ldi r1, 0xf11
016: ldi r4, 0x10 ; destination = src - 0xf0 = 0x10
018: st [r1], r4
019: ldi r1, 0xf12
01b: ldi r4, 4 ; four words
01d: st [r1], r4
01e: ldi r1, 0xf13
020: ldi r4, 0xc0de ; PRNG seed
022: st [r1], r4
023: ldi r1, 0xf14
025: ldi r4, 0 ; bank 0
027: st [r1], r4
028: hlt
029: ldi r1, 0xf15
02b: ldi r4, 1
02d: st [r1], r4 ; trigger replay
032: clrhlt
033: ldi r0, 0x50554e ; "PUN"The replay unit (MMIO 0xF15) copies count words from code memory at src, XORs each with a
PRNG stream seeded by 0xF13, and writes the result into data memory at src + (0xF11 - 0xF10):
edx = seed
for i in range(count):
edx = (edx * 0x19660d + 0x3c6ef3) mod 2^24
mem[dst + i] = code[src + i] XOR edxFor the shipped configuration this deobfuscates the four constants into data memory 0x10..0x13:
c = [0x112233, 0x445566, 0x778899, 0xaabbcc]The main routine then reads the four user words from port 0xF40 and runs four rounds. Note that the
global RNG (0x5a4b3c, advanced by writes to 0xF31) is a different stream from the replay seed.
Pseudo-code:
r0 = 0x50554e # "PUN"
r7 = 0
for i in 0..3:
r0 = add24(r0, input[i]) # addition mod 2^24-1
r0 = gfmul(r0, c[i]) # GF(2^24) product
advance_rng() # rng = rng*0x19660d + 0x3c6ef3 mod 2^24
r0 = r0 XOR rng
r7 = r7 OR (r0 XOR K[i])
out(0xF50, r7)with acceptance constants:
K = [0xf6c2a9, 0xa29720, 0x4f8923, 0xb1e1e9]AWAKE requires r7 == 0, i.e. every round's r0 must equal the corresponding K[i].
3.6 Solving for the legal sequence
Forward recurrence:
A0 = 0x50554e
Ai+1 = K[i]
Ri = f^(i+1)(0x5a4b3c) f(x) = (x*0x19660d + 0x3c6ef3) mod 2^24For round i we need:
gfmul(add24(Ai, xi), c[i]) XOR Ri = K[i]so
add24(Ai, xi) = gfmul(K[i] XOR Ri, c[i]^-1)
xi = (that - Ai) mod (2^24 - 1)Numerically:
c = [0x112233, 0x445566, 0x778899, 0xaabbcc]
R = [0xa828ff, 0x031de6, 0xc497a1, 0x5b4820]
x = [0x005241, 0x005641, 0x004e41, 0x002121]i.e. 0x5241 0x5641 0x4e41 0x2121 — "RAVANA!!" split into four 16-bit words.
Verification:
$ ./recovery-emulator 0x5241 0x5641 0x4e41 0x2121
AWAKE
TAG c891d4e5The live service agrees:
POST /firmware/recover {"sequence":[21057,22081,20033,8481]}
→ 200 {"recovery": true, "coordinator_tag": "c891d4e5"}Solver used:
M = (1 << 24) - 1
POLY = 0x100001b
def gf_mul(a, b):
r = 0
for _ in range(24):
if b & 1:
r ^= a
b >>= 1
a <<= 1
if a & 0x1000000:
a ^= POLY
a &= 0x1ffffff
return r & 0xffffff
def gf_inv(a): # a^(2^24-2)
r, e = 1, (1 << 24) - 2
while e:
if e & 1:
r = gf_mul(r, a)
a = gf_mul(a, a)
e >>= 1
return r
def f(x): return (x * 0x19660d + 0x3c6ef3) & 0xffffff
def add24(a, b):
s = a + b
return (s + (s >> 24)) & 0xffffff
code = [0x00dd8c0a, 0x00d8a8be, 0x0006ea72, 0x00f1ad2e]
edx = 0xc0de
c = []
for w in code:
edx = f(edx)
c.append(w ^ edx) # 0x112233, 0x445566, 0x778899, 0xaabbcc
r = 0x5a4b3c
R = []
for _ in range(4):
r = f(r)
R.append(r)
K = [0xf6c2a9, 0xa29720, 0x4f8923, 0xb1e1e9]
A = 0x50554e
xs = []
for i in range(4):
y = gf_mul(K[i] ^ R[i], gf_inv(c[i]))
xs.append((y - A) % M)
A = K[i]
print([hex(x) for x in xs]) # ['0x5241','0x5641','0x4e41','0x2121']4. Smriti (V) — state objects and the allocator
The storage layer treats a state object as a slot block: two 64-bit words (lo, hi) whose value is
value = lo + hi · 2^64/sequencer/event {subject, amount} commits an object whose id is hid(subject, amount). Observed
behavior:
Event with a huge amount stores
amount mod 2^64inlo(the high word stays 0). Example:event("b", 2^64+5)later reports a committed amount of5./storage/allocreturns{handle, base};baseadvances by 2 per allocation (two words per block). An event also consumes a block from the same allocator./storage/free {handle}marks the block free but keeps the handle alive: freeing the same handle twice returns200both times, and the nextallocrecycles that base./storage/snapshotis required before any recovery replay (Smriti: no recovery snapshot takenotherwise)./storage/recovery_apply {target_id, handle, hi}resumes replay for one object and setstextcommitted(target_id) = lo(target_id) + hi · 2^64It returns
{"applied": true, "committed_amount": "<value>"}.
A subtle but crucial observed detail: hi is honored for handles allocated after the snapshot;
handles that existed at snapshot time silently ignore hi (the value stays at the snapshot low
word). The working exploit therefore allocates the recovery handle after /storage/snapshot.
Object recycling is also exploitable on its own. Because free() does not invalidate the handle and
does not check ownership, this sequence makes id A point at another transaction's block:
alloc H -> base 0
free H -> block 0 free
event A(5) -> reuses block 0
free H -> frees block 0 again (A's block!)
event B(9) -> reuses block 0
release id(A) -> 200, amount "9"The vault still answered sovereign: false, which told us that a plain amount mismatch or a value
above LIMIT is not the trigger. The composition check re-opens the proof against the committed
value — the committed value must be a valid opening of the transcript.
5. Pramana (VI) — the modulus collision
commit(x, r) reduces the exponent x modulo Q. Therefore
commit(C, r) == commit(C mod Q, r)Choose
hi = k·Q (k ≥ 1)
C = lo + k·Q·2^64Because 2^64 is coprime to Q, C ≡ lo (mod Q). The exact transcript produced for lo
(commitment, x = lo, r) remains a valid opening of C. The proof genuinely opens the commitment
— it is not forged — but the committed value is larger than the envoy limit by an astronomically
large factor.
This is the "eleventh truth": the authorization and the proof speak about 5, the committed storage
object speaks about 5 + Q·2^64, and the vault's join key (id) makes all three agree.
6. The exploit
Preconditions: LIMIT = 1000, id = hid(subject, amount), recovery sequence from §3.6.
import hashlib, json, urllib.request
BASE = "http://137.23.48.54:8080"
SEQ = [0x5241, 0x5641, 0x4e41, 0x2121] # "RAVANA!!"
P, Q, G = ... # from source/crypto.py or /proof/params
def commit(x, r):
return pow(G, x % Q, P) * pow(H, r % Q, P) % P
def hid(s, a):
return hashlib.sha256(f"{s}|{a}".encode()).hexdigest()
# 1. session
team = post("/gate/session", {})["team"]
HDR = {"X-Team": team}
# 2. authorize the small transaction (≤ 1000)
subj, amt = "a", 5
tid = hid(subj, amt)
post("/identity/authorize", {"subject": subj, "amount": amt})
# 3. produce a real, valid proof for 5
r = 777
post("/proof/verify", {
"subject": subj,
"commitment": commit(amt, r),
"x": amt,
"r": r,
})
# 4. commit the same transaction
post("/sequencer/event", {"subject": subj, "amount": amt})
# 5. take a storage snapshot
post("/storage/snapshot", {})
# 6. drive Punarjanma into recovery mode
post("/firmware/recover", {"sequence": SEQ})
# 7. allocate a recovery handle AFTER the snapshot, then
# write a high word that is a multiple of Q
handle = post("/storage/alloc", {})["handle"]
post("/storage/recovery_apply", {
"target_id": tid,
"handle": handle,
"hi": Q, # any k*Q works
})
# 8. the composed check now accepts an impossible transaction
post("/vault/release", {"id": tid})Last response:
{
"sovereign": true,
"amount": "298071770112995961569683563916029592779515629640460594957032611956101644948227666537916641126314235259790026302878903664547785216707081...",
"flag": "PINAKA{ten_heads_agreed_on_a_truth_that_never_was}"
}For comparison, the same flow with hi = 1 (not a multiple of Q) releases as an ordinary dispatch
(sovereign: false), and a plain committed amount of 9 or 5000 (via recycling) also stays
ordinary. Only a value that is both large and a valid opening of the transcript is "globally
impossible".
7. Why each head was locally correct
| Head | Local rule | Verdict |
|---|---|---|
| I Dvara | session routing | fine |
| II Pehchan | id = H(subject‖amount) | fine |
| III Niyama | amount ≤ 1000 | fine (authorized 5) |
| IV Kala | append-only journal | fine |
| V Smriti | lo + hi·2^64 reconstruction | fine (128-bit values are legal) |
| VI Pramana | Pedersen opening + x ≤ 1000 | fine (x = 5, opening valid) |
| VII Maya | alternate view | non-authoritative |
| VIII Punarjanma | legal sequence only | fine (sequence is legal) |
| IX Sakshi | append-only ledger | records the contradiction |
| X Rajakosha | join on id, re-open transcript | the composition is fooled |
The contradiction only exists between authorities: the identity relation is injective on amounts
during normal operation, but recovery replay introduces a second representation (lo, hi) whose
high word is invisible to the modulo-Q proof layer.
8. Reproduction notes / pitfalls
- Send
X-Teamon every request; a newPOST /gate/sessioncreates an isolatedDashaSession. eventdoes not require prior authorization and does not append to the audit ledger.- The audit ledger only gains entries for
authorize,verify,recovery_apply, and successfulrelease. Entries are returned as{n, commit}(hash-chained, contents not exposed). recovery_applyneeds: recovery mode on, a prior snapshot, a valid live handle, and (as observed) a handle created after the snapshot forhito take effect.himay be an arbitrary-precision JSON integer;Qis a 1024-bit number.- The proof must be generated against the low value, so
xstays in[0, 1000]and passes Pramana's range check. - Any multiple of
Qworks forhi;hi = Qis sufficient.
9. Flag
PINAKA{ten_heads_agreed_on_a_truth_that_never_was}