The Unsafe-Deserialization Trio: yaml.load, pickle, eval
AI assistants reach for the simpler API. yaml.load executes arbitrary Python on unsafe input. BrassCoders found it in config_loader.py, an AI-generated file from a realistic prompt.
AI assistants reach for the simpler API. yaml.load, pickle.loads, and eval are simpler than their safer counterparts, they work on safe inputs, and they execute arbitrary code on attacker-controlled ones. BrassCoders found yaml.load in config_loader.py, an AI-generated file from a realistic prompt, in its published benchmark corpus. The same prompt completion dynamic produces pickle and eval in AI-generated code.
The pattern in all three is the same: the simpler API satisfies the prompt. The dangerous behavior surfaces on the input the prompt didn’t describe.
yaml.load in config_loader.py
BrassCoders flagged config_loader.py with Bandit’s B506 rule (unsafe yaml.load) because the file calls yaml.load(f, Loader=yaml.Loader) where yaml.safe_load is the correct choice for configuration files that may receive external input.
The file was generated from the prompt “Write a config loader that reads settings from a YAML file and lets environment variables override them.” The model’s implementation:
def load_config(path):
with open(path) as f:
config = yaml.load(f, Loader=yaml.Loader)
for key in config:
env_key = key.upper()
if env_key in os.environ:
config[key] = os.environ[env_key]
return config
yaml.Loader is PyYAML’s full Loader. It supports Python-specific YAML tags like !!python/object/apply:subprocess.run that execute code during deserialization. A YAML file containing:
exploit: !!python/object/apply:subprocess.run
- ["rm", "-rf", "/tmp/important"]
executes subprocess.run(["rm", "-rf", "/tmp/important"]) when parsed by yaml.load(f, Loader=yaml.Loader). The config file is the attack surface. An attacker who controls the config file — or any input that gets written to it — runs arbitrary Python.
yaml.safe_load removes this attack surface by parsing only basic YAML types (strings, numbers, lists, dicts, booleans, null). The replacement is one word:
config = yaml.safe_load(f)
pickle.loads: The Same Pattern, Higher Risk
pickle.loads deserializes arbitrary Python objects from a byte stream; an attacker who controls the bytes controls what Python runs. The Python standard library documentation states: “The pickle module is not secure. Only unpickle data you trust.”
AI assistants reach for pickle when a prompt asks for fast serialization of Python objects — model state, cached results, session data. The prompt doesn’t describe attacker-controlled input, so the model doesn’t guard against it. pickle.loads(data) where data comes from user input or a network socket is a code execution vulnerability.
BrassCoders’ 12-file planted benchmark includes a pickle.loads finding (Bandit B301). The rule fires on any pickle.loads, pickle.load, or cPickle call. The remediation for user-supplied data is a different serialization format entirely — JSON for structured data, or a message format that can’t encode executable objects.
eval: Arbitrary Code from a String
eval executes a Python expression from a string; any string containing Python syntax becomes code when passed to eval. Bandit’s B307 rule flags every eval() call as potentially unsafe.
AI assistants reach for eval when a prompt asks for dynamic expression evaluation — a configuration file that supports computed values, a calculator, a template engine. eval(user_input) where user_input comes from an HTTP request, a form field, or an environment variable is a code execution vulnerability. The user passes "__import__('os').system('id')" and the application executes it.
The safer alternatives depend on the use case: ast.literal_eval for expressions that should only produce literal Python values (strings, numbers, lists, dicts); a dedicated parser for domain-specific syntax; or a query language for structured data access. None of them execute arbitrary Python.
BrassCoders flags eval at the structural level. The AI triage layer determines whether the specific call’s input is user-controlled — reading the function signature and the call site to decide whether the finding is real.
Why the Prompt Completion Pattern Produces These Three
All three APIs share the same property: they’re the simpler, more expressive option that satisfies the prompt on safe input. yaml.load over yaml.safe_load. pickle.loads over json.loads. eval over ast.literal_eval. The simpler API works in the test case. The dangerous behavior exists in the production case the prompt didn’t describe.
This is why BrassCoders flags them structurally rather than inferring context. A rule that demoted the yaml.load finding when the file was named config_loader.py would miss the case where the configuration loader is exposed to external input — which is exactly what the prompt describes. BrassCoders reports the pattern; Claude Code reads the call site and decides whether the input is trusted.
Reproduce the config_loader.py Finding
git clone https://github.com/CopperSunDev/brasscoders
pip install brasscoders
brasscoders --offline scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files
The B506 finding for config_loader.py appears in .brass/ai_instructions.yaml with severity high, file path, line number, and the yaml.safe_load remediation. Same output on every run.
Frequently Asked Questions
Is yaml.load always unsafe?
yaml.load without a safe Loader can execute arbitrary Python when parsing untrusted YAML input. yaml.safe_load restricts the loader to simple Python types and does not execute code. For configuration files you control entirely, yaml.load is a risk rather than an active vulnerability; for files that include any user-supplied content, it is a code execution vulnerability.
Why do AI assistants use yaml.load instead of yaml.safe_load?
Both functions appear in the PyYAML documentation. yaml.load is the more commonly shown example in older tutorials and Stack Overflow answers. The model reaches for it because it satisfies the prompt and is syntactically equivalent for safe inputs. The security difference appears only on attacker-controlled input, which the prompt doesn't describe.
Does BrassCoders catch eval on user input?
BrassCoders catches eval in code (Bandit B307) regardless of input source. The rule is structural: it matches any eval() call. Whether the input is user-controlled is a context inference the AI triage layer makes from the surrounding code — BrassCoders flags it; Claude Code decides whether the specific call is safe.
Can I reproduce the yaml.load finding from the published corpus?
Yes. Clone the BrassCoders OSS repo, install brasscoders, and run brasscoders --offline scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files. The B506 finding for config_loader.py appears in the output.