Некоторые уязвимости, связанные с повреждением памяти, невероятно сложны для эксплуатации. Они могут вызывать состояния гонки, приводить к сбоям системы и накладывать разные ограничения, которые усложняют жизнь исследователя. Работа с такими «хрупкими» багами требует значительно больше времени и усилий. CVE-2024-50264 в ядре Linux — как раз одна из таких сложных уязвимостей, которая получила премию Pwnie Award 2025 в категории «Лучшее повышение привилегий» (Best Privilege Escalation). В этой статье я представлю свой проект kernel-hack-drill и покажу, как он помог мне разработать прототип эксплойта для уязвимости CVE-2024-50264.

История об одновременном открытии

В 2021 году я обнаружил уязвимость в подсистеме AF_VSOCK ядра Linux и опубликовал об этом статью «Сила четырех байтов: эксплуатация уязвимости CVE-2021-26708 в ядре Linux». Спустя 3 года, в апреле 2024-го, я решил снова позаниматься исследованием AF_VSOCK и нашел еще один сбой ядра методом прицельного фаззинга с помощью модифицированного фаззера syzkaller. Я сделал минимальный репродюсер, приводящий к отказу ядра, отключил санитайзер KASAN и обнаружил, что баг приводит к немедленному разыменованию нулевого указателя в рабочем потоке ядра (null-ptr-deref в kworker). Это выглядело не очень перспективной целью для атакующих исследований. Кроме того, я выяснил, что эта ошибка вызвана сложным состоянием гонки (race condition) и для ее полноценного исправления нужна существенная переработка подсистемы AF_VSOCK. В итоге я на некоторое время отложил это исследование. А зря.

Позже, осенью 2024 года я решил снова посмотреть на этот баг и получил обнадеживающие результаты. Но тихим ноябрьским вечером я обнаружил, что Хёнву Ким (v4bel) и Вонги Ли (qwerty) уже сообщили об этой уязвимости (CVE-2024-50264) и использовали ее на соревновании kernelCTF. Их патч не исправил состояние гонки, но все же превратил мой PoC-эксплойт в null-ptr-deref:

Среди исследователей безопасности это называется bug collision. Любой, кто попадал в такую ситуацию, может представить, что я почувствовал. Возник вопрос, что мне делать: продолжить изучать эту уязвимость или смириться и оставить ее?

Виктор Васнецов «Витязь на распутье» (1882):

Стратегия эксплуатации от v4bel и qwerty выглядела слишком сложной. У меня были свои идеи, поэтому я решил все-таки продолжить исследование. В качестве цели для PoC-эксплойта я выбрал Ubuntu Server 24.04 со свежим ядром OEM/HWE (v6.11).

Анализ CVE-2024-50264

Уязвимость CVE-2024-50264 была привнесена в код ядра Linux v4.8 коммитом 06a8fc78367d в августе 2016 года. Это состояние гонки в реализации виртуальных сокетов AF_VSOCK, которое происходит между системным вызовом connect() и обработкой POSIX-сигналов, что приводит к использованию памяти после освобождения (use-after-free, UAF). Эта уязвимость особо опасна, поскольку обычный пользователь может спровоцировать ее без дополнительных привилегий, не задействуя пространства имен пользователей (user namespaces).

Ядро ошибочно использует освобожденный объект virtio_vsock_sock, размер которого составляет 80 байт, что соответствует кэшу kmalloc-96 слэб-аллокатора (я решил переводить slab allocator таким образом, поскольку слово «слэб» уже используется в деревообработке 😊). Повреждение памяти проявляется как запись после освобождения (UAF-write), происходящая в рабочем потоке ядра (kernel worker).

Однако у этой уязвимости есть множество неприятных ограничений для эксплуатации. Пожалуй, это самый неудобный для эксплуатации баг из всех, что я когда-либо видел. Неспроста он получил Pwnie Award. Далее я подробно расскажу об этих ограничениях.

Воспроизведение уязвимости с помощью «бессмертного сигнала»

Итак, сначала создаем серверный виртуальный сокет (server vsock):

#define UAF_PORT 0x2712

int ret = -1;
int vsock1 = 0;
struct sockaddr_vm addr = {
	.svm_family = AF_VSOCK,
	.svm_port = UAF_PORT,
	.svm_cid = VMADDR_CID_LOCAL
};

vsock1 = socket(AF_VSOCK, SOCK_STREAM, 0);
if (vsock1 < 0)
	err_exit("[-] creating vsock");

ret = bind(vsock1, (struct sockaddr *)&addr, sizeof(struct sockaddr_vm));
if (ret != 0)
	err_exit("[-] binding vsock");

ret = listen(vsock1, 0); /* backlog = 0 */
if (ret != 0)
	err_exit("[-] listening vsock");

Затем пытаемся установить соединение с ним для клиентского виртуального сокета (client vsock):

int vsock2 = 0;

vsock2 = socket(AF_VSOCK, SOCK_STREAM, 0);
if (vsock2 < 0)
	err_exit("[-] creating vsock");

ret = connect(vsock2, (struct sockaddr *)&addr, sizeof(struct sockaddr_vm));

Чтобы спровоцировать баг, нужно прервать системный вызов connect() с помощью POSIX-сигнала. Исследователи v4bel и qwerty использовали SIGKILL, но он убивает процесс эксплойта. Мой фаззер нашел более хитрый способ, который меня удивил:

struct sigevent sev = {};
timer_t race_timer = 0;

sev.sigev_notify = SIGEV_SIGNAL;
sev.sigev_signo = 33;
ret = timer_create(CLOCK_MONOTONIC, &sev, &race_timer);

Фаззер обнаружил, что таймер может запустить сигнал 33, который прервет connect(). Особенность сигнала 33 в том, что библиотека Native POSIX Threads Library (NPTL) использует его для внутренних нужд, и операционная система экранирует приложения от него. В руководстве (man 7 nptl) читаем:

NPTL makes internal use of the first two real-time signals (signal numbers 32 and 33).
One of these signals is used to support thread cancellation and POSIX timers (see timer_create(2)); the other is used as part of a mechanism that ensures all threads in a process always have the same UIDs and GIDs, as required by POSIX. These signals cannot be used in applications.

Верно, эти сигналы недоступны для приложений, но они идеально подходят для моего эксплойта 😉

Для созданного таймера race_timer я использую timer_settime(), что позволяет выбрать точный момент, когда сигнал 33 должен прервать уязвимый системный вызов connect(). Более того, этот сигнал невидим для процесса эксплойта и не вредит ему.

О повреждении памяти

Состояние гонки возникает, когда системный вызов connect() прерывается сигналом. Если при этом уязвимый сокет находится в состоянии TCP_ESTABLISHED, то он переходит в состояние TCP_CLOSING:

if (signal_pending(current)) {
	err = sock_intr_errno(timeout);
	sk->sk_state = sk->sk_state == TCP_ESTABLISHED ? TCP_CLOSING : TCP_CLOSE;
	sock->state = SS_UNCONNECTED;
	vsock_transport_cancel_pkt(vsk);
	vsock_remove_connected(vsk);
	goto out_wait;
}

Повторная попытка подключить уязвимый vsock, находящийся в таком состоянии, к серверному vsock с другим svm_cid (VMADDR_CID_HYPERVISOR) приводит к повреждению памяти.

