Neven Marić
FortID 2026

MiscFortID 2026

EU Gift

A Flask app hands the flag to any EUDI wallet user whose country is not on its block list, and the list names every country on Earth. An unquoted NO in the YAML loads as the boolean False, so Norway slips through.

  • yaml
  • type-confusion
  • deny-list
  • eudi
  • openid4vp
  • sd-jwt

What this one teaches

  • The YAML "Norway problem": an unquoted NO (also yes, no, on, off, y, n, any case) loads as a boolean under YAML 1.1, and you catch it by diffing the loaded list against the values it was meant to hold.
  • Why a deny list that tries to enumerate everything is brittle, and why an allow list fails closed when one entry is wrong.
  • What a verifiable-credential presentation (OpenID4VP, an SD-JWT PID with holder binding) proves for you, and what it leaves entirely to the relying party's own logic.

Task description

The handout ships no task text. The site itself is titled “Free Flags for EU Citizens”, with the subtitle “Anyone with the official EUDI wallet can reveal the flag.” and a single button, “Continue with EUDI wallet”.

What the app checks

The handout is a Flask app (app.py, config.yml, a Dockerfile, templates and static assets). Pressing the button starts an OpenID4VP verification. OpenID for Verifiable Presentations is a standard where a verifier asks a digital wallet to present selected claims from a credential it holds, and gets back a signed, holder-bound response rather than a plain form submission.

The app asks the wallet for a PID (Personal Identification Data credential, type urn:eudi:pid:1, format dc+sd-jwt) and requests exactly one claim, issuing_country. When the presentation comes back verified, two functions decide access:

COUNTRY_CODE_PATTERN = re.compile(r"[A-Z]{2}")

def normalize_country_value(value):
    values = value if isinstance(value, list) else [value]
    normalized = []
    for item in values:
        if isinstance(item, str) and COUNTRY_CODE_PATTERN.fullmatch(item) and item in VALID_COUNTRY_CODES:
            normalized.append(item)
    return normalized

def evaluate_country_claims(claims):
    issuing_country = normalize_country_value(read_claim(claims, "issuing_country"))
    if not issuing_country:
        return None, "missing_issuing_country"
    country = issuing_country[0]
    if country in BLOCKED_COUNTRIES:     # the only real check
        return country, "blocked_country"
    return country, "allowed"

normalize_country_value keeps a value only if it is two capital letters ([A-Z]{2} under fullmatch) and is a real ISO 3166-1 alpha-2 code according to pycountry. So a country is admitted when it is a genuine code and is not in BLOCKED_COUNTRIES. Everything hinges on what that set actually contains.

A deny list with every country on it

BLOCKED_COUNTRIES is built straight from config.yml:

BLOCKED_COUNTRIES = set(CONFIG.get("blocked_countries", []))   # CONFIG from yaml.safe_load
VALID_COUNTRY_CODES = load_valid_country_codes()               # alpha-2 codes from pycountry

The YAML block under blocked_countries lists 249 two-letter codes, AD through ZW, which looks like the entire ISO alpha-2 set. If the list genuinely covered everything, subtracting it from the set of valid codes would leave nothing, and no country could ever pass. So the first thing worth doing is to load the config exactly as the app does and compute that difference both ways:

import yaml, pycountry
cfg = yaml.safe_load(open("config.yml", encoding="utf-8"))
blocked = set(cfg["blocked_countries"])
valid = {c.alpha_2 for c in pycountry.countries}
print("blocked raw len:", len(cfg["blocked_countries"]))
print("valid count:", len(valid))
print("allowed (valid - blocked):", sorted(valid - blocked))
print("bad entries (blocked - valid):", sorted(blocked - valid, key=str))

Running it against the handout’s config.yml:

blocked raw len: 249
valid count: 249
allowed (valid - blocked): ['NO']
bad entries (blocked - valid): [False]

One valid code survives, NO (Norway), and the block list holds the boolean False where a code should be. Both lists are 249 entries long, which is why the total count looks right while one of the entries is the wrong type. "NO" in BLOCKED_COUNTRIES is False, and False in BLOCKED_COUNTRIES is True, so the set that was supposed to block Norway is blocking a boolean instead.

The Norway problem

The file has - NO on its own line (line 209), unquoted:

blocked_countries:
  - AD
  - AE
  # ...
  - NO        # meant to be the string "NO"
  # ...
  - ZW

yaml.safe_load follows YAML 1.1 implicit typing (PyYAML has no 1.2 mode), which coerces several bare tokens to booleans. NO becomes False, so the entry lands in the set as False, not "NO". Norway is then both a valid pycountry code and absent from the deny list, which makes it the single country that can reveal the flag. The theme fits: Norway is in the EEA and participates in EUDI, yet it is not an EU member, so the “EU gift” falls to the one non-EU country.

