the_open_door
coffee
ESEN
← Back to index

Python eval() Injection

Full command arsenal in the cheatsheet

eval() in Python takes a string and evaluates it as a Python expression, returning its result. It's handy for "computing" something dynamic, but if the string contains user-controlled data, eval() runs whatever the user wants: it's arbitrary code execution.

The design mistake. Programmers often think eval("109+50") only does arithmetic. But the string can be any valid expression, including one that imports modules and launches system commands:

eval("__import__('os').system('id')")

__import__('os') loads the os module within the expression itself (no prior import needed), and .system(...) runs a shell command. There's no real limit: whatever is evaluated runs with the process's privileges.

Sandbox bypass. If they filter import, os or eval as words, you reach the same modules through the class hierarchy or alternative builtins:

# classic chain: climb to object and walk subclasses until a Popen/os
().__class__.__bases__[0].__subclasses__()
# execute without writing "import" (via builtins)
[].__class__.__mro__[1].__subclasses__()[<idx>]('id',shell=True,stdout=-1).communicate()
().__class__.__base__.__subclasses__()[<idx>].__init__.__globals__['system']('id')
# obfuscated builtins
getattr(__builtins__,'__imp'+'ort__')('os').system('id')

Why it's privilege escalation. The dangerous pattern is a script running as root (via sudo, a cron, a setuid) that at some point passes user input through eval(). In BountyHunter, a sudo-runnable ticket validator evaluated the "ticket code" line with eval(). All it took was a ticket that passed the format checks and sneaked in the payload:

**109+__import__('os').system('chmod u+s /bin/bash')**

The 109 cleared the script's arithmetic filter (% 7 == 4), and the rest of the expression SUID-ed /bin/bash. Afterwards, bash -p keeps root's EUID and gives a superuser shell. It's a cousin of SUID abuse: a legitimate function (validation) turned into execution.

Not just escalation: also a foothold. The same pattern shows up in web apps. If an endpoint evaluates a user parameter (say, a REST API that "validates" a code with eval()), the injection yields direct RCE as the service user, no credentials needed. In Hetemit, a Flask API's /verify route evaluated the code parameter: 7*7 returned 49 (while {{7*7}} failed, ruling out a Jinja template) and os.system('...') ran commands. That difference is the key tell: it confirms a plain eval() rather than a template injection, which you'd exploit with a different payload.

Equally dangerous functions. exec() (runs statements, not just expressions), eval/exec in templates, pickle.loads on untrusted data, yaml.load without SafeLoader, and os.system/subprocess with shell=True and input interpolation.

How to detect it. In a web app, the access-log signature is parameters with telltale Python expressions: __import__, os.system, subprocess, ().__class__, or the 7*7/{{7*7}} probe; a WAF inspecting those patterns flags it. Confirmation is the Python process (Flask/gunicorn, or the script under sudo) spawning sh/bash/id as a child (auditd/Sysmon for Linux over execve with the interpreter as ppid), something evaluating an arithmetic expression never triggers. In the escalation case, a sudo to the validator script in /var/log/auth.log followed by a SUID bash closes the chain.

Defence. Never pass external data to eval()/exec(). To evaluate literals or bounded arithmetic, use ast.literal_eval (literals only) or an explicit arithmetic parser that rejects names and calls. And in sudo, don't grant NOPASSWD over scripts that process user input.

Machines where it's used