Изучение виртуального адресного пространства и алгоритма преобразования адресов заметно упростится, если начать с несложного практического примера. Для этого напишем простую программу, выводящую адрес локальной переменной:
int main()
{
unsigned i = 0xDEADBEEF;
std::cout << "address of i is " << std::hex << &i;
std::cin.get(); //Чтобы процесс не завершился
return 0;
}
Затем попробуем найти физический адрес и просмотреть значение по этому адресу.
Будем рассматривать 32-битную Windows (без всяких Physical Address Extension), потому что 64-битная сложнее. Описание преобразования упрощено, но достаточно для нашего эксперимента. Рекомендую проверять в виртуалке. Неважно в какой, но в конце я покажу как выгрузить дамп памяти в VirtualBox.
Основы
В моем случае адрес получился равным 0x22FF2C. Вообще, он может различаться при каждом запуске программы (см. ASLR). У других процессов по этому адресу могут находиться какие-то свои значения, потому что это не физический, а виртуальный адрес. Пожалуй, основное предназначение виртуального адресного пространства – возможность предоставлять каждому процессу собственное адресное пространство, в котором он бы не мешал другим. Размер виртуального адресного пространства зависит от платформы. Для x86 теоретический максимальный размер составляет 4 Гб. По умолчанию, первая половина (0 – 0x7FFFFFFF) является пространством процессов пользователя, в котором располагается образ исполняемого файла текущего процесса, его стек, куча и прочее. Вторая половина (0x80000000 – 0xFFFFFFFF) – системным. С некоторыми оговорками можно считать, что пространство процессов пользователя уникально для каждого процесса, а системное только одно. Адрес 0x22FF2C, очевидно, попал в первую половину.
Виртуальное адресное пространство разбито на 0x100000 (1048576) страниц размером 4096 байт каждая. Физическая память также разбита на страницы такого же размера, называемых страничными блоками. Страницы (не все, конечно) отображаются на страничные блоки, поэтому для каждой страницы нужна информация о ее расположении в физической памяти. Всем 0x100000 страницам соответствует столько же 4-байтовых записей, называемых PTE (page table entry – запись таблицы страниц). В виртуальном пространстве они расположены в диапазоне адресов 0xC0000000 — 0xC03FFFFF, и занимают 1024 страницы, называемых таблицами страниц. Получить запись просто: k-й странице соответствует k-я запись.
Оранжевым отмечены таблицы страниц.
virtual_address = 0x22FF2C
page_index = virtual_address / 4096
pte_addr = 0xC0000000 + page_index * 4
Умножение на 4 потому что PTE – 4-байтовые. Получаем, что в нашем случае pte_addr = 0xC00008BC
Наивная попытка
Адрес PTE есть, пробуем узнать что там:
std::cout << "PTE is " << std::hex << *(unsigned*)0xC00008BC;
Ну, ой. Аппаратное исключение. А все потому, что мы пытались читать из системного пространства. ReadProcessMemory тоже не поможет. Вызов VirtualQuery скажет нам PAGE_NOACCESS. Получить доступ можно только получив привилегии режима ядра. Пожалуй, самый простой способ для нашей исследовательской задачи — использовать отладчик ядра.
Использование отладчика ядра
Ставим KD и LiveKd. LiveKD позволяет запускать отладчики ядра Microsoft Kd и Windbg, входящие в пакет инструментов отладки для Windows, в действующей системе в локальном режиме. По последней ссылке также небольшая справка по установке и справке.
Запускаем наш пример (пусть он называется main.exe). Запускаем LiveKd. Пишем "!process 0 0
", чтобы вывести список всех работающих процессов, или сразу "!process 0 0 main.exe
"
0: kd> !process 0 0 main.exe
PROCESS 86530118 SessionId: 1 Cid: 0dcc Peb: 7ffdd000 ParentCid: 0428
DirBase: 2402e000 ObjectTable: 8879f430 HandleCount: 16.
Image: main.exe
Нас интересует адрес после слова PROCESS (это адрес на структуру EPROCESS, содержащей атрибуты процесса). Подключаемся к процессу:
0: kd> .process 86530118
Implicit process is now 86530118
Проверяем содержимое по адресу 0x22FF2C, чтобы убедиться, что все сделали правильно:
0: kd> dd 22FF2C L1
0022ff2c deadbeef
По умолчанию используются шестнадцатеричные числа. Команда dd
выводит несколько 4-байтовых значений, начиная с указанного виртуального адреса. L1 – вывод только одного значения.
Чтение PTE
0: kd> dd C00008BC L1
c00008bc 6612f847
Можно было самим не считать:
dd C0000000 + (22FF2C >> 0xC) * 4 L1
c00008bc 6612f847
В значении PTE записи 6612f847 первые 20 бит (5 hex-цифр) – индекс страничного блока, остальное – различные флаги. Чтобы получить адрес страничного блока, нужно индекс умножить на размер блока – 4096 байт.
page_block_index = 0x6612F
page_block_address = page_block_index << 12 = 0x6612F000 //Умножение на 4096
Порядок байтов внутри страницы и страничного блока совпадает, поэтому необходимо рассчитать смещение внутри страницы и прибавить к адресу страничного блока:
virtual_adress = 0x22FF2C
offset = virtual_adress & 0xFFF = 0xF2C //Последние три hex-цифры
phisycal_address = page_block_address + offset = 0x6612FF2C
Проверяем:
0: kd> !dd 6612FF2C L1
#6612ff2c deadbeef
Команда !dd
аналогична dd
, только принимает физические адреса.
Мы выяснили, что наш адрес можно представить так:
0x22FF2C = b 00000000001000101111 111100101100
20 бит 12 бит
page_index byte_offset
Но еще заметьте, что найденная PTE находится в 0-й таблице страниц с индексом 0x22F внутри нее. И наш адрес может быть представлен так:
0x22FF2C = b 0000000000 1000101111 111100101100
10 бит 10 бит 12 бит
table_idx PTE_index byte_offset
We need to go deeper (PDE)
Пользоваться виртуальными адресами PTE неспортивно. Ведь они тоже являются обычными страницами которым нужно найти страничные блоки. А раз так, то просто найдем свои PTE для этих страниц. Всего у нас 1024 таких страниц (называемых таблицами страниц) и все PTE для них помещаются в одной странице. Эту страницу называют каталогом страниц и она содержит 1024 записи (называемых PDE – page directory entry, запись каталога страниц) с адресами на таблицы страниц.
Синим отмечен каталог таблиц, оранжевым – таблицы страниц.
Поступаем точно так же, как уже делали:
pte_addr = 0xC00008BC
page_index = pte_addr / 4096 = 0xC0000
pde_addr = 0xC0000000 + page_index * 4 = 0xC0300000
Получили адрес PDE = 0xC0300000 (все PDE хранятся в странице по адресу 0xC0300000, мы попали в нулевую PDE). Проверяем содержимое:
0: kd> dd C0300000 L1
c0300000 0b21d867
Полностью аналогично: PDE, содержащая 0b21d867, дает нам адрес 0x0B21D000 страничного блока с таблицей страниц. Осталось найти в ней нужную PTE. Вспомним, что адресу 0x22FF2C соответствует PTE с индексом 0x22F в 0-й таблице (со смещением 0x22F * 4). Значит, PTE находится по адресу 0x0B21D000 + 0x22F * 4.
0: kd> !dd 0b21d000 + 0x22f * 4
# b21d8bc 6612f847
С адресом 6612f847 мы уже работали.
Осталось выяснить, где в физической памяти находится каталог (так как мы получали PDE с помощью виртуальной адресации). Адрес был указан в DirBase, когда мы просмотрели информацию о процессе командой "!process 0 0 main.exe
". В нашем случае DirBase = 2402e000
0: kd> !dd 2402e000
#2402e000 0b21d867
Итоговая формула
0x22FF2C = b 0000000000 1000101111 111100101100
10 бит 10 бит 12 бит
PDE_index PTE_index byte_offset
pde_addr = DirBase + PDE_index * 4
pte_addr = ((*pde_addr) & 0xFFFFF000) | (PTE_index * 4)
value_addr = ((*pte_addr) & 0xFFFFF000) | byte_offset
Ищем в дампе
Думаю, снять дамп с работающей системы несколько проблематично, поэтому снимем с VirtualBox. Для этого нужно запустить в режиме отладки:
VirtualBox.exe --dbg --startvm VM_name
Выбрать в меню "Отладка" -> "Командная строка..." и набрать:
.pgmphystofile "path_to_dump_file"
Открываем файл (я пользуюсь HxD), переходим на 6612ff2c:
Зная DirBase и виртуальный адрес можно искать значение сразу в дампе, без отладчика. Вообще, в дампе можно найти значение DirBase по имени процесса, но это уже другая история.