ANT-2026-203E7E95 · twigphp/twig
auth-bypass high
CVE-2026-46639 GHSA-mm6w-gr99-p3jj
Severity Claude high · Security research firm high · Maintainer high
Discovered by Claude Mythos Preview
Anthropic's analysis, sealed at approval. Disclosure to the maintainer was performed by Anvil Security.
ANT-2026-203E7E95: Sandbox bypass via object-destructuring assignment
In Twig's ObjectDestructuringSetBinary::compile() (ObjectDestructuringSetBinary.php:67), the compiled call to CoreExtension::getAttribute() passes the 9th argument $sandboxed as a literal false instead of checking for SandboxExtension as the normal GetAttrExpression path does. This causes the checkPropertyAllowed and checkMethodAllowed guards in CoreExtension (lines 1694, 1781, 1896) to be skipped, and because the call type is ANY_CALL it will both read public properties and invoke getX()/isX()/hasX()/__call() methods. An untrusted template author can write {{ ({field: v} = obj) ? '' }}{{ v }} to read any property or call any zero-arg getter on any object in the context. SandboxNodeVisitor does not rewrite this node and operators are not covered by the tag/filter/function allowlist, so no SecurityPolicy configuration can block it — the sandbox's property and method allowlists are fully defeated.
Target
Project: twigphp/Twig
Location: src/Node/Expression/Binary/ObjectDestructuringSetBinary.php:67
Discovery: static analysis — not yet dynamically reproduced
Technical Details
The root cause is a hard-coded false for the $sandboxed positional argument in the getAttribute() call emitted by ObjectDestructuringSetBinary::compile(), whereas the correct behavior (as in GetAttrExpression.php:146) is to emit $env->hasExtension(SandboxExtension::class). With $sandboxed=false, CoreExtension::getAttribute() never consults the SecurityPolicy before resolving properties or invoking getter-style methods, and the destructuring = operator is reachable from plain {{ ... }} output without requiring any allowlisted tag.
Reproduction
- Attacker authors a template using the core
=infix operator with object-destructuring syntax inside a{{ ... }}expression, e.g.{{ ({password: p} = user) ? '' }}{{ p }}. - Twig compiles the destructuring node via ObjectDestructuringSetBinary::compile(), emitting CoreExtension::getAttribute(..., ANY_CALL, false, false, false, ...).
- At render time getAttribute() reads the public property or falls through to invoking getPassword()/isPassword()/hasPassword()/__call() without calling checkPropertyAllowed or checkMethodAllowed.
- Attacker outputs the captured variable, exfiltrating the value; repeat for any property/zero-arg getter on any reachable object.
[No reproducer or sanitizer output attached — request from security-cvd@anthropic.com if needed.]
Suggested Fix
Make object-destructuring attribute reads honor the sandbox: emit the $sandboxed argument as $env->hasExtension(SandboxExtension::class) (matching GetAttrExpression) rather than hard-coding false, so checkPropertyAllowed/checkMethodAllowed run for destructuring just as for normal attribute access.
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-203E7E95.
Reference: ANT-2026-203E7E95
Anthropic CVD Policy: https://www.anthropic.com/coordinated-vulnerability-disclosure
Triage and disclosure were performed by Anvil Security.
- Verdict
- true positive
- Severity
- high
The change that resolved this finding.
diff --git a/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php b/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php
index b47c50fe896..8c5df6a8028 100644
--- a/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php
+++ b/src/Node/Expression/Binary/ObjectDestructuringSetBinary.php
@@ -13,6 +13,7 @@
use Twig\Compiler;
use Twig\Error\SyntaxError;
+use Twig\Extension\SandboxExtension;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\Variable\ContextVariable;
@@ -64,7 +65,7 @@ public function compile(Compiler $compiler): void
if ($i) {
$compiler->raw(', ');
}
- $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($mapping['property'])->raw(', [], \\Twig\\Template::ANY_CALL, false, false, false, ')->repr($this->getNode('right')->getTemplateLine())->raw(')');
+ $compiler->raw('CoreExtension::getAttribute($this->env, $this->source, ')->subcompile($this->getNode('right'))->raw(', ')->repr($mapping['property'])->raw(', [], \\Twig\\Template::ANY_CALL, false, false, ')->repr($compiler->getEnvironment()->hasExtension(SandboxExtension::class))->raw(', ')->repr($this->getNode('right')->getTemplateLine())->raw(')');
}
$compiler->raw(']');
}
diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php
index 6d8e5035a73..93201d897be 100644
--- a/tests/Extension/SandboxTest.php
+++ b/tests/Extension/SandboxTest.php
@@ -423,6 +423,40 @@ public function testSandboxAllowProperty()
$this->assertEquals('bar', $twig->load('1_basic4')->render(self::$params), 'Sandbox allow some properties');
}
+ public function testSandboxAllowDestructuring()
+ {
+ $template = '{% do {bar: x, foo: y} = obj %}{{ x }}-{{ y }}';
+ $twig = $this->getEnvironment(true, [], ['index' => $template], ['do'], [], ['Twig\Tests\Extension\FooObject' => 'foo'], ['Twig\Tests\Extension\FooObject' => 'bar']);
+ FooObject::reset();
+ $this->assertSame('bar-foo', $twig->load('index')->render(self::$params), 'Sandbox allows destructuring when properties and methods are allowed');
+ }
+
+ public function testSandboxUnallowedDestructuringProperty()
+ {
+ $template = '{% do {bar: x} = obj %}{{ x }}';
+ $twig = $this->getEnvironment(true, [], ['index' => $template], ['do']);
+ try {
+ $twig->load('index')->render(self::$params);
+ $this->fail('Sandbox throws a SecurityError exception if an unallowed property is read via destructuring');
+ } catch (SecurityNotAllowedPropertyError $e) {
+ $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
+ $this->assertSame('bar', $e->getPropertyName());
+ }
+ }
+
+ public function testSandboxUnallowedDestructuringMethod()
+ {
+ $template = '{% do {foo: y} = obj %}{{ y }}';
+ $twig = $this->getEnvironment(true, [], ['index' => $template], ['do'], [], [], ['Twig\Tests\Extension\FooObject' => 'foo']);
+ try {
+ $twig->load('index')->render(self::$params);
+ $this->fail('Sandbox throws a SecurityError exception if an unallowed method is called via destructuring');
+ } catch (SecurityNotAllowedMethodError $e) {
+ $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName());
+ $this->assertSame('foo', $e->getMethodName());
+ }
+ }
+
public function testSandboxAllowFunction()
{
$twig = $this->getEnvironment(true, [], self::$templates, [], [], [], [], ['cycle']);https://github.com/twigphp/Twig/commit/3fe13f98f890449af5e7f69f0404b59b8fb776ae
Dates from discovery through public reveal.
- 2026-04-21 Reported to tracker
- 2026-04-27 Sent to maintainer
- 2026-04-27 Maintainer acknowledged
- 2026-05-19 Patch released
- 2026-08-17 Publicly revealed
SHA-3-512 hash:
699e1b73d73a9a017b40c501f94100bad5af00bad49138a465139357628fd2011ed4e03bf327e6d8f77674936eddec6f58ade4ccacd0aceb164b14b22623fc1e
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-203E7E95",
"bug_class": "auth_bypass",
"claude_severity": "high",
"commit_sha": null,
"created_at": "2026-04-21T16:56:58+00:00",
"description": "In Twig's ObjectDestructuringSetBinary::compile() (ObjectDestructuringSetBinary.php:67), the compiled call to CoreExtension::getAttribute() passes the 9th argument $sandboxed as a literal `false` instead of checking for SandboxExtension as the normal GetAttrExpression path does. This causes the checkPropertyAllowed and checkMethodAllowed guards in CoreExtension (lines 1694, 1781, 1896) to be skipped, and because the call type is ANY_CALL it will both read public properties and invoke getX()/isX()/hasX()/__call() methods. An untrusted template author can write `{{ ({field: v} = obj) ? '' }}{{ v }}` to read any property or call any zero-arg getter on any object in the context. SandboxNodeVisitor does not rewrite this node and operators are not covered by the tag/filter/function allowlist, so no SecurityPolicy configuration can block it — the sandbox's property and method allowlists are fully defeated.",
"discovered_at": "2026-04-19T00:00:00+00:00",
"location": "src/Node/Expression/Binary/ObjectDestructuringSetBinary.php:67",
"poc_sha256": null,
"preimage_version": 1,
"project": "twigphp/Twig",
"reproduction": [
"1. Attacker authors a template using the core `=` infix operator with object-destructuring syntax inside a `{{ ... }}` expression, e.g. `{{ ({password: p} = user) ? '' }}{{ p }}`.",
"2. Twig compiles the destructuring node via ObjectDestructuringSetBinary::compile(), emitting CoreExtension::getAttribute(..., ANY_CALL, false, false, false, ...).",
"3. At render time getAttribute() reads the public property or falls through to invoking getPassword()/isPassword()/hasPassword()/__call() without calling checkPropertyAllowed or checkMethodAllowed.",
"4. Attacker outputs the captured variable, exfiltrating the value; repeat for any property/zero-arg getter on any reachable object."
],
"technical_details": "The root cause is a hard-coded `false` for the $sandboxed positional argument in the getAttribute() call emitted by ObjectDestructuringSetBinary::compile(), whereas the correct behavior (as in GetAttrExpression.php:146) is to emit `$env->hasExtension(SandboxExtension::class)`. With $sandboxed=false, CoreExtension::getAttribute() never consults the SecurityPolicy before resolving properties or invoking getter-style methods, and the destructuring `=` operator is reachable from plain `{{ ... }}` output without requiring any allowlisted tag.",
"title": "Sandbox bypass via object-destructuring assignment",
"vendor_severity": "high"
}