Несмотря на то, что исполняемые файлы имеют чёткую структуру и документированное описание, найти полезную информацию в интернете о том как читать PE заголовок достаточно сложно (если вообще возможно).
Переписывать документацию наверное не имеет смысла. Вместо этого я покажу примеры кода как читать конкретные секции (заодно, поверхностно, рассмотрим защиту одной игры от взлома).
Код я буду писать как говорится "чистый", без всяких дефайнов, обёрток и прочей ерунды, которая только путает и забивает понимание того, что происходит.
Сначала инициализируем DOS и NT заголовки (и сразу заголовок секций, т.к. в NT заголовке этой структуры нет)
C++ | 1
2
3
| IMAGE_DOS_HEADER * __ptr64 pDos = (IMAGE_DOS_HEADER * __ptr64)ImageBaseAddr;
IMAGE_NT_HEADERS64 * __ptr64 pNt = (IMAGE_NT_HEADERS64 * __ptr64)((unsigned __int64)ImageBaseAddr + pDos->e_lfanew);
IMAGE_SECTION_HEADER * __ptr64 pSec = (IMAGE_SECTION_HEADER * __ptr64)((unsigned __int64)ImageBaseAddr + pDos->e_lfanew + sizeof(IMAGE_NT_HEADERS64)); |
|
ImageBaseAddr получается стандартно - поочерёдным вызовом трех функций:
C++ | 1
2
3
| HANDLE hFile = CreateFile("PathToFile", ...);
HANDLE hFileMapping = CreateFileMapping(hFile, ...);
unsigned __int64* __ptr64 ImageBaseAddr = MapViewOfFile(hFileMapping, ...); |
|
IMAGE_SECTION_HEADER
Начнём с IMAGE_SECTION_HEADER. Заголовок содержит данные, которые размещаются в адресном пространстве процесса во время загрузки исполняемого файла в память.
Здесь располагаются секции кода, ресурсов, импорта, экспорта, и прочее.
Именно эти секции пытаются защитить от реверса, обфусцируя их виртуальными машинами, добавляя мусор и мутации кода и многое другое.
Чтобы осуществить взлом необходимо найти в секции кода так называемую OEP (original entry point), или базовую точку входа. OEP часто путают с EP (entry point). EP прописывается в PE заголовке и не является базовой точкой входа. Найдя OEP можно спокойно отсоединять отладчиком защиту от исполняемого файла.
Информацию о секциях можно добыть следующим кодом:
C++ | 1
2
3
4
5
6
| wprintf(L"****************************************************************\n IMAGE_SECTION_HEADER\n****************************************************************");
wprintf(L" Name --- VA --- pRawData --- VS --- sRawData\n");
for (unsigned __int16 i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
printf("%-8s --- 0x%08X --- 0x%08X --- %08X --- %08X\n", pSec[i].Name, pSec[i].VirtualAddress, pSec[i].PointerToRawData, pSec[i].Misc.VirtualSize, pSec[i].SizeOfRawData);
} |
|
Пример вывода данных в консоль

После исполнения кода получаем информацию об именах, виртуальных адресах, смещениях и прочее.
Опытный реверсер сразу обратит внимание на такие секции как .xcode, .xdata, .vmp0 - это и есть обфусцированные виртуальной машиной секции.
Приставка .x говорит о том, что секция зашифрована средствами защиты Denuvo, а .vmp - это виртуальная машина VMProtect.
Их расшифровка отдельная тема, и достаточно сложная, поэтому оставим её и перейдем к секции экспорта.
IMAGE_EXPORT_DIRECTORY
Исполняемые файлы (будь то exe или dll) могут иметь (или не иметь) секции экспорта и импорта.
Экспорт для dll файла означает, что созданные в его коде функции могут использоваться путём их импорта в exe файле.
Таблицу экспорта получаем следующим кодом:
C++ | 1
2
3
4
5
6
7
8
9
10
11
12
| wprintf(L"****************************************************************\n IMAGE_EXPORT_DIRECTORY\n****************************************************************");
IMAGE_EXPORT_DIRECTORY* __ptr64 pExpDir = (IMAGE_EXPORT_DIRECTORY * __ptr64)((unsigned __int64)ImageBaseAddr + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress);
if (pExpDir->NumberOfNames != 0) {
unsigned __int32* __ptr64 Name = (unsigned __int32* __ptr64)((unsigned __int64)ImageBaseAddr + pExpDir->AddressOfNames);
unsigned __int32* __ptr64 Func = (unsigned __int32* __ptr64)((unsigned __int64)ImageBaseAddr + pExpDir->AddressOfFunctions);
for (unsigned __int32 i = 0; i < pExpDir->NumberOfNames; i++) {
printf("%-51s --- ", (unsigned __int64)ImageBaseAddr + Name[i]);
wprintf(L"0x%08X\n", Func[i]);
}
}
else wprintf(L"\nExport directory not found!\n\n"); |
|
Пример вывода данных в консоль

