Why is Python's eval() a code-execution risk on a string that came from a user?
answer
- It runs the text, not just reads it
- One expression is still the whole language
- Builtins reach modules without an import statement
- Parsing builds a tree; evaluating runs it
basics
~20 seval() compiles and runs its text as a Python expression, so one expression can import a module and touch files, processes or sockets with the privileges of your process. Parse untrusted text; never evaluate it.
solid answer
~50 s`eval(text)` compiles the text in expression mode and executes it, returning the value — it is the interpreter, not a calculator. "A single expression" still gives an attacker calls, attribute access, subscripting, comprehensions and the walrus operator, and because `__import__` is an ordinary builtin, `__import__("os")` reaches any module from inside one expression; that code runs with your process's permissions and secrets. Word blocklists lose, since an expression has unbounded spellings, and `try`/`except` catches errors rather than effects. The rule is to keep parsing separate from executing: `ast.parse()` and `compile()` build a tree or a code object without running anything, while only `eval()` or `exec()` runs it. If you need a value, accept a data format and parse it; if you must accept Python-shaped text, walk the parsed tree and refuse every node kind you did not choose to support.
code
pycon · 4 lines>>> eval("2 + 2")
4
>>> eval("__import__('os').getpid() > 0")
Truego deeper
Be ready to say plainly that eval() runs its text as Python and hands back the value, so user-supplied text runs as code with your program's privileges. Know that the answer to "how do I make eval() safe on this input" is to stop evaluating that input.
Explain the mechanics: eval compiles in expression mode, executes in the caller's namespace when you pass no mappings, and reaches any module through builtins such as import. Show the parse-versus-execute boundary — ast.parse and compile run nothing; eval and exec do.
Demonstrate production judgement. Find where evaluated text has crept in — config, templates, repr round-trips — classify it as remote code execution rather than a lint nit, and replace it with a parsed data format or a vetted tree walk, with a regression test that asserts hostile text is refused.
Own it as policy. Evaluating external text is a design decision with a blast radius, not a review comment, so decide whether the product needs customer-supplied expressions at all; if it does, fund an owned mini-language plus process or container isolation rather than a filtered eval() someone will loosen later.
## What `eval()` actually does `eval(source)` accepts a `str`, a `bytes` object or an already-compiled code object. When it is text, CPython compiles it in expression mode and then *executes* the resulting code object, returning whatever value the expression produced. That word — **executes** — is the whole answer. `eval()` is not a parser, a calculator or a type converter; it is the same interpreter that is running the rest of your program, aimed at a string that came from somewhere else. With no mapping arguments it even runs against the caller's own namespace, so your module's names are in scope for it. ## "It is only an expression" bounds nothing People reason that `eval()` refuses statements — `eval("x = 1")` really does raise `SyntaxError` — and conclude the damage is capped at arithmetic. It is not. A Python expression may contain: - function calls, attribute access and subscripting; - conditional expressions; - comprehensions with their own loops; - and the walrus operator, which binds a name. Every builtin is in scope, and `__import__` is an ordinary builtin function: `__import__("os")` inside an expression reaches any importable module, and through it the entire standard library — reading and writing files, spawning subprocesses, opening sockets, reading environment variables. One line of user-supplied text is therefore **arbitrary code execution** running as your process, with your file permissions, your secrets in the environment and your position inside the network. ## The trust boundary, not the function, is the subject `eval()` is legitimate where the text is yours: a debugger, a REPL, a tool that generates code from source you shipped. The bug is the source of the string, and that source moves. Text you call "internal" arrives: - from config files that a deploy pipeline templates, an admin screen writes, or a mounted volume supplies; - from a "just evaluate the `repr()`" round-trip somebody added to a cache; - from a templating layer whose expressions compile to Python; - from a product feature such as an invoice-PDF renderer that lets a customer configure how a total is computed. "We control that file" is a statement about today's deployment, not an invariant of the system. ## Why the usual patches fail - **Blocklisting** words like `import` or `os` before evaluating loses immediately, because an expression has unbounded spellings — string concatenation, `chr()` arithmetic, escapes, attribute chains — so a filter is a puzzle for the attacker rather than a boundary. - Wrapping the call in `try`/`except` catches exceptions, not effects: the file was already deleted before anything was raised. - A **timeout** bounds one flavour of harm and does nothing about exfiltration. - Shrinking the mapping you hand `eval()` narrows the *convenient* names but is not a security boundary either. None of these convert code into data, which is the only change that helps. ## The safe door: keep parsing separate from executing `ast.parse(text)` builds an **abstract syntax tree** from the text and executes none of it. `compile(text, "<user>", "exec")` likewise produces a code object and runs nothing; only `eval()`, `exec()` or calling that object executes. That boundary is a real tool, not just a distinction. If you truly must accept Python-shaped text: 1. parse it, 2. walk the resulting tree, 3. reject every node kind outside a small set you deliberately chose to support, 4. and then evaluate that vetted structure with an interpreter you wrote — so the user supplies data your code interprets, never code the interpreter runs. If what you actually wanted was a value, accept a data format and use its parser; the Python interpreter was never the right reader for configuration. ## What parsing does not buy - Parsing is not a sandbox: parse a hostile tree and then `exec` it and you are exactly where you started. - Parsing is not free either — an enormous input costs memory and CPU whether or not you run it, and CPython's parser rejects input nested past its limit with a `SyntaxError`, which is still work done before the refusal. - A tree-walking evaluator you write is only as tight as its accept list, and a hand-written one that forgets to bound recursion has an availability bug. Those are resource-exhaustion and correctness problems, though — a smaller and much better-behaved class than remote code execution, and that downgrade is the whole point of the exercise. ## How to answer it in an interview 1. Lead with the mechanism: `eval()` compiles and runs the text as an expression, expressions reach builtins, so evaluating text you did not write is remote code execution. 2. Then say the fix is a **change of category** rather than a hardening step — accept data, not code. 3. Then, if pressed, show the parse-versus-execute boundary and the vetted tree walk. What loses the point is arguing about which filter would finally be long enough.
- A product feature must let a customer supply a formula for an invoice total. How would you build that without eval()?Define a tiny expression language you own. Parse the submitted text with `ast.parse()` in expression mode, walk the tree, and reject any node kind outside the small set you support — numbers, the named fields you expose, arithmetic operators and comparisons — then evaluate that vetted tree with your own walker. Better still, drop free text and accept a structured formula, an operator plus operands, from the client. Either way the customer supplies data your code interprets, never code the interpreter runs.
- Where does evaluated text creep into a codebase without anyone deciding to run user code?Through indirection. A cache or message format that stores a `repr()` and evaluates it on the way back; a templating layer whose expressions compile to Python; a rules or filter field in a configuration file that the deploy pipeline templates or an admin screen writes; a plugin loader that evaluates a settings string. In each case nobody wrote `eval(request.body)` — the text simply travelled far enough from its author that the trust claim stopped being true.
- If the string only ever comes from a config file your own team writes, is eval() acceptable?Treat it as untrusted anyway. Config files are written by pipelines, admin tooling, mounted volumes and support staff, so "we control it" describes today's deployment rather than a property of the system, and any write path into that file becomes a code-execution path into the service. It also makes the config part of your executable surface, invisible to type checkers and linters. Prefer a data format with a real parser and compute derived values in code from named settings.
Calling eval() on user text is not offering a calculator button; it is handing the user a shell prompt that already holds your process's file access, environment secrets and network position.
saying these in an interview costs you the question
- Says eval() only handles literals and arithmetic
- Claims a blocklist of words like import or os makes eval() safe
- Thinks eval() is harmless because expressions cannot contain statements
- Believes wrapping eval() in try/except neutralises the risk
- Assumes text from a config file or an internal caller is trusted
- Confuses parsing or compiling the text with executing it