struct sockaddr_vm addr = {
	.svm_family = AF_VSOCK,
	.svm_port = UAF_PORT,
	.svm_cid = VMADDR_CID_HYPERVISOR
};

/* this connect will schedule the kernel worker performing UAF */
ret = connect(vsock2, (struct sockaddr *)&addr, sizeof(struct sockaddr_vm));

Что происходит под капотом? При обработке системного вызова connect() ядро вызывает функцию vsock_assign_transport(). Она переключает виртуальный сокет на новый транспорт svm_cid и освобождает ресурсы, связанные с предыдущим vsock-транспортом:

if (vsk->transport) {
	if (vsk->transport == new_transport)
		return 0;

	/* transport->release() must be called with sock lock acquired.
	 * This path can only be taken during vsock_connect(), where we
	 * have already held the sock lock. In the other cases, this
	 * function is called on a new socket which is not assigned to
	 * any transport.
	 */
	vsk->transport->release(vsk);
	vsock_deassign_transport(vsk);
}

В ходе этой процедуры в virtio_transport_close() закрывается старый vsock-транспорт, и в virtio_transport_destruct() освобождается объект virtio_vsock_sock. Однако из-за ошибочного состояния сокета (TCP_CLOSING) функция virtio_transport_close() инициирует дальнейший обмен данными. Для их обработки ядро пробуждает поток kworker, и он обращается к освобожденной памяти в функции virtio_transport_space_update():

static bool virtio_transport_space_update(struct sock *sk, struct sk_buff *skb)
{
	struct virtio_vsock_hdr *hdr = virtio_vsock_hdr(skb);
	struct vsock_sock *vsk = vsock_sk(sk);
	struct virtio_vsock_sock *vvs = vsk->trans; /* ptr to freed object */
	bool space_available;

	if (!vvs)
		return true;

	spin_lock_bh(&vvs->tx_lock); /* proceed if 4 bytes are zero (UAF write non-zero to lock) */
	vvs->peer_buf_alloc = le32_to_cpu(hdr->buf_alloc); /* UAF write 4 bytes */
	vvs->peer_fwd_cnt = le32_to_cpu(hdr->fwd_cnt); /* UAF write 4 bytes */
	space_available = virtio_transport_has_space(vsk); /* UAF read, not interesting */
	spin_unlock_bh(&vvs->tx_lock); /* UAF write, restore 4 zero bytes */
	return space_available;
}

На следующей схеме показано, как происходит UAF на уязвимом объекте virtio_vsock_sock:

Желтым цветом выделено поле tx_lock, которое должно быть равно нулю. В противном случае ядро зависнет при попытке захватить спинлок в функции virtio_transport_space_update(). Красным цветом отмечены поля peer_buf_alloc и peer_fwd_cnt, в которые происходит запись после освобождения (UAF-write). Разыменования указателей в освобожденном объекте нет.

Значение, которое записывается в поле virtio_vsock_sock.peer_buf_alloc, атакующий может контролировать из пользовательского пространства:

/* Increase the range for the value that we want to write during UAF: */
uaf_val_limit = 0x1lu; /* can't be zero */
setsockopt(vsock1, PF_VSOCK, SO_VM_SOCKETS_BUFFER_MIN_SIZE,
           &uaf_val_limit, sizeof(uaf_val_limit));
uaf_val_limit = 0xfffffffflu;
setsockopt(vsock1, PF_VSOCK, SO_VM_SOCKETS_BUFFER_MAX_SIZE,
           &uaf_val_limit, sizeof(uaf_val_limit));

/* Set the 4-byte value that we want to write during UAF: */
setsockopt(vsock1, PF_VSOCK, SO_VM_SOCKETS_BUFFER_SIZE,
           &uaf_val, sizeof(uaf_val));

А в поле virtio_vsock_sock.peer_fwd_cnt записывается количество байтов, которые были переданы через vsock с помощью sendmsg()/recvmsg(). По умолчанию оно равно нулю (четыре нулевых байта).

Рано радоваться: у CVE-2024-50264 есть серьезные ограничения

Как я уже упоминал, у этой уязвимости масса неприятных нюансов, усложняющих эксплуатацию:

  1. Уязвимый клиентский объект virtio_vsock_sock создается вместе с серверным объектом. Выделение их в одном слэбе не позволяет провести кросс-кэш-атаку (cross-cache attack).

  2. Необходимое состояние гонки воспроизводится очень нестабильно.

  3. Запись в освобожденный объект происходит в kworker спустя всего несколько микросекунд после kfree(). Этого времени недостаточно для проведения кросс-кэш-атаки.

  4. После UAF-записи в kworker происходит разыменование нулевого указателя (null-ptr-deref). Это именно та проблема, из-за которой я поначалу отложил этот баг.

  5. Даже если удается избежать этого отказа ядра, через VSOCK_CLOSE_TIMEOUT (восемь секунд) в kworker возникает еще одно разыменование нулевого указателя.

  6. При этом kworker зависает в spin_lock_bh(), если поле virtio_vsock_sock.tx_lock ненулевое, как упоминалось выше.

Разрабатывая PoC-эксплойт для CVE-2024-50264, я обнаруживал эти препятствия одно за другим. Я думаю, именно из-за них этот баг получил Pwnie Award 2025 в категории «Лучшее повышение привилегий» (Best Privilege Escalation).

Размышления о стратегии эксплуатации

Вот план атаки v4bel и qwerty, который показался мне очень сложным:

  1. Создать огромное количество BPF-программ (BPF JIT spraying), чтобы заполнить ими значительную часть физической памяти.

  2. Применить технику SLUBStick от исследователей Грацского технического университета, чтобы:

  • Определить количество объектов в активном слэбе с помощью утечки информации по побочному каналу по времени.

  • Затем развести объекты virtio_vsock_sock клиента и сервера по разным слэбам, создав один объект в конце одного слэба, а другой объект — в начале следующего.

  1. Применить технику Dirty Page Table, чтобы через UAF-объект модифицировать запись в таблице страниц (Page Table Entry, PTE).

  2. Наудачу изменить PTE, чтобы она стала указывать на память с BPF-кодом. Вероятность того, что это сработает, зависит от количества оперативной памяти и объема BPF JIT spraying.

  3. Внедрить в BPF-код полезную нагрузку для повышения привилегий.

  4. Инициировать сетевое взаимодействие через сокет, к которому привязана модифицированная BPF-программа, чтобы повысить привилегии в системе.

Я чувствовал, что мой PoC-эксплойт для CVE-2024-50264 будет намного проще. Идея была следующая: направить UAF-запись в такой ядерный объект, который сможет дать полезный для атаки эксплойт-примитив.

Я не стал искать объект-жертву (victim object) в том же кэше kmalloc-96, потому что в Ubuntu Server 24.04 включены средства защиты аллокатора, которые нейтрализуют стандартную технику heap spraying для эксплуатации UAF:

  • При включенном параметре компиляции CONFIG_SLAB_BUCKETS=y ядро создает набор отдельных слэб-кэшей для объектов с пользовательскими данными.

  • При включенном параметре компиляции CONFIG_RANDOM_KMALLOC_CACHES=y ядро создает несколько копий слэб-кэшей и при выделении памяти заставляет kmalloc() выбирать одну из этих копий в зависимости от адреса кода, вызывающего данную функцию.

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

