Содержание статьи
Введение
Итак, рассмотрим уязвимость CVE-2017-2636. Это состояние гонки (race condition) в драйвере n_hdlc
(drivers/tty/n_hdlc.c
), который предоставляет поддержку протокола HDLC
для последовательных портов. Этот драйвер поставляется многими дистрибутивами Linux, имеющими настройку CONFIG_N_HDLC=m
в конфигурационном файле ядра. Поэтому данной уязвимости оказались подвержены такие дистрибутивы, как RHEL 6/7, Fedora, SUSE, Debian и Ubuntu.
На данный момент уязвимость уже исправлена в «ванильном» ядре и публично разглашена. Ошибка была внесена довольно давно, в итоге мой патч был применен ко всем стабильным версиям Linux.
Но мы сегодня будем говорить не об исправлении уязвимости, а о ее эксплуатации. Я покажу, как работает стабильный и быстрый эксплоит для данной уязвимости. Он редко приводит к панике ядра и получает привилегии root менее чем за 20 секунд (как минимум на моих машинах), умеет обходить SMEP, однако обнаруживается с помощью Supervisor Mode Access Prevention (SMAP). Хотя и это средство защиты можно обмануть, приложив некоторые дополнительные усилия.
Забегая немного вперед, скажу, что для работы этому эксплоиту требуется базовый адрес ядра, неизвестный из-за Kernel Address Space Layout Randomization (KASLR). Но это средство защиты не стоит рассматривать как проблему — нужный адрес можно получить с помощью информационной утечки либо атакой по сторонним каналам (теория, практика). Ну а теперь обо всем по порядку.
Состояние гонки в n_hdlc
Исследование уязвимости началось с подозрительного отчета фаззера syzkaller. Это отличный проект, с его помощью было найдено множество ошибок в ядре Linux, и он достоин отдельного материала. Но сегодня речь не о нем.
Итак, изначально в уязвимом драйвере n_hdlc
для передаваемых данных использовался самописный односвязный список буферов. При ошибке передачи данных в указатель n_hdlc.tbuf
записывался адрес текущего буфера. Это работало, но в 2009 году был принят коммит be10eb75893
, который добавил поддержку сброса данных и внес состояние гонки при обращении к n_hdlc.tbuf
.
Если при отправке данных произошла ошибка, то в последующем функции flush_tx_queue()
и n_hdlc_send_frames()
обращаются к n_hdlc.tbuf
и при одновременном исполнении могут дважды поместить один и тот же буфер в список tx_free_buf_list
. Это приводит к двойному освобождению памяти в n_hdlc_release()
. Буферы данных представлены структурой n_hdlc_buf
и выделяются в slab-кеше kmalloc-8192
.
Мой патч
Для исправления этой уязвимости я избавился от n_hdlc.tbuf
, используя стандартные связные списки ядра Linux и спинлоки (spinlocks). В случае ошибки передачи данных текущий буфер помещается в начало списка tx_buf_list
.
Эксплуатация уязвимости
Из-за чего возникает уязвимость, мы выяснили, займемся ее эксплуатацией. Давай разберем код основного цикла эксплоита. Он исполняется, пока не будут получены привилегии пользователя root.
for (;;) {
long tmo1 = 0;
long tmo2 = 0;
if (loop % 2 == 0)
tmo1 = loop % MAX_RACE_LAG_USEC;
else
tmo2 = loop % MAX_RACE_LAG_USEC;
Счетчик loop
увеличивается каждую итерацию, изменяются и переменные tmo1
и tmo2
. Они задают задержку в конкурирующих потоках исполнения, которые:
- синхронизируются на
pthread_barrier
; - ожидают заданное количество микросекунд в холостом цикле;
- наконец, взаимодействуют с интерфейсом драйвера
n_hdlc
.
Такой способ столкновения потоков исполнения позволяет скорее достичь состояния гонки.
ptmd = open("/dev/ptmx", O_RDWR);
if (ptmd < 0) {
perror("[-] open /dev/ptmx");
goto end;
}
ret = ioctl(ptmd, TIOCSETD, &ldisc);
if (ret < 0) {
perror("[-] TIOCSETD");
goto end;
}
Здесь мы открываем псевдотерминал (ptmd
) и устанавливаем для него протокол N_HDLC
. Подробную информацию об этом можешь посмотреть в man ptmx
, Documentation/serial/tty.txt
и данном обсуждении программных компонентов псевдотерминала.
Установка N_HDLC
для последовательной линии приводит к автоматической загрузке драйвера n_hdlc
, поставляемого как модуль ядра. Этот же эффект может быть достигнут и с помощью сервиса ldattach
.
ret = ioctl(ptmd, TCXONC, TCOOFF);
if (ret < 0) {
perror("[-] TCXONC TCOOFF");
goto end;
}
bytes = write(ptmd, buf, TTY_BUF_SZ);
if (bytes != TTY_BUF_SZ) {
printf("[-] write to ptmx (bytes)");
goto end;
}
Теперь мы приостанавливаем вывод данных псевдотерминала (см. man tty_ioctl
) и пишем в него один буфер. Функция n_hdlc_send_frames()
не может выполнить отправку данного буфера и сохраняет его адрес в n_hdlc.tbuf
.
Все готово, попытаемся воспроизвести состояние гонки. Запускаем два потока, которые могут исполняться на всех доступных ядрах процессора:
- поток 1 сбрасывает данные псевдотерминала с помощью
ioctl(ptmd, TCFLSH, TCIOFLUSH)
; - поток 2 возобновляет приостановленный вывод с помощью
ioctl(ptmd, TCXONC, TCOON)
.
У этих потоков есть шанс дважды поместить в tx_free_buf_list
тот единственный буфер, который был записан в псевдотерминал.
Теперь возвращаем исполнение эксплоита на нулевое ядро процессора и вызываем возможную ошибку двойного освобождения памяти:
ret = sched_setaffinity(0, sizeof(single_cpu), &single_cpu);
if (ret != 0) {
perror("[-] sched_setaffinity");
goto end;
}
ret = close(ptmd);
if (ret != 0) {
perror("[-] close /dev/ptmx");
goto end;
}
Мы закрыли псевдотерминал. Функция n_hdlc_release()
проходит по спискам n_hdlc_buf_list
и освобождает память, занятую под буферы n_hdlc_buf
. Если мы выиграли гонку, то здесь должно произойти двойное освобождение памяти (double-free error).
Эта ошибка успешно обнаруживается с помощью Kernel Address Sanitizer (KASAN), который сообщает о ней как об использовании после освобождения (use-after-free), происходящем прямо перед вторым вызовом kfree()
.
Завершающая часть главного цикла:
ret = exploit_skb(socks, sockaddrs, payload, loop % SOCK_PAIRS);
if (ret != EXIT_SUCCESS)
goto end;
if (getuid() == 0 && geteuid() == 0) {
printf("[+] race #%ld: WIN! flush(%ld), TCOON(%ld)",
loop, tmo1, tmo2);
break; /* :) */
}
loop++;
}
printf("[+] finish as: uid=0, euid=0, start sh...");
run_sh();
В функции exploit_skb()
мы пробуем проэксплуатировать двойное освобождение памяти с помощью перезаписи struct sk_buff
. Если успешно — выходим из цикла и с помощью execve()
запускаем root shell в дочернем процессе.
Эксплуатация sk_buff
Как было сказано, экземпляры n_hdlc_buf
выделяются в slab-кеше kmalloc-8192
. Для эксплуатации двойного освобождения в этом кеше потребуются некоторые сущности в ядре Linux, размер которых немного меньше 8 Кбайт. На самом деле нужно два типа таких объектов:
- объекты, имеющие какой-либо указатель на функцию,
- объекты с контролируемым содержимым на месте данного указателя.
Поиск таких объектов и эксперименты с ними заняли у меня значительное время. Наконец я выбрал sk_buff
с его destructor_arg
в структуре skb_shared_info
. Эта идея не нова — рекомендую ознакомиться с отличной статьей о CVE-2016-2384.
Структура sk_buff
в ядре Linux служит для представления сетевых пакетов. Важно то, что интересующая нас skb_shared_info
и сетевые данные располагаются в одном и том же блоке памяти, на который указывает sk_buff.head
(см. схемы). Таким образом, создание сетевого пакета размером 7500 байт в пользовательском пространстве приведет к появлению skb_shared_info
в памяти из slab-кеша kmalloc-8192
. Ровно так, как нужно для эксплоита.
Но есть одна трудность: функция n_hdlc_release()
освобождает сразу тринадцать буферов n_hdlc_buf
. Сначала я пытался выполнять атаку heap spraying параллельно с n_hdlc_release()
, но не смог добиться вызова kmalloc()
между соответствующими kfree()
. В итоге нашелся другой путь. Выполнение heap spraying после n_hdlc_release()
может дать два экземпляра sk_buff
с полем head
, ссылающимся на одну и ту же область памяти. Это выглядит многообещающе.
Однако для успеха данного этапа атаки нужно как можно дольше сохранять в памяти ядра отправляемые сетевые пакеты, чтобы минимально воздействовать на аллокатор. Одной пары сетевых сокетов для этого недостаточно — размер очереди ограничен, непоместившиеся пакеты отбрасываются. Решением стало открывать одновременно достаточно большое количество соединений.
С помощью socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP)
было открыто 402 сокета:
- один клиентский сокет для отправки всех UDP-пакетов;
- один специальный серверный сокет для приема пакетов с дублированным значением
sk_buff.head
; - 200 серверных сокетов для остальных пакетов, отправляемых при heap spraying;
- 200 серверных сокетов для дополнительных пакетов, отправляемых при стабилизации состояния slab-аллокатора.
Итак, теперь нам нужен другой объект ядра с контролируемым содержимым, способным переписать skb_shared_info.destructor_arg
. Мы не можем повторно использовать для этого sk_buff.head
, так как данная структура расположена по фиксированному отступу от начала sk_buff.head
и мы ее не контролируем. Я был очень рад обнаружить системный вызов add_key
, с помощью которого можно поместить контролируемые данные в память, выделенную kmalloc-8192
. Но к моему сожалению, количество данных, размещаемых в памяти ядра Linux с помощью add_key
, ограничено квотами /proc/sys/kernel/keys/
. Изменять их может только пользователь root.
Значение /proc/sys/kernel/keys/maxbytes
по умолчанию равно 20000. Это означает, что для полезной нагрузки размером 8 Кбайт только два вызова add_key
выполнятся успешно и сохранят ее в памяти ядра. Однако мне помогла замечательная идея из выступления Ди Шэня (Di Shen), исследователя из Keen Security Lab. Я обнаружил, что успешное выполнение heap spraying возможно, даже если add_key
возвращает ошибку переполнения квоты. Оказалось, что копирование данных в память ядра происходит до проверки квоты и возврата ошибки.
Итак, взглянем на код функции init_payload()
:
#define MMAP_ADDR 0x10000lu
#define PAYLOAD_SZ 8100
#define SKB_END_OFFSET 7872
#define KEY_DATA_OFFSET 18
int init_payload(char *p)
{
struct skb_shared_info *info = (struct skb_shared_info *)(p +
SKB_END_OFFSET - KEY_DATA_OFFSET);
struct ubuf_info *uinfo_p = NULL;
Определение структур skb_shared_info
и ubuf_info
скопировано в код эксплоита из соответствующего заголовочного файла ядра (include/linux/skbuff.h
).
Буфер с полезной нагрузкой будет передан системному вызову add_key
в качестве параметра. Данные, которые расположены в нем по отступу 7872 – 18 = 7854
байт, наложатся на структуру skb_shared_info
перезаписываемого сетевого пакета.
char *area = NULL;
void *target_addr = (void *)(MMAP_ADDR);
area = mmap(target_addr, 0x1000, PROT_READ | PROT_WRITE,
MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (area != target_addr) {
perror("[-] mmap");
return EXIT_FAILURE;
}
uinfo_p = target_addr;
uinfo_p->callback = (uint64_t)root_it;
info->destructor_arg = (uint64_t)uinfo_p;
info->tx_flags = SKBTX_DEV_ZEROCOPY;
Если в skb_shared_info.tx_flags
взведен флаг SKBTX_DEV_ZEROCOPY
, то при выполнении skb_release_data()
будет вызвана функция с адресом в ubuf_info.callback
. В нашем случае экземпляр ubuf_info
располагается в пользовательском пространстве, поэтому обращение к нему из пространства ядра будет обнаружено механизмом SMAP.
Как бы то ни было, теперь callback
указывает на функцию root_it()
, в которой выполняется классическое commit_creds(prepare_kernel_cred(0))
. Код данной функции также расположен в пользовательском пространстве, а значит, его исполнение из ring 0 обнаруживается механизмом SMEP, обход которого будет показан ниже.
Атака и стабилизация
Как было упомянуто, функция n_hdlc_release()
освобождает тринадцать буферов n_hdlc_buf
при закрытии псевдотерминала. Функция exploit_skb()
вызывается вскоре после этого. В ней мы выполняем heap spraying, посылая двадцать UDP-пакетов размером 7500 байт. Было экспериментально установлено, что пакеты номер 12, 13, 14 и 15 с высокой вероятностью будут эксплуатируемы, поэтому они посылаются на специальный серверный сокет.
Теперь нужно выполнить использование после освобождения для sk_buff.data
:
- принимаем четыре пакета на указанном сокете,
- после каждого принятого пакета выполняем несколько системных вызовов
add_key
для буфера, подготовленного вinit_payload()
:
k[0] = syscall(__NR_add_key, "user", "payload0",
payload, PAYLOAD_SZ, KEY_SPEC_PROCESS_KEYRING);
Количество вызовов add_key
, дающее наилучший результат, было найдено эмпирически с помощью многократного тестирования эксплоита.
Если было достигнуто состояние гонки в драйвере n_hdlc
и затем была удачно выполнена атака heap spraying, то наш shell-код будет исполнен при приеме перезаписанного UDP-пакета.
Успешно созданные ключи более не нужны, удаляем их:
for (i = 0; i < KEYS_N; i++) {
if (k[i] > 0)
syscall(__NR_keyctl, KEYCTL_INVALIDATE, k[i]);
}
Теперь нам нужно подготовить кучу к следующей попытке поймать состояние гонки. Файл /proc/slabinfo
показывает, что в одном slab для kmalloc-8192
содержится только четыре объекта размером 8 Кбайт, значит, двойное освобождение памяти с большой вероятностью может вызвать панику в аллокаторе. Но избежать отказа и сделать эксплоит намного более стабильным поможет такой трюк: посылаем дюжину UDP-пакетов, которые заполняют опустевшие места в kmalloc-8192
.
Обход SMEP
Как было сказано, shell-код root_it()
расположен в пользовательском пространстве. Исполнение его в пространстве ядра обнаруживается при включенном SMEP. Это функция платформы x86, которая включается 20-м битом регистра CR4.
Есть несколько способов обойти SMEP. Например, Виталий Николенко описывает, как выключить SMEP с помощью возвратно-ориентированного программирования (return oriented programming, ROP). Это прекрасно работает, но мне не хотелось просто копировать этот подход, поэтому я нашел другой интересный путь обхода SMEP без использования ROP.
В файле arch/x86/include/asm/special_insns.h
я обнаружил функцию native_write_cr4()
:
static inline void native_write_cr4(unsigned long val)
{
printk("wcr4: 0x%lx", val);
asm volatile("mov %0,%%cr4": : "r" (val), "m" (__force_order));
}
Она записывает значение единственного аргумента в CR4.
Теперь посмотрим на код функции skb_release_data()
, которая вызывает контролируемый эксплоитом callback
в ring 0:
if (shinfo->tx_flags & SKBTX_DEV_ZEROCOPY) {
struct ubuf_info *uarg;
uarg = shinfo->destructor_arg;
if (uarg->callback)
uarg->callback(uarg, true);
}
Мы видим, что функция callback
принимает адрес uarg
в качестве первого аргумента. Мы контролируем данный адрес при эксплуатации sk_buff
. Поэтому я решил записать адрес функции native_write_cr4()
в указатель ubuf_info.callback
, а структуру ubuf_info
с помощью mmap()
расположить по адресу, соответствующему корректному значению CR4 с выключенным SMEP.
В этом случае на данном процессорном ядре SMEP будет выключен без использования ROP. Однако для эксплуатации придется достичь состояния гонки дважды: первый раз для отключения SMEP, второй раз для выполнения shell-кода. Но для данного эксплоита это не будет проблемой, поскольку он быстрый и надежный.
Итак, подготовим полезную нагрузку для add_key
по-новому:
#define CR4_VAL 0x0406e0lu
void *target_addr = (void *)(CR4_VAL & 0xfffff000lu);
area = mmap(target_addr, 0x1000, PROT_READ | PROT_WRITE,
MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (area != target_addr) {
perror("[-] mmap");
return EXIT_FAILURE;
}
uinfo_p = (struct ubuf_info *)CR4_VAL;
uinfo_p->callback = NATIVE_WRITE_CR4;
info->destructor_arg = (uint64_t)uinfo_p;
info->tx_flags = SKBTX_DEV_ZEROCOPY;
Этот способ обхода SMEP выглядит остроумно, однако накладывает дополнительное требование — для его работы требуется взведенный 18-й бит регистра CR4 (OSXSAVE). Без этого бита target_addr
становится равным 0, а mmap()
на нулевую страницу запрещен.
Подводя итоги
Кто-то может спросить: а где же готовый исходный код эксплоита? Его, по понятным причинам, я выкладывать не стал. Но если он тебе нужен, у тебя теперь вполне достаточно информации, чтобы постараться воспроизвести его самостоятельно. Я же на этом месте закончу свой рассказ. В завершение хочу добавить, что исследование CVE-2017-2636
и написание этой статьи было для меня большим удовольствием, и поблагодарить Positive Technologies за возможность провести эту работу. И конечно, буду рад обратной связи.
WWW
Видео с демонстрацией работы эксплоита смотри на YouTube-канале компании Positive Technologies.