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

Гайд Запуск x64 PE из под WoW64 (Heaven's Gate)

Начинающий
Начинающий
Статус
Оффлайн
Регистрация
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

Код, который я использовал (вызов функции с аргументом):

Код:
Expand Collapse Copy
; 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 0x86640x014c
  • Переделывание релокаций
  • Изменение названия функции (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!KernelBaseDllInitializekernelbase!ConsoleInitializekernelbase!ConsoleCreateConnectionObject

В ней происходила проверка ConsoleHandle и дальнейшее подключение к консоли.

При обычном запуске без дебаггера (из консоли) этот хэндл наследовался из родительского процесса. Так как x86 KernelBase уже инициализировал эту консоль и подключился к ней, при инициализации уже x64 KernelBase повторное подключение не заканчивалось успехом.

Когда же запуск происходил из-под дебаггера, у которого не было аллоцированной консоли, следовательно, наш процесс наследовал одно из следующих sentinel-значений (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):

C:
Expand Collapse Copy
// 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):

Код:
Expand Collapse Copy
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:
Expand Collapse Copy
#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:
Expand Collapse Copy
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:
Expand Collapse Copy
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:
Expand Collapse Copy
decrypted = gCookie ^ **ROR4**(encrypted, 32 - (gCookie & 0x1F));

64-битная расшифровка:
C:
Expand Collapse Copy
decrypted = gCookie ^ **ROR8**(encrypted, 64 - (gCookie & 0x3F));

Несколько решений приходят на ум:

  • Брут cookie, которая подойдет
Python:
Expand Collapse Copy
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:
Expand Collapse Copy
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:
Expand Collapse Copy
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, который возвращал 0xC0000005STATUS_ACCESS_VIOLATION.

Двойной указатель IoStatusBlock​


В nt!NtDeviceIoControlFile в ядре при вызове из WoW64-процесса выполняется следующий код:

Код:
Expand Collapse Copy
mov eax, [rcx]
mov [rcx], eax

Оказалось, что соглашение вызовов отличается (ввиду разницы в размерах указателей):

x64:
Код:
Expand Collapse Copy
IoStatusBlock argument → 16-byte IO_STATUS_BLOCK structure

WoW64:
Код:
Expand Collapse Copy
IoStatusBlock argument → pointer → 8-byte IO_STATUS_BLOCK structure

В случае WoW64 ядро ожидает увидеть указатель на структуру с реальным IoStatusBlock, и для этого проверяет указатель на доступность:

C:
Expand Collapse Copy
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:
Expand Collapse Copy
// 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 ГБ памяти система вернет 0xC000000DSTATUS_INVALID_PARAMETER:

C:
Expand Collapse Copy
VirtualAlloc(NULL, 1024 * 1024 * 1024 * 5,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// FAILS

Однако если задать BaseAddress, то система успешно выделит память:

C:
Expand Collapse Copy
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-байтовые указатели

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

Запуск x64-бинарников из-под WoW64 (Heaven's Gate)​


Вкратце про 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

Код, который я использовал (вызов функции с аргументом):

Код:
Expand Collapse Copy
; 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 обычно концентрируются на исполнении маленького куска кода или на том, чтобы скрытно сделать сискол. Однако я задался более масштабной целью:



На этом моменте можно рассмотреть причины и зачем всё это можно делать.

Дебаг и трассировка​


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 0x86640x014c
  • Переделывание релокаций
  • Изменение названия функции (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!KernelBaseDllInitializekernelbase!ConsoleInitializekernelbase!ConsoleCreateConnectionObject

В ней происходила проверка ConsoleHandle и дальнейшее подключение к консоли.

При обычном запуске без дебаггера (из консоли) этот хэндл наследовался из родительского процесса. Так как x86 KernelBase уже инициализировал эту консоль и подключился к ней, при инициализации уже x64 KernelBase повторное подключение не заканчивалось успехом.

Когда же запуск происходил из-под дебаггера, у которого не было аллоцированной консоли, следовательно, наш процесс наследовал одно из следующих sentinel-значений (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):

C:
Expand Collapse Copy
// 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):

Код:
Expand Collapse Copy
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:
Expand Collapse Copy
#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:
Expand Collapse Copy
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:
Expand Collapse Copy
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:
Expand Collapse Copy
decrypted = gCookie ^ **ROR4**(encrypted, 32 - (gCookie & 0x1F));

64-битная расшифровка:
C:
Expand Collapse Copy
decrypted = gCookie ^ **ROR8**(encrypted, 64 - (gCookie & 0x3F));

Несколько решений приходят на ум:

  • Брут cookie, которая подойдет
Python:
Expand Collapse Copy
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:
Expand Collapse Copy
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:
Expand Collapse Copy
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, который возвращал 0xC0000005STATUS_ACCESS_VIOLATION.

Двойной указатель IoStatusBlock​


В nt!NtDeviceIoControlFile в ядре при вызове из WoW64-процесса выполняется следующий код:

Код:
Expand Collapse Copy
mov eax, [rcx]
mov [rcx], eax

Оказалось, что соглашение вызовов отличается (ввиду разницы в размерах указателей):

x64:
Код:
Expand Collapse Copy
IoStatusBlock argument → 16-byte IO_STATUS_BLOCK structure

WoW64:
Код:
Expand Collapse Copy
IoStatusBlock argument → pointer → 8-byte IO_STATUS_BLOCK structure

В случае WoW64 ядро ожидает увидеть указатель на структуру с реальным IoStatusBlock, и для этого проверяет указатель на доступность:

C:
Expand Collapse Copy
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:
Expand Collapse Copy
// 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 ГБ памяти система вернет 0xC000000DSTATUS_INVALID_PARAMETER:

C:
Expand Collapse Copy
VirtualAlloc(NULL, 1024 * 1024 * 1024 * 5,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// FAILS

Однако если задать BaseAddress, то система успешно выделит память:

C:
Expand Collapse Copy
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-байтовые указатели

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

Вкратце про 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

Код, который я использовал (вызов функции с аргументом):

Код:
Expand Collapse Copy
; 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 обычно концентрируются на исполнении маленького куска кода или на том, чтобы скрытно сделать сискол. Однако я задался более масштабной целью:



На этом моменте можно рассмотреть причины и зачем всё это можно делать.

Дебаг и трассировка​


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 0x86640x014c
  • Переделывание релокаций
  • Изменение названия функции (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!KernelBaseDllInitializekernelbase!ConsoleInitializekernelbase!ConsoleCreateConnectionObject

В ней происходила проверка ConsoleHandle и дальнейшее подключение к консоли.

При обычном запуске без дебаггера (из консоли) этот хэндл наследовался из родительского процесса. Так как x86 KernelBase уже инициализировал эту консоль и подключился к ней, при инициализации уже x64 KernelBase повторное подключение не заканчивалось успехом.

Когда же запуск происходил из-под дебаггера, у которого не было аллоцированной консоли, следовательно, наш процесс наследовал одно из следующих sentinel-значений (
Пожалуйста, авторизуйтесь для просмотра ссылки.
):

C:
Expand Collapse Copy
// 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):

Код:
Expand Collapse Copy
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:
Expand Collapse Copy
#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:
Expand Collapse Copy
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:
Expand Collapse Copy
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:
Expand Collapse Copy
decrypted = gCookie ^ **ROR4**(encrypted, 32 - (gCookie & 0x1F));

64-битная расшифровка:
C:
Expand Collapse Copy
decrypted = gCookie ^ **ROR8**(encrypted, 64 - (gCookie & 0x3F));

Несколько решений приходят на ум:

  • Брут cookie, которая подойдет
Python:
Expand Collapse Copy
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:
Expand Collapse Copy
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:
Expand Collapse Copy
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, который возвращал 0xC0000005STATUS_ACCESS_VIOLATION.

Двойной указатель IoStatusBlock​


В nt!NtDeviceIoControlFile в ядре при вызове из WoW64-процесса выполняется следующий код:

Код:
Expand Collapse Copy
mov eax, [rcx]
mov [rcx], eax

Оказалось, что соглашение вызовов отличается (ввиду разницы в размерах указателей):

x64:
Код:
Expand Collapse Copy
IoStatusBlock argument → 16-byte IO_STATUS_BLOCK structure

WoW64:
Код:
Expand Collapse Copy
IoStatusBlock argument → pointer → 8-byte IO_STATUS_BLOCK structure

В случае WoW64 ядро ожидает увидеть указатель на структуру с реальным IoStatusBlock, и для этого проверяет указатель на доступность:

C:
Expand Collapse Copy
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:
Expand Collapse Copy
// 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 ГБ памяти система вернет 0xC000000DSTATUS_INVALID_PARAMETER:

C:
Expand Collapse Copy
VirtualAlloc(NULL, 1024 * 1024 * 1024 * 5,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
// FAILS

Однако если задать BaseAddress, то система успешно выделит память:

C:
Expand Collapse Copy
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-байтовые указатели

В итоге это сводится к сложной задаче классификации, в которой наш аллокатор должен будет решать, кому можно будет давать «премиальные» низкие указатели, а кому можно дать и обычный высокий.
+rep
Мне нравится
 
Назад
Сверху Снизу