ANT-2026-J3EVXWDY · twigphp/twig

code-injection critical

CVE-2026-46633 GHSA-7p85-w9px-jpjp

Severity Claude high · Security research firm high · Maintainer critical

Discovered by Claude Mythos Preview

REPORT

Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Anvil Security.

ANT-2026-J3EVXWDY: PHP code injection via single quote in {% use %} template name

In ModuleNode::compileConstructor() (lines 251-255), the error message for an undefined trait block is built by writing a single-quoted PHP string and interpolating the trait template name via subcompile(). Compiler::string() escapes double quotes and backslashes but not single quotes, and UseTokenParser accepts arbitrary template-name strings. A single quote in the {% use %} template name therefore closes the surrounding PHP string literal and drops the attacker into PHP expression context inside the generated __construct(). Because this code runs before the sandbox security check (and 'use' is always permitted anyway), an attacker who can author templates and control template names achieves arbitrary PHP execution and sandbox escape.

Target

Project: twigphp/Twig
Location: src/Node/ModuleNode.php:254
Discovery: static analysis — not yet dynamically reproduced

Technical Details

The compiler opens a single-quoted PHP literal with ->write("throw new RuntimeError('Block ") and does not close it before subcompiling the trait template-name node; Compiler::string() uses addcslashes on \0\t\"\$\ only, so single quotes pass through unescaped. A template name containing ' terminates the outer string and the remainder is parsed as live PHP expression code. Lines 235-237 in the same function handle a similar case correctly by closing and reopening the quoted literal around the subcompile.

Reproduction

  1. Create a template in the loader named '.system('id').' containing {% block x %}{% endblock %}
  2. Create a second template containing: {% use "'.system('id').'" with foo as bar %}
  3. Compilation emits: throw new RuntimeError('Block "foo" is not defined in trait "'.system('id').'".', 1, $this->source);
  4. Render the second template; instantiating the compiled class runs __construct() and executes the injected system() call before any sandbox check

[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]

Suggested Fix

Ensure values emitted via Compiler::string()/repr() are only placed where a standalone double-quoted PHP literal is valid; in compileConstructor(), close the surrounding single-quoted literal before subcompiling the ConstantExpression and reopen it afterward, mirroring the correct pattern at lines 235-237.

Acknowledgement

This vulnerability was discovered by Claude, Anthropic's AI assistant, and triaged by the Anthropic security team in collaboration with Anthropic Research. Please direct questions to security-cvd@anthropic.com and reference ANT-2026-J3EVXWDY.


Reference: ANT-2026-J3EVXWDY
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure

SECURITY RESEARCH FIRM ANALYSIS

Triage and disclosure were performed by Anvil Security.

Verdict
true positive
Severity
high
UPSTREAM FIX

The change that resolved this finding.

diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php
index 71c57201982..a3f66827ff6 100644
--- a/src/Node/ModuleNode.php
+++ b/src/Node/ModuleNode.php
@@ -248,11 +248,11 @@ protected function compileConstructor(Compiler $compiler)
                         ->string($key)
                         ->raw("])) {\n")
                         ->indent()
-                        ->write("throw new RuntimeError('Block ")
+                        ->write("throw new RuntimeError(sprintf('Block \"%s\" is not defined in trait \"%s\".', ")
                         ->string($key)
-                        ->raw(' is not defined in trait ')
+                        ->raw(', ')
                         ->subcompile($trait->getNode('template'))
-                        ->raw(".', ")
+                        ->raw('), ')
                         ->repr($node->getTemplateLine())
                         ->raw(", \$this->source);\n")
                         ->outdent()
diff --git a/tests/Node/ModuleTest.php b/tests/Node/ModuleTest.php
index df28815c569..b8df54f77e7 100644
--- a/tests/Node/ModuleTest.php
+++ b/tests/Node/ModuleTest.php
@@ -21,6 +21,7 @@
  */
 
 use Twig\Environment;
+use Twig\Error\RuntimeError;
 use Twig\Loader\ArrayLoader;
 use Twig\Node\BodyNode;
 use Twig\Node\EmptyNode;
