Перейти к содержанию

Защита памяти

Операционная система назначает каждой странице виртуальной памяти набор прав доступа, хранящихся в записях таблицы страниц (PTE). При любом обращении к памяти MMU проверяет эти права; нарушение немедленно генерирует Page Fault, который ядро превращает в сигнал SIGSEGV для процесса-нарушителя. Это основной механизм изоляции процессов и защиты данных в современных системах.

Права доступа к страницам

Каждая страница может иметь независимую комбинацию из трёх флагов:

Флаг Значение
PROT_READ Страницу можно читать
PROT_WRITE В страницу можно писать
PROT_EXEC Из страницы можно выполнять код
PROT_NONE Любой доступ запрещён (сторожевые страницы, guard pages)

Типичные комбинации:

Область Права
Код программы (.text) r-x
Данные (.data, .bss) rw-
Строковые константы r--
Стек rw-
Guard page стека ---

Права устанавливаются при создании отображения через mmap и могут быть изменены вызовом mprotect.

mprotect

#include <sys/mman.h>

int mprotect(void *addr, size_t len, int prot);

mprotect изменяет права доступа для диапазона страниц, начинающегося с addr и охватывающего len байт. Адрес должен быть выровнен по границе страницы (4 КБ); len ядро автоматически округляет до следующей границы страницы. Возвращает 0 при успехе, -1 при ошибке (и устанавливает errno).

Как mprotect меняет биты в page table entry

Каждой виртуальной странице соответствует запись PTE (Page Table Entry) в таблице страниц. mprotect обновляет биты прав в этих записях и сбрасывает TLB для затронутых страниц:

Page Table Entry (PTE, x86-64, упрощённо)

  бит 63   бит 2   бит 1   бит 0
  ┌──────┬────────┬───────┬───────┬────────────────────────────────────────┐
  │  NX  │  U/S   │  R/W  │   P   │       Physical Page Number (PFN)       │
  └──────┴────────┴───────┴───────┴────────────────────────────────────────┘
     │       │        │       │
     │       │        │       └── P=1: страница присутствует (mapped)
     │       │        └────────── R/W=1: запись разрешена; R/W=0: только чтение
     │       └─────────────────── U/S=1: страница доступна из user mode;
     │                            U/S=0: только kernel
     └─────────────────────────── NX=1: исполнение запрещено (No-Execute bit)

Комбинации флагов PROT_* → биты PTE:

  PROT_NONE       →  P=1 + _PAGE_PROTNONE (специальный bit, разный для x86),
                     не P=0                          ← любой доступ вызывает #PF
  PROT_READ       →  P=1, R/W=0, NX=1                ← только чтение, нет X
  PROT_READ|WRITE →  P=1, R/W=1, NX=1                ← чтение + запись, нет X
  PROT_READ|EXEC  →  P=1, R/W=0, NX=0                ← чтение + исполнение (W^X)

Пример изменения прав трёх страниц через mprotect:

До mprotect(buf, 3*4096, PROT_READ):

  виртуальный         PTE                   физическая
  адрес               R/W  NX               страница
  ┌────────────┐      ┌──────────┐          ┌──────────┐
  │ buf+0x0000 │─────▶│  R/W=1   │          │  page A  │
  │            │      │  NX=1    │          └──────────┘
  ├────────────┤      ├──────────┤          ┌──────────┐
  │ buf+0x1000 │─────▶│  R/W=1   │          │  page B  │
  │            │      │  NX=1    │          └──────────┘
  ├────────────┤      ├──────────┤          ┌──────────┐
  │ buf+0x2000 │─────▶│  R/W=1   │          │  page C  │
  │            │      │  NX=1    │          └──────────┘
  └────────────┘      └──────────┘

После mprotect(buf, 3*4096, PROT_READ):

  виртуальный         PTE                   физическая
  адрес               R/W  NX               страница
  ┌────────────┐      ┌──────────┐          ┌──────────┐
  │ buf+0x0000 │─────▶│  R/W=0   │          │  page A  │  ◀── изменено
  │            │      │  NX=1    │          └──────────┘
  ├────────────┤      ├──────────┤          ┌──────────┐
  │ buf+0x1000 │─────▶│  R/W=0   │          │  page B  │  ◀── изменено
  │            │      │  NX=1    │          └──────────┘
  ├────────────┤      ├──────────┤          ┌──────────┐
  │ buf+0x2000 │─────▶│  R/W=0   │          │  page C  │  ◀── изменено
  │            │      │  NX=1    │          └──────────┘
  └────────────┘      └──────────┘

  Попытка записи → MMU видит R/W=0 → генерирует #PF → ядро → SIGSEGV

