Safeguard
AppSec

Code Injection in Python: How It Happens and How to Prevent It

Code injection python vulnerabilities almost always trace back to eval, exec, or a template engine handed untrusted input; here is how the attack works and how to close it off.

Safeguard Research Team
Research
6 min read

Code injection python vulnerabilities happen when untrusted input reaches a function that can execute arbitrary code, most commonly eval(), exec(), or a template engine's rendering call, letting an attacker run their own logic inside your process instead of just supplying data to it. This is a distinct and more severe category than most injection bugs, because the attacker is not manipulating a query or a command string, they are getting the interpreter itself to execute code they control, with the same privileges as your application.

This post walks through how python code injection actually happens in real applications, the functions most commonly responsible, and the concrete patterns that prevent it.

How does injection code actually get into a Python application?

The mechanism is almost always the same shape: user-controlled input flows, directly or after some transformation, into a function that evaluates strings as code. eval() evaluates a string as a Python expression and returns its value. exec() evaluates a string as a full block of Python statements. Both will happily run anything syntactically valid, including calls to os.system, subprocess, or __import__, regardless of where the string came from.

A classic, if now well-known, example is a calculator-style feature that takes a user-submitted math expression and runs it through eval() to compute the result. That looks harmless for 2 + 2, but nothing stops a user from submitting __import__('os').system('id') instead of an arithmetic expression, and eval() will execute it exactly the same way. The developer's mental model was "users will type math," but the interpreter has no such assumption built in.

Template engines create a related but distinct risk, sometimes called server-side template injection. If user input is concatenated into a template string before rendering, rather than passed in as template data, an attacker can inject template syntax that the engine then executes, which in some engines allows reaching arbitrary code execution through the engine's own expression language.

Where do real applications introduce this without realizing it?

Beyond the obvious calculator or rule-engine example, injection risk shows up in a few recurring patterns. Configuration or plugin systems that let administrators or users supply "formula" or "expression" fields, intended for filtering or computed values, frequently get evaluated with eval() for convenience during initial development and never get revisited. Deserialization is a related and arguably more dangerous cousin: Python's pickle module will execute arbitrary code embedded in a crafted pickle stream during deserialization, which means calling pickle.loads() on untrusted data is functionally equivalent to an injection vulnerability even though no eval() call is visible in the code.

Debug or admin tooling is another common source. It is not unusual to find an internal admin panel with a "run a snippet" or "test this filter" text box that quietly wraps exec() around whatever is typed in, built for developer convenience and never hardened because it was assumed only trusted staff would ever reach it. Those assumptions break the moment access control has any gap at all, or the moment that internal tool gets exposed further than intended.

What does exploitation actually look like?

Once an attacker can get arbitrary code executed inside your Python process, the practical impact usually escalates quickly to full remote code execution, since Python has direct access to the operating system through modules like os and subprocess. From there, an attacker can read environment variables and secrets, access the filesystem, pivot to other systems reachable from the compromised host, or establish persistence. This is why code injection is treated as a critical-severity finding in essentially every static analysis tool, on par with other paths to remote code execution, rather than as a lower-tier input-validation issue.

How do you actually prevent it?

The most reliable fix is to never call eval() or exec() on any string that contains or was derived from user input, full stop. For the calculator-style use case, use a proper expression-parsing library that evaluates a restricted grammar, like ast.literal_eval() for simple literal structures, or a dedicated math-expression library that supports only arithmetic operations and no arbitrary code execution, rather than the general-purpose Python interpreter.

For deserialization, avoid pickle for any data that crosses a trust boundary, and prefer a format like JSON that has no code-execution semantics by design. If you must accept serialized objects from external sources, use a format with a strict, non-executable schema and validate it before use.

For template rendering, always pass user-supplied values as template variables through the engine's own data-binding mechanism, never by string-concatenating them into the template source itself before rendering. Most modern template engines make the safe pattern the default and the unsafe pattern something you have to go out of your way to construct, so a code review focused on any place a template string is built dynamically is worth the time.

Static analysis tooling should be configured to flag every call to eval(), exec(), pickle.loads(), and similar dangerous sinks, and to trace whether any path to those calls originates from user-controlled input. A SAST scan that does full data-flow analysis across your codebase, rather than a simple keyword search for eval, will also catch the cases where the dangerous call is several functions removed from the actual point where user input entered the system, which is exactly where manual code review tends to miss things.

FAQ

Is eval() always dangerous in Python?

Not inherently, but it is dangerous the moment its input can be influenced, even indirectly, by anything outside your own trusted codebase. If you control every string ever passed to eval() at development time, the risk is low. The moment user input reaches it, treat it as a critical vulnerability.

Is exec() worse than eval()?

exec() can run full statements including imports, loops, and function definitions, giving an attacker more surface than the single-expression scope of eval(). Both should be treated as equally dangerous when fed untrusted input, since both provide a path to arbitrary code execution.

Does using ast.literal_eval() fully solve the problem?

ast.literal_eval() only evaluates Python literal structures, like numbers, strings, lists, and dicts, and refuses to execute function calls or arbitrary expressions, which makes it a safe replacement for many eval() use cases involving simple data, but it is not a general-purpose expression evaluator for things like arithmetic with variables.

Can a linter catch this automatically?

A basic linter can flag the presence of eval() or exec() calls, but confirming whether they are actually exploitable requires tracing whether user-controlled input reaches them, which needs proper data-flow analysis rather than a simple pattern match.

Never miss an update

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