Twig Template RCE via Dynamic Macro Syntax
CVE-2026-46640: A code injection flaw in Twig 3.15–3.25 allows attackers to inject raw PHP via dynamic macro attribute syntax, enabling RCE.
Overview
CVE-2026-46640 is a code injection vulnerability in the Twig PHP template engine affecting versions 3.15.0 through 3.25.x. The flaw resides in the way Twig’s compiler processes dynamic attribute access on the _self special variable and on imported macro aliases. When either _self.(<expression>) or an equivalent import-alias dynamic attribute call is evaluated with an attacker-controlled string, the Twig compiler concatenates that string directly into a MacroReferenceExpression name during code generation — without any identifier sanitization. The result is that arbitrary PHP is emitted verbatim into the compiled template cache file and subsequently executed by the PHP runtime when the template is loaded.
The vulnerability was identified during a security audit of Twig’s compilation pipeline and disclosed responsibly to the maintainers, who addressed it in version 3.26.0. Any application that exposes template authoring — or any portion of a template’s dynamic content — to untrusted users is in scope. This includes multi-tenant SaaS platforms, CMS systems built on Symfony or Craft CMS, e-commerce storefronts, and any PHP application that passes user-supplied data into a Twig template string that is then compiled and cached.
With a CVSS score of 8.8 (High), the severity reflects the near-direct path from controlled input to arbitrary PHP execution. The attack does not require authentication in the common case where template rendering accepts user content, and no memory-corruption primitives are involved — this is a straightforward logic flaw in a compiler.
Technical Analysis
Twig compiles template source into PHP class files that are cached on disk or in memory. The compilation step walks an abstract syntax tree (AST) and emits PHP code for each node. The MacroReferenceExpression node is responsible for generating the PHP call that invokes a named macro, and it does so by embedding the macro name as a literal string fragment inside the emitted PHP source.
Prior to 3.26.0, when Twig encounters the dynamic attribute syntax _self.(<expr>) or a macro alias called with a parenthesized expression, the compiler resolves the attribute name by evaluating <expr> at compile time and splicing it into the generated PHP without validation:
// Simplified representation of the vulnerable code generation path
// in Twig\Node\Expression\MacroReferenceExpression (pre-3.26.0)
protected function compileCallable(Compiler $compiler): void
{
// $this->getAttribute('name') returns an attacker-influenced string
// that was never validated as a legal PHP identifier.
$macroName = $this->getAttribute('name');
$compiler
->raw('$this->env->getRuntime(\'Twig\\Runtime\\MacroRuntime\')->callMacro(')
->string($this->getAttribute('template'))
->raw(', ')
// VULNERABILITY: $macroName is interpolated without identifier validation.
// An attacker can supply: "foo"); system('id'); //
// which breaks out of the callMacro() call entirely.
->raw('"macro_' . $macroName . '"')
->raw(', [...])')
;
}
Because ->raw() writes bytes directly into the output PHP file and the macro name is not validated against a strict identifier pattern (e.g., /^[a-zA-Z_][a-zA-Z0-9_]*$/), an attacker who controls <expr> can close the string literal, terminate the function call, and append arbitrary PHP statements. Consider a template such as:
{# Attacker controls the value of the `macro_name` variable #}
{% import "macros.html" as macros %}
{{ _self.(macro_name)() }}
If macro_name resolves to the string foo"); system('id'); //, the compiled PHP cache file will contain:
// Inside the compiled template class — emitted to disk and then included
$this->env->getRuntime('Twig\Runtime\MacroRuntime')->callMacro(
"__string_template__",
"macro_foo"); system('id'); //",
[...]
)
The PHP runtime executes system('id') the moment the cached file is included — which happens at template load time, before any sandbox or security policy can intervene. This is not a sandbox escape in the traditional Twig security-policy sense; it is a compiler-level injection that produces malformed PHP files that the runtime then faithfully executes.
The attack surface expanded in 3.15.0 when the parenthesized dynamic attribute syntax was introduced, and it went unnoticed because the feature was primarily intended for cases like _self.(someVar)() where someVar is a compile-time constant expression. However, the implementation did not enforce that restriction.
Impact
An attacker who can influence the string that flows into the dynamic macro attribute expression achieves remote code execution on the PHP application server. Because the injected code runs at template-load time — before any output buffering, exception handling, or sandbox isolation — the impact is equivalent to arbitrary code execution with the privileges of the web server process.
Concretely, a successful exploit allows an attacker to:
- Execute OS commands (
system,exec,passthru,shell_exec) - Read and exfiltrate arbitrary files, including application secrets,
.envfiles, and database credentials - Write backdoors or web shells to disk
- Pivot laterally to internal services accessible from the server
The CVSS 8.8 score is consistent with a network-exploitable, low-complexity attack that requires no privileges and no user interaction, affecting confidentiality, integrity, and availability at a high level (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H — the PR:L reflects that most realistic deployments require at least some form of authenticated session to submit template content).
Every PHP application using Twig 3.15.0–3.25.x that allows any user-controlled data to influence a dynamic macro attribute expression is affected, regardless of whether Twig’s sandbox mode is enabled.
How to Fix It
Upgrade immediately. The fix is a one-line version bump in your composer.json:
composer require "twig/twig:^3.26.0"
composer update twig/twig
Verify the installed version:
composer show twig/twig | grep versions
The patch in 3.26.0 introduces strict identifier validation before any string is accepted as a macro name in a dynamic attribute expression. The corrected code generation path validates the resolved name against a legal PHP identifier pattern and throws a SyntaxError at compile time if the value does not conform:
// Corrected pattern (post-3.26.0)
protected function compileCallable(Compiler $compiler): void
{
$macroName = $this->getAttribute('name');
// Validate that $macroName is a legal identifier before emitting it.
if (!preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*$/', $macroName)) {
throw new SyntaxError(
sprintf('Invalid macro name "%s".', $macroName),
$this->getTemplateLine(),
$this->getSourceContext()
);
}
$compiler
->raw('$this->env->getRuntime(\'Twig\\Runtime\\MacroRuntime\')->callMacro(')
->string($this->getAttribute('template'))
->raw(', ')
->string('macro_' . $macroName)
->raw(', [...])')
;
}
Note the shift from ->raw('"macro_' . $macroName . '"') to ->string('macro_' . $macroName) — the ->string() method applies proper PHP string escaping in addition to the identifier pre-check.
If an immediate upgrade is not possible, the interim mitigation is to avoid all use of the _self.(<expr>) and import-alias dynamic call syntax with any runtime variable, and to audit templates for occurrences of these patterns.
Our Take
This vulnerability is a textbook example of a compiler injection flaw — a category that is consistently underestimated because developers associate injection with runtime input handling, not compile-time code generation. When a language or framework generates source code as part of its execution model (as Twig, Blade, Smarty, and similar template engines do), every point where external data influences that generated source is effectively an injection sink.
The pattern recurs because the dynamic features that make template engines expressive — runtime attribute resolution, programmatic macro dispatch — are built on top of a code generation layer that was designed with trusted inputs in mind. As these engines add more dynamic capabilities, each new feature must be audited against the code generation layer, not just the runtime behavior.
For enterprises, this class of vulnerability is particularly dangerous in multi-tenant environments where template authoring is a product feature. The compiled template cache is a direct execution surface; any injection into it bypasses WAFs, rate limiters, and application-layer sandboxes entirely.
Detection with SAST
This vulnerability class maps to CWE-94: Improper Control of Generation of Code (‘Code Injection’) and, more specifically, CWE-116: Improper Encoding or Escaping of Output applied to a code generation context.
Offensive360’s SAST engine detects this class of flaw by modeling template engine compilation pipelines as code generation sinks. Specifically, the engine flags:
- Unsanitized taint propagation from user-controlled sources into template compiler
raw()or equivalent direct-emit methods - Missing identifier validation before a runtime string value is used as a symbol name in generated source code
- Dynamic attribute resolution patterns (
_self.(<expr>),obj.(var)) where<expr>originates from a tainted source - Template string compilation of inputs that have not been processed through a strict identifier allowlist
The relevant SAST rule categories are code-generation-injection and template-compiler-sink. In a CI/CD integration, these rules trigger on any data-flow path that connects an HTTP parameter, database value, or file-read result to a template compilation step that lacks identifier normalization.
References
Detect this vulnerability class in your codebase
Offensive360 SAST scans your source code for CVE-2026-46640-class vulnerabilities and thousands of other patterns — across 60+ languages.