Miscellaneous / Pinaka CTF 2026

DASHA-MUKHA — Ravana’s Ten Heads

02 Nov 2026 · 14 min read

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:

  1. Authorized by Niyama (amount ≤ LIMIT),
  2. Committed by Smriti (state reconstructed from the Kala journal), and
  3. 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 as lo + hi·2^64.

  • POST /storage/recovery_apply (reachable only after driving the Punarjanma firmware into recovery mode) lets us choose hi arbitrarily.

  • Pramana commits with G^x · H^r mod P where the exponent is reduced modulo Q = (P-1)/2. Since 2^64 is invertible modulo Q, the value

    text
    C = lo + (k·Q)·2^64

    is congruent to lo (mod Q). The same proof transcript that validates lo also opens the commitment for C, even though C ≫ 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:

text
[0x5241, 0x5641, 0x4e41, 0x2121]      # "RAVANA!!" as four 16-bit words

1. The artifact

12-Dashanana.zip contains:

PathPurpose
CHALLENGE.txtStory / target
docs/architecture.mdThe ten authorities, the global invariant
source/main.pyThe FastAPI surface (the core dasha.py is withheld)
source/crypto.pyPedersen parameters and commit(), hid()
binaries/recovery-emulatorStripped ELF implementing the Punarjanma VM
binaries/firmware.romThe 24-bit instruction ROM
tools/dasha_client.pyMinimal client (no solution)
evidence/*.jsonSample 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.

EndpointAuthorityBody
POST /gate/sessionDvara—
POST /identity/authorizePehchan/Niyama{subject, amount}
POST /sequencer/eventKala{subject, amount}
POST /firmware/recoverPunarjanma{sequence: [w0,w1,w2,w3]}
POST /storage/allocSmriti—
POST /storage/freeSmriti{handle}
POST /storage/snapshotSmriti—
POST /storage/recovery_applySmriti{target_id, handle, hi}
POST /proof/verifyPramana{subject, commitment, x, r}
GET /auditSakshi—
POST /vault/releaseRajakosha{id}

2.1 Cryptography (source/crypto.py)

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

OperationAcceptedRejected
authorizeamount ≤ 1000amount ≥ 1001 (Niyama: amount exceeds envoy limit)
verify0 ≤ x ≤ 1000x ≥ 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:

json
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.

text
$ ./recovery-emulator 0 0 0 0
DORMANT
TAG 05f19d9e

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

OffsetMeaning
+0x00 … +0x1c8 × 24-bit general registers r0…r7
+0x20PC (14-bit index into code memory)
+0x24SP (24-bit, data-address space)
+0x28bank (3 bits)
+0x2cZ flag
+0x30sign flag (bit 23 of result)
+0x34carry flag
+0x38overflow flag
+0x3chalted / trap marker
+0x40code memory (260 words copied from .rodata at 0x2200)
+0x10040 …8 banks × 4096 words of data memory
+0x2c040stack (addressed via SP)
+0x30040+MMIO / control registers
+0x3007cthe 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:

text
 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, imm10 is 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

OpMnemonicSemantics
0x00nop—
0x01mov S,Dr[S] = r[D]
0x02ldi S,immr[S] = imm (sign-extended imm10 or 24-bit word 2)
0x03ld S,[D+imm]r[S] = mem[bank:((r[D]+imm)&0xFFF)]; bank-0 ≥0xF00 → MMIO read
0x04st [D+imm],Smem[...] = r[S]; bank-0 ≥0xF00 → MMIO write
0x05add S,Dend-around-carry add, i.e. addition mod 2^24-1
0x06sub S,DS + ~D
0x07and S,Dbitwise
0x08or S,Dbitwise
0x09xor S,Dbitwise
0x0ashl S,imm8shift left by low 8 bits of the word
0x0bshr S,imm8shift right
0x0cnot Sone's complement, 24-bit
0x0dsbc S,Dsubtract with complement, flag variants
0x0eaddi S,imm10add signed immediate
0x0fjmprelative (mode 0) or absolute word 2
0x10jz absjump if Z
0x11jnz absjump if !Z
0x12jc absjump if carry
0x13js absjump if sign
0x14call abspush PC, jump
0x15retpop PC
0x16push Dpush register
0x17pop Spop register
0x18setbank imm3select bank
0x19getbank Sread bank
0x1ain S,port8MMIO read from 0xF00+port8
0x1bout port8,DMMIO write to 0xF00+port8
0x1chltset halted/trap marker (yield)
0x1dclrhltclear halt marker
0x1eretirestore flags+bank and PC from stack
0x1fxor S,rngXOR register with RNG state
0x20mul S,DGF(2²⁴) multiply, reduction polynomial 0x100001b
0x21exitstop the run
0x22nop—

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

PortReadWrite
0xF00timer valuetimer reload
0xF01IRQ enableIRQ enable
0xF100recovery source start
0xF110recovery destination delta
0xF120recovery word count
0xF130recovery PRNG seed
0xF140recovery target bank
0xF150trigger recovery replay
0xF20IRQ maskIRQ mask
0xF21IRQ status—
0xF22—clear IRQ bits
0xF30RNG state—
0xF31—advance RNG
0xF40next input word—
0xF41inputs remaining—
0xF50verdict marker/valuesets verdict

The final verdict logic is:

text
AWAKE  ⟺  [0x34204] != 0  ∧  [0x34208] == 0

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

text
0x00dd8c0a  0x00d8a8be  0x0006ea72  0x00f1ad2e

The ROM's reset path (pc = 0 jumps to 0x00e) configures the replay unit and triggers it:

asm
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):

text
edx = seed
for i in range(count):
    edx = (edx * 0x19660d + 0x3c6ef3) mod 2^24
    mem[dst + i] = code[src + i] XOR edx

For the shipped configuration this deobfuscates the four constants into data memory 0x10..0x13:

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

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

text
K = [0xf6c2a9, 0xa29720, 0x4f8923, 0xb1e1e9]

AWAKE requires r7 == 0, i.e. every round's r0 must equal the corresponding K[i].

Forward recurrence:

text
A0 = 0x50554e
Ai+1 = K[i]
Ri = f^(i+1)(0x5a4b3c)          f(x) = (x*0x19660d + 0x3c6ef3) mod 2^24

For round i we need:

text
gfmul(add24(Ai, xi), c[i]) XOR Ri = K[i]

so

text
add24(Ai, xi) = gfmul(K[i] XOR Ri, c[i]^-1)
xi = (that - Ai) mod (2^24 - 1)

Numerically:

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

text
$ ./recovery-emulator 0x5241 0x5641 0x4e41 0x2121
AWAKE
TAG c891d4e5

The live service agrees:

json
POST /firmware/recover {"sequence":[21057,22081,20033,8481]}
→ 200 {"recovery": true, "coordinator_tag": "c891d4e5"}

Solver used:

python
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

text
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^64 in lo (the high word stays 0). Example: event("b", 2^64+5) later reports a committed amount of 5.

  • /storage/alloc returns {handle, base}; base advances 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 returns 200 both times, and the next alloc recycles that base.

  • /storage/snapshot is required before any recovery replay (Smriti: no recovery snapshot taken otherwise).

  • /storage/recovery_apply {target_id, handle, hi} resumes replay for one object and sets

    text
    committed(target_id) = lo(target_id) + hi · 2^64

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

text
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

text
commit(C, r) == commit(C mod Q, r)

Choose

text
hi = k·Q        (k ≥ 1)
C  = lo + k·Q·2^64

Because 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.

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

json
{
  "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

HeadLocal ruleVerdict
I Dvarasession routingfine
II Pehchanid = H(subject‖amount)fine
III Niyamaamount ≤ 1000fine (authorized 5)
IV Kalaappend-only journalfine
V Smritilo + hi·2^64 reconstructionfine (128-bit values are legal)
VI PramanaPedersen opening + x ≤ 1000fine (x = 5, opening valid)
VII Mayaalternate viewnon-authoritative
VIII Punarjanmalegal sequence onlyfine (sequence is legal)
IX Sakshiappend-only ledgerrecords the contradiction
X Rajakoshajoin on id, re-open transcriptthe 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-Team on every request; a new POST /gate/session creates an isolated DashaSession.
  • event does not require prior authorization and does not append to the audit ledger.
  • The audit ledger only gains entries for authorize, verify, recovery_apply, and successful release. Entries are returned as {n, commit} (hash-chained, contents not exposed).
  • recovery_apply needs: recovery mode on, a prior snapshot, a valid live handle, and (as observed) a handle created after the snapshot for hi to take effect.
  • hi may be an arbitrary-precision JSON integer; Q is a 1024-bit number.
  • The proof must be generated against the low value, so x stays in [0, 1000] and passes Pramana's range check.
  • Any multiple of Q works for hi; hi = Q is sufficient.

9. Flag

text
PINAKA{ten_heads_agreed_on_a_truth_that_never_was}