@@ -56,6 +57,29 @@ public function testConstructor()
         $this->assertEquals($source->getName(), $node->getTemplateName());
     }
 
+    public function testUseTagTemplateNameDoesNotInjectPhpInCompiledOutput()
+    {
+        $evilName = "evil' . print('BAD-EOL') . '.twig";
+        $loader = new ArrayLoader([
+            $evilName => '{% block existing %}ok{% endblock %}',
+            'main.twig' => "{% use \"$evilName\" with absent_block as alias %}",
+        ]);
+        $twig = new Environment($loader);
+
+        ob_start();
+        $message = null;
+        try {
+            $twig->load('main.twig');
+        } catch (RuntimeError $e) {
+            $message = $e->getMessage();
+        }
+        $stdout = ob_get_clean();
+
+        $this->assertSame('', $stdout, 'No code from the template name must execute when the trait is loaded.');
+        $this->assertNotNull($message, 'A RuntimeError must be raised for the missing block.');
+        $this->assertStringContainsString($evilName, $message, 'The error message must contain the literal template name.');
+    }
+
     public static function provideTests(): iterable
     {
         $twig = new Environment(new ArrayLoader(['foo.twig' => '{{ foo }}']));

https://github.com/twigphp/Twig/commit/e9ff55f6910832428e48a35b2e0748189ad49ae3

TIMELINE

Dates from discovery through public reveal.

  1. 2026-04-21 Reported to tracker
  2. 2026-04-27 Sent to maintainer
  3. 2026-04-27 Maintainer acknowledged
  4. 2026-05-18 Patch released
  5. 2026-08-17 Publicly revealed
PROVENANCE

SHA-3-512 hash:

93b28dc10565abff791346a81f85dd6ab280b8d67795c451349092fc8cacd8d74fa1efe5ea8621424843330fcf14161d7bd1d3108e0fd946afbe9cba8e5d9d2f

Committed 2026-05-19 14:41 PT

Revealed 2026-08-17 10:47 PT

Verify (download preimage.json)

Show preimage JSON
{
  "ant_id": "ANT-2026-J3EVXWDY",
  "bug_class": "code_injection",
  "claude_severity": "high",
  "commit_sha": null,
  "created_at": "2026-04-21T16:56:59+00:00",
  "description": "In ModuleNode::compileConstructor() (lines 251-255), the error message for an undefined trait block is built by writing a single-quoted PHP string and interpolating the trait template name via subcompile(). Compiler::string() escapes double quotes and backslashes but not single quotes, and UseTokenParser accepts arbitrary template-name strings. A single quote in the {% use %} template name therefore closes the surrounding PHP string literal and drops the attacker into PHP expression context inside the generated __construct(). Because this code runs before the sandbox security check (and 'use' is always permitted anyway), an attacker who can author templates and control template names achieves arbitrary PHP execution and sandbox escape.",
  "discovered_at": "2026-04-19T00:00:00+00:00",
  "location": "src/Node/ModuleNode.php:254",
  "poc_sha256": null,
  "preimage_version": 1,
  "project": "twigphp/Twig",
  "reproduction": [
    "Create a template in the loader named '.system('id').' containing {% block x %}{% endblock %}",
    "Create a second template containing: {% use \"'.system('id').'\" with foo as bar %}",
    "Compilation emits: throw new RuntimeError('Block \"foo\" is not defined in trait \"'.system('id').'\".', 1, $this->source);",
    "Render the second template; instantiating the compiled class runs __construct() and executes the injected system() call before any sandbox check"
  ],
  "technical_details": "The compiler opens a single-quoted PHP literal with ->write(\"throw new RuntimeError('Block \") and does not close it before subcompiling the trait template-name node; Compiler::string() uses addcslashes on \\0\\t\\\"\\$\\\\ only, so single quotes pass through unescaped. A template name containing ' terminates the outer string and the remainder is parsed as live PHP expression code. Lines 235-237 in the same function handle a similar case correctly by closing and reopening the quoted literal around the subcompile.",
  "title": "PHP code injection via single quote in {% use %} template name",
  "vendor_severity": "high"
}