Which bare tokens YAML 1.1 turns into booleans

Under the YAML 1.1 implicit-typing rules PyYAML implements, all of these load as booleans when unquoted: y, n, yes, no, true, false, on, off, in lower, upper or title case. The country code trap is NO to False, but ON (Ontario in some data sets) and anything shaped like a bare y/n flag have the same failure mode. Numbers that look sexagesimal (12:30) or leading-zero octal can be coerced too.

The fix is to quote any scalar that could collide ("NO"), or to parse with a YAML 1.2 “core schema” loader (for example ruamel.yaml in 1.2 mode), where no stays a string. The underlying lesson is that the type of a config value is decided by the parser’s rules, not by what you meant, so config parsing is a place where a silent type change becomes a security bug.

Why the credential cannot be forged

The country check is the weak link, but the claim still has to arrive inside a presentation the verifier accepts. The app creates each session with a trustConfig that pins an x5chain_root, the FortID Dev CA certificate embedded in config.yml, and the request requires cryptographic holder binding (a key-binding JWT proving the presenter controls the credential’s private key). The FortID verifier service therefore only accepts a PID whose issuer certificate chains to that CA, and only from the holder. A hand-built credential fails both tests. The intended path is to obtain a genuine PID with issuing_country set to NO, from FortID’s own issuer, and present it.

Getting a Norway PID

FortID runs public issuer and wallet services for the CTF, usable through a documented playground credential. I drove the holder role through those services; the handout contains no solver for this part, so the steps below are the ones I ran against them. API keys, the service hostnames and the orchestration script are left out on purpose.

  1. Create an issuer and assign it the PID SD-JWT configuration (eu.europa.ec.eudi.pid_vc_sd_jwt). The issuer metadata confirms it emits vct: urn:eudi:pid:1, the exact value the app’s query filters on.
  2. Initiate issuance with the claims I wanted, Norway throughout:
{
  "credentialData": {
    "credentialConfigurationId": "eu.europa.ec.eudi.pid_vc_sd_jwt",
    "claims": {
      "family_name": "Nordmann", "given_name": "Ola", "birthdate": "1990-01-01",
      "place_of_birth": "NO", "nationalities": ["NO"],
      "issuing_country": "NO", "issuing_authority": "NO",
      "date_of_issuance": "2025-01-01T00:00:00Z",
      "date_of_expiry": "2030-01-01T00:00:00Z"
    }
  },
  "protocolConfig": { "grant_type": "PreAuthorizedCodeWithoutTxCode" }
}
  1. The response carried a credentialOfferUri. I created a hosted wallet and loaded the credential through that offer. Because it is a pre-authorized-code flow, consent is a single empty call, after which the wallet listed ('urn:eudi:pid:1', 'NO').
  2. On the challenge site, pressing the button renders the authorization request as a QR code. I handed the decoded request to the wallet’s verify endpoint, selected the matching Norway credential, and presented it.
  3. The wallet reported CONSENT_REQUEST_READY, the present call returned 200, and the challenge’s status endpoint moved to VERIFICATION_SUCCEEDED with a redirect to the flag page.

The one fiddly part was the QR. The app renders it with qrcode’s SvgPathImage, so the code is an SVG <path> rather than a raster image. I rebuilt the module matrix from the path’s M x,y coordinates (one move per dark module), snapped them to a grid by the smallest step between coordinates, painted that to a bitmap and decoded it. Once the request URL was in hand, the rest was ordinary wallet calls.

The result page showed “Verified issuing country: NO” and the flag as an image. The handout’s private/flag.svg is only a placeholder (it renders FortID{this_is_a_fake_flag} with the caption “Local handout placeholder”), so the real flag exists only on the live instance and could not be checked against anything in the handout. The flag string recorded here comes from that live run.

Defence

Quote any code that can collide with YAML’s implicit types ("NO"), or load config with a YAML 1.2 parser. Type-check list contents after loading, for example assert that every blocked_countries entry is a two-letter string, so a mis-parsed boolean fails loudly at startup instead of silently dropping a country. For access control, express the intent as an allow list of the countries you mean to admit rather than a deny list of all the others, so a single wrong entry fails closed. The credential cryptography here did its job: the PID had to be a real, issuer-signed, holder-bound Norway credential. The hole was entirely in the relying party’s own filtering, which is the usual reminder that strong credential crypto does not rescue weak application-layer authorization.

The flagRevealFortID{Erl1ng_15_7h3_5olut1on_7o_Th3_Norw4y_Pr0bl3m}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.