В качестве первой жертвы я взял объект cred. Его размер — 184 байта, и аллокатор выделяет такие объекты в слэбах, разделенных на фрагменты по 192 байта. При этом фрагменты памяти, в которых располагаются уязвимые объекты virtio_vsock_sock, в два раза меньше (96 байт). Поэтому в целевом cred возможно два варианта смещения, по которым произойдет UAF-запись. На схеме ниже показано, как два уязвимых объекта virtio_vsock_sock перекрываются объектом cred. Повреждение памяти может произойти в одном из объектов virtio_vsock_sock.

К сожалению, размещение cred на месте освобожденных объектов virtio_vsock_sock не дает атакующему никакой пользы:

  • Если UAF происходит на первом virtio_vsock_sock, ядро зависает в функции spin_lock_bh(), потому что на месте virtio_vsock_sock.tx_lock объект cred имеет ненулевое поле uid.

  • Если UAF происходит на втором virtio_vsock_sock, запись контролируемых данных в поле virtio_vsock_sock.peer_buf_alloc повреждает указатель cred.request_key_auth. Без предварительной утечки информации из ядра это трудно превратить в полезный эксплойт-примитив.

Итак, объект cred мне не подошел, и я стал искать дальше. Следующей моей жертвой для повреждения памяти стал msg_msg. Этот объект мне нравится: впервые я применил его для heap spraying в 2021 году (подробности можно найти в статье «Сила четырех байтов: эксплуатация уязвимости CVE-2021-26708 в ядре Linux»). Техника эксплуатации, которую я тогда изобрел, стала популярной в среде исследователей безопасности ядра Linux. И в этот раз я решил снова придумать что-то новое с объектом msg_msg.

Для эксперимента я выбрал 96-байтный msg_msg, чтобы слэб-аллокатор использовал для msg_msg и virtio_vsock_sock фрагменты (chunks) одинакового размера. Это позволило зафиксировать смещение UAF-записи внутри объекта msg_msg. На следующей схеме показано, что происходит с объектом msg_msg, размещенным на месте освобожденного virtio_vsock_sock:

msg_msg.m_list.prev — это указатель на предыдущий объект в связном списке в адресном пространстве ядра. При создании msg_msg этот указатель равен нулю (см. CONFIG_INIT_ON_ALLOC_DEFAULT_ON), а затем он получает ненулевое значение, когда msg_msg помещается в очередь сообщений. К сожалению, этот ненулевой указатель интерпретируется как virtio_vsock_sock.tx_lock, из-за чего функция virtio_transport_space_update() зависает при выполнении spin_lock_bh().

Чтобы обойти это ограничение, нужно было добиться, чтобы ядро проинициализировало msg_msg.m_list.prev уже после выполнения UAF-записи. Я стал искать способ отложить помещение msg_msg в очередь сообщений — и в конце концов нашел решение.

Новая техника: msg_msg spraying с восстановлением поля m_list

Я придумал способ выполнить heap spraying объектами msg_msg так, чтобы ядро автоматически восстанавливало значения указателей в поле msg_msg.m_list в случае их повреждения. Порядок действий такой:

  1. Почти полностью заполняем очередь сообщений до отправки целевых msg_msg.

  • Размер очереди сообщений составляет MSGMNB=16384 байт.

  • Чтобы ее заполнить, отправляем 2 больших сообщения-пустышки по 8191 байт каждое, не вызывая для них msgrcv().

  • Тогда в очереди останется лишь 2 байта свободного места.

  • Чтобы отличать эти msg_msg от целевых, используем для них mtype = 1.

  1. Далее начинаем heap spraying: создаем целевые объекты msg_msg с помощью системного вызова msgsnd().

  • Используем для этих сообщений mtype = 2, чтобы отличать их от пустышек.

  • Вызываем msgsnd() из отдельных pthread-потоков.

  • В результате каждого такого вызова ядро выделяет память под целевой msg_msg, после чего блокирует поток до тех пор, пока в очереди не появится достаточно места:

    	if (msg_fits_inqueue(msq, msgsz))
    		break;
    
    	/* queue full, wait: */
    	if (msgflg & IPC_NOWAIT) {
    		err = -EAGAIN;
    		goto out_unlock0;
    	}
    
    	/* enqueue the sender and prepare to block */
    	ss_add(msq, &s, msgsz);
    
    	if (!ipc_rcu_getref(&msq->q_perm)) {
    		err = -EIDRM;
    		goto out_unlock0;
    	}
    
    	ipc_unlock_object(&msq->q_perm);
    	rcu_read_unlock();
    	schedule();
    
  1. Пока системные вызовы msgsnd() ожидают места в очереди сообщений, выполняем UAF-запись в начало одного из целевых объектов msg_msg, повреждая его поля m_list, m_type и m_ts.

  2. После UAF-записи вызываем msgrcv() для сообщений-пустышек с типом 1.

  3. В очереди сообщений освобождается место, и ядро пробуждает потоки, заблокированные в системном вызове msgsnd(). Теперь целевые объекты msg_msg благополучно добавляются в очередь, и при этом ядро восстанавливает поле m_list, значение которого было повреждено в одном из объектов:

	if (!pipelined_send(msq, msg, &wake_q)) {
		/* no one is waiting for this message, enqueue it */
		list_add_tail(&msg->m_list, &msq->q_messages);
		msq->q_cbytes += msgsz;
		msq->q_qnum++;
		percpu_counter_add_local(&ns->percpu_msg_bytes, msgsz);
		percpu_counter_add_local(&ns->percpu_msg_hdrs, 1);
	}

Готово! Эта техника позволяет выполнить перезапись msg_msg вслепую, например, при выполнении записи за границей массива (out-of-bounds write). Благодаря тому, что ядро само восстанавливает поврежденные указатели в поле m_list, атакующему не требуется утечка информации о ядерных адресах. А в моем случае этот трюк дополнительно позволил избежать зависания virtio_transport_space_update() при выполнении spin_lock_bh():

Чтобы реализовать UAF-запись в объект msg_msg, мне нужно было выполнить кросс-кэш-атаку, чтобы разместить msg_msg на месте virtio_vsock_sock. В Ubuntu Server 24.04 объекты virtio_vsock_sock размещаются в одном из 16 слэб-кэшей kmalloc-rnd-?-96, которые создаются благодаря CONFIG_RANDOM_KMALLOC_CACHES=y. В свою очередь объекты msg_msg живут в выделенном слэб-кэше msg_msg-96, который создается в системе благодаря CONFIG_SLAB_BUCKETS=y.

Чтобы реализовать кросс-кэш-атаку, мне нужно было понять, как такие атаки работают на актуальном ядре в Ubuntu Server. Но проверять гипотезы и экспериментировать с этим нестабильным состоянием гонки было настоящим мучением. Тогда пришла идея:

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

Kernel Hack Drill

Еще в 2017 году я создал для своих студентов проект под названием kernel-hack-drill. Это тестовая среда для изучения и экспериментов с эксплойтами для ядра Linux. Я вспомнил про этот свой проект и решил использовать его при разработке эксплойт-примитивов для CVE-2024-50264.

