Защита памяти¶
Операционная система назначает каждой странице виртуальной памяти набор прав доступа, хранящихся в записях таблицы
страниц (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¶
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). Формально:
Зачем это нужно. Классические атаки на переполнение буфера помещали шелл-код прямо в стек или кучу, а затем
перенаправляли на него управление. Если стек и куча лишены бита 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-компиляторов:
- Выделить страницу с
PROT_READ | PROT_WRITE. - Записать машинный код.
- Переключить на
PROT_READ | PROT_EXECчерезmprotect. - Выполнить код.
NX-бит и аппаратная поддержка¶
NX-бит (No-Execute bit) — бит 63 в PTE на x86-64. Если он установлен, процессор откажется выполнять инструкции из этой страницы. До появления NX-бита (добавлен в процессоры AMD64 в 2003 году, затем Intel как XD — eXecute Disable) защита кода данными была невозможна аппаратно.
Проверить поддержку NX-бита на Linux:
Ядро Linux включает NX для стека, кучи и всех анонимных отображений по умолчанию. Флаг можно увидеть в
/proc/<pid>/maps — отсутствие x в поле permissions означает, что NX-бит установлен:
Стек помечен 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изменения в памяти попали бы обратно в файл.
Связанные темы¶
- Виртуальная память — PTE, права доступа на уровне таблиц страниц
- mmap и маппинг файлов — создание отображений с нужными правами
- Защита от переполнения буфера — stack canary, CFI, SafeStack
- Прерывания и исключения — Page Fault как аппаратное исключение
- Процессы: основы — изоляция процессов через виртуальную память
Источники¶
man 2 mprotect— изменение прав доступа к страницамman 2 mmap— создание отображений в памятиman 7 signal— описание сигналов, включая SIGSEGV и SIGILLman 5 proc— формат/proc/PID/maps,/proc/sys/kernel/randomize_va_space- Intel® 64 and IA-32 Architectures Software Developer's Manual, Volume 3A — раздел 4.6 (Access Rights), бит NX в PTE
- Intel® CET specification, Volume 1 — раздел 18 (Shadow Stack, IBT)
- Linux kernel documentation: No-Execute support
- Linux kernel docs: page table isolation (KPTI)
- Linux kernel docs: CET shadow stack
- PaX Project: W^X implementation
- Meltdown paper — Lipp et al., 2018
- KAISER: thwarting KASLR side channels — Gruss et al., 2017
- Shacham et al. — On the effectiveness of ASLR (CCS 2004)