{"repro_id":"REPRO-2026-00151","version":8,"title":"Twig: sandbox bypass via SourcePolicy filter check enables arbitrary PHP callables","repro_type":"security","status":"published","severity":"high","description":"Twig's sandbox can be enabled *per-template* through a\n`SourcePolicyInterface`, rather than enabled globally for every template.\n\nWhen the sandbox is enabled this way, the runtime security check for the\n`sort`, `filter`, `map` and `reduce` filters **fails to consult the current\ntemplate's source**. As a result the `SourcePolicy` is never applied to those\nfour filters.\n\nAn attacker who can supply template content can therefore pass an arbitrary\nPHP callable to one of these filters — for example `system` — and have it\nexecuted, escaping the sandbox and achieving code execution.","root_cause":"# RCA Report: CVE-2026-24425 — Twig Sandbox Bypass via SourcePolicy\n\n## Summary\n\nCVE-2026-24425 is a sandbox bypass vulnerability in Twig (the PHP templating engine). When the sandbox is enabled per-template through a `SourcePolicyInterface` (rather than globally for every template), the runtime security check for the `sort`, `filter`, `map`, `reduce`, and `find` filters fails to consult the current template's source. As a result, the `SourcePolicy` is never applied to those filters. An attacker who can supply template content can therefore pass an arbitrary PHP callable to one of these filters (e.g., `system` or `shell_exec`) and have it executed, escaping the sandbox and achieving arbitrary code execution.\n\n## Impact\n\n- **Package/component affected**: `twig/twig` (Twig templating engine for PHP)\n- **Affected versions**: `3.9.0` through `3.25.x` (and the `2.16.x` line)\n- **Fixed versions**: `3.26.0`\n- **Risk level**: High — CVSS 8.7\n- **Consequences**: Remote Code Execution (RCE) via sandbox bypass. An attacker who controls template content can execute arbitrary PHP functions even when per-template sandboxing is active.\n\n## Root Cause\n\nThe root cause lies in how the runtime security check for arrow/callable arguments was implemented in `src/Extension/CoreExtension.php`.\n\nIn the vulnerable code (Twig 3.25.0 and earlier), filters like `map`, `filter`, `sort`, `reduce`, and `find` declared `needs_environment => true` but **did not** declare `needs_is_sandboxed => true`. Their runtime methods (e.g., `map()`, `filter()`) called `self::checkArrow($env, $arrow, ...)`.\n\nInside `checkArrow()`, the sandbox status was determined by querying the `SandboxExtension` directly:\n\n```php\nif ($env->hasExtension(SandboxExtension::class) && $env->getExtension(SandboxExtension::class)->isSandboxed()) {\n    throw new RuntimeError(...);\n}\n```\n\nHowever, `SandboxExtension::isSandboxed()` only returns `true` when the sandbox is enabled **globally** (via `SandboxExtension::enableSandbox()`). When sandboxing is instead enabled per-template through a `SourcePolicyInterface`, the runtime check `isSandboxed()` returns `false`, so the `checkArrow()` validation is bypassed. This means a non-Closure callable (e.g., a string function name like `'shell_exec'`) is accepted and executed.\n\nThe fix in Twig 3.26.0 adds `needs_is_sandboxed => true` to the filter declarations for `sort`, `filter`, `map`, `reduce`, `find`, and `column`. This causes Twig's runtime to pass the actual sandbox state (which correctly reflects per-template sandboxing via `SourcePolicy`) as a boolean parameter to the filter function. The `checkArrow()` method now receives this boolean directly:\n\n```php\npublic static function checkArrow(bool $isSandboxed, $arrow, $thing, $type)\n{\n    if ($arrow instanceof \\Closure) {\n        return;\n    }\n    if ($isSandboxed) {\n        throw new RuntimeError(...);\n    }\n}\n```\n\nBecause `$isSandboxed` is now derived from the template's source policy at runtime, the sandbox check correctly blocks non-Closure callables even when sandboxing is enabled per-template.\n\nFix commit/tag: The fix is contained in the `v3.26.0` release tag. Key files changed:\n- `src/Extension/CoreExtension.php` — added `needs_is_sandboxed` to affected filters and updated `checkArrow()` signature.\n- `src/Extension/SandboxExtension.php` — added `ensureSpreadAllowed()` helper.\n- `src/NodeVisitor/SandboxNodeVisitor.php` — refactored to-string wrapping logic.\n\n## Reproduction Steps\n\nThe reproduction is fully automated by `repro/reproduction_steps.sh`. The script performs the following:\n\n1. **Installs the vulnerable build** (`twig/twig:3.25.0`) in a scratch directory.\n2. **Installs the fixed build** (`twig/twig:3.26.0`) in a separate scratch directory.\n3. **Creates a PHP test harness** for each version that:\n   - Defines a helper function `rce_helper($cmd, $ignored = null)` that wraps `shell_exec($cmd)`.\n   - Configures a Twig `Environment` with a `SourcePolicyInterface` that returns `true` for all templates (enabling per-template sandboxing).\n   - Allows only the `map` and `join` filters in the `SecurityPolicy`.\n   - Renders the template `{{ ['id']|map('rce_helper')|join }}`.\n4. **Executes both test harnesses** and captures output.\n\n### Expected Evidence\n\n| Build | Behavior |\n|-------|----------|\n| **3.25.0 (vulnerable)** | The template renders and `rce_helper('id')` is executed. The output contains `uid=0(root) gid=0(root) groups=0(root)`, proving that `shell_exec('id')` ran inside the sandboxed context. |\n| **3.26.0 (fixed)** | Twig throws `Twig\\Error\\RuntimeError: The callable passed to the \"map\" filter must be a Closure in sandbox mode`, blocking the non-Closure callable before it executes. |\n\n## Evidence\n\n- **Log file**: `logs/reproduction.log`\n- **Runtime manifest**: `repro/runtime_manifest.json`\n\n### Key excerpts from `logs/reproduction.log`\n\n**Vulnerable (3.25.0)** — callable executed despite sandbox:\n```\n=== Testing VULNERABLE version (3.25.0) ===\nVULNERABLE_OUTPUT:\nuid=0(root) gid=0(root) groups=0(root)\n```\n\n**Fixed (3.26.0)** — callable correctly blocked:\n```\n=== Testing FIXED version (3.26.0) ===\nFIXED_EXCEPTION: Twig\\Error\\RuntimeError: The callable passed to the \"map\" filter must be a Closure in sandbox mode in \"index\" at line 1.\n```\n\nThese results prove that:\n1. In the vulnerable version, the per-template `SourcePolicy` sandbox is bypassed for the `map` filter, allowing arbitrary PHP function execution.\n2. In the fixed version, the same payload is correctly rejected with a sandbox security error.\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to Twig `3.26.0` or later. The fix is minimal and targeted; upgrading is low-risk.\n2. **Verify sandbox configuration** — if your application uses `SourcePolicyInterface`, ensure the runtime is on a patched version.\n3. **Audit template inputs** — any attacker-controlled template source should be considered potentially compromised until patched.\n4. **Regression tests** — the Twig project added `testSourcePolicyBlocksNonClosureCallableInArrow` in `tests/Extension/SandboxTest.php`. Adopt similar tests in your own CI pipeline.\n5. **Alternative mitigations** (if immediate upgrade is impossible):\n   - Disable user-supplied template rendering entirely.\n   - Globally enable the sandbox via `SandboxExtension::enableSandbox()` — while this does not fix the bug, it may reduce exposure in some configurations.\n\n## Additional Notes\n\n- **Idempotency confirmed**: `reproduction_steps.sh` was executed twice consecutively and produced identical results on both runs.\n- **Payload details**: The payload `{{ ['id']|map('rce_helper')|join }}` was chosen because:\n  - `map` is one of the affected filters.\n  - `rce_helper` accepts two arguments (Twig passes value and key to the callable), avoiding PHP 8+ fatal errors from mismatched argument counts.\n  - The output of `shell_exec('id')` is a concrete, verifiable indicator of code execution.\n- **Limitations**: The reproduction requires a PHP environment with `shell_exec` enabled. If `shell_exec` is disabled (e.g., via `disable_functions`), an alternative function with a visible side effect can be substituted.\n","ghsa_id":"GHSA-2q52-x2ff-qgfr","cve_id":"CVE-2026-24425","cwe_id":"CWE-693 (Protection Mechanism Failure)","package":{"name":"twig/twig","ecosystem":"composer","affected_versions":"2.16.*, >=3.9.0, <3.26.0","fixed_version":"3.26.0"},"reproduced_at":"2026-05-22T18:23:06.861735+00:00","duration_secs":787.2747020721436,"tool_calls":172,"turns":149,"handoffs":3,"total_cost_usd":1.4506700499999994,"agent_costs":{"repro":0.38132521999999985,"support":0.053380920000000005,"vuln_variant":1.01596391},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.38132521999999985},"support":{"accounts/fireworks/models/kimi-k2p6":0.053380920000000005},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.01596391}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T18:23:08.754095+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":7186,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6015,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":8284,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3846,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3261,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":611,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3508,"category":"ticket"},{"path":"repro/runtime_manifest.json","filename":"runtime_manifest.json","size":683,"category":"other"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1308,"category":"other"},{"path":"vuln_variant/test_variants.php","filename":"test_variants.php","size":1647,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1213,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":6225,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3053,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2024,"category":"other"},{"path":"logs/composer_fixed.log","filename":"composer_fixed.log","size":1104,"category":"log"},{"path":"logs/reproduction.log","filename":"reproduction.log","size":281,"category":"log"},{"path":"logs/vuln_variant/test_variants.php","filename":"test_variants.php","size":2449,"category":"other"},{"path":"logs/vuln_variant/variant_test.log","filename":"variant_test.log","size":680,"category":"log"},{"path":"logs/composer_vulnerable.log","filename":"composer_vulnerable.log","size":1176,"category":"log"}]}