Подписывайтесь на наш Telegram и не пропускайте важные новости! Перейти

Гайд Принцип чтения памяти игры используя механики ядра ОС Windows (Virtual Memory, Physical Memory)

Продавец
Продавец
Статус
Оффлайн
Регистрация
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. Он является уникальным для каждого процесса. Без него физическая память ( для нас ) является бесполезной.

Код перевода виртуальной памяти в физическую:

Код:
Expand Collapse Copy
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

Код чтения физической памяти:

Код:
Expand Collapse Copy
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, ниже прикреплен код для получение оффсета на него:

Код:
Expand Collapse Copy
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

Пример аттача:

Код:
Expand Collapse Copy
uint64_t get_process_cr3( PEPROCESS process ) {
    KAPC_STATE state{ };
    KeStackAttachProcess( process, &state );

    const auto cr3 = __readcr3( );

    KeUnstackDetachProcess( &state );

    return cr3;
}

Пример брутфорса:

Код:
Expand Collapse Copy
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, то используйте брутфорс для его получения.

Полезные материалы:


Также, хочу поблагодарить @battleye за то, что помог устранить некоторые неточности в статье.
 
Последнее редактирование:
PT ( Уровень 1. Это последняя таблица, которая указывает на конкретную физическую таблицу. )
PT (Page Table) содержит записи PTE (Page Table Entry) каждая из которых хранит физ. адрес *страницы ( Page Frame ) ( ДОЕБ )
 
Всем привет. В этой статье разберем, как работать с физической памятью и почему использование чтения / записи виртуальной памяти в условиях популярных анти-читов - плохое решение.

Основные термины:

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. Он является уникальным для каждого процесса. Без него физическая память ( для нас ) является бесполезной.

Код перевода виртуальной памяти в физическую:

Код:
Expand Collapse Copy
uint64_t translate_linear_address( uint64_t directory_table_base, uint64_t virtual_address ) {
    static const uint64_t PMASK = ( ~0xfull << 8 ) & 0xfffffffffull;
    directory_table_base &= ~0xf;

    uint64_t page_offset = virtual_address & ~( ~0ul << 12 );
    uint64_t pte = ( ( virtual_address >> 12 ) & ( 0x1ffll ) );
    uint64_t pt = ( ( virtual_address >> 21 ) & ( 0x1ffll ) );
    uint64_t pd = ( ( virtual_address >> 30 ) & ( 0x1ffll ) );
    uint64_t pdp = ( ( virtual_address >> 39 ) & ( 0x1ffll ) );

    size_t readsize = 0;
    uint64_t pdpe = 0;

    read_phys_address( PVOID( directory_table_base + 8 * pdp ), &pdpe, sizeof( pdpe ), &readsize );

    if ( ~pdpe & 1 )
        return 0;

    uint64_t pde = 0;
    read_phys_address( PVOID( ( pdpe & PMASK ) + 8 * pd ), &pde, sizeof( pde ), &readsize );

    if ( ~pde & 1 )
        return 0;

    if ( pde & 0x80 )
        return ( pde & ( ~0ull << 42 >> 12 ) ) + ( virtual_address & ~( ~0ull << 30 ) );

    uint64_t pte_addr = 0;
    read_phys_address( PVOID( ( pde & PMASK ) + 8 * pt ), &pte_addr, sizeof( pte_addr ), &readsize );

    if ( ~pte_addr & 1 )
        return 0;

    if ( pte_addr & 0x80 )
        return ( pte_addr & PMASK ) + ( virtual_address & ~( ~0ull << 21 ) );

    virtual_address = 0;
    read_phys_address( PVOID( ( pte_addr & PMASK ) + 8 * pte ), &virtual_address, sizeof( virtual_address ), &readsize );
    virtual_address &= PMASK;

    if ( !virtual_address )
        return 0;

    return virtual_address + page_offset;
}

Принцип записи физической памяти:
- Берем нужный виртуальный адрес, который мы хотим прочитать
- Используя полученный DTB процесса, мы используя функцию под названием "translate_linear_address", получаем из виртуального физический адрес
- Берем этот физический адрес
- Используем MmMapIoSpace ( Маппим физическую память, чтобы получить верный указатель на данные. Плюс такого метода заключается в том, что мы можем записывать память даже без Pte.Writable флага )
- Копируем данные по указателю от MmMapIoSpace'а в буфер через memcpy
- Освобождаем маппнутый участок памяти с помощью MmUnmapIoSpace

Принцип чтения физической памяти:
- Вызов MmCopyMemory с флагом MM_COPY_MEMORY_PHYSICAL

Код чтения физической памяти:

Код:
Expand Collapse Copy
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, ниже прикреплен код для получение оффсета на него:

Код:
Expand Collapse Copy
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 ( каллбек, который вызывается в обработчике исключений ядра RtlDispatchException, который используется для KD дебаггера )
2. Записывает в KPROCESS + 0x28 ( т.e. DirectoryTableBase ) невалидное значение ( или же 0 )
3. Когда происходит попытка использования шифрованного регистра, то выбрасывается исключение, которое перехватывает обработчик ( хукнутый анти-читом KdTrap ) и обрабатывает его ( т.е выдает дешифрованный регистр )

- Есть много вариантов расшифровать DTB, но я расскажу о двух:

1. Аттач к процессу, чтение CR3 и извлечение DTB - т.к при аттаче к приложению мы попадаем в контекст приложения ( т.е. функция выполняет __writecr3( __readcr3( ) ) ), то мы спокойно можем получить, запомнить CR3, извлечь из него DTB и в дальнейшем его использовать.
2. Брутфорс - проходим по всей физической памяти, пробуем перевести базовый адрес игры в физический, используя "тестовый" DTB ( то есть текущий адрес по которому мы сейчас проходим ), попробовать его прочитать и если прочитанное значение == PE сигнатуре ( 4D 5A 90 ( 78 ) - в зависимости от компилятора ), то мы просто его запоминаем и в дальнейшем можем его использовать. Для большей точности, я еще проверяю ImageBase в OptionalHeader'e

Пример аттача:

Код:
Expand Collapse Copy
uint64_t get_process_cr3( PEPROCESS process ) {
    KAPC_STATE state{ };
    KeStackAttachProcess( process, &state );

    const auto cr3 = __readcr3( );

    KeUnstackDetachProcess( &state );

    return cr3;
}

Пример брутфорса:

Код:
Expand Collapse Copy
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, то используйте брутфорс для его получения.

Полезные материалы:


Также, хочу поблагодарить @battleye за то, что помог устранить некоторые неточности в статье.
ПОБЕЖАЛ ПАСТИТЬ
 
многочисленные
Также, хочу поблагодарить @battleye за то, что помог устранить некоторые неточности в статье.
Когда горела додо пицца, я первый побежал на выход, мне кричали: товарищ, маргинал, вы не доели, а бортики! я ответил: абортики это убийство
 
Назад
Сверху Снизу