Insecure Dynamic Code Evaluation and Execution in Python
Python provides the ability to dynamically evaluate and execute expressions at runtime using the built-in eval() function.
This function takes a string containing a valid Python expression and evaluates it as if it were a piece of code written directly in the program.
eval() function executes the input string within the current Python environment, meaning it has access to all available variables, functions, and system resources.
Passing user-controlled data to the eval() function is a critical security risk that can lead to severe consequences.
Since eval() interprets and executes the input string as Python code, any malicious input can be used to execute arbitrary commands, access sensitive data, or manipulate the behavior of the application.
For example
>>> string = "__import__('os').system('id')"
>>> eval(string)
uid=1001(user) gid=1001(user) groups=1001(user),27(sudo),100(users)
0
>>>
In the previous example, the payload __import__('os').system('id') calls the built-in __import__() function to dynamically import the os module, then invokes the system() method from the imported module, passing the string 'id' as the command to be executed on the operating system.
The eval() function parses and executes this code, leading to the execution of the id system command.
Insecure Dynamic Code Execution Using exec()
In addition to eval(), Python provides the built-in exec() function, which offers even broader dynamic code execution capabilities.
Unlike eval(), which only evaluates single expressions, exec() can execute entire blocks of Python statements, including control flow, function definitions, loops, class definitions, and more.
For example:
>>> code = '''
... def hello():
... print("Hello from exec()")
... hello()
... '''
>>> exec(code)
Hello from exec()
>>>
And similar to what we did with eval() we will execute the same payload:
>>> code = "import os; os.system('id')"
>>> exec(code)
uid=1001(user) gid=1001(user) groups=1001(user),27(sudo),100(users)
>>>
Controlling the Namespace with globals, locals, and __builtins__
Both eval() and exec() accept optional second and third arguments that control the namespaces the code runs in: a globals dictionary and a locals dictionary. When they are omitted, the code runs in the caller’s current namespace with full access to everything in scope. When a globals dictionary is supplied, Python uses it as the execution environment, and if that dictionary does not already contain a __builtins__ key, Python automatically injects a reference to the full builtins module:
>>> ns = {}
>>> eval("1 + 1", ns)
2
>>> "__builtins__" in ns
True
This automatic injection is why eval() and exec() can reach open, __import__, print, and every other builtin even when you hand them an empty dictionary. The __builtins__ entry is the bridge between the evaluated code and the rest of the interpreter.
A common attempt at sandboxing is to override that entry, denying the evaluated code access to builtins:
>>> eval("print('hi')", {"__builtins__": {}})
Traceback (most recent call last):
...
NameError: name 'print' is not defined
With __builtins__ set to an empty dictionary, names like print and __import__ no longer resolve, and the obvious payloads stop working.
Why Stripping __builtins__ Is Not a Sandbox
That defense is shallow. Stripping builtins removes the direct references, but the evaluated code can still reach any object the language exposes through the object model, and from there recover a live __builtins__ table. A single literal is enough to climb to object, enumerate its subclasses, and pull a real __import__ back out of some loaded module’s globals:
payload = (
"[c for c in ().__class__.__base__.__subclasses__() "
"if c.__name__ == 'catch_warnings'][0]"
".__init__.__globals__['__builtins__']['__import__']"
"('os').system('id')"
)
exec(payload, {"__builtins__": {}})
# uid=1001(user) gid=1001(user) groups=1001(user) ...
Even with __builtins__ emptied, the payload runs id. It never references a builtin by name; it walks the class graph to a function, reads that function’s module globals, and finds the builtins table the rest of the interpreter relies on. The mechanics of this climb are covered in Python Object Model and MRO, and the full set of jail-escape variations in Escaping Python exec and eval Sandboxes.
One quirk worth knowing while testing: in the __main__ module __builtins__ is the builtins module, while in imported modules it is a dictionary. Code that pokes at __builtins__ directly has to account for both shapes, which is why payloads usually recover it through a function’s __globals__ rather than referencing __builtins__ by name.
The practical takeaway is that eval() and exec() cannot be made safe by filtering names or emptying namespaces. The only reliable control is to never pass untrusted input to them. For parsing data structures, use ast.literal_eval or a real data format like json instead.
Why dynamic code execution matters from an offensive security perspective
A reachable eval or exec is the highest-value finding I can get short of an outright RCE chain, because it is the RCE chain. The application has volunteered to compile and run whatever string I hand it, inside its own interpreter, with the privileges of its own process. Even when the developer has wrapped the call in a stripped namespace, the object-graph climb above recovers __builtins__ and turns “expression evaluator” back into “arbitrary code”. I treat any path from input to one of these sinks as game over and spend my time proving reach, not impact.
These sinks appear wherever a feature wants to be configurable at runtime: formula and rules engines, plugin and expression fields, “advanced filter” boxes, template and report builders, math/calculator endpoints, and the lazy eval(request.args['x']) that should have been int() or json.loads. The tell that excites me most is a homemade sandbox around the call, because a stripped __builtins__ or a name blocklist tells me the developer knew it was dangerous and tried to contain it in the one layer that cannot contain it.
On an assessment these are what I grep for:
eval(,exec(, andcompile(with a non-literal argument. Any of them fed a variable, f-string, or request field is a direct execution sink.- A globals dict passed in to “limit” the call.
eval(x, {"__builtins__": {}})and friends signal a sandbox attempt that the__subclasses__()chain walks straight through. - Input-text blocklists. Code that rejects strings containing
import,os, or__is filtering spelling, not behavior, and falls togetattr,chr(), and split literals. literal_evalon data that can contain calls, or used as a stand-in for real parsing. It is safe for pure literals only, and its presence flags code worth reading for the boundary.- Indirect reach. A value that lands in a template, a serialized object, or a config string that later gets evaluated counts the same as a direct call.
For defenders the takeaway is that no amount of namespace surgery makes eval/exec safe; the sink itself is the vulnerability, and the only audit-pass answer is that untrusted data never reaches it. The escape mechanics live in Escaping Python exec and eval Sandboxes and Walking the Python Object Graph with subclasses().
Mitigation
The fix is to remove the dynamic-execution sink rather than try to sanitize what reaches it, because eval, exec, and compile on untrusted input cannot be made safe by filtering when the object graph offers so many equivalent paths back to __builtins__. Replace them with a purpose-built parser for the input you actually expect, use ast.literal_eval when you only need literal data structures, and map user choices onto a fixed dispatch table of known-safe callables instead of evaluating their text. If user-supplied code genuinely must run, it belongs in an isolated, resource-limited sandbox process and never in the application’s own interpreter.