Типичные сценарии использования:

#include <sys/mman.h>
#include <stdlib.h>

// Выделить буфер и защитить его от записи после инициализации.
char *buf = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                 MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
buf[0] = 'H';  // инициализация

mprotect(buf, 4096, PROT_READ);  // теперь только чтение
// buf[0] = 'X';  // здесь будет SIGSEGV

// Снять ограничение:
mprotect(buf, 4096, PROT_READ | PROT_WRITE);
buf[0] = 'X';  // теперь снова можно

Особый случай — PROT_NONE: страница недоступна ни для чтения, ни для записи, ни для исполнения. Это используется для создания защитных страниц (guard pages) вокруг стека потока: первое обращение к стеку за его пределами вызовет SIGSEGV вместо молчаливого переполнения.

Принцип W^X (Write XOR Execute)

W^X (также известный как DEP — Data Execution Prevention, или NX/XD bit) — политика безопасности, при которой страница памяти не может одновременно быть доступна для записи (W) и для исполнения (X). Формально:

для каждой страницы: NOT (PROT_WRITE AND PROT_EXEC)

Зачем это нужно. Классические атаки на переполнение буфера помещали шелл-код прямо в стек или кучу, а затем перенаправляли на него управление. Если стек и куча лишены бита X, то исполнить внедрённый код невозможно — CPU сгенерирует Page Fault при попытке fetch инструкции из такой страницы.

Как реализовано аппаратно. На процессорах x86-64 существует бит NX (No-Execute) в PTE: если он установлен, страница не может содержать исполняемых инструкций. На ARM соответствующий бит называется XN (Execute Never). Ядро Linux управляет этим битом автоматически в соответствии с запрошенными правами доступа.

Пример нарушения W^X.

#include <sys/mman.h>
#include <string.h>
#include <stdio.h>