kernel-hack-drill — открытый проект, я опубликовал его под лицензией GPL-3.0. В его составе:

  • drill_mod.c — исходный код небольшого модуля ядра Linux, который предоставляет простой интерфейс в пользовательском пространстве через файл /proc/drill_act. Этот модуль содержит синтетические уязвимости, с которыми можно удобно экспериментировать.

  • drill.h — заголовочный файл, описывающий интерфейс модуля drill_mod.ko:

    enum drill_act_t {
    	DRILL_ACT_NONE = 0,
    	DRILL_ACT_ALLOC = 1,
    	DRILL_ACT_CALLBACK = 2,
    	DRILL_ACT_SAVE_VAL = 3,
    	DRILL_ACT_FREE = 4,
    	DRILL_ACT_RESET = 5
    };
    
    #define DRILL_ITEM_SIZE 95
    
    struct drill_item_t {
    	unsigned long foobar;
    	void (*callback)(void);
    	char data[]; /* C99 flexible array */
    };
    
    #define DRILL_N 10240
    
  • drill_test.c — тест для drill_mod.ko с примерами использования /proc/drill_act, который запускается из пользовательского пространства. Этот тест аккуратно взаимодействует с drill_mod.ko без повреждения ядерной памяти и успешно завершается в том числе при CONFIG_KASAN=y.

  • README.md — подробное пошаговое руководство по настройке и использованию kernel-hack-drill (спасибо контрибьюторам, которые поучаствовали в его написании).

Забавный факт: когда я придумал название kernel-hack-drill для этого проекта, я использовал слово drill в значении «тренировка», то есть отработка навыков по безопасности ядра Linux. Но мои друзья и студенты поняли иначе. Они представили себе нечто такое:

Проект kernel-hack-drill немного похож на KRWX, но гораздо проще. Кроме того, он содержит целый набор готовых PoC-эксплойтов для синтетических уязвимостей в drill_mod.ko:

  • drill_uaf_callback.c — UAF-эксплойт, вызывающий функцию callback из освобожденной структуры drill_item_t. Он перехватывает поток управления в ядре и выполняет локальное повышение привилегий.

  • drill_uaf_w_msg_msg.c — UAF-эксплойт, выполняющий запись в освобожденную структуру drill_item_t. Он выполняет кросс-кэш-атаку и перезаписывает msg_msg.m_ts, что позволяет читать память ядра за границей буфера. Я написал этот PoC-эксплойт во время исследования, описанного в данной статье.

  • drill_uaf_w_pipe_buffer.c — UAF-эксплойт, также выполняющий запись в освобожденную структуру drill_item_t. Он выполняет кросс-кэш-атаку и перезаписывает pipe_buffer.flags, чтобы реализовать технику Dirty Pipe и добиться локального повышения привилегий. Этот PoC-эксплойт тоже был разработан во время экспериментов с CVE-2024-50264.

Недавно контрибьюторы добавили несколько новых интересных вариантов:

  • drill_uaf_callback_rop_smep.c — доработанная версия drill_uaf_callback.c с ROP-цепочкой для обхода средства защиты SMEP на x86_64.

  • drill_uaf_w_pte.c — UAF-эксплойт, выполняющий запись в освобожденную структуру drill_item_t. Он выполняет кросс-аллокаторную атаку и модиф��цирует запись в таблице страниц (PTE), чтобы реализовать технику Dirty Page Table и выполнить локальное повышение привилегий на x86_64.

  • drill_uaf_w_pud.c — улучшенная версия drill_uaf_w_pte.c, которая перезаписывает не PTE, а запись в Page Directory Pointer Table (PDPT), которая в ядре Linux называется Page Upper Directory (PUD). Это позволяет реализовать атаку Dirty Page Table через большие страницы (huge pages).

В тот момент, когда я снова взялся за kernel-hack-drill при исследовании CVE-2024-50264, проект уже много лет не обновлялся. Но теперь kernel-hack-drill содержит хороший набор практических материалов для исследователей безопасности ядра Linux.

Эксперименты с кросс-кэш-атаками при помощи kernel-hack-drill

Мне предстояло изучить нюансы кросс-кэш-атак на HWE-версии ядра Ubuntu Server с включенными средствами защиты слэб-аллокатора.

Я реализовал стандартную кросс-кэш-атаку в drill_uaf_w_msg_msg.c. Полный код есть в репозитории, здесь я опишу общий порядок действий. Чтобы вникнуть в тему, советую посмотреть доклад Андрея Коновалова «SLUB Internals for Exploit Developers».

При планировании атаки нужно было собрать информацию из /sys/kernel/slab. Структуры virtio_vsock_sock (80 байт) и drill_item_t (95 байт) хранятся в слэб-кэшах, которые содержат:

  • по 42 фрагмента (chunks) в каждом слэбе (objs_per_slab=42),

  • по 120 частично заполненных слэбов для каждого ядра процессора (cpu_partial=120).

Алгоритм кросс-кэш-атаки:

  1. Создаем новый активный слэб, выделив objs_per_slab объектов. Активным называется слэб, который будет использоваться ядром для следующей аллокации.

  2. Выделяем objs_per_slab * cpu_partial объектов, чтобы подготовить cpu_partial полных слэбов, которые позже потребуются для заполнения списка partial list на шаге 6.

  3. Формируем слэб, содержащий UAF-объект. Для этого выделяем objs_per_slab объектов и сохраняем висячую ссылку на уязвимый объект в этом слэбе.

  4. Снова создаем новый активный слэб, выделив objs_per_slab объектов. Этот шаг очень важен для поддержания стабильности кросс-кэш-атаки. В противном случае слэб с уязвимым объектом останется активным и не сможет вернуться в страничный аллокатор (page allocator).

  5. Полностью освобождаем слэб, в котором находится UAF-объект. Для этого освобождаем (objs_per_slab * 2 - 1) объектов, которые были выделены непосредственно перед самым последним. Теперь активный слэб содержит только последний аллоцированный объект, и полностью освобожденный слэб с UAF-объектом уходит в partial list.

  6. Заполняем partial list: освобождаем по одному из objs_per_slab объектов в слэбах, зарезервированных на шаге 2. Это заставляет слэб-аллокатор произвести очистку списка частично заполненных слэбов (partial list) и передать освобожденный слэб с UAF-объектом в страничный аллокатор.

  7. Переиспользуем страницу с UAF-объектом в другом слэб-кэше: для этого создаем множество целевых объектов msg_msg (другими словами, распыляем их, выполняем heap spraying). В результате один msg_msg окажется на месте, где раньше находился уязвимый объект (drill_item_t в данном случае).

  8. Дальше — эксплуатируем UAF! Например, используя висячую ссылку на уязвимый объект, перезаписываем msg_msg.m_ts и получаем возможность читать память ядра за границами буфера.

Я видел немало статей про кросс-кэш-атаки, но ни одна из них не объясняет, как такие атаки отлаживать. Заполню этот пробел.

Давайте разберемся на примере из drill_uaf_w_msg_msg.c. Чтобы наблюдать за ходом атаки и отлаживать ее, вносим следующие изменения в исходный код ядра:

