Продавец
- Статус
- Оффлайн
- Регистрация
- 6 Май 2025
- Сообщения
- 223
- Реакции
- 189
Всем привет. В этой статье разберем, как работать с физической памятью и почему использование чтения / записи виртуальной памяти в условиях популярных анти-читов - плохое решение.
Основные термины:
CR3 - Control Register 3
DTB - Directory Table Base
PML4 - Page Map Level 4
PDPT - Page Directoy Pointer Table
PD - Page Directory
PT - Page Table
Всего существует два способа чтения памяти:
- Чтение виртуальной памяти ( MmCopyVirtualMemory )
- Чтение физической памяти ( MmMapIoSpace, MmCopyMemory with MM_COPY_MEMORY_PHYSICAL flag )
Почему крупные проекты не использует чтение виртуальной памяти?
- Дело все в том, что когда пользователь вызывает MmCopyVirtualMemory, то эта функция по цепочке вызывает "KiStackAttachProcess" - она подключается к приложению. При подключении к процессу в CR3 запишется DTB и в ApcState.Process текущего потока запишется процесс к которому мы присоединяемся. Анти-читы очень легко это замечают, как минимум делая проверки на ApcState потоков ( если ApcState.Process текущего потока будет равен игровому или в CR3 находится игровой DTB, то это значит, что к игре был произведен аттач. Моментальный бан или же репорт )
Принцип перевода виртуальной памяти в физическую:
- Для того, чтобы прочитать адрес 0xdeadc0dedeadbeef, то нам надо пройтись по цепочке таблиц: PML4E ( Самый верхний уровень. Указатель на него хранится в DTB. ) -> PDPT ( Уровень 3 ) -> PD ( Уровень 2 ) -> PT ( Уровень 1. Это последняя таблица, которая указывает на конкретную физическую таблицу. ) -> PA ( Берем его из PTE. Там хранится настоящий физический адрес и флаги таблицы ( Права доступа и так далее ) ). Чтобы это сделать нам необходим DTB. Он является уникальным для каждого процесса. Без него физическая память ( для нас ) является бесполезной.
Код перевода виртуальной памяти в физическую:
Принцип записи физической памяти:
- Берем нужный виртуальный адрес, который мы хотим прочитать
- Используя полученный DTB процесса, мы используя функцию под названием "translate_linear_address", получаем из виртуального физический адрес
- Берем этот физический адрес
- Используем MmMapIoSpace ( Маппим физическую память, чтобы получить верный указатель на данные. Плюс такого метода заключается в том, что мы можем записывать память даже без Pte.Writable флага )
- Копируем данные по указателю от MmMapIoSpace'а в буфер через memcpy
- Освобождаем маппнутый участок памяти с помощью MmUnmapIoSpace
Принцип чтения физической памяти:
- Вызов MmCopyMemory с флагом MM_COPY_MEMORY_PHYSICAL
Код чтения физической памяти:
Поиск DTB:
- Если игра не шифрует DTB, то мы просто берем DirectoryTableBase из KPROCESS.
- Если не получилось получить дтб из DirectoryTableBase, то мы возьмем его из другого филда, под именем UserDirectoryTableBase в той же структуре KPROCESS, ниже прикреплен код для получение оффсета на него:
- Если игра шифрует DTB ( например, игры с последней версией EasyAntiCheat_EOS, т.е. Fortnite, Rust, Etc ), то нам необходимо для начала разобраться каким именно образом происходит шифрования регистра. Конкретно EasyAntiCheat делает это следующим образом:
1. Анти-чит хукает KdTrap ( каллбек, который вызывается в обработчике исключений ядра KiDispatchException, который используется для KD дебаггера )
2. Записывает в KPROCESS + 0x28 ( т.e. DirectoryTableBase ) невалидное значение ( или же 0 )
3. Когда происходит попытка использования шифрованного регистра, то выбрасывается исключение, которое идёт в KiDispatchException, в котором вызывается перехваченный античитом KdTrap и уже он обрабатывает его ( т.е выдает дешифрованный регистр )
- Есть много вариантов расшифровать DTB, но я расскажу о двух:
1. Аттач к процессу, чтение CR3 и извлечение DTB - т.к при аттаче к приложению мы попадаем в контекст приложения ( т.е. функция выполняет __writecr3( __readcr3( ) ) ), то мы спокойно можем получить, запомнить CR3, извлечь из него DTB и в дальнейшем его использовать.
2. Брутфорс - проходим по всей физической памяти, пробуем перевести базовый адрес игры в физический, используя "тестовый" DTB ( то есть текущий адрес по которому мы сейчас проходим ), попробовать его прочитать и если прочитанное значение == PE сигнатуре ( 4D 5A 90 ( 78 ) - в зависимости от компилятора ), то мы просто его запоминаем и в дальнейшем можем его использовать. Для большей точности, я еще проверяю ImageBase в OptionalHeader'e
Пример аттача:
Пример брутфорса:
Самым лучшим методом является брутфорс по нескольким причинам:
1. Самый безопасный, ведь нам не приходится изменить контекст своего потока
2. Быстрая работа ( если ограничить диапазон поиска )
3. Точный - анти-чит не может подменить физическую память, ведь процессору все равно придется работать с настоящим регистром
! ВАЖНО !
- Возможно, вам надо будет через определенное время обновлять CR3, ведь анти-чит может реаллоцировать таблицы и старый DTB станет неверным.
Вывод:
- Чтение виртуальной памяти является максимально плохим методом, вследствие того, что когда вы его используете, вы оставляется очень много следов, из-за которых вас максимально легко обнаружить анти-читу. Если планируете делать чит-проект, то всегда используйте физическое чтение памяти. Если вы столкнулись с зашифрованным DTB, то используйте брутфорс для его получения.
Полезные материалы:
back.engineering
www.unknowncheats.me
Также, хочу поблагодарить @battleye за то, что помог устранить некоторые неточности в статье.
Основные термины:
CR3 - Control Register 3
DTB - Directory Table Base
PML4 - Page Map Level 4
PDPT - Page Directoy Pointer Table
PD - Page Directory
PT - Page Table
Всего существует два способа чтения памяти:
- Чтение виртуальной памяти ( MmCopyVirtualMemory )
- Чтение физической памяти ( MmMapIoSpace, MmCopyMemory with MM_COPY_MEMORY_PHYSICAL flag )
Почему крупные проекты не использует чтение виртуальной памяти?
- Дело все в том, что когда пользователь вызывает MmCopyVirtualMemory, то эта функция по цепочке вызывает "KiStackAttachProcess" - она подключается к приложению. При подключении к процессу в CR3 запишется DTB и в ApcState.Process текущего потока запишется процесс к которому мы присоединяемся. Анти-читы очень легко это замечают, как минимум делая проверки на ApcState потоков ( если ApcState.Process текущего потока будет равен игровому или в CR3 находится игровой DTB, то это значит, что к игре был произведен аттач. Моментальный бан или же репорт )
Принцип перевода виртуальной памяти в физическую:
- Для того, чтобы прочитать адрес 0xdeadc0dedeadbeef, то нам надо пройтись по цепочке таблиц: PML4E ( Самый верхний уровень. Указатель на него хранится в DTB. ) -> PDPT ( Уровень 3 ) -> PD ( Уровень 2 ) -> PT ( Уровень 1. Это последняя таблица, которая указывает на конкретную физическую таблицу. ) -> PA ( Берем его из PTE. Там хранится настоящий физический адрес и флаги таблицы ( Права доступа и так далее ) ). Чтобы это сделать нам необходим DTB. Он является уникальным для каждого процесса. Без него физическая память ( для нас ) является бесполезной.
Код перевода виртуальной памяти в физическую:
Код:
uint64_t translate_linear_address( uint64_t directory_table_base, uint64_t address ) {
uint16_t pml4 = ( uint16_t )( ( address >> 39 ) & 0x1FF );
uint16_t pdpt = ( uint16_t )( ( address >> 30 ) & 0x1FF );
uint16_t pd = ( uint16_t )( ( address >> 21 ) & 0x1FF );
uint16_t pt = ( uint16_t )( ( address >> 12 ) & 0x1FF );
uint64_t pml4e{ };
read( directory_table_base + ( uint64_t )pml4 * sizeof( uint64_t ), ( uint8_t* )&pml4e, sizeof( pml4e ) );
if ( !pml4e )
return 0;
uint64_t pdpte{ };
read( ( pml4e & 0xFFFF1FFFFFF000 ) + ( uint64_t )pdpt * sizeof( uint64_t ), ( uint8_t* )&pdpte, sizeof( pdpte ) );
if ( !pdpte )
return 0;
if ( ( pdpte & ( 1 << 7 ) ) != 0 )
return ( pdpte & 0xFFFFFC0000000 ) + ( address & 0x3FFFFFFF );
uint64_t pde{ };
read( ( pdpte & 0xFFFFFFFFFF000 ) + ( uint64_t )pd * sizeof( uint64_t ), ( uint8_t* )&pde, sizeof( pde ) );
if ( !pde )
return 0;
if ( ( pde & ( 1 << 7 ) ) != 0 )
return ( pde & 0xFFFFFFFE00000 ) + ( address & 0x1FFFFF );
uint64_t pte{ };
read( ( pde & 0xFFFFFFFFFF000 ) + ( uint64_t )pt * sizeof( uint64_t ), ( uint8_t* )&pte, sizeof( pte ) );
if ( !pte )
return 0;
return ( pte & 0xFFFFFFFFFF000 ) + ( address & 0xFFF );
}
Принцип записи физической памяти:
- Берем нужный виртуальный адрес, который мы хотим прочитать
- Используя полученный DTB процесса, мы используя функцию под названием "translate_linear_address", получаем из виртуального физический адрес
- Берем этот физический адрес
- Используем MmMapIoSpace ( Маппим физическую память, чтобы получить верный указатель на данные. Плюс такого метода заключается в том, что мы можем записывать память даже без Pte.Writable флага )
- Копируем данные по указателю от MmMapIoSpace'а в буфер через memcpy
- Освобождаем маппнутый участок памяти с помощью MmUnmapIoSpace
Принцип чтения физической памяти:
- Вызов MmCopyMemory с флагом MM_COPY_MEMORY_PHYSICAL
Код чтения физической памяти:
Код:
NTSTATUS read_phys_address( void* address, void* buffer, size_t size, size_t* read ) {
MM_COPY_ADDRESS copy{ };
copy.PhysicalAddress.QuadPart = LONGLONG( address );
return call( MmCopyMemory, buffer, copy, size, MM_COPY_MEMORY_PHYSICAL, read );
}
Поиск DTB:
- Если игра не шифрует DTB, то мы просто берем DirectoryTableBase из KPROCESS.
- Если не получилось получить дтб из DirectoryTableBase, то мы возьмем его из другого филда, под именем UserDirectoryTableBase в той же структуре KPROCESS, ниже прикреплен код для получение оффсета на него:
Код:
uint32_t get_user_directory_table_base_offset( ) {
RTL_OSVERSIONINFOW ver{ };
call( RtlGetVersion, &ver );
if ( ver.dwBuildNumber <= o( 20180 ) )
return o( 0x0388 );
else if ( ver.dwBuildNumber <= o( 19041 ) )
return o( 0x0280 );
else if ( ver.dwBuildNumber >= 26100 )
return o( 0x0158 );
else
return o( 0x0278 );
return 0;
}
uint64_t get_process_dtb( PEPROCESS process ) {
const auto offset = get_user_directory_table_base_offset( );
const auto dir_base = *reinterpret_cast< uint64_t* >( ( uint8_t* )process + o( 0x28 ) );
if ( !dir_base )
return *reinterpret_cast< uint64_t* >( ( uint8_t* )process + offset );
return dir_base;
}
- Если игра шифрует DTB ( например, игры с последней версией EasyAntiCheat_EOS, т.е. Fortnite, Rust, Etc ), то нам необходимо для начала разобраться каким именно образом происходит шифрования регистра. Конкретно EasyAntiCheat делает это следующим образом:
1. Анти-чит хукает KdTrap ( каллбек, который вызывается в обработчике исключений ядра KiDispatchException, который используется для KD дебаггера )
2. Записывает в KPROCESS + 0x28 ( т.e. DirectoryTableBase ) невалидное значение ( или же 0 )
3. Когда происходит попытка использования шифрованного регистра, то выбрасывается исключение, которое идёт в KiDispatchException, в котором вызывается перехваченный античитом KdTrap и уже он обрабатывает его ( т.е выдает дешифрованный регистр )
- Есть много вариантов расшифровать DTB, но я расскажу о двух:
1. Аттач к процессу, чтение CR3 и извлечение DTB - т.к при аттаче к приложению мы попадаем в контекст приложения ( т.е. функция выполняет __writecr3( __readcr3( ) ) ), то мы спокойно можем получить, запомнить CR3, извлечь из него DTB и в дальнейшем его использовать.
2. Брутфорс - проходим по всей физической памяти, пробуем перевести базовый адрес игры в физический, используя "тестовый" DTB ( то есть текущий адрес по которому мы сейчас проходим ), попробовать его прочитать и если прочитанное значение == PE сигнатуре ( 4D 5A 90 ( 78 ) - в зависимости от компилятора ), то мы просто его запоминаем и в дальнейшем можем его использовать. Для большей точности, я еще проверяю ImageBase в OptionalHeader'e
Пример аттача:
Код:
uint64_t get_process_cr3( PEPROCESS process ) {
KAPC_STATE state{ };
KeStackAttachProcess( process, &state );
const auto cr3 = __readcr3( );
KeUnstackDetachProcess( &state );
return cr3;
}
Пример брутфорса:
Код:
uint64_t get_process_cr3( uint64_t game_base_address ) {
// 0x400000000 - 16 гигабайт, но можно расширить или же использовать MmGetPhysicalRanges
for ( uint64_t candidate{ }; candidate < 0x400000000; candidate += 0x1000 ) {
const auto phys = translate( candidate, game_base_address )
if ( !phys )
continue;
const auto pool = winapi::allocate_pool( 0x1000 );
if ( !read_buffer( phys, pool ) )
continue;
const auto dos_header = reinterpret_cast< IMAGE_DOS_HEADER* >( pool );
if ( dos_header->magic != 0x5A4D )
continue;
const auto nt_headers = reinterpret_cast< IMAGE_NT_HEADERS* >( reinterpret_cast< uint64_t >( pool ) + dos_header->e_lfanew );
if ( nt_headers->Signature != 0x4550 )
continue;
if ( nt_headers->OptionalHeader.ImageBase == game_base_address ) {
winapi::free_pool( pool );
return candidate;
}
}
return { };
}
Самым лучшим методом является брутфорс по нескольким причинам:
1. Самый безопасный, ведь нам не приходится изменить контекст своего потока
2. Быстрая работа ( если ограничить диапазон поиска )
3. Точный - анти-чит не может подменить физическую память, ведь процессору все равно придется работать с настоящим регистром
! ВАЖНО !
- Возможно, вам надо будет через определенное время обновлять CR3, ведь анти-чит может реаллоцировать таблицы и старый DTB станет неверным.
Вывод:
- Чтение виртуальной памяти является максимально плохим методом, вследствие того, что когда вы его используете, вы оставляется очень много следов, из-за которых вас максимально легко обнаружить анти-читу. Если планируете делать чит-проект, то всегда используйте физическое чтение памяти. Если вы столкнулись с зашифрованным DTB, то используйте брутфорс для его получения.
Полезные материалы:
Virtual Memory - Intro to Paging Tables
Virtual memory is probably one of the most interesting topics of modern computer science. Although virtual memory was originally designed back when physical memory was not an abundant resource to allow the use of disk space as ram, it has stuck with us, offering security, modularity, and...
read process physical memory, no attach
Code: ULONG_PTR GetKernelDirBase() { PUCHAR process = (PUCHAR)PsGetCurrentProcess(); ULONG_PTR cr3 = *(PULONG_PTR)(process + 0x28); //dirbase x64, 32b
Также, хочу поблагодарить @battleye за то, что помог устранить некоторые неточности в статье.
Последнее редактирование: