Гайд [Reverse-Engineering] Разбор Overwatch 2: Исключения, ремаппинг, антиотладка

Murasaki
Разработчик
Статус
Онлайн
Регистрация
18 Мар 2020
Сообщения
431
Реакции[?]
870
Поинты[?]
206K
Шалом. Давно я не выпускал публичные статьи для форума, уйдя полностью в бусти.
Особо контента не было на публику, но тут мне напомнили об легендарной Overdroch 2, чьи импорты я уже разбирал прошлым летом, почитать можно здесь: https://yougame.biz/threads/300963/

Мне поведали о том, что Blizzard перетерпела множество изменений, в особенности во время инициализации важных данных на самом старте. И поскольку год назад я не анализировал её инициализацию и анти-дебаг, зациклившись на импортах, то на сегодняшний день, учитывая мою инактивность на форуме, я решил подарить вам, хоть и не так много, но всё же контента.

Вся инициализация игры в основном состоит из дешифрования и шифрования разных нужных ему данных в последующем, будь-то для вызова, для чтения и т.д., укромно спрятав это под изначально зашифрованный код в памяти.

Статья не будет такой огромной и содержательной, но вы держитесь.

TLS-Callback Spoofing

Перед тем, как перейти к исключениям, я заранее скажу, что на самом старте программы (а именно первый TLS-Callback) идет проверка флага NtGlobalFlag:
C++:
MOV     EAX, DWORD PTR DS:[RCX + BC]
AND     EAX, 70
CMP     AL, 70
JE      7FF68D935B8C
Такая проверка тут нужна просто чтобы удостовериться, что во время дешифрования второго TLS-каллбека не подключен отладчик.

Но если всё гуд, то идем дальше.

Игра вызывает VirtualProtect по своему первому TLS-каллбеку:
C++:
mov [rsp + unknown*0x1:64 bv[63..0] + 0x20], 0x110
mov rax, 0x4
mov rcx, 0x7ff6906fb240
mov rdx, 0x110
mov [rsp + unknown*0x1:64 bv[63..0] + 0x28], 0x4
mov r8, 0x4 ; r8=0x4
call rbx
Теперь к самим каллбекам.

1728897073337.png

Overdroch 2 использовал VirtualProtect по адресу, где расположены указатели на все TLS-каллбеки. По началу кажется, что все эти каллбеки просто не расшифрованы, и к ним еще вернуться для декрипьа, но после вызова VirtualProtect, он заменяет указатель на второй каллбек своим адресом, где есть валидный код, и передает ему управление. Опять же, если вы провалили проверку на NtGlobalFlag, то спуфа не произойдет, и вас кинет на второй оригинальный каллбек, где сидят случайные байты.

Вообще, в этом и кроется весь смысл первого каллбека, при старте программы можно самому направить на спуфнутый адрес, и это никак не повлияет на дальнейшее выполнение кода.

Любовь к исключениям

Overdroch 2 активно спамит исключениями, даже во время самой игры в отдельных потоках, делает он это по разным причина, и, ебать, его, в, рот, как же их много будет во время исполнения, игра буквально не может жить без этого.

1728897112930.jpeg

На самом старте программы ставится пока что только обычный Veh.
После установки обработчика исключений, Overdroch триггерит первое исключение, а именно - C0000096, что в простонародье означает попытку выполнить привилегированную инструкцию (EXCEPTION_PRIV_INSTRUCTION), делает он это через инструкцию hlt.

1728897142076.png

Сразу скажу, что обработчик Overdroch 2 служит для нескольких целей:
  1. Обнаружение DR7 на самом старте.
  2. Исполнение глобальных счетчиков, связанных с чеком времени исполнения кода за определенный промежуток, которые добавляются со временем, которые мы рассмотрим чуть позже.
  3. Манипуляция с кодом, шифрование уже выполненного кода, и дешифрование следующего кода перед исполнением.
Когда происходит первое исключение, в целом, ничего такого криминального не происходит. (Казалось бы)
В коде куча мертвого кода и обфускации констант, так что я буду показывать псевдо-код того, что происходит в обработчике при разных обстоятельствах (К обфускации мы еще вернемся).

После 19 полученных исключений от hlt, разработчики добавили новую инструкцию, которая триггерит тот же самый эксепшн, а именно: wbindv.



