Safeguard
Application Security

Python code injection: eval, exec, and pickle explained

eval(), exec(), and pickle.load() can each hand an attacker a Python interpreter — CVE-2020-1747 shows how one unsafe deserialization call became a real RCE.

Safeguard Research Team
Research
5 min read

Python hands attackers more rope than most languages, because three of its most ordinary-looking built-ins — eval(), exec(), and pickle.load() — can each be coaxed into running arbitrary code supplied by an attacker. OWASP defines code injection as "the general term for attack types which consist of injecting code that is then interpreted/executed by the application," and draws a sharp line against command injection: with code injection "an attacker is only limited by the functionality of the injected language itself," while command injection just leverages existing code to run shell commands. Python makes that distinction concrete: CVE-2020-1747 shows a single unsafe deserialization call, yaml.load() with PyYAML's FullLoader, letting an attacker abuse the python/object/new constructor to execute code on load — a flaw first mitigated in PyYAML 5.1 (2019) and finally patched in 5.3.1. Meanwhile the Python documentation itself carries a standing warning that "it is possible to construct malicious pickle data which will execute arbitrary code during unpickling," and Trail of Bits demonstrated exactly that against machine learning model files in a March 2021 post that introduced their fickling static analyzer. This post walks through why eval, exec, and pickle are dangerous, what OWASP's root-cause framing tells you about fixing them, and which built-in alternatives close each hole without breaking your code.

What makes eval() and exec() dangerous in Python?

eval() compiles and runs a string as a Python expression, and exec() does the same for full statements — including imports, assignments, and function definitions. Neither restricts what the resulting code can do by default: an eval() call fed attacker-controlled text can do anything an inline Python expression can, including __import__('os').system(...). This is OWASP's code-injection concept applied directly, since the vulnerable page's canonical example — unsanitized string interpolation into a PHP eval() reachable via ?arg=1; system('id') — maps almost line-for-line onto a Python Flask view that runs eval(request.args.get('formula')) to "support user math expressions." The globals/locals dictionary arguments to eval() narrow the namespace the expression sees, but they do not sandbox it — attackers have long chained subclass introspection (walking object.__subclasses__()) to reach dangerous classes even inside a restricted namespace, which is why the Python docs never present eval() restriction as a security boundary.

Why is pickle deserialization a code execution vector?

Pickle is not a data format like JSON — it is a serialized stream of opcodes for a stack-based virtual machine, and one of those opcodes, REDUCE, calls an arbitrary callable with attacker-supplied arguments during unpickling. That means a crafted pickle can invoke os.system or subprocess.Popen the moment pickle.load() reads it, with no separate "execute" step. CWE-502 (Deserialization of Untrusted Data) is the formal classification, and it isn't hypothetical in Python's ecosystem: Trail of Bits' Evan Sultanik documented in "Never a dill moment" (March 2021) that machine learning frameworks routinely ship model weights as pickle files, and platforms hosting them had no built-in way to detect a malicious payload — prompting Trail of Bits to release fickling, which symbolically executes pickle opcodes instead of running them, specifically so a scanner can inspect a suspicious file without triggering it.

How does OWASP's framing of code injection apply here?

OWASP attributes code injection to poor handling of untrusted data — specifically a lack of proper input validation around permitted characters, expected format, and expected volume — and its risk-factors section states that a successful exploit "could cover loss of confidentiality, loss of integrity, loss of availability, and/or loss of accountability," which is a fair description of what an eval()- or pickle-based RCE gets an attacker. OWASP also lists CWE-77 and CWE-78 (command injection) alongside CWE-89 (SQL injection) as related weaknesses, but is explicit that code injection is broader: command injection borrows existing OS commands within a restricted shell context, while code injection hands the attacker the full expressiveness of the host language. That distinction is exactly why a Python-specific mitigation list has to go beyond "don't shell out with untrusted input" — eval(), exec(), and pickle.load() never touch a shell at all, yet each satisfies OWASP's definition of code injection precisely because they interpret untrusted data as code.

What are safe alternatives to eval, exec, and pickle?

For parsing literal Python data structures — tuples, lists, dicts, numbers, strings, booleans — ast.literal_eval() does the job eval() is usually misused for, because its parser rejects anything that isn't a literal, so __import__('os') simply fails to parse rather than running. For structured data exchanged between systems, json.loads() is safer still, since JSON has no executable grammar at all. For exec()-style plugin or rules-engine use cases, the safer pattern is an explicit allow-listed dispatch table (a dict mapping known operation names to known functions) rather than compiling arbitrary text. For serialization, pickle should never touch data from outside your own process boundary; prefer json, msgpack, or Protocol Buffers for anything crossing a trust boundary, and if you must accept YAML, use yaml.safe_load() — never yaml.load() or FullLoader — since safe_load() has been the documented, unsafe-constructor-free path since PyYAML 5.1.

How Safeguard helps

Catching these sinks by grep is noisy — every eval() call isn't exploitable, only the ones that trace back to untrusted input. Safeguard's SAST engine, currently rolling out with Python as a phase-1 supported language alongside JavaScript/TypeScript and Java, traces untrusted input from a source (a request parameter, a CLI argument, a file read) to a dangerous sink across functions and files, and returns each finding with the source-to-sink dataflow trace plus a CWE/OWASP mapping rather than a bare line number — so a pickle.load() call fed by an upload endpoint surfaces differently than one reading a file your own build pipeline wrote. That reachability context is what turns "we have 40 eval() calls in this repo" into "these three are worth a sprint."

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.