IMAGE_IMPORT_DESCRIPTOR
Аналогично получаем информацию о таблице импорта:
C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| wprintf(L"****************************************************************\n IMAGE_IMPORT_DESCRIPTOR\n****************************************************************");
IMAGE_IMPORT_DESCRIPTOR* __ptr64 pImpDir = (IMAGE_IMPORT_DESCRIPTOR * __ptr64)((unsigned __int64)ImageBaseAddr + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
if (pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].Size != 0) {
while (pImpDir->Name) {
IMAGE_THUNK_DATA64* __ptr64 OriginalFirstThunk = (IMAGE_THUNK_DATA64 * __ptr64)((unsigned __int64)ImageBaseAddr + pImpDir->OriginalFirstThunk);
IMAGE_THUNK_DATA64 * __ptr64 FirstThunk = (IMAGE_THUNK_DATA64 * __ptr64)((unsigned __int64)ImageBaseAddr + pImpDir->FirstThunk);
IMAGE_THUNK_DATA64 * __ptr64 ThunkData = OriginalFirstThunk;
if (!ThunkData) ThunkData = FirstThunk;
printf("\n%s (OFT: 0x%08X, FT: 0x%08X)\n", (unsigned __int64)ImageBaseAddr + pImpDir->Name, pImpDir->OriginalFirstThunk, pImpDir->FirstThunk);
while (ThunkData->u1.AddressOfData) {
if (!(ThunkData->u1.Ordinal & IMAGE_ORDINAL_FLAG64)) {
IMAGE_IMPORT_BY_NAME* __ptr64 ImportFunctionName = (IMAGE_IMPORT_BY_NAME * __ptr64)((unsigned __int64)ImageBaseAddr + ThunkData->u1.AddressOfData);
printf(" %-50s --- ", ImportFunctionName->Name);
wprintf(L"0x%016llX\n", ThunkData->u1.AddressOfData);
}
ThunkData++;
}
pImpDir++;
}
}
else wprintf(L"\nImport directory not found!\n\n"); |
|
Пример вывода данных в консоль

Здесь стоит сделать небольшое примечание - чем отличаются OriginalFirstThunk и FirstThunk.
Если работать с файлом как с набором байт на диске, а не как с загруженным образом в память, то эти поля будут идентичны.
В случае с загруженным образом эти поля будут отличаться: OriginalFirstThunk так и будет ссылаться на массив имён (import name table - INT), но FirstThunk будет ссылаться на таблицу прокси вызовов (import address table - IAT), где адрес нужного Thunk будет прокси вызовом call dword ptr[&Thunk] (FF 15 XX XX XX XX).
IMAGE_TLS_DIRECTORY64
Перейдем к наверно самой интересной с точки зрения реверса таблице - TlsCallback.
Немного расскажу о значимости этой таблицы в части защиты кода от взлома.
TLSCallBack вызывается из системы при создании потоков. Главная фишка этой таблицы – вызов потока до точки входа в приложение (секция CRT).
Если создать поток до точки входа и вызывать из него функцию антидебага (к примеру IsDebuggerPresent), то при попытке реверса такого файла (структура DEBUG_EVENT, WaitForDebugEvent/ContinueDebugEvent, etc.) мы попадем на IMAGE_TLS_CALLBACK с последующим TerminateProcess.
Небольшой пример:
C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| #include <Windows.h>
#pragma comment (linker, "/INCLUDE:_tls_used")
#pragma section (".CRT$XLY", long, read)
void __stdcall TLSCallBack(void *, unsigned long, void *) {
if (IsDebuggerPresent()) {
MessageBox(nullptr, "Debugger is detected", "Test", MB_ERROR);
TerminateProcess(hProcess, 0);
}
}
__declspec(allocate(".CRT$XLY"))PIMAGE_TLS_CALLBACK pTLSCallBack = TLSCallBack;
int __stdcall WinMain(HINSTANCE, HINSTANCE, char *, int) {
MessageBox(nullptr, "All ok", "Test", MB_OK);
return 0;
} |
|
При обычном исполнении такой программы будет выведено сообщение что всё хорошо.
Но при попытке отладить программу в отладчике, определенный в CRT поток IMAGE_TLS_CALLBACK, сработает быстрее чем точка входа WinMain и приложение завершится так и не добравшись до OEP.
Определить имеется ли TlsCallback в коде приложения можно следующим образом:
C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| typedef void(__fastcall* __ptr64 PIMAGE_TLS_CALLBACK)(void* __ptr64 DllHandle, void* __ptr64 Reason, void* __ptr64 Reserved);
wprintf(L"****************************************************************\n IMAGE_TLS_DIRECTORY64\n****************************************************************");
IMAGE_TLS_DIRECTORY64* __ptr64 pTlsDir = (IMAGE_TLS_DIRECTORY64 * __ptr64)((unsigned __int64)ImageBaseAddr + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_TLS].VirtualAddress);
PIMAGE_TLS_CALLBACK* __ptr64 TlsCallback = (PIMAGE_TLS_CALLBACK * __ptr64)pTlsDir->AddressOfCallBacks;
if (*(__int64* __ptr64)TlsCallback != 0) {
wprintf(L"\n");
__int32 i = 1;
while (*TlsCallback) {
wprintf(L"TlsCallback_%d: 0x%016llX\n", i, *(__int64* __ptr64)TlsCallback);
TlsCallback++;
i++;
}
}
else wprintf(L"\nTLS directory not found!\n\n"); |
|
Пример вывода данных в консоль

