Начинающий
- Статус
- Оффлайн
- Регистрация
- 9 Июл 2024
- Сообщения
- 14
- Реакции
- 12
Вкратце про WoW64
- Процессоры на архитектуре
x86_64имеют несколько режимов исполнения:long mode(x64) иcompatibility mode(x86). Есть и другие, но они не важны в данном контексте. - Когда на 64-битной Windows создается 32-битный процесс (WoW64-процесс), процессор нативно исполняет код в
compatibility mode, но всё взаимодействие с системой остается вlong mode. - Юзермод в WoW64-процессах фактически разделен на 2 части:
- 32-битный мир: x86-библиотеки, x86 PEB/TEB, весь пользовательский код
- 64-битный «островок»: x64
ntdll.dllи WoW64-прослойки для взаимодействия с 64-битной системой (wow64.dll,wow64win.dll,wow64cpu.dll). Стоит обратить внимание на то, что 64-битная среда в таком процессе сильно урезана и находится на уровне базового минимума для взаимодействия с системой.
Вкратце про Heaven's Gate: изменение режима исполнения процессора x86 → x64
Основной принцип выглядит так:
- Изменение
CS-селектора с0x23на0x33через вызовret far/jmp far - Выполнение x64-кода
- Возврат путем возвращения
0x23вCS
Код, который я использовал (вызов функции с аргументом):
Код:
; fastcall call64(void* f, void* args)
_call64@8:
j64:
pop eax ; return address
pop ecx ; x64 function ptr
pop edx ; arg to the function
push ebx
mov ebx, esp ; save stack
and esp, 0xfffffff0 ; align it to 16 bytes
sub esp, 12
push eax ; push the return address
mov eax, j32
push 0
push eax ; push the stub to return to after x64
push 0x33 ; push the selector
push ecx ; push the pointer to x64 function
mov ecx, edx
retf
j32:
; after we return from the x64 function
; 0: e8 00 00 00 00 call 0x5
; 5: c7 44 24 04 23 00 00 mov DWORD PTR [esp+0x4],0x23
; c: 00
; d: 83 04 24 11 add DWORD PTR [esp],0x11
; 11: 59 pop ecx
; 12: ff 6c 24 f8 jmp FWORD PTR [esp-0x8]
db 0xE8, 0x00, 0x00, 0x00, 0x00, 0xC7, 0x44, 0x24, 0x04, 0x23, 0x00, 0x00, 0x00, 0x83, 0x04, 0x24, 0x11, 0x59, 0xFF, 0x6C, 0x24, 0xF8
call64return:
pop eax
mov esp, ebx
pop ebx
jmp eax
Упаковка произвольных x64 PE в x86 PE (bin64→bin32)
Все предыдущие упоминания Heaven's Gate обычно концентрируются на исполнении маленького куска кода или на том, чтобы скрытно сделать сискол. Однако я задался более масштабной целью:
Может ли WoW64-процесс выполнять обычный x64 PE с импортами, TLS, SEH?
На этом моменте можно рассмотреть причины и зачем всё это можно делать.
Дебаг и трассировка
Heaven's Gate — это далеко не новая техника и известна уже более 15 лет. Несмотря на это современные инструменты абсолютно к этому не готовы: дебагеры и трейсеры либо вообще это не поддерживают, либо часто крашат.
Нишевые бонусы — DEP
DEP (Data Execution Protection) отвечает за то, что если выполняется код на странице без флага
EXECUTE, то вылетает STATUS_ACCESS_VIOLATION.В
OptionalHeader -> DllCharacteristics есть бит IMAGE_DLLCHARACTERISTICS_NX_COMPAT, который игнорируется в 64-битных PE, но не в 32-битных приложениях.Исходя из этого можно выключать этот бит в упакованном приложении и спокойно запускать код со страниц с
PAGE_READONLY даже в 64-битном режиме исполнения.Архитектура упаковщика
Я не буду акцентировать много внимания на формате PE и в основном сфокусируюсь на WoW64 и взаимодействии с ним в процессе разработки, но пару слов всё-таки скажу.
Базовое устройство упаковщика выглядит так:
Compile-time (упаковка)
- Разместить x64 PE внутри x32-лоудера
- Размапить секции
- Переделать секции релокаций и исключений
Из-за того что я маплю секции и изменяю таблицу релокаций в compile-time, основную работу выполняет загрузчик Windows, но, к сожалению, не всё так радужно и что-то приходится чинить во время runtime.
Runtime
- Загрузка 64-битных импортов
- Обработка TLS и вызов колбэков
- Вызов оригинального Entrypoint
На бумаге всё довольно просто, но на деле всё оказывается чуть сложнее.
Билд-система: COFF-манипуляции
На протяжении всей статьи можно заметить куски кода на C и мой собственный CRT по типу
my_memset() вместо обычного memset(). Это не любовь к изобретению велосипеда, а вынужденные ограничения.Я хотел писать всю нужную инициализацию не на ассемблере, а на C, в связи с чем встал вопрос линковки x64 и x86 COFF-файлов. Он решился написанием скрипта, который конвертирует x64 COFF в x86 COFF.
Принцип работы скрипта:
- Изменение магии в COFF header
0x8664→0x014c - Переделывание релокаций
- Изменение названия функции (name mangling —
init_tls→_init_tls)
Важное замечание: x86-линкер в своей обычной конфигурации не примет секции
.pdata/.xdata, потому что SEH устроен по-разному в x86 и в x64. Мне пришлось переходить на gcc и писать собственный линкер-скрипт для их поддержки.В результате получается «32-битный» объектный файл, содержащий x64-код со всеми дебаг-символами, который принимается x86-линкером.
К сожалению:
- Нет доступа к CRT и импортам
- Вызывать x64-функции нужно через вышеописанную обертку
call64
Инициализация KernelBase.dll
Базовый вывод «Hello world» в консоль крашился на моменте резолюции импортов. Проблема оказалась в
KernelBase.dll.Вызов
ntdll!LdrLoadDll возвращал:0xC0000142(STATUS_DLL_INIT_FAILED)0xC0000139(STATUS_ENTRYPOINT_NOT_FOUND)
Интересная заметка: при запуске из дебаггера инициализация проходила правильно и вывод в консоль был, но при обычном запуске ошибка присутствовала.
Неудача крылась в:
kernelbase!KernelBaseDllInitialize → kernelbase!ConsoleInitialize → kernelbase!ConsoleCreateConnectionObjectВ ней происходила проверка
ConsoleHandle и дальнейшее подключение к консоли.При обычном запуске без дебаггера (из консоли) этот хэндл наследовался из родительского процесса. Так как x86
KernelBase уже инициализировал эту консоль и подключился к ней, при инициализации уже x64 KernelBase повторное подключение не заканчивалось успехом.Когда же запуск происходил из-под дебаггера, у которого не было аллоцированной консоли, следовательно, наш процесс наследовал одно из следующих sentinel-значений (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):
C:
// dll/win32/kernel32/include/console.h
#define HANDLE_DETACHED_PROCESS (HANDLE)-1 // 0xFFFFFFFFFFFFFFFF
#define HANDLE_CREATE_NEW_CONSOLE (HANDLE)-2 // 0xFFFFFFFFFFFFFFFE
#define HANDLE_CREATE_NO_WINDOW (HANDLE)-3 // 0xFFFFFFFFFFFFFFFD
В связи с чем выделялась новая консоль и краша не было.
Решение
Перед импортом
KernelBase.dll я подменяю ConsoleHandle на HANDLE_CREATE_NO_WINDOW (−3):
Код:
mov rax, gs:0x60 ; PEB
mov rax, [rax + 0x20] ; ProcessParameters
lea rax, [rax + 0x10] ; ConsoleHandle
mov r8, 0xFFFFFFFFFFFFFFFD ; (== -3) spoofed handle
mov [rax], r8
..... ; load/initialize kernelbase
Графический стэк GDI и TLS
После того как вывод в консоль работал, я решил сделать поддержку базового GUI:
C:
#include <stdint.h>
#include <windows.h>
int main() {
MessageBoxW(NULL, L"Hello from Win64!", L"My Message", MB_OK);
return 0;
}
Первая проблема: gdi32full.dll крашит в __dyn_tls_init
Краш происходил, потому что в
gs:0x58 лежал 0. gs:0x58 указывает на TLS array для x64-среды.В нормальном 64-битном процессе его инициализация происходит благодаря x64-загрузчику, но для WoW64-процесса это не нужно и, соответственно, не происходит.
Решение
При обычной загрузке x64 PE
ntdll вызывает ntdll!LdrpInitializeTls как раз с целью инициализировать этот массив.Заметка: Эта функция не экспортируется, а скачивать .pdb в рантайме не выглядит как хорошее решение для bin2bin.
Пожалуйста, авторизуйтесь для просмотра ссылки.
сигнатур для всех версий Windows.Вторая проблема: создание потоков не той битности
После ручной инициализации главного потока создание других выявляет еще одно предположение Windows про наш процесс.
Когда поток в WoW64-процессе создается через
ntdll!NtCreateThreadEx (ntdll!NtCreateThread не поддерживается в WoW64), процесс выглядит примерно так:ntdll!LdrInitializeThunk- →
ntdll!NtContinue - → переходит в 32-битную
ntdll - → инициализируется как 32-битный поток
Решение
Для устранения проблемы я хукнул
ntdll!NtCreateThreadEx и сделал создание потока через ntdll!NtCreateThread с установкой CS на нужные 0x33:
C:
NTSTATUS hookNtCreateThreadEx(HANDLE *ThreadHandle, ACCESS_MASK DesiredAccess,
OBJECT_ATTRIBUTES *ObjectAttributes,
HANDLE ProcessHandle, void *StartRoutine,
PVOID Argument, ULONG CreateFlags,
SIZE_T ZeroBits, SIZE_T StackSize,
SIZE_T MaximumStackSize,
PPS_ATTRIBUTE_LIST AttributeList) {
SIZE_T reserveSize = MaximumStackSize ? MaximumStackSize : 0x1000 * 0x1000;
SIZE_T commitSize = StackSize ? StackSize : 0x1000 * 64;
INITIAL_TEB **attribute**((aligned(16))) initialTeb;
CONTEXT **attribute**((aligned(16))) ctx;
my_memset((void *)&initialTeb, 0, sizeof(INITIAL_TEB));
my_memset((void *)&ctx, 0, sizeof(CONTEXT));
PVOID stackReserve = oVirtualAlloc(NULL, reserveSize,
MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
if (!stackReserve) {
__asm("int3"); // debug
return 1;
}
DWORD oldProtect;
oVirtualProtect(stackReserve, 0x1000, PAGE_READWRITE | PAGE_GUARD,
&oldProtect);
initialTeb.StackAllocationBase = stackReserve;
initialTeb.StackBase = (PVOID)((ULONG_PTR)stackReserve + reserveSize);
initialTeb.StackLimit = (PVOID)((ULONG_PTR)stackReserve + 0x1000);
ctx.ContextFlags = CONTEXT_FULL;
ctx.Rip = (DWORD64)&thread64_prologue;
ctx.Rcx = (DWORD64)StartRoutine;
ctx.Rdx = (DWORD64)Argument;
ctx.Rsp = (DWORD64)initialTeb.StackBase - 0x28;
ctx.SegCs = 0x33;
ctx.Rsp &= ~0xF;
CLIENT_ID clientId = {0};
BOOLEAN suspended = (CreateFlags & THREAD_CREATE_FLAGS_CREATE_SUSPENDED) != 0;
NTSTATUS status =
oNtCreateThread(ThreadHandle, DesiredAccess, ObjectAttributes,
ProcessHandle, &clientId, &ctx, &initialTeb, suspended);
if (!NT_SUCCESS(status)) {
asm("int3");
oVirtualFree(stackReserve, 0, MEM_RELEASE);
}
return status;
}
Третья проблема: зашифрованные указатели GDI
После инициализации TLS краши стали происходить в
gdi32full!SelectObjectImpl на моменте расшифровки указателей.Это выглядит примерно так:
C:
GdiGetEntry(gdi_handle, &output_buffer);
encrypted_pointer = _(uint64_t_)(output_buffer + 16);
pointer = decrypt(cookie, encrypted_pointer);
// pointer is bogus -> crash
Значение cookie инициализируется в
win32u!NtGdiInit2.Заметка: Указатели GDI начали шифроваться только начиная с Windows 11, так что подобного бага не будет в Windows 10 и уже на этот момент MessageBox будет выводиться на экран.
Почему указатели не расшифровываются
Ядро генерирует указатели и шифрует их, но логика для x64-процесса отличается от WoW64. Указатели разных размеров, поэтому рутины отличаются:
32-битная расшифровка:
C:
decrypted = gCookie ^ **ROR4**(encrypted, 32 - (gCookie & 0x1F));
64-битная расшифровка:
C:
decrypted = gCookie ^ **ROR8**(encrypted, 64 - (gCookie & 0x3F));
Несколько решений приходят на ум:
- Брут cookie, которая подойдет
Python:
def gen_cookie64_bruteforce(c32, enchdc):
res32 = decode32(c32, enchdc) & 0xFFFFFFFF
for s in range(64):
R = ror64(enchdc, 64 - s)
C = (R ^ res32) & 0xFFFFFFFFFFFFFFFF
if (C & 0x3F) == s:
return C
return None
К сожалению, полученная cookie будет подходить ТОЛЬКО к одному указателю.
- Расшифровывать указатели по 32-битной рутине и зашифровывать так, чтобы 64-битная рутина правильно их расшифровывала
Python:
def generate_enc_ptr_prime_64(cookie64, encptr):
decrypted = decode32(cookie64 & 0xffffffff, encptr)
rotation = 64 - (cookie64 & 0x3F) if (cookie64 & 0x3F) != 0 else 0
target_rotated = decrypted ^ cookie64
encptr_prime = rol64(target_rotated, rotation)
if decode64(cookie64, encptr_prime) == decrypted:
return encptr_prime
return None
Этот подход уже лучше, но оказывается, что
win32u!GdiSharedHandleTable — таблица, которая содержит эти указатели, находится в ReadOnly-памяти и так просто этот протект не поменять.Решение
- Выделение фейковой
GdiSharedHandleTable - Подмена указателя в
win32uна нашу таблицу - При доступе к нашей таблице — копирование и преобразование указателя из реальной таблицы в фейковую
Для последнего я использовал комбинацию
PAGE_GUARD + установку trap-флага:
C:
LONG VEH_handler(EXCEPTION_POINTERS *ExceptionInfo) {
DWORD oldprotect;
if (ExceptionInfo->ExceptionRecord->ExceptionCode == 0x80000001) {
faulting_address =
(void *)ExceptionInfo->ExceptionRecord->ExceptionInformation[1];
ExceptionInfo->ContextRecord->EFlags |= 0x100;
char *original_gdi_pointer = (char *)faulting_address -
fake_gdi_handle_table +
original_gdi_handle_table;
my_memcpy(faulting_address, original_gdi_pointer, 16);
uint64_t *encrypted_pointer = faulting_address;
if (*encrypted_pointer < 0xffffffff) {
uint64_t recrypted_pointer = generate_enchdc_prime_64(
global_gdi_cookie, (uint32_t)(*encrypted_pointer & 0xffffffff));
if ((decode64(global_gdi_cookie, recrypted_pointer) & 7) != 0) {
return -1;
}
*encrypted_pointer = recrypted_pointer;
}
return -1;
}
if (ExceptionInfo->ExceptionRecord->ExceptionCode == 0x80000004) {
if (faulting_address) {
oVirtualProtect((void *)(((uint64_t)faulting_address)), 0x8, PAGE_READWRITE | PAGE_GUARD, &oldprotect);
faulting_address = 0;
ExceptionInfo->ContextRecord->EFlags &= ~0x100;
} else {
return 0;
}
return -1;
}
return 0;
}
Сетевой стэк и IOCTL
После успешного MessageBox я решил добиться работы базового WinHTTP-клиента, который скачивал файл.
Ошибка происходила после вызова
WinHttpSendRequest, который возвращал 10014 — “The system detected an invalid pointer address”.Проблема оказалась в
ntdll!NtDeviceIoControlFile, который возвращал 0xC0000005 — STATUS_ACCESS_VIOLATION.Двойной указатель IoStatusBlock
В
nt!NtDeviceIoControlFile в ядре при вызове из WoW64-процесса выполняется следующий код:
Код:
mov eax, [rcx]
mov [rcx], eax
Оказалось, что соглашение вызовов отличается (ввиду разницы в размерах указателей):
x64:
Код:
IoStatusBlock argument → 16-byte IO_STATUS_BLOCK structure
WoW64:
Код:
IoStatusBlock argument → pointer → 8-byte IO_STATUS_BLOCK structure
В случае WoW64 ядро ожидает увидеть указатель на структуру с реальным IoStatusBlock, и для этого проверяет указатель на доступность:
C:
ioStatusPtrShadow = (unsigned int *)*ioStatusPtr; // treat first 8 bytes as pointer
*ioStatusPtrShadow = *ioStatusPtrShadow; // "touch" to validate
ioStatusPtr = ioStatusPtrShadow; // use the dereferenced value
Размер указателей, расположение аргументов и nt!IoIs32bitProcess
После починки IoStatusBlock я столкнулся с другим классом проблем: вызовы к драйверам почти всегда возвращали
STATUS_BUFFER_TOO_SMALL / STATUS_INVALID_BUFFER_SIZE / STATUS_INVALID_PARAMETER.Драйвера, которые я трейсил (AFD/TCPIP), делали проверку битности процесса через
nt!IoIs32bitProcess, которое в свою очередь проверяло флаг в структуре EPROCESS.Несмотря на то, что весь код, который выполняется в процессе после моего 32-битного загрузчика, — в 64 битах, ядро об этом никто не оповестил и флаг WoW64-процесса там остался. Поэтому, когда драйвер хочет распаковать
InputBuffer, он будет ожидать конфигурацию параметров для WoW64-процесса с 4-байтовыми указателями.Решение
Я не нашел универсального решения, кроме как ловить все провальные вызовы, идти реверсить структуру буфера и переупаковывать
InputBuffer. Типичный хэндлер выглядит примерно так:
C:
// KERNEL PiCMGetRelatedDeviceInstance
// 0x470823
if (InputBufferLength == 0x28) {
*(uint32_t *)InputBuffer = 0x1c;
*(uint32_t *)(InputBuffer + 4) = *(uint32_t *)(InputBuffer + 4);
*(uint32_t *)(InputBuffer + 8) = *(uint32_t *)(InputBuffer + 8);
*(uint64_t *)(InputBuffer + 0xC) = *(uint32_t *)(InputBuffer + 0x10);
*(uint32_t *)(InputBuffer + 0x10) = *(uint32_t *)(InputBuffer + 0x18);
*(uint32_t *)(InputBuffer + 0x14) = *(uint32_t *)(InputBuffer + 0x1C);
*(uint32_t *)(InputBuffer + 0x18) = *(uint32_t *)(InputBuffer + 0x20);
res = sNtDeviceIoControlFile(FileHandle, Event, ApcRoutine, ApcContext,
IoStatusBlock, IoControlCode, InputBuffer,
0x1c, OutputBuffer, OutputBufferLength);
} else {
res = sNtDeviceIoControlFile(FileHandle, Event, ApcRoutine, ApcContext,
IoStatusBlock, IoControlCode, InputBuffer,
InputBufferLength, OutputBuffer,
OutputBufferLength);
}
Оставшиеся и нерешенные проблемы
DirectX / GPU-взаимодействие
Работоспособность DirectX12 сильно зависит от вендора графической карты и их взаимодействия с драйверами. Я смог добиться того, что на виртуальной машине у меня запустился простенький треугольник на DirectX12, так что если очень постараться, это будет работать.
4 ГБ на самом деле не предел
Так как 32-битные процессы оперируют в
compatibility mode, все регистры 4-байтовые, соответственно в этом режиме нельзя обращаться к памяти выше 4 ГБ (0xFFFFFFFF). Исходя из этого, даже находясь в 64-битном режиме исполнения, при попытке выделить больше 4 ГБ памяти система вернет 0xC000000D — STATUS_INVALID_PARAMETER:
C:
VirtualAlloc(NULL, 1024 * 1024 * 1024 * 5,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// FAILS
Однако если задать
BaseAddress, то система успешно выделит память:
C:
VirtualAlloc((void*)0x100000000, 1024 * 1024 * 1024 * 5,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// SUCCEEDS
Если посмотреть на реализацию
nt!MiAllocateVirtualMemoryPrepare:- При
BaseAddress == NULLаллокатор не выдает регионов вышеHighestUserAddress(для WoW64-процесса это значение не больше0xFFFFFFFF) - При
BaseAddress != NULLаллокатор проверяет его с захардкоженным0x7FFFFFFFFFFF, что и позволяет выделять большие куски памяти
Почему просто выделение больших адресов не решит проблему
Для процессов, которым нужно больше 4 ГБ памяти, наивным решением будет хукнуть
ntdll!NtAllocateVirtualMemory и написать собственный аллокатор поверх, который подставляет BaseAddress.Это не будет работать по следующим причинам:
- Зашифрованные указатели GDI должны быть меньше 4 байт, чтобы их можно было перешифровывать
- Драйвера ожидают 4-байтовые указатели
В итоге это сводится к сложной задаче классификации, в которой наш аллокатор должен будет решать, кому можно будет давать «премиальные» низкие указатели, а кому можно дать и обычный высокий.