diff --git a/mm/slub.c b/mm/slub.c
index be8b09e09d30..e45f055276d1 100644
--- a/mm/slub.c
+++ b/mm/slub.c
@@ -3180,6 +3180,7 @@ static void __put_partials(struct kmem_cache *s, struct slab *partial_slab)
        while (slab_to_discard) {
                slab = slab_to_discard;
                slab_to_discard = slab_to_discard->next;
+               printk("__put_partials: cache 0x%lx slab 0x%lx\n", (unsigned long)s, (unsigned long)slab);
 
                stat(s, DEACTIVATE_EMPTY);
                discard_slab(s, slab);

diff --git a/ipc/msgutil.c b/ipc/msgutil.c
index c7be0c792647..21af92f531d6 100644
--- a/ipc/msgutil.c
+++ b/ipc/msgutil.c
@@ -64,6 +64,7 @@ static struct msg_msg *alloc_msg(size_t len)
        msg = kmem_buckets_alloc(msg_buckets, sizeof(*msg) + alen, GFP_KERNEL);
        if (msg == NULL)
                return NULL;
+       printk("msg_msg 0x%lx\n", (unsigned long)msg);
 
        msg->next = NULL;
        msg->security = NULL;

Здесь в __put_partials() мы выводим адрес слэба, который возвращается в страничный аллокатор при выполнении discard_slab(). В alloc_msg() мы выводим адрес, по которому ядро создало новый объект msg_msg.

При успешной кросс-кэш-атаке слэб, содержавший объекты drill_item_t, передается обратно страничному аллокатору и затем переиспользуется для объектов msg_msg. Мы можем пронаблюдать это при запуске PoC-эксплойта drill_uaf_w_msg_msg:

  • В журнале ядра:

    [   32.719582] drill: kmalloc'ed item 5123 (0xffff88800c960660, size 95)
    
  • Затем в stdout:

    [+] done, current_n: 5124 (next for allocating)
    [!] obtain dangling reference from use-after-free bug
    [+] done, uaf_n: 5123
    
  • Затем в GDB (используя bata24/gef):

    gef> slab-contains 0xffff88800c960660
    [+] Wait for memory scan
    slab: 0xffffea0000325800
    kmem_cache: 0xffff888003c45300
    base: 0xffff88800c960000
    name: kmalloc-rnd-05-96  size: 0x60  num_pages: 0x1
    
  • Наконец, снова в журнале ядра:

    [   36.778165] drill: free item 5123 (0xffff88800c960660)
    ...
    [   36.807956] __put_partials: cache 0xffff888003c45300 slab 0xffffea0000325800
    ...
    [   36.892053] msg_msg 0xffff88800c960660
    

Здесь мы видим, как объект drill_item_t по адресу 0xffff88800c960660, находившийся в слэбе 0xffffea0000325800, был заново выделен уже как msg_msg. Это означает, что кросс-кэш-атака сработала.

В ходе экспериментов с kernel-hack-drill на Ubuntu Server 24.04 я обнаружил, что CONFIG_RANDOM_KMALLOC_CACHES и CONFIG_SLAB_BUCKETS блокируют наивную эксплуатацию UAF, но при этом делают кросс-кэш-атаки полностью стабильными. Похоже, картина получается такая:

Получается, что без механизма SLAB_VIRTUAL или аналогичного средства защиты ядро Linux остается уязвимым для кросс-кэш-атак.

Адаптация кросс-кэш-атаки для CVE-2024-50264

Как уже упоминалось в списке ограничений, уязвимый клиентский объект virtio_vsock_sock создается вместе с серверным объектом (ограничение № 1). Выделение их в одном слэбе не позволяет провести кросс-кэш-атаку (cross-cache attack), так как это мешает освободить слэб полностью. Получается безвыходная ситуация:

  • Если оставить серверный vsock открытым, то слэб с UAF-объектом не освобождается полностью и потому не передается в страничный аллокатор. Кросс-кэш-атака срывается.

  • Если закрыть серверный vsock, то перестает воспроизводиться уязвимость и UAF не происходит.

Что с этим делать? v4bel и qwerty использовали технику SLUBStick, чтобы с помощью утечки информации через побочный канал по времени определить, когда аллокатор переключается на новый активный слэб. Я пошел другим путем:

А что если почти сразу прервать сигналом системный вызов connect()?

По сути, я использовал еще одно состояние гонки, чтобы проэксплуатировать основное состояние гонки — и это сработало:

  • Отправляем «бессмертный» сигнал 33 через 10000 нс после начала уязвимого системного вызова connect(). Это гораздо меньше задержки, которая нужна для UAF.

  • Затем проверяем, воспроизвелось ли раннее состояние гонки:

    1. Системный вызов connect() должен вернуть результат «Interrupted system call».

    2. Другой тестовый клиентский vsock должен без проблем подключиться к серверному vsock.

Если оба эти условия выполнились, то можно быть уверенным, что ядро создало только один клиентский virtio_vsock_sock. Я обнаружил, что в этом случае прерывающий сигнал приходит еще до того, как завершилась коммуникация между виртуальными сокетами, и ядро еще не успело создать второй объект virtio_vsock_sock для серверного vsock. После этого можно снова вызвать connect() и отправить сигнал 33 после обычной задержки, чтобы теперь спровоцировать UAF. Такой трюк позволил мне обойти ограничение № 1 (парное создание объектов), и кросс-кэш-атака на virtio_vsock_sock стала возможной.

Попытки достичь этого раннего состояния гонки довольно быстро работают в цикле. После того, как они увенчались успехом, основное состояние гонки, приводящее к UAF, срабатывает намного стабильнее: мой прототип эксплойта получил возможность выполнять UAF примерно раз в секунду вместо одного раза в несколько минут. Это решило проблему нестабильности из ограничения № 2. Такой «спидран» для уязвимости также смягчил ограничение № 5: теперь я мог сделать около пяти перезаписей через UAF, прежде чем kworker падал с null-ptr-deref после восьми секунд (VSOCK_CLOSE_TIMEOUT).

Чтобы обойти ограничение № 4 (null-ptr-deref в kworker сразу после UAF), я применил третье состояние гонки, аналогично подходу v4bel и qwerty. Сразу после системного вызова connect() я вызываю listen() на уязвимом vsock. Если listen() срабатывает раньше потока kworker, то состояние vsock меняется на TCP_LISTEN, из-за чего kworker отрабатывает по другому кодовому пути, где разыменование нулевого указателя не происходит. К сожалению, этот шаг — самая нестабильная часть всего эксплойта, listen() не всегда обгоняет kworker. Остальные части цепочки работают гораздо надежнее.

К тому моменту мой список препятствий для эксплуатации CVE-2024-50264 выглядел так:

  1. Уязвимый клиентский объект virtio_vsock_sock создается вместе с серверным объектом. Выделение их в одном слэбе не позволяет провести кросс-кэш-атаку.

  2. Необходимое состояние гонки воспроизводится очень нестабильно.

  3. Запись в освобожденный объект происходит в kworker спустя всего несколько микросекунд после kfree(). Этого времени недостаточно для проведения кросс-кэш-атаки.

  4. После UAF-записи в kworker происходит разыменование нулевого указателя (null-ptr-deref). Это именно та проблема, из-за которой я поначалу отложил этот баг.

  5. Даже если удается избежать этого отказа ядра, через VSOCK_CLOSE_TIMEOUT (восемь секунд) в kworker возникает еще одно разыменование нулевого указателя.

  6. При этом kworker зависает в spin_lock_bh(), если поле virtio_vsock_sock.tx_lock ненулевое.

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

Слишком медленно! Кросс-кэш-атака запаздывает на вечеринку

Как отмечено в ограничении № 3, UAF-запись в потоке kworker происходит всего через несколько микросекунд после вызова kfree() для virtio_vsock_sock. А для кросс-кэш-атаки требуется гораздо больше времени, поэтому UAF-запись просто попадает в освобожденный объект virtio_vsock_sock, который еще не превратился в msg_msg.

Я не знал, как ускорить саму кросс-кэш-процедуру, зато я знал, как можно замедлить выполнение какого-либо кода в ядре. Этот метод описан в статье Яна Хорна (Jann Horn) «Racing against the clock». Он позволил затормозить мой kworker так, чтобы медленная кросс-кэш-атака успела выполниться.

Суть в том, чтобы «отвлечь» kworker на обработку событий от таймера timerfd, за которым следит множество экземпляров epoll. Порядок действий такой (подробности см. в статье Яна):

  1. Вызвать timerfd_create(CLOCK_MONOTONIC, 0).

  2. Создать 8 дочерних процессов (forks).

  3. В каждом дочернем процессе 100 раз вызвать dup() для timerfd.

  4. В каждом дочернем процессе 500 раз вызвать epoll_create().

  5. Для каждого экземпляра epoll включить отслеживание событий на всех доступных копиях timerfd с помощью epoll_ctl().

  6. В конце настроить timerfd так, чтобы прерывание случилось в нужный момент работы потока kworker:

timerfd_settime(timerfd, TFD_TIMER_CANCEL_ON_SET, &retard_tmo, NULL)

Такой прием увеличил окно состояния гонки примерно в 80 раз.

Мне хотелось получить побольше времени, чтобы гарантированно завершить кросс-кэш-атаку. Но я столкнулся с лимитом, о котором не было сказано в оригинальной статье. Если превысить количество /proc/sys/fs/epoll/max_user_watches, вызов epoll_ctl() завершается с ошибкой. В man 7 epoll читаем:

/proc/sys/fs/epoll/max_user_watches задает ограничение на общее количество файловых дескрипторов, которые пользователь может зарегистрировать во всех экземплярах epoll в системе. Ограничение привязывается к реальному идентификатору пользователя. Каждый зарезервированный файловый дескриптор занимает приблизительно 90 байт в 32-битном ядре и приблизительно 160 байт в 64-битном ядре. В настоящее время значение по умолчанию для max_user_watches равно 1/25 (4%) доступной памяти ядра (low memory), поделенное на значение размера дескриптора в байтах.

На Ubuntu Server 24.04 с 2 ГиБ оперативной памяти /proc/sys/fs/epoll/max_user_watches составляет 431838, и это не так много. Получается, я мог позволить себе 8 дочерних процессов × 500 экземпляров epoll × 100 копий файловых дескрипторов, итого 400000 отслеживаний через epoll (epoll watches).

Однако, этого хватило, чтобы преодолеть ограничение № 3, и мне наконец-то удалось перезаписать размер сообщения msg_msg: UAF в vsock изменил msg_msg.m_ts с 48 байт на 8192 (MSGMAX). После этого я смог выполнить чтение памяти ядра за границами msg_msg, используя системный вызов msgrcv().

Разбираем добычу

Поврежденный msg_msg позволил мне прочитать 8 КиБ данных из пространства ядра. Я стал разбирать эту «добычу» и нашел полезную утечку информации: ядерный адрес 0xffffffff8233cfa0 [1]. Утечка оказалась стабильной и воспроизводилась с высокой вероятностью на каждом запуске эксплойта, поэтому я решил использовать ее без дополнительных манипуляций с кучей (heap feng shui). С помощью GDB я выяснил, что этот адрес — указатель на функцию socket_file_ops(), который содержится в ядерном объекте file. Это меня очень порадовало, потому что в struct file неподалеку находится указатель f_cred [2], который также удалось прочитать в эксплойте.

Вот как я анализировал содержимое памяти, прочитанной за границами объекта msg_msg по адресу 0xffff88800e75d600:

gef> p *((struct file *)(0xffff88800e75d600 + 96*26 + 64))
$61 = {
  f_count = {
    counter = 0x0
  },
  f_lock = {
    {
      rlock = {
        raw_lock = {
          {
            val = {
              counter = 0x0
            },
            {
              locked = 0x0,
              pending = 0x0
            },
            {
              locked_pending = 0x0,
              tail = 0x0
            }
          }
        }
      }
    }
  },
  f_mode = 0x82e0003,
  f_op = 0xffffffff8233cfa0 <socket_file_ops>,    [1]
  f_mapping = 0xffff88800ee66f60,
  private_data = 0xffff88800ee66d80,
  f_inode = 0xffff88800ee66e00,
  f_flags = 0x2,
  f_iocb_flags = 0x0,
  f_cred = 0xffff888003b7ad00,                    [2]
  f_path = {
    mnt = 0xffff8880039cec20,
    dentry = 0xffff888005b30b40
  },
  ...

Таким образом, мой PoC-эксплойт добыл указатель на struct cred — структуру, где хранятся учетные данные (credentials) текущего процесса. До повышения привилегий оставался один шаг — запись ядерной памяти по произвольному адресу (arbitrary address writing). Обладая этой возможностью, я смог бы перезаписать учетные данные процесса эксплойта и стать суперпользователем root. Это была бы атака только на данные (data-only attack), без перехвата потока управления.

В поисках примитива записи по произвольному адресу

С этого момента началась самая интересная и сложная часть исследования. Мне нужен был объект ядра, который можно модифицировать с помощью моей ограниченной UAF-записи и получить за счет этого эксплойт-примитив записи по произвольному адресу. Поиски оказались изнурительными. Что я попробовал:

  • Изучил десятки различных объектов ядра Linux.

  • Перечитал множество статей про эксплуатацию уязвимостей в ядре.

  • Попробовал Kernel Exploitation Dashboard от Эдуардо Вела (Eduardo Vela) и команды KernelCTF.

Одна из идей заключалась в том, чтобы применить мою ограниченную UAF-запись для атаки Dirty Page Table. Эту технику эксплуатации хорошо описал Николас Ву (Nicolas Wu). Ее суть в том, что манипуляции с таблицами страниц позволяют атакующему читать и записывать память по произвольному физическому адресу.

Теоретически я мог бы выполнить кросс-кэш-атаку (а точнее, кросс-аллокаторную атаку), чтобы с помощью UAF-записи в объект virtio_vsock_sock модифицировать таблицы страниц. Но чтобы атаковать ядерный код или кучу, нужно знать физический адрес целевой памяти. На ум пришли два варианта:

  1. Сделать перебор физических адресов (bruteforcing). В моем случае этот способ не годился: прототип эксплойта мог выполнить UAF примерно пять раз до отказа kworker. Этого было явно недостаточно для перебора.

  2. Использовать утечку информации о KASLR-смещении, которую я получил за счет чтения ядерной памяти с помощью msg_msg. Я решил попробовать этот вариант.

Я быстро проверил, как ведет себя KASLR на X86_64, включив параметры CONFIG_RANDOMIZE_BASE и CONFIG_RANDOMIZE_MEMORY. Несколько раз перезагрузив виртуальную машину, я сравнил физические и виртуальные адреса кода ядра.

Первый запуск VM:

gef> ksymaddr-remote
[+] Wait for memory scan
0xffffffff98400000 T _text

gef> v2p 0xffffffff98400000
Virt: 0xffffffff98400000 -> Phys: 0x57400000

Второй запуск VM:

gef> ksymaddr-remote
[+] Wait for memory scan
0xffffffff81800000 T _text

gef> v2p 0xffffffff81800000
Virt: 0xffffffff81800000 -> Phys: 0x18600000

Затем я вычислил разницу между виртуальными и физическими адресами:

  • Первый запуск VM: 0xffffffff98400000 - 0x57400000 = 0xffffffff41000000

  • Второй запуск VM: 0xffffffff81800000 - 0x18600000 = 0xffffffff69200000

Поскольку 0xffffffff41000000 не равно 0xffffffff69200000, информация о KASLR-смещении кода ядра в виртуальном адресном пространстве не помогает вычислить его смещение в физической памяти.

Таким образом, чтобы реализовать атаку Dirty Page Table, мне нужно было как-то добыть физический адрес ядра. Можно было бы придумать какие-то манипуляции со страничным аллокатором (page-allocator feng shui) перед чтением памяти ядра через msg_msg. Но такой поход показался чересчур запутанным, а хотелось найти более простое и красивое решение.

Я продолжил поиски объекта-жертвы для UAF-записи, который обеспечил бы запись памяти ядра по произвольному адресу, и в итоге остановил свой выбор на pipe_buffer.

При создании канала (pipe) с помощью системного вызова pipe() ядро выделяет массив структур pipe_buffer. Каждый элемент pipe_buffer в этом массиве соответствует странице памяти, где хранятся данные, записанные в канал. На схеме ниже показано внутреннее устройство этого объекта:

Этот объект оказался отличной целью для UAF-записи. Я смог создать массив объектов pipe_buffer того же размера, что и virtio_vsock_sock, изменив емкость канала: fcntl(pipe_fd[1], F_SETPIPE_SZ, PAGE_SIZE * 2). В результате этого вызова ядро изменяет размер массива на 2 * sizeof(struct pipe_buffer) = 80 bytes, что в точности совпадает с размером virtio_vsock_sock.

Кроме того, при UAF-записи в virtio_vsock_sock, 4 контролируемых байта по смещению 24 позволяют изменить поле pipe_buffer.flags, как в изначальной атаке Dirty Pipe от Макса Келлермана (Max Kellermann).

Этот вариант Dirty Pipe вообще не требует утечки информации и дает повышение привилегий за одну UAF-запись. Вдохновившись, я решил поэкспериментировать с pipe_buffer в моем kernel-hack-drill.

Эксперименты с Dirty Pipe

Я реализовал атаку Dirty Pipe для синтетической уязвимости в kernel-hack-drill. PoC-эксплойт drill_uaf_w_pipe_buffer.c доступен в репозитории. Что делает этот эксплойт:

  1. Выполняет кросс-кэш-атаку и превращает слэб с объектами drill_item_t в слэб с объектами pipe_buffer.

  2. Эксплуатирует UAF-запись в drill_item_t. Контролируемые атакующим байты, записанные в drill_item_t по смещению 24, меняют значение поля pipe_buffer.flags.

  3. Реализует атаку Dirty Pipe, добиваясь повышения привилегий «в один выстрел» и без утечки информации — красота!

Чтобы применить эту технику в моем PoC-эксплойте для CVE-2024-50264, нужно было обойти последнее ограничение № 6: поток kworker зависал прямо перед UAF-записью, если значение в поле virtio_vsock_sock.tx_lock не равно нулю. Я нашел, как обойти эту проблему: сделал splice() из обычного файла в канал, начиная с нулевого смещения:

	loff_t file_offset = 0;
	ssize_t bytes = 0;

	/* N.B. splice modifies the file_offset value */
	bytes = splice(temp_file_fd, &file_offset, pipe_fd[1], NULL, 1, 0);
	if (bytes < 0)
		err_exit("[-] splice");
	if (bytes != 1)
		err_exit("[-] splice short");

В этом случае поле pipe_buffer.offset остается нулевым, поэтому kworker не зависает при захвате спинлока:

Казалось бы, все получилось — но тут я заметил, что UAF-запись повреждает еще и указатель на функцию pipe_buffer.ops, записывая в него четыре нулевых байта из peer_fwd_cnt. Этот неприятный побочный эффект приводил к отказу ядра (kernel crash) при любой дальнейшей работе с pipe_buffer ☹️.

Это привело меня к следующей цепочке рассуждений:

  1. Чтобы завершить атаку Dirty Pipe, нужен исправный pipe_buffer с нетронутым значением указателя ops.

  2. Чтобы сохранить 0xffffffff в старших байтах указателя pipe_buffer.ops, нужно иметь это значение в peer_fwd_cnt.

  3. Для установки ненулевого значения peer_fwd_cnt в virtio_vsock_sock нужно отправить данные через виртуальный сокет.

  4. Чтобы отправить данные через vsock, сначала нужен успешный connect().

  5. Но успешный connect() на уязвимом виртуальном сокете не позволяет воспроизвести состояние гонки и UAF ⛔.

Увы!

Продолжение приключений с pipe_buffer

Итак, классический вариант Dirty Pipe не подошел для моей уязвимости. Но вдруг меня осенило:

Что если создать канал с емкостью PAGE_SIZE * 4, чтобы заставить ядро выделить массив из четырех объектов pipe_buffer в kmalloc-192?

Тогда перекрытие объектов в памяти выглядит так: четыре объекта pipe_buffer в одном фрагменте из kmalloc-192 оказываются на месте, где раньше жили два объекта virtio_vsock_sock в двух соседних фрагментах из kmalloc-96. Это отражено на следующей схеме:

Здесь повреждение памяти может произойти на одном из двух объектов virtio_vsock_sock. Разберем оба этих варианта по очереди.

При UAF на первом virtio_vsock_sock

Чтобы избежать зависания и падения ядра, когда UAF возникает на первом virtio_vsock_sock, я применил два трюка:

  1. Выполнил splice() из обычного файла в канал с нулевым начальным смещением. Как говорилось выше, поле offset первого pipe_buffer при этом остается нулевым, поэтому поток kworker не зависает при захвате спинлока перед UAF.

  2. Вычитал и тем самым сбросил первый pipe_buffer до UAF, не трогая его поле offset:

    /* Remove the first pipe_buffer without changing the `pipe_buffer.offset` */
    bytes = splice(pipe_fd[0], NULL, temp_pipe_fd[1], NULL, 1, 0);
    if (bytes < 0)
    	err_exit("[-] splice");
    if (bytes == 0)
    	err_exit("[-] splice short");
    
    /*
     * Let's read this byte and empty the first pipe_buffer.
     * So if the UAF writing corrupts the first pipe_buffer,
     * that will not crash the kernel. Cool!
     */
    bytes = read(temp_pipe_fd[0], pipe_data_to_read, 1); /* 1 spliced byte */
    if (bytes < 0)
    	err_exit("[-] pipe read 1");
    if (bytes != 1)
    	err_exit("[-] pipe read 1 short");
    

После такой последовательности вызовов splice() и read() первый pipe_buffer становится неактивным. Даже если последующая UAF-запись повредит его указатель ops, дальнейшая работа с каналом не вызовет разыменование поврежденного указателя в этом pipe_buffer и отказа ядра не будет.

При UAF на втором virtio_vsock_sock

Я хотел проэксплуатировать UAF на втором virtio_vsock_sock, чтобы перезаписать четвертый pipe_buffer. Чтобы предотвратить зависание ядра, когда UAF попадает во второй virtio_vsock_sock, я еще два раза выполнил тот же splice(temp_file_fd, &file_offset, pipe_fd[1], NULL, 1, 0). Эти системные вызовы инициализировали второй и третий объекты pipe_buffer, оставив их поля flags равными нулю (данная операция с каналом не выставляет ни один из битов PIPE_BUF_FLAG_*). Следовательно, если UAF случится на втором virtio_vsock_sock, то поток kworker не зависает на вызове spin_lock_bh() в функции virtio_transport_space_update().

Такая подготовка канала перед эксплуатацией уязвимости дала мне возможность перезаписать четыре младших байта указателя page в четвертом объекте pipe_buffer!

При экспериментах с массивом объектов pipe_buffer очень помог мой тестовый полигон kernel-hack-drill. Без него было бы чрезвычайно трудно разработать и отладить эксплойт-примитив с перезаписью pipe_buffer.page для состояния гонки CVE-2024-50264.

AARW и последняя месть KASLR

В объекте pipe_buffer поле page содержит адрес экземпляра struct page внутри виртуальной карты памяти (vmemmap). vmemmap — это массив данных структур, который позволяет ядру эффективно адресовать физическую память системы. Подробности можно найти в Documentation/arch/x86/x86_64/mm.rst:

____________________________________________________________|___________________________________________________________
                  |            |                  |         |
 ffff800000000000 | -128    TB | ffff87ffffffffff |    8 TB | ... guard hole, also reserved for hypervisor
 ffff880000000000 | -120    TB | ffff887fffffffff |  0.5 TB | LDT remap for PTI
 ffff888000000000 | -119.5  TB | ffffc87fffffffff |   64 TB | direct mapping of all physical memory (page_offset_base)
 ffffc88000000000 |  -55.5  TB | ffffc8ffffffffff |  0.5 TB | ... unused hole
 ffffc90000000000 |  -55    TB | ffffe8ffffffffff |   32 TB | vmalloc/ioremap space (vmalloc_base)
 ffffe90000000000 |  -23    TB | ffffe9ffffffffff |    1 TB | ... unused hole
 ffffea0000000000 |  -22    TB | ffffeaffffffffff |    1 TB | virtual memory map (vmemmap_base)
 ffffeb0000000000 |  -21    TB | ffffebffffffffff |    1 TB | ... unused hole
 ffffec0000000000 |  -20    TB | fffffbffffffffff |   16 TB | KASAN shadow memory
__________________|____________|__________________|_________|____________________________________________________________

Следовательно, когда мне удалось выполнить UAF-запись контролируемых данных в указатель pipe_buffer.page, я получил возможность через канал читать и писать ядерную память по произвольному адресу (Arbitrary Address Reading and Writing, AARW). Однако я не мог многократно менять целевой адрес для AARW (см. ограничение № 5), поэтому цель внутри vmemmap пришлось выбирать очень тщательно.

Первой мыслью было перезаписать часть кода ядра. Но при включенном KASLR я не знал физический адрес сегмента _text и, соответственно, не мог определить его позицию внутри vmemmap.

Поэтому я решил применить AARW через канал против struct cred в динамической памяти ядра (куче). Ранее я уже добыл виртуальный адрес cred через чтение за границами буфера в msg_msg. Этот виртуальный адрес имел вид 0xffff888003b7ad00, из чего я понял, что он принадлежит прямому отображению физической памяти (direct mapping). Дальше я рассчитал смещение соответствующей struct page в vmemmap по формуле:

#define STRUCT_PAGE_SZ 64lu
#define PAGE_ADDR_OFFSET(addr) (((addr & 0x3ffffffflu) >> 12) * STRUCT_PAGE_SZ)
uaf_val = PAGE_ADDR_OFFSET(cred_addr);

Идея проста:

  • Вычисление addr & 0x3ffffffflu дает смещение объекта struct cred относительно начала прямого отображения памяти (page_offset_base).

  • Сдвиг полученного результата вправо на 12 бит дает номер страницы памяти, где лежит struct cred.

  • Наконец, умножение номера страницы на 64 (это размер struct page) дает смещение соответствующего экземпляра struct page в vmemmap.

Эту формулу нужно скорректировать, если в системе более 4 ГиБ оперативной памяти. В таком случае ZONE_NORMAL, где ядро аллоцирует свои объекты, обычно начинается с адреса 0x100000000. Следовательно, чтобы получить смещение нужного экземпляра struct page, просто прибавляем (0x100000000 >> 12) * STRUCT_PAGE_SZ.

Отлично! Эта формула не зависит от KASLR для физических адресов. Значит, по ней можно точно посчитать младшие четыре байта адреса struct cred в vmemmap, чтобы направить туда AARW через канал. Почему мне нужны были только четыре младших байта pipe_buffer.page?

  • Моя UAF-запись в peer_buf_alloc меняла только первую половину указателя pipe_buffer.page (это называется partial overwriting, см. схему выше).

  • В x86_64 используется порядок байтов little-endian, поэтому первая половина указателя содержит четыре младших байта адреса.

Но когда я попробовал этот подход, механизм KASLR нанес последний удар: он рандомизировал адрес vmemmap_base, и в четырех младших байтах указателей на struct page оказались два случайных бита. Ох, засада!

Тем не менее, я решил сделать перебор этих двух битов (то есть 4 вариантов), ведь у меня была возможность выполнить около пяти UAF-записей до того, как kworker словит null-ptr-deref по истечении 8 секунд (VSOCK_CLOSE_TIMEOUT).

Я обнаружил, что «прощупывание» различных значений pipe_buffer.page из пользовательского пространства отлично работает:

  • В случае неудачи чтение из канала просто возвращает Bad address.

  • В случае успеха чтение из канала выдает содержимое struct cred.

То, что надо! Наконец-то я смог определить корректный целевой адрес для AARW, записать в канал и тем самым перезаписать поля euid и egid в struct cred нулями, чтобы получить права root. Представляю вам демонстрацию прототипа эксплойта для CVE-2024-50264.

Заключение

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

Работа с этим сложным состоянием гонки при множестве ограничений помогла мне изобрести новые приемы эксплуатации уязвимостей и прокачать мой проект kernel-hack-drill — тестовую среду для исследователей безопасности ядра Linux. Присоединяйтесь, пробуйте и предлагайте улучшения!

Спасибо за внимание.