DEV Community

CopperSunDev
CopperSunDev

Posted on • Originally published at coppersun.dev

The Unsafe-Deserialization Trio: yaml.load, pickle, eval

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)