К моменту, когда мы дойдем до этого исключения, обработчик будет иметь при себе в арсенале большое количество глобальных счетчиков, тут мы остановимся и рассмотрим детальнее псевдокод:
C++:
LONG WINAPI VectoredExceptionHandler(PEXCEPTION_POINTERS ExceptionInfo)
{
    if (ExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_PRIV_INSTRUCTION)
    {
        PCONTEXT ctx = ExceptionInfo->ContextRecord;
        BYTE* instruction = (BYTE*)ctx->Rip;
      
        if (*instruction == 0xF4)
        {
            ctx->Rip++;
          
            InterlockedIncrement((LONG*)0x7FF7D7225290);
          
            ULONGLONG timing = __rdtsc() & 0x3FF;
            if (timing == 0)
            {
                InterlockedIncrement((LONG*)0x7FF7D7225294);
            }
          
            if (CheckTimingConditions())
            {
                // Set trap flag for single-stepping
                ctx->EFlags |= 0x100;
            }
          
            return EXCEPTION_CONTINUE_EXECUTION;
        }
        else if (*instruction == 0x0F && *(instruction + 1) == 0x09)
        {
            ctx->Rip += 2;
          
            UpdateCountersAndPerformChecks();
          
            return EXCEPTION_CONTINUE_EXECUTION;
        }
    }
  
    return EXCEPTION_CONTINUE_SEARCH;
}
Каждое исключение, вызванное инструкциями HLT или WBINVD, не только обновляет глобальные счетчики, но и выполняет сложную серию проверок:
  • Счетчик 1 (0x7FF7D7225290): Это основной счетчик исключений. Он увеличивается с каждым исключением и используется для отслеживания общего прогресса выполнения защитного кода.
  • Счетчик 2 (0x7FF7D7225294): Этот счетчик увеличивается только когда результат RDTSC & 0x3FF равен нулю. Он служит для внесения элемента случайности в механизм.
  • Счетчик 3 (0x7FF7D72252A0): Увеличивается при определенных условиях, связанных с timing_value. Может использоваться для обнаружения аномалий во времени выполнения.
  • Счетчик 4 (0x7FF7D72252A8): Еще один счетчик, зависящий от timing_value. Используется для дополнительной проверки времени.
  • Счетчик 5 (0x7FF7D72252AC): Последний из основных счетчиков в нашем примере, по сути его роль остаются такой же, как и у остальных.
Каждый счетчик имеет свои пороговые значения, и только при соблюдении всех условий программа продолжает нормальное выполнение, но на деле вышло так, что ни одну проверку я не провалил, и исполнял код дальше.

Single-step я не показываю по той причине, что он делает абсолютно всё тоже самое. К слову, все single-step исключения можно обойти либо добавив их в исключения отладчику, либо из инструкции
C:
OR      EDX, ECX
сделать
C:
MOV     EDX, ECX
Либо же просто скипнуть их, запатчив обработчик)0, но это сработает лишь частично, чуть позже объясню почему.

Исходя из текущей логики обработчика, на данном этапе он выглядит примерно так, без учета SINGLE_STEP исключений:


Я "попытался" примерно реализовать подобную шизу с автоматическим добавлением глобальных счетчиков при EXCEPTION_BREAKPOINT, в целом, отработало все нормально, кусочек кода из обработчика (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C++:
std::uint32_t exception_handler( EXCEPTION_POINTERS* exception )
{
     const auto code = exception->ExceptionRecord->ExceptionCode;

     if ( code == EXCEPTION_SINGLE_STEP || code == EXCEPTION_BREAKPOINT || code == EXCEPTION_PRIV_INSTRUCTION )
     {
          if ( !is_address_in_module( (void*) exception->ContextRecord->Rip ) )
          {
               exception->ContextRecord->EFlags &= 0x100;
               return EXCEPTION_CONTINUE_EXECUTION;
          }

          if ( exception->ContextRecord->Dr7 != 0 )
          {
               exception->ContextRecord->Rsp ^= 0xdeadc0de;
               return EXCEPTION_CONTINUE_SEARCH;
          }

          bool all_checks_passed = true;
          for ( size_t i = 0; i < counters.size( ); ++i )
          {
               if ( !check_time( counters ) )
               {
                    all_checks_passed = false;
                    break;
               }
          }

          if ( all_checks_passed )
          {
               exception->ContextRecord->EFlags |= 0x100;

               std::uint8_t offset;
               switch ( code )
               {
               case EXCEPTION_BREAKPOINT:
                    offset = 1;
                    if ( counters.size( ) < 10 )
                    {
                         counters.push_back( { 0, 0x200 + (std::uint32_t) counters.size( ) * 0x100, 0x7E0 } );
                    }
                    break;
               case EXCEPTION_SINGLE_STEP:
                    offset = 0;
                    break;
               case EXCEPTION_PRIV_INSTRUCTION:
                    offset = 2;
                    break;
               default:
                    return EXCEPTION_CONTINUE_SEARCH;
               }

               exception->ContextRecord->Rip += offset;
               return EXCEPTION_CONTINUE_EXECUTION;
          }
          else
          {
               return EXCEPTION_CONTINUE_SEARCH;
          }
     }
     return EXCEPTION_CONTINUE_SEARCH;
}
Имплементация Self-Remap у Blizzard

Шо ж, впервые я такую технику увидел еще давным-давно, но в этой игре Blizzard максимально облегчили себе задачу и сделали вот какой алгоритм. Сама суть техник заключается в защите от патча кода засчёт изменения прав региона до маппинга.

Что делает Overdroch:
  • Игра выделяет новый блок памяти, размер которого равен размеру всего образа игры.
  • Все содержимое оригинального образа копируется в новый блок памяти.
Функция в новом блоке памяти вызывается для:
  • Отмапливание оригинального образа с помощью UnmapViewOfFile.
  • Создание нового отображения файла с помощью CreateFileMapping.
  • Отображение нового файлового отображения на место оригинального образа с помощью MapViewOfFileEx.
  • Копирование содержимого из нового блока памяти в только что созданное отображение.
Я написал что-то подобное по схожости (
Пожалуйста, авторизуйтесь для просмотра ссылки.
)):
C++:
namespace warden
{
     namespace remap
     {
          using RemapFunction = void ( * )( void* original_base, std::size_t size, void* new_base );