В данной игре навесили аж 2 потока IMAGE_TLS_CALLBACK, которых при реверсе обойти будет не так то просто.
Но и это не предел возможностей данной таблицы. Анализируя защиту другой игры, я обнаружил так называемый вложенный TlsCallback
Assembler | 1
| mov rax, gs:[0x58] inputhost!TlsCallback_0/1 |
|
Отлаживая поток IMAGE_TLS_CALLBACK я понял, что код вызывает ещё 2 TlsCallback из inputhost.dll и причём так хитро, что самой библиотеки inputhost.dll изначально не было ни в таблице импорта, ни в таблице отложенного импорта.
TlsCallback из inputhost.dll работает следующим образом:
все необработанные исключения уходят в kernel32!UnhandledExceptionFilter
но в импорте игры в экспорте kernel32.dll не было UnhandledExceptionFilter
если SEH отсутствует, указатель на регистр с адресом-смещением rip->UnhandledExceptionFilter(EXCEPTION_POINTERS) устанавливается через SetUnhandledExceptionFilter(0)
C++ | 1
2
3
4
5
6
7
| RaiseSecurity(struct _EXCEPTION_POINTERS* __ptr64 ExceptionInfo) {
SetUnhandledExceptionFilter(0);
UnhandledExceptionFilter(ExceptionInfo);
if (!IsDebuggerPresent())
_crt_debugger_hook(1);
return TerminateProcess(hProcess, 0xC0000409);
} |
|
IMAGE_DELAYLOAD_DESCRIPTOR
Таблица отложенного импорта содержит информацию, необходимую для отложенной загрузки импортируемых библиотек.
Отложенная загрузка импорта означает, что dll присоединена к исполняемому файлу, но загружается в память не сразу, как при обычном импорте, а только при первом обращении программы к символу, импортируемому из этой dll.
Получение информации о таблице отложенного импорта:
C++ | 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| wprintf(L"****************************************************************\n IMAGE_DELAYLOAD_DESCRIPTOR\n****************************************************************");
IMAGE_DELAYLOAD_DESCRIPTOR* __ptr64 pDelayDir = (IMAGE_DELAYLOAD_DESCRIPTOR * __ptr64)((unsigned __int64)ImageBaseAddr + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT].VirtualAddress);
if (pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT].Size != 0) {
while (pDelayDir->DllNameRVA) {
IMAGE_THUNK_DATA64* __ptr64 ImportNameTable = (IMAGE_THUNK_DATA64 * __ptr64)((unsigned __int64)ImageBaseAddr + pDelayDir->ImportNameTableRVA);
IMAGE_THUNK_DATA64 * __ptr64 ImportAddressTable = (IMAGE_THUNK_DATA64 * __ptr64)((unsigned __int64)ImageBaseAddr + pDelayDir->ImportAddressTableRVA);
printf("\n%s (INT: 0x%016llX, IAT: 0x%016llX, BoundIAT: 0x%016llX)\n",
(unsigned __int64)ImageBaseAddr + pDelayDir->DllNameRVA,
(unsigned __int64)ImageBaseAddr + pDelayDir->ImportNameTableRVA,
(unsigned __int64)ImageBaseAddr + pDelayDir->ImportAddressTableRVA,
(unsigned __int64)ImageBaseAddr + pDelayDir->BoundImportAddressTableRVA);
while (ImportNameTable->u1.AddressOfData) {
if (!(ImportNameTable->u1.Ordinal & IMAGE_ORDINAL_FLAG64)) {
IMAGE_IMPORT_BY_NAME* __ptr64 ImportFunctionName = (IMAGE_IMPORT_BY_NAME * __ptr64)((unsigned __int64)ImageBaseAddr + ImportNameTable->u1.AddressOfData);
printf(" %-50s --- ", ImportFunctionName->Name);
wprintf(L"0x%016llX\n", ImportAddressTable->u1.AddressOfData);
}
ImportNameTable++;
ImportAddressTable++;
}
pDelayDir++;
}
}
else wprintf(L"\nDelayload directory not found!\n\n"); |
|
Пример вывода данных в консоль

Здесь видно, что библиотека, необходимая для запуска клиента игры, находится в таблице отложенного импорта.
При взломе эту таблицу эмулируют, получая от неё данные в процессе отладки.
Но здесь поступили хитро, убрав библиотеку из основного импорта, поэтому отлаживать её возможно только после запуска игры.
Подведём итоги проанализированной защиты:
1. код программы обфусцирован двумя виртуальными машинами (Denuvo, VMProtrect)
2. имеется два TlsCallback (с возможно вложенными потоками)
3. основная библиотека клиента игры не доступна для отладки без запуска самой игры.
PS: это не все таблицы. По мере тестирования буду добавлять остальные. |