int main(void) {
    // Запрос страницы с одновременно W и X — нарушает W^X.
    // На современном ядре с CONFIG_STRICT_W_X это может быть запрещено
    // или привести к SIGSEGV при попытке исполнения.
    void *ptr = mmap(NULL, 4096,
                     PROT_READ | PROT_WRITE | PROT_EXEC,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (ptr == MAP_FAILED) {
        perror("mmap");
        return 1;
    }

    // Запись байта инструкции RET (0xC3) и вызов.
    // Это корректный пример JIT-компиляции.
    unsigned char code[] = { 0xC3 };  // ret
    memcpy(ptr, code, sizeof(code));

    // Соблюдение W^X требует: убрать W перед выполнением.
    mprotect(ptr, 4096, PROT_READ | PROT_EXEC);

    void (*f)(void) = (void (*)(void))ptr;
    f();  // теперь исполняем только-для-чтения+исполнения страницу

    munmap(ptr, 4096);
    return 0;
}

Правильная схема для JIT-компиляторов:

  1. Выделить страницу с PROT_READ | PROT_WRITE.
  2. Записать машинный код.
  3. Переключить на PROT_READ | PROT_EXEC через mprotect.
  4. Выполнить код.

NX-бит и аппаратная поддержка

NX-бит (No-Execute bit) — бит 63 в PTE на x86-64. Если он установлен, процессор откажется выполнять инструкции из этой страницы. До появления NX-бита (добавлен в процессоры AMD64 в 2003 году, затем Intel как XD — eXecute Disable) защита кода данными была невозможна аппаратно.

Проверить поддержку NX-бита на Linux:

grep -m1 'nx' /proc/cpuinfo
# flags: ... nx ...

Ядро Linux включает NX для стека, кучи и всех анонимных отображений по умолчанию. Флаг можно увидеть в /proc/<pid>/maps — отсутствие x в поле permissions означает, что NX-бит установлен:

7fff0e5dd000-7fff0e5fe000 rw-p  ...  [stack]

Стек помечен rw- (нет x) — выполнение кода со стека будет заблокировано аппаратно.

ASLR (Address Space Layout Randomization)

ASLR — техника рандомизации базовых адресов ключевых областей виртуального адресного пространства при каждом запуске процесса. Рандомизируются:

  • база исполняемого файла (если он собран с -fPIE -pie);
  • база разделяемых библиотек;
  • начало стека;
  • начало кучи (смещение от конца .bss);
  • базовые адреса анонимных mmap-отображений;
  • адрес vDSO ([vdso] в /proc/<pid>/maps).

Зачем. Многие атаки (return-to-libc, ROP — Return-Oriented Programming) требуют знания точного адреса функций или гаджетов в памяти. ASLR делает эти адреса непредсказуемыми, существенно затрудняя использование уязвимостей.

Одна и та же программа при каждом запуске получает разные адреса:

Запуск #1                              Запуск #2
┌─────────────────────────────┐        ┌─────────────────────────────┐
│ [stack]   0x7ffd_a4f3_0000  │        │ [stack]   0x7ffe_1c8b_0000  │
│ libc.so   0x7f12_8d54_0000  │        │ libc.so   0x7f93_b2e7_0000  │
│ [heap]    0x5612_3a91_0000  │        │ [heap]    0x55d8_7e44_0000  │
│ .text     0x5612_3a72_0000  │        │ .text     0x55d8_7e25_0000  │
│ [vdso]    0x7ffd_a4fa_b000  │        │ [vdso]    0x7ffe_1c92_3000  │
└─────────────────────────────┘        └─────────────────────────────┘
       └─────────────┬─────────────────────────────┘
                     └── атакующий не может зашить адрес гаджета в эксплойт

Уровни ASLR в Linux:

Значение randomize_va_space Что рандомизируется
0 ASLR отключён полностью
1 (conservative) stack, vDSO, shared memory
2 (full, default) то же + brk-heap, mmap-области, base PIE-бинаря
# Посмотреть текущий уровень ASLR
cat /proc/sys/kernel/randomize_va_space

# Временно отключить ASLR (только для текущего запуска, требует root)
echo 0 | sudo tee /proc/sys/kernel/randomize_va_space

# Запустить конкретный процесс без ASLR (для отладки, не требует root)
setarch $(uname -m) -R ./program

Внутри gdb ASLR отключён по умолчанию для воспроизводимости — управляется командой set disable-randomization on/off.

Проверить, что адреса действительно рандомизируются:

cat /proc/self/maps | grep stack
cat /proc/self/maps | grep stack
# Адреса будут разными при каждом запуске

Ограничения ASLR.

  • Без PIC/PIE сам исполняемый файл загружается по фиксированному адресу. Для полной защиты нужно компилировать с -fPIE -pie (включено по умолчанию в большинстве современных дистрибутивов).
  • На 32-bit системах энтропии слишком мало: только ~8 бит для mmap-региона. Brute-force за минуты — атака **Shacham 2004 ** показала перебор всех вариантов локально за секунды на десктопе того времени.
  • ASLR не защищает от info leak-уязвимостей: одна утечка адреса (например, через printf с пользовательской format string) выдаёт базовый адрес целого модуля, после чего ASLR в этом модуле бесполезен.
  • KASLR (kernel ASLR) долгое время обходился через side-channel атаки типа Meltdown и Prefetch Side-Channel (Gruss et al., 2016).
# Для production-сборки
gcc -fPIE -pie -fstack-protector-strong -D_FORTIFY_SOURCE=2 -O2 prog.c -o prog

KPTI и Meltdown

Meltdown (CVE-2017-5754, опубликован в январе 2018) — аппаратная уязвимость в большинстве процессоров Intel (и некоторых ARM), связанная с speculative execution. CPU выполняет инструкции спекулятивно, не дожидаясь проверки прав доступа; результат позже откатывается, если права не позволяли. Однако спекулятивный код успевает оставить след в * cache* — это side-channel, через который атакующий восстанавливает прочитанные байты.

Ядро Linux традиционно отображало всю физическую память (включая собственные структуры) в адресном пространстве * каждого* user-процесса, помечая страницы битом supervisor — обращение из user mode должно было приводить к Page Fault. Meltdown позволял прочитать эти страницы спекулятивно ещё до того, как CPU успевал проверить supervisor-бит.

До KPTI: один CR3 на процесс
┌─────────────────────────────────────────────┐
│ user pages          (accessible from R3)    │
├─────────────────────────────────────────────┤
│ kernel pages        (supervisor bit set)    │ ← Meltdown читает спекулятивно
└─────────────────────────────────────────────┘
            user-process page table ────────┘
            (один и тот же CR3 в обоих режимах)

KPTI (Kernel Page Table Isolation) — патч Linux (изначально KAISER от Gruss et al.), вошёл в ядро 4.15. Решение: две отдельные page table на процесс:

  • User table — только user-страницы + минимальный stub из kernel-кода (entry trampoline);
  • Kernel table — полный набор kernel-страниц.

При каждом переходе в kernel mode (syscall, прерывание, exception) загружается новый CR3 — указатель на kernel-таблицу; при возврате обратно — на user-таблицу. Теперь user-процесс физически не может видеть kernel-страницы — их просто нет в его адресном пространстве.

С KPTI: два CR3, переключение на каждом syscall
                  CR3 переключается на каждом ↔
   user mode                                       kernel mode
┌──────────────────┐                            ┌──────────────────┐
│  user pages      │   ── syscall / IRQ ───▶    │  user pages      │
│  entry trampoline│   ◀── iret / sysret ───    │  kernel pages    │
└──────────────────┘                            └──────────────────┘
       user table                                    kernel table

Накладные расходы. Переключение CR3 дорогое (особенно без PCID — Process-Context Identifier — оно сбрасывает весь TLB). На процессорах без PCID — потери 5-30% производительности на syscall-heavy workloads (БД, веб-серверы). На процессорах с PCID (Westmere+) — порядка 1-5%. Включение/отключение KPTI на загрузке:

# Проверить, активен ли KPTI
dmesg | grep -i 'kernel/user page tables isolation'
cat /sys/devices/system/cpu/vulnerabilities/meltdown

# Отключить (только для бенчмарков, оставляет систему уязвимой)
# kernel cmdline: pti=off  или  nopti

Современные процессоры (Cascade Lake+, AMD Ryzen) аппаратно защищены от Meltdown — KPTI автоматически отключается ядром.

CET (Control-flow Enforcement Technology)

CET — аппаратное расширение Intel (с Tiger Lake, 2020) и AMD (с Zen 3, 2020) для защиты от code-reuse attacks: ROP (Return-Oriented Programming), JOP (Jump-Oriented), COP (Call-Oriented). Состоит из двух независимых механизмов: shadow stack и IBT.

Shadow stack

Параллельно обычному стеку CPU ведёт shadow stack — отдельную страницу с особым флагом в PTE, доступную только для записи через shadow-stack-инструкции (CALL/RET/INCSSP). Каждая инструкция CALL пишет return address одновременно в обычный стек и shadow stack; каждый RET сравнивает два значения и при несовпадении генерирует #CP (Control Protection exception).

CALL f:                          RET:
┌─────────────┐                  ┌─────────────┐
│ обычный     │  push RA         │ обычный     │  pop RA1
│ стек (RW)   │ ──────▶          │ стек (RW)   │ ──────▶
└─────────────┘                  └─────────────┘
                                                       сравнение
┌─────────────┐                  ┌─────────────┐       RA1 ?= RA2
│ shadow      │  push RA         │ shadow      │  pop RA2
│ stack       │ ──────▶          │ stack       │ ──────▶  не равно? → #CP
│ (shadow PTE)│                  │ (shadow PTE)│
└─────────────┘                  └─────────────┘

Атаки ROP перезаписывают return address на стеке, чтобы перенаправить RET на нужный gadget. Shadow stack делает это невозможным: оригинальный return address остаётся в защищённой памяти, и проверка при RET его поймает.

IBT (Indirect Branch Tracking)

Защита от JOP/COP: атакующий перенаправляет JMP или CALL через указатель на середину функции (или на gadget, заканчивающийся JMP). IBT требует, чтобы каждая цель indirect-branch (JMP reg, CALL reg, JMP [mem]) начиналась со специальной инструкции ENDBR64 (на x86-64) или ENDBR32. CPU отслеживает «ожидающее endbr» состояние после indirect branch; если следующая инструкция — не ENDBR, генерируется #CP.

Корректный indirect call:        Атака JOP:
   CALL rax                         CALL rax  (rax указывает в середину функции)
        ↓                                ↓
   ENDBR64       ← OK                ADD rsi, 8   ← не ENDBR → #CP
   push rbp                          ...
   ...

Прямые CALL imm/JMP imm не требуют ENDBR — целевой адрес уже зашит в инструкции и не подделывается. ENDBR — это NOP на CPU без CET, поэтому код полностью обратно-совместим.

Поддержка и активация.

# Проверить поддержку CET в процессоре
grep -E 'ibt|shstk|user_shstk' /proc/cpuinfo

# Скомпилировать с CET (gcc 8+, glibc 2.28+, ядро 6.6+ для user-mode shadow stack)
gcc -fcf-protection=full prog.c -o prog
#   full   = branch + return (IBT + shadow stack)
#   branch = только IBT
#   return = только shadow stack

В Linux user-space shadow stack стал официально поддерживаться с ядра 6.6 (октябрь 2023). До этого был только IBT и kernel-mode shadow stack. Windows 10 с Hardware-enforced Stack Protection (2020) использует тот же механизм CET.

SIGILL — Illegal Instruction

Сигнал SIGILL доставляется процессу, когда процессор пытается выполнить некорректную или недопустимую инструкцию. Основные причины:

  • Байты в памяти не являются валидным машинным кодом для данной архитектуры.
  • Инструкция требует привилегированного режима (например, HLT, LGDT из user-space).
  • Используется расширение набора инструкций, не поддерживаемое данным CPU (например, AVX-512 на процессоре без его поддержки).
  • Переход попал на середину многобайтовой инструкции из-за повреждения кода или стека.

Пример: исполнение произвольных байт как кода.

Следующий пример записывает в исполняемую страницу байт 0xFF 0xFF, который не является корректной инструкцией x86-64, и передаёт туда управление:

#include <sys/mman.h>
#include <string.h>
#include <stdio.h>

int main(void) {
    // Выделить страницу: сначала W, запишем код, затем переключим на X.
    void *ptr = mmap(NULL, 4096,
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS,
                     -1, 0);
    if (ptr == MAP_FAILED) { perror("mmap"); return 1; }

    // 0xFF 0xFF — не является корректной инструкцией x86-64.
    memset(ptr, 0xFF, 4096);

    // Переключить в режим исполнения (без записи — соблюдаем W^X).
    mprotect(ptr, 4096, PROT_READ | PROT_EXEC);

    void (*f)(void) = (void (*)(void))ptr;
    f();  // Процессор попытается декодировать 0xFF 0xFF → SIGILL

    munmap(ptr, 4096);
    return 0;
}

0xFF 0xFF — не валидная инструкция, тогда как 0x0F 0x0B — это инструкция UD2, специально зарезервированная как "гарантированно недопустимая" и предназначенная для намеренной генерации SIGILL (используется в assert-ах, отладчиках, санитайзерах).

SIGILL в отличие от SIGSEGV. Если страница вообще не помечена как исполняемая, попытка её выполнения приводит к SIGSEGV (нарушение прав доступа), а не к SIGILL. SIGILL возникает именно тогда, когда страница доступна для исполнения, но её содержимое не является допустимым машинным кодом.

Загрузка кода из файла и выполнение через mmap

На практике для загрузки и выполнения кода из внешних файлов используют dlopen/dlsym. Однако понять механизм помогает «сырой» вариант через mmap:

// libmath.c — компилируем: gcc -shared -fPIC -o libmath.so libmath.c
double square(double value) {
    return value * value;
}
#include <sys/mman.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int main(int argc, char **argv) {
    // argv[1] — путь к скомпилированному .so
    int fd = open(argv[1], O_RDONLY);
    if (fd < 0) { perror("open"); return 1; }

    struct stat st;
    fstat(fd, &st);

    // Отобразить файл с правами r-x (соблюдаем W^X).
    void *addr = mmap(NULL, st.st_size,
                      PROT_READ | PROT_EXEC,
                      MAP_PRIVATE,
                      fd, 0);
    close(fd);
    if (addr == MAP_FAILED) { perror("mmap"); return 1; }

    // В реальном .so нужно парсить ELF и получать символ через таблицу.
    // Здесь для демонстрации используется фиксированное смещение.
    double (*func)(double) = (double (*)(double))((char *)addr + 0x40);
    printf("%f\n", func(3.0));  // должно вывести 9.0

    munmap(addr, st.st_size);
    return 0;
}

Ключевые моменты:

  • Файл отображён с PROT_READ | PROT_EXEC и без PROT_WRITE — W^X соблюдён.
  • В production-коде символы находят через dlopen/dlsym с правильным парсингом ELF.
  • Без MAP_PRIVATE изменения в памяти попали бы обратно в файл.

Связанные темы

Источники