          void __declspec( noinline ) do_remap( void* original_base, std::size_t size, void* new_base )
          {
               const auto file =
                   CreateFileMappingA( INVALID_HANDLE_VALUE, nullptr, PAGE_EXECUTE_READWRITE, 0, static_cast<DWORD>( size ), nullptr );

               if ( !file )
               {
                    std::printf( "Failed to create file mapping." );
                    return;
               }

               if ( !UnmapViewOfFile( original_base ) )
               {
                    std::printf( "Failed to unmap original view." );
                    CloseHandle( file );
                    return;
               }

               // Map the view of the file at the original base address
               void* mapped_base = MapViewOfFileEx( file, FILE_MAP_ALL_ACCESS | FILE_MAP_EXECUTE, 0, 0, size, original_base );
               if ( !mapped_base )
               {
                    std::printf( "Failed to map view of file at original address." );
                    CloseHandle( file );
                    return;
               }

               // Copy the content from our new allocation to the mapped view
               std::memcpy( mapped_base, new_base, size );

               std::printf( "Remap successful.\n" );
               CloseHandle( file );
          }

          void __declspec( noinline ) remap_trampoline( )
          {
               auto base = GetModuleHandle( nullptr );

               MODULEINFO module_info;
               if ( !GetModuleInformation( GetCurrentProcess( ), base, &module_info, sizeof( module_info ) ) )
               {
                    std::printf( "Failed to get module information." );
                    return;
               }

               void* new_base = VirtualAlloc( nullptr, module_info.SizeOfImage, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE );
               if ( !new_base )
               {
                    std::printf( "Failed to allocate memory for remapping." );
                    return;
               }

               // Copy the module to the new location
               std::memcpy( new_base, module_info.lpBaseOfDll, module_info.SizeOfImage );

               // Calculate the offset to the do_remap function in the new allocation
               auto offset     = reinterpret_cast<uintptr_t>( do_remap ) - reinterpret_cast<uintptr_t>( base );
               auto remap_func = reinterpret_cast<RemapFunction>( reinterpret_cast<uintptr_t>( new_base ) + offset );

               // Call the remapping function from the new allocation
               remap_func( module_info.lpBaseOfDll, module_info.SizeOfImage, new_base );

               VirtualFree( new_base, 0, MEM_RELEASE );
          }

     }
}
Снова исключения. SEH + KiUserExceptionDispatcher hook

Overdroch 2 удаляет обработчик исключений, и ставит новый. Но вместо простой установки VEH, он перехватывает KiUserExceptionDIspatcher, и теперь в качестве исключений использует такие инструкции, как ud2 и int 0x2c.

После удаления обработчика, он вызывает ud2. Но к этому моменту, ни обработчика, ни хука нет. Overdroch 2 использует альтернативный вариант хендла исключений в виде SEH (Structured Exception Handling).



Но как вообще код передается в __except блок?

Чуть-чуть теории.

Когда в программе происходит исключение (например, деление на ноль или обращение к недопустимому региону памяти), процессор генерирует прерывание. Это как сигнал тревоги в системе. Windows перехватывает этот сигнал в ядре и передает управление функции под названием KiUserExceptionDispatcher в юзермоде.

KiUserExceptionDispatcher, получив управление, вызывает функцию RtlDispatchException. Эта функция отвечает за поиск подходящего обработчика для нашего исключения.

RtlDispatchException, найдя подходящий обработчик, передает управление функции RtlpExecuteHandlerForException. Эта функция отвечает за подготовку и выполнение найденного обработчика.

Наконец, RtlpExecuteHandlerForException вызывает __C_specific_handler. Это специальный обработчик, который является ключевым компонентом в реализации конструкций __try/__except/__finally в Windows.



Но в случае с Overdroch, роль Seh слегка отличается от простого хендлинга исключений, она помимо обработки еще и вызывает нужные ей функции в __except блоке, заранее передав ей аргументы (
Пожалуйста, авторизуйтесь для просмотра ссылки.
)):
C++:
template <typename Func, typename... Args>
auto call( Func&& func, Args&&... args ) -> decltype( func( std::forward<Args>( args )... ) )
{
     using ReturnType = decltype( func( std::forward<Args>( args )... ) );
     ReturnType result{ };

     __try
     {
          __debugbreak( );
     }
     __except ( EXCEPTION_EXECUTE_HANDLER )
     {
          if constexpr ( std::is_same_v<ReturnType, void> )
          {
               func( std::forward<Args>( args )... );
          }
          else
          {
               result = func( std::forward<Args>( args )... );
          }
     }

     return result;
}
Внутри обработчика игра постоянно взаимодействует с текущим TEB, и использует его адрес, как значение для последующего декрипта данных в стеке.



