Луч­ший друг теории — это прак­тика. Что­бы понять, как работа­ют уяз­вимос­ти в ядре Linux и как их исполь­зовать, мы соз­дадим свой модуль ядра Linux и с его помощью повысим себе при­виле­гии до супер­поль­зовате­ля. Затем мы соберем само ядро Linux с уяз­вимым модулем, под­готовим все, что нуж­но для запус­ка ядра в вир­туаль­ной машине QEMU, и авто­мати­зиру­ем про­цесс заг­рузки модуля в ядро. Мы научим­ся отла­живать ядро, а потом вос­поль­зуем­ся при­емом ROP, что­бы получить пра­ва root.
 

Подготовка

Что­бы выпол­нить все задуман­ное, нам понадо­бят­ся сле­дующие ути­литы:

  • 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
Глав­ное меню 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
Вклю­чение сим­волов отладки и 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 вот с таким кон­тентом:

#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 вот с таким кон­тентом:

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
Вклю­чение модуля 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. Пос­ле это­го надо запус­тить само ядро.

Продолжение доступно только участникам

Вариант 1. Присоединись к сообществу «Xakep.ru», чтобы читать все материалы на сайте

Членство в сообществе в течение указанного срока откроет тебе доступ ко ВСЕМ материалам «Хакера», позволит скачивать выпуски в PDF, отключит рекламу на сайте и увеличит личную накопительную скидку! Подробнее

Вариант 2. Открой один материал

Заинтересовала статья, но нет возможности стать членом клуба «Xakep.ru»? Тогда этот вариант для тебя! Обрати внимание: этот способ подходит только для статей, опубликованных более двух месяцев назад.


  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    3 комментариев
    Старые
    Новые Популярные
    Межтекстовые Отзывы
    Посмотреть все комментарии