И проверки ради я решил изменить один из получившихся байтов, и ожидаемо мне дали пизды, поскольку оказалось, что они служат в последующем для декрипта адреса функции, которую им нужно вызывать в будущем.

C:
CMP     BYTE PTR DS:[RCX - 5], E8
JNE     7FF6A00B7130
MOVSXD  RAX, DWORD PTR DS:[RCX - 4]
LEA     RDI, QWORD PTR DS:[7FF6A00B6FD0]
ADD     RAX, RCX
CMP     RAX, RDI
JNE     7FF6A00B7130
MOV     RAX, QWORD PTR SS:[RSP + 10]
CMP     RCX, RAX
JB      7FF6A00B7130
В хуке KiUserExceptionDIspatcher мы видим следующую картину:
C++:
if (*(uint32_t*)0x7FF7DE8BB100 != 0) {
    if (rcx->ExceptionCode == 0xC0000096) {
        if (rcx->ExceptionCode == 0x80000001) {
            if (rcx->ExceptionCode == 0x80000004) { {
                uint64_t r8 = *(uint64_t*)(0x7FF7DE8BB100 + 0x1170);
                
                const uint64_t r14 = 0xFDE7AEEDD0E3D097;
                const uint64_t r15 = 0xB400444ACE64BD4B;
                        
                uint32_t r9 = (uint32_t)r8;
                r8 = (r8 & 0xFFFFFFFF00000000) | ((r9 << 20) ^ (0x4B7AC120B16F545C ^ r9));
                r9 = ((uint32_t)(r8 - 0x5C28264) << 20) ^ (r8 & 0xFFFFFFFF00000000) | (r8 & 0xFFFFFFFF);
                r9 ^= r15;
                        
                uint64_t rdtsc_value = __rdtsc();
                uint32_t r10 = rdtsc_value & 0x3FF;
                uint32_t rbx = 1 + r10;
                        
                r8 = ((rbx * 2 - rbx) & 0xFFFFFFFF);
                r8 = (r8 << 20) | rbx;
                r10 = ((uint32_t)(r8 - 0x5C28264) << 20) | (r8 & 0xFFFFFFFF);
                r10 ^= r15;
                        
                r8 = ~(uint32_t)r10;
                uint32_t rdx = (uint32_t)r10;
                uint32_t rax = r14 & 0xFFF;
                r10 &= 0xFFFFFFFF00000000;
                        
                uint64_t rcx = 0x5A45527EE176371F;
                rcx >>= 0x20;
                rcx = _rotr(rcx, 0xB);
                r8 ^= rcx;
                r8 = (r8 << 20) ^ r10 | rdx;
                        
                rcx = (uint32_t)r8;
                r9 = r14 >> 0x34;
                rdx = (uint32_t)r8;
                r8 &= 0xFFFFFFFF00000000;
                        
                rcx -= *(uint32_t*)(r9 + 0x380B520);
                rcx = (rcx << 20) ^ r8 | rdx;
                        
                *(uint64_t*)(0x7FF7DE8BB100 + 0x1170) = rcx;
            }
        }
    }
}
Опять манипуляция с данными какой-то шизы по адресу [00007FF7DE8BC270] во время обработки разных исключений, которые выбрасывает игра. Назвать это чем-то важным не особо язык поворачивается, поскольку это значение никаким потоком не читается в дальнейшем (кроме самим хендлером в самом начале).

Как вы поняли, изменение этого значения вообще никак не влияет на работоспособность игры в будущем, так что это выглядит как песок в глаза.

Также пока я снимал трассировку, то краем глаза обнаружил забавный проверки целостности возвращаемого адреса. Эта инструкция проверяет первый байт релативного вызова, который его вызвал, в случае несовпадения отправляет в бесконечный цикл, пока стек не умрет:
C++:
cmp byte ptr ds:[rcx-5],E8
Эта последовательность вычисляет адрес возврата из вызывающей инструкции CALL и сравнивает его с ожидаемым адресом (7FF6A00B6FD0). Это проверка того, что функция была вызвана из ожидаемого места в коде:
C++:
movsxd rax,dword ptr ds:[rcx-4]
lea rdi,qword ptr ds:[7FF6A00B6FD0]
add rax,rcx
cmp rax,rdi
А эти инструкции выполняют проверку диапазона адресов:
C++:
mov rax,qword ptr ss:[rsp+10]
cmp rcx,rax
jb 7FF6A00B7130
mov rax,qword ptr ss:[rsp+18]
cmp rcx,rax
jae 7FF6A00B7130
Они проверяют, что адрес вызова (rcx) находится в определенном диапазоне адресов, заданном значениями в стеке.

И все они, в случае несовпадения, отправляют погулять RIP в бесконечный цикл, вызывая этим шеллом исключение STATUS_RESOURCE_NOT_OWNED:
C++:
MOV     RAX, QWORD PTR GS:[60]
MOV     QWORD PTR SS:[RSP + 20], RAX
MOV     RDI, QWORD PTR DS:[RAX + 18]
LEA     RSI, QWORD PTR SS:[RSP + 20]
MOV     ECX, 1000
REP MOVSB   
JMP     7FF6A00B7190
Печалька в анти-отладке

Анти-отладка, тут, к сожалению, оказалась не очень.
Фактически, все функции, связанные с анти-дебагом, вызываются в ммапнутом ntdll, чтобы убить Anti-anti-debug плагины.

Игра аллоцирует через NtAllocateVirtualMemory выделенную память для ntdll и через NtReadFile читает файл с диска, поскольку CreateFileMapping/MapViewOfFile шлют на все четыре стороны, если задать флаг выполнения.

Примерная моя реализация была такой (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C++:
void init( )
{
     // Hardcoded path to ntdll.dll
     constexpr std::wstring_view file_path = L"C:\\Windows\\System32\\ntdll.dll";

     HANDLE file_handle = CreateFileW( file_path.data( ), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr,
                                       OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr );

     if ( file_handle == INVALID_HANDLE_VALUE )
     {
          return;
     }

     LARGE_INTEGER file_size = { };
     if ( !GetFileSizeEx( file_handle, &file_size ) )
     {
          CloseHandle( file_handle );
          return;
     }

     LPVOID buffer =
         VirtualAlloc( nullptr, static_cast<SIZE_T>( file_size.QuadPart ), MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE );

     if ( !buffer )
     {
          CloseHandle( file_handle );
          return;
     }

     DWORD bytes_read = 0;
     if ( !ReadFile( file_handle, buffer, static_cast<DWORD>( file_size.QuadPart ), &bytes_read, nullptr ) )
     {
          VirtualFree( buffer, 0, MEM_RELEASE );
          CloseHandle( file_handle );
          return;
     }

     if ( bytes_read != file_size.QuadPart )
     {
          VirtualFree( buffer, 0, MEM_RELEASE );
          CloseHandle( file_handle );
          return;
     }

     CloseHandle( file_handle );

     auto* dos_header = static_cast<IMAGE_DOS_HEADER*>( buffer );
     if ( dos_header->e_magic != IMAGE_DOS_SIGNATURE )
     {
          VirtualFree( buffer, 0, MEM_RELEASE );
          return;
     }

     auto* nt_headers = reinterpret_cast<IMAGE_NT_HEADERS*>( reinterpret_cast<uint8_t*>( buffer ) + dos_header->e_lfanew );
     if ( nt_headers->Signature != IMAGE_NT_SIGNATURE )
     {
          VirtualFree( buffer, 0, MEM_RELEASE );
          return;
     }

     ntdll = reinterpret_cast<std::uint64_t>( buffer );
     printf( "Ntdll loaded at: 0x%p.\n", reinterpret_cast<void*>( ntdll ) );
}
Возвращаясь к ммапед ntdll, первым что он там выполняет это NtProtectVirtualMemory:
Код:
[!] Syscall executed in 0x0000021D7D840B32 with 0x50 number
NtProtectVirtualMemory(ProcessHandle: 0xFFFFFFFFFFFFFFFF, BaseAddress: 0x00007FFC8AF93E30, RegionSize: 0x4, NewProtect: 0x40, OldProtect: 0x00007FF725EC1000) call
Указывая на DbgBreakPoint и DbgUserBreakPoint, запатчив обоих на RET, но я года два тому назад ещё говорил в одной из своих статей, что это никак не влияет на работу x64dbg.

Затем происходит создание двух поток из ntdll через NtCreateThreadEx с флагом HideThreadFromDebugger, эти потоки в свою очередь, создают еще пару потоков.

Созданные потоки начинают друг за другом вызывать NtSetInformationThread, чтобы скрывать свои потоки. Но непонятно что мешало их сразу создать со скрытым флагом, как это и делал изначально Overdroch.

Далее один из потоков, в том же ммапнутом ntdll, вызывает NtQueryInformationProcess с флагом 0x1F - (ProcessDebugFlags), если вас детектит, то вы улетаете в нуллптр. Ии, на этом всё. Весь антидебаг в ините именно в этом и крылся.

Складывается ощущение, что разрабы просто зашли на сайт
Пожалуйста, авторизуйтесь для просмотра ссылки.
, перепастили оттуда пару трюков, которым по 10+ лет, запихнули их в heartbeat-потоки для постоянного наблюдения и пошли попивать чаек.

Но это во время инита, а что у нас происходит во время запущенной игры?

Код:
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
SystemKernelDebuggerInformation by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
SystemKernelDebuggerInformation by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
ProcessDebugFlags by 12912 (Overwatch.exe)
Игра, находясь в главном меню, просто спамит из нескольких потоков чтение поля BeingDebugged + вызывая дефолтные анти-отладочные трюки по несколько раз, приправив все это флудом исключений.


Сисколлы кстати никуда не делись, они также продолжают выполняться внутри ммапед ntdll:


Да и когда вы захотите аттачнуться, вероятно, вам прилетит по лбу, поскольку все эти потоки уже были скрыты и вас просто дропнет с отладчика.

Как подобное контрить? Если бы не ммапед ntdll, который оказался исполняемым, то со всем этим спокойно справлялись популярные usermode-плагины вроде ScyllaHide и SharpOD, и в ScyllaHide даже намек есть на Nirvana hook, но в нашей плачебной ситуации нас выручит TitanHide (
Пожалуйста, авторизуйтесь для просмотра ссылки.
) + EfiGuard (
Пожалуйста, авторизуйтесь для просмотра ссылки.
). Т.к. все, что вызывается в качестве анти-отладке спокойно покрывается титанхайдом.

После того, как у вас благодаря EfiGuard отключен PatchGuard + DSE, основной проблемой становится попытка аттачнуться к процессу, у которого абсолютно все потоки скрыты, ведь нас по прежнему будет дропать с отладчика.

Почему так?

В ядре при ThreadHideFromDebugger в ETHREAD->CrossThreadFlags задается флаг PS_CROSS_THREAD_FLAGS_HIDEFROMDBG. В дальнейшем во функции "DbgkForwardExceptionPattern" мы встретим следующую проверку:
C++:
if (PsGetCurrentThread( )->CrossThreadFlags & PS_CROSS_THREAD_FLAGS_HIDEFROMDBG) {
    Port = NULL;
} else {
    Port = Process->DebugPort;
}
Т.е. при обнаружении флага, DebugPort отлаживаемого процесса просто зануляется. Те, кто читал мою статью про защиту процесса из кернела (
Пожалуйста, авторизуйтесь для просмотра ссылки.
) уже видели, что будет с отладчиком, если вот так в наглую изменить DebugPort. Именно по этой причине отладка дальше невозможна и в случае x64dbg, дизассемблер визуально исчезает, а приложение завершается с исключением 0x80000003/0x80000004.

Оригинальный источник с ресерчем: (
Пожалуйста, авторизуйтесь для просмотра ссылки.
)

Поскольку у нас в арсенале есть EfiGuard и PG нам не страшен, то мы можем пропатчить ядро, сделав так, чтобы наша проверка никогда не передала управление коду, который убьет DebugPort.

Это уже писал один человек два года тому назад - https://yougame.biz/threads/243341/

Я воспользовался этим драйвером, изменив там паттерн и метод получения адреса Ntoskrnl (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):


И, как вы уже заметили, меня перестало кикать с отладчика в скрытых потоках :)



Всего этого вполне хватает, чтобы беспрепятственно аттачнуться к игре и вытворять свои махинации.

Бонус: Пишем деобфускатор

Во время анализа трейса, я неоднократно сталкивался с обфускацией констант, анти-дизасмом и фейк JCC.

Анти-дизасм и фейк JCC устроены таким образом, чтобы фейк JCC никогда не гарантировал выполнение куска кода, коим и является анти-дизасм, сигнатурой такой техники является то, что фейк JCC всегда прыгает на NOP инструкцию:





Забавно, что год тому назад я правильно интерпретировал работу этого анти-дизасма, но облажался, показав неверный пример на графе, в котором нет кода из самой игры:


Теперь перейдем к обфускатору констант. Для тех, кто в танке, и не знает вообще что такое обфускатор констант, это - метод скрытия истинных значений в коде. Он работает путем применения серии математических операций к начальному зашифрованному значению, чтобы в итоге получить желаемую константу. Вот как это работает:
  • Инициализация: Начальное значение помещается в память (обычно на стек).
  • Серия операций: Выполняется последовательность математических операций (сложение, вычитание, XOR, ROL, и т.д.) над этим значением.
  • Промежуточные сохранения: После каждой операции результат сохраняется обратно в память.
  • Финальное значение: В конце серии операций в памяти оказывается нужная константа.
Все эти пункты легко и просто применимы к обфускатору Overdroch 2. Давайте рассмотрим пару примеров:
C++:
MOV     QWORD PTR SS:[RSP + 198], FFFFFFFFFFFFFD67
MOV     RAX, QWORD PTR SS:[RSP + 198]
XOR     RAX, B79
MOV     QWORD PTR SS:[RSP + 198], RAX
MOV     RAX, QWORD PTR SS:[RSP + 198]
ADD     RAX, 6B4
MOV     QWORD PTR SS:[RSP + 198], RAX
MOV     RAX, QWORD PTR SS:[RSP + 198]
ADD     RAX, 32E
MOV     QWORD PTR SS:[RSP + 198], RAX
MOV     RAX, QWORD PTR SS:[RSP + 198]
В этом примере происходит всё то, что было описано мною раннее, это самый обычный и лёгкий обфускатор: инициализация, серия операция, промежуточные сохранения и финальное значение.

C++:
MOV     QWORD PTR SS:[RSP + 20], 1F22
MOV     RAX, QWORD PTR SS:[RSP + 20]
ADD     RAX, FFFFFFFFFFFFECE8
MOV     QWORD PTR SS:[RSP + 20], RAX
MOV     RAX, QWORD PTR SS:[RSP + 20]
MOV     QWORD PTR SS:[RSP + 20], RAX
MOV     RAX, QWORD PTR SS:[RSP + 20]
MOV     QWORD PTR SS:[RSP + 20], RAX
MOV     RAX, QWORD PTR SS:[RSP + 20]
ADD     RAX, FFFFFFFFFFFFF44B
MOV     QWORD PTR SS:[RSP + 20], RAX
MOV     RAX, QWORD PTR SS:[RSP + 20]
Абсолютно та же картина происходящего. Такой обфускатор очень прост в реализации, используя compile-time (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C++:
template <uint64_t X, uint64_t key = random( compile_time_seed( ) )>
struct obfuscated_constant
{
     static constexpr uint64_t random1     = random( X ^ key );
     static constexpr uint64_t random2     = random( random1 );
     static constexpr uint64_t random3     = random( random2 );
     static constexpr uint64_t crypted_val = ( ( ( X ^ random1 ) + random2 ) ^ random3 ) + key;

     __forceinline static uint64_t decrypt( )
     {
          uint64_t v = crypted_val;
          v -= key;
          v ^= random3;
          v -= random2;
          v ^= random1;
          return v;
     }
};

template <uint64_t X, uint64_t key = random( compile_time_seed( ) )>
uint64_t obfuscate( )
{
     return obfuscated_constant<X, key>::decrypt( );
}
Результат:
C++:
MOV     RAX, 0xADFF6DF3F3D2B8EF
MOV     QWORD PTR SS:[RSP], RAX
MOV     RAX, 0xF68ADEBB419C580
MOV     RCX, QWORD PTR SS:[RSP]
SUB     RCX, RAX
MOV     RAX, RCX
MOV     QWORD PTR SS:[RSP], RAX
MOV     RAX, 0xA7D3B9F5B60612C0
MOV     RCX, QWORD PTR SS:[RSP]
XOR     RCX, RAX
MOV     RAX, RCX
MOV     QWORD PTR SS:[RSP], RAX
MOV     RAX, 0x4D03B8AFB7C07D40
MOV     RCX, QWORD PTR SS:[RSP]
ADD     RCX, RAX
MOV     RAX, RCX
MOV     QWORD PTR SS:[RSP], RAX
MOV     RAX, 0x58E48C429FD2E000
MOV     RCX, QWORD PTR SS:[RSP]
XOR     RCX, RAX
MOV     RAX, RCX
MOV     QWORD PTR SS:[RSP], RAX
MOV     RAX, QWORD PTR SS:[RSP]
MOV     QWORD PTR SS:[RSP + 8], RAX
MOV     RAX, QWORD PTR SS:[RSP + 8]
Учитывая, как мало контента я завез в статью, то напоследок я решил написать деобфускатор, используя Triton. Скрипт будет оптимизировать подобный код, и показывать в регистрах/стеке деобфусцированные значения.

Основная идея такая:
  • Загружаем обфусцированный код в эмулятор Triton
  • Эмулируем выполнение инструкций одну за другой
  • На каждом шаге отслеживаем изменения в регистрах
  • В конце смотрим на финальные значения регистров
Python:
class Deobfuscator:
    def __init__(self, bin_path):
        self.bin_path = bin_path
        self.ctx = triton.TritonContext()
        self.ctx.setArchitecture(triton.ARCH.X86_64)
        self.start_address = 0x1000

    def initialize(self):
        with open(self.bin_path, 'rb') as f:
            function_bytes = f.read()
        self.ctx.setConcreteMemoryAreaValue(self.start_address, function_bytes)
        self.ctx.setConcreteRegisterValue(self.ctx.registers.rip, self.start_address)
Тут мы инициализируем контекст Triton, загружаем байты нашей обфусцированной функции в эмулируемую память и устанавливаем начальное значение RIP.

Теперь переходим к деобфускации:
Python:
def deobfuscate(self):
    pc = self.start_address
    final_state = {}

    while True:
        opcode = self.ctx.getConcreteMemoryAreaValue(pc, 16)
        instruction = triton.Instruction(pc, opcode)
        self.ctx.processing(instruction)

        if instruction.getType() == triton.OPCODE.X86.ADD and \
           instruction.getOperands()[0].getType() == triton.OPERAND.MEM and \
           instruction.getOperands()[0].getBaseRegister().getName() == 'rax' and \
           instruction.getOperands()[1].getName() == 'al':
            break

        self.process_instruction(instruction, final_state)
        pc = instruction.getNextAddress()

    return self.generate_optimized_code(final_state)
Этот метод - сердце нашего деобфускатора. Мы начинаем с начального адреса и эмулируем выполнение инструкций одну за другой. Для каждой инструкции мы:
  • Получаем ее опкод из эмулируемой памяти
  • Создаем объект инструкции
  • Обрабатываем инструкцию в контексте Triton
Причина по которой здесь сидит проверка на инструкцию ADD [RAX], AL, заключается в том, что я по ленивому копировал байты в аллоцированную память, дампил её, и скармливал скрипту, не спрашивайте почему так)0

Теперь посмотрим на process_instruction:
Python:
def process_instruction(self, instruction, final_state):
    disasm = instruction.getDisassembly()
    if disasm.startswith(('mov', 'xor', 'add')):
        dest, src = instruction.getOperands()
        dest_name = self.get_operand_name(dest)
        src_value = self.get_value(src)
        
        if disasm.startswith('xor') and dest_name == self.get_operand_name(src):
            final_state[dest_name] = 0
        elif disasm.startswith('add'):
            dest_value = self.get_value(dest)
            final_state[dest_name] = (dest_value + src_value) & 0xFFFFFFFFFFFFFFFF
        else:
            final_state[dest_name] = src_value
    elif disasm.startswith('cmp'):
        left, right = instruction.getOperands()
        final_state['cmp'] = (self.get_value(left), self.get_value(right))
Здесь мы обрабатываем каждую инструкцию и обновляем final_state - словарь, хранящий текущие значения регистров. Почему мы фокусируемся только на MOV, XOR, ADD и CMP? Потому что это основные инструкции, используемые в обфускации констант в Overwatch.

"CMP" здесь проверяется, поскольку обфускация констант часто встречалась в обработчиках исключений, где происходит много проверок на такое-то значение эксепшена.

Если operand XOR самого себя, это обнуляет значение, казалось бы, юзлесс проверка. Но она была добавлена, поскольку она попалась в одном из обфусцированных констант.

Наконец, generate_optimized_code:
Python:
def generate_optimized_code(self, final_state):
    optimized = []
    for reg, value in final_state.items():
        if reg != 'cmp':
            optimized.append(f"mov {reg}, {hex(value)}")
    if 'cmp' in final_state:
        left, right = final_state['cmp']
        optimized.append(f"cmp {hex(left)}, {hex(right)}")
    return optimized
Этот метод генерирует оптимизированный код на основе final_state. Мы создаем простые инструкции MOV для каждого регистра и его финального значения.
Тут я сильно наговнокодил (снова), особенно с аргументами в CMP, где вместо регистров подставлял получившиеся IMM-значения, просто потому что мне было так удобнее, такие дела :)

Давайте проверим деобфускатор на пару примерчиков, первой жертвой станет наш код, который мы запилили для примера, закидываем ему код, и получаем такой вывод:
C++:
Optimized code:
mov rax, 0xdeadbeefdeadbeef
mov [rsp + unknown*0x1:64 bv[63..0] + 0x0], 0xdeadbeefdeadbeef
mov rcx, 0x58e48c429fd2e000
mov [rsp + unknown*0x1:64 bv[63..0] + 0x8], 0xdeadbeefdeadbeef
Поскольку в рантайме наш код действительно сохраняет это значение еще и в стеке, то это тоже логгируется, но можно было это сократить до простого
C++:
mov rax, 0xdeadbeefdeadbeef
Но меня устраивает и текущий результат, так что, давайте теперь проверим на обфускаторе Overdroch 2, мы скормим скрипту вот такой код:
C++:
MOV     QWORD PTR SS:[RSP + 50], 17A9
LEA     RDX, QWORD PTR SS:[RSP + 150]
MOV     RAX, QWORD PTR SS:[RSP + 50]
LEA     RCX, QWORD PTR SS:[RSP + 1C8]
ADD     RAX, 2D1
MOV     QWORD PTR SS:[RSP + 50], RAX
MOV     RAX, QWORD PTR SS:[RSP + 50]
ADD     RAX, FFFFFFFFFFFFF45C
MOV     QWORD PTR SS:[RSP + 50], RAX
MOV     RAX, QWORD PTR SS:[RSP + 50]
ADD     RAX, FFFFFFFFFFFFF12B
MOV     QWORD PTR SS:[RSP + 50], RAX
MOV     RAX, QWORD PTR SS:[RSP + 50]
И получаем следующий код:
C++:
Optimized code:
mov [rsp + unknown*0x1:64 bv[63..0] + 0x50], 0x1
mov rax, 0x1
Наш деобфускатор справился на ура. Но, по факту вся наша деобфускация состояла из простого выполнения кода с мониторингом регистров, поскольку я сам новичок в использовании Triton и смотря на другие примеры его использования, то понимаю, что есть моменты, где можно было не костылить проверками, а пойти по иному и более легкому пути.

Сам исходный код деобфускатора лежит здесь:
Пожалуйста, авторизуйтесь для просмотра ссылки.


Конец

Спасибо за чтение этой статьи. Я немного задержался с выходом этой статьи, поскольку в очередной N-ый раз умудрился заболеть. Мне будет очень приятно, если вы поддержите меня и поднимите мотивацию для выхода статей на моем Boosty:
Пожалуйста, авторизуйтесь для просмотра ссылки.


Всего доброго, пока!

 
expenis 3.1 paster
Пользователь
Статус
Оффлайн
Регистрация
20 Апр 2021
Сообщения
1,450
Реакции[?]
34
Поинты[?]
46K
Эксперт
Статус
Оффлайн
Регистрация
29 Мар 2021
Сообщения
1,605
Реакции[?]
607
Поинты[?]
48K
Начинающий
Статус
Оффлайн
Регистрация
21 Апр 2024
Сообщения
33
Реакции[?]
22
Поинты[?]
21K
да че кринжик, очередная статья про вех на югейме от дениса колби пясят семь
сука мирон хватит хейтить,выложу лучше свою статью про определения протектора по размеру файла
1728908569296.pngНу не я нашел одну
 
Сверху Снизу