
Фреймворк для разработки шеллкода в пользовательском режиме Windows (WUMSDF)
v1.1Проект SILVERPICK — это Windows User-Mode Shellcode Development Framework (WUMSDF), единственная цель которого — дать разработчикам специализированных средств простой способ создавать Position Independent Code (PIC)-заготовки для Windows x64 на C/C++, снижая тем самым стоимость разработки таких решений.
Он происходит от проекта WILDBEAST и, соответственно, использует:
Visual Studio Code в качестве редактора кодаMinGW-w64 в качестве компилятораGNU Make в качестве системы сборкиИнструкции по настройке вы можете найти здесь: GCC-Clang-Setup-Windows
Обратите внимание, что в этом проекте используется MSYS2.
Написание шеллкода на языках программирования высокого уровня — не новость, и с 2010 года об этом опубликовано бесчисленное множество постов в блогах и исследовательских работ. Так что же нового в SILVERPICK?
Что ж, рад, что вы спросили.
У SILVERPICK в рукаве припрятано несколько неплохих трюков, но самое главное — это моё видение этой темы.
Итак, без лишних предисловий, представляю вам мой первый трюк.
С тех пор как Мэтт Грэбер популяризировал написание шеллкода на C, большинство людей используют его 16-байтовый стаб выравнивания стека, написанный на Assembly.
Хотя это и не проблема, но, поскольку мы не IKEA, ассемблер не должен быть обязательным — и, действительно, это так.
Существует GCC Function Attribute, который сам сгенерирует стаб выравнивания стека.
Встречайте атрибут функции force_align_arg_pointer в виде удобного макроса ALIGN_STACK, который генерирует следующий ассемблерный код:
Disassembly of section .init:
<PicEntry>:
push rbp
mov rbp,rsp
and rsp,0xfffffffffffffff0
sub rsp,0x20
call <PicEntry+0x11> IMAGE_REL_AMD64_REL32 .text$payload
leave
ret
Что такое секция .init, спросите вы? Что ж, это отличный переход к моему второму трюку.
Возможно, Мэтт Грэбер в своё время популяризировал написание шеллкода на C, но на самом деле именно Пол Унгури возродил это чёрное искусство в Stardust.
В Stardust используется скрипт компоновщика Binutils, чтобы управлять размещением функций и данных в нужной секции PE в правильном порядке. Сама эта техника основана на работе Остина Хадсона, и многие используют вариант его скриптов компоновщика.
Хотя скрипты компоновщика отлично подходят для упорядочивания секций, они не нужны, если вам нужно лишь поместить определённую функцию в начало секции кода.
Представляю вам атрибут функции section с особым именем секции .init, который сообщает компоновщику, что функция содержит код инициализации времени выполнения до main() и должна быть первой в порядке компоновки.
Для этой цели был создан макрос CODE_BEGIN.
Для своего третьего трюка представляю вам макрос STACK_STRING.
В C вы можете создать стековую строку (строку, динамически формируемую на стеке), объявив строковый литерал как массив символов ANSI:
char charrHelloKitty[] = { 'H', 'e', 'l', 'l', 'o', 'K', 'i', 't', 't', 'y', '\0' };
В C++ вы можете создать стековую строку, просто пометив массив char как constexpr:
constexpr char charrHelloKitty[]{ "HelloKitty" };
Однако обе эти техники становятся бесполезными перед лицом оптимизаций компилятора, если строковые литералы достаточно велики. В отличие от нашего решения, которое работает независимо от длины строки и уровня оптимизаций, — благодаря одному остроумному трюку с шаблонным метапрограммированием на C++ от Джана Бёлюка.
Использовать этот макрос довольно просто:
STACK_STRING(sstrText, "an extra long hello world!");
STACK_STRING(sstrCaption, "Demo");
MessageBoxA(nullptr, sstrText.data(), sstrCaption.data(), MB_OK);
Это приведёт к генерации следующего ассемблерного кода:
mov [rsp+58h+var_23], 61h ; 'a'
mov [rsp+58h+var_22], 6Eh ; 'n'
mov [rsp+58h+var_21], 20h ; ' '
mov [rsp+58h+var_20], 65h ; 'e'
mov [rsp+58h+var_1F], 78h ; 'x'
mov [rsp+58h+var_1E], 74h ; 't'
mov [rsp+58h+var_1D], 72h ; 'r'
mov [rsp+58h+var_1C], 61h ; 'a'
mov [rsp+58h+var_1B], 20h ; ' '
mov [rsp+58h+var_1A], 6Ch ; 'l'
mov [rsp+58h+var_19], 6Fh ; 'o'
mov [rsp+58h+var_18], 6Eh ; 'n'
mov [rsp+58h+var_17], 67h ; 'g'
mov [rsp+58h+var_16], 20h ; ' '
mov [rsp+58h+var_15], 68h ; 'h'
mov [rsp+58h+var_14], 65h ; 'e'
mov [rsp+58h+var_13], 6Ch ; 'l'
mov [rsp+58h+var_12], 6Ch ; 'l'
mov [rsp+58h+var_11], 6Fh ; 'o'
mov [rsp+58h+var_10], 20h ; ' '
mov [rsp+58h+var_2F], 0
mov [rsp+58h+var_F], 77h ; 'w'
mov [rsp+58h+var_E], 6Fh ; 'o'
mov [rsp+58h+var_D], 72h ; 'r'
mov [rsp+58h+var_C], 6Ch ; 'l'
mov [rsp+58h+var_B], 64h ; 'd'
mov [rsp+58h+var_A], 21h ; '!'
mov [rsp+58h+var_33], 44h ; 'D'
mov [rsp+58h+var_32], 65h ; 'e'
mov [rsp+58h+var_31], 6Dh ; 'm'
mov [rsp+58h+var_30], 6Fh ; 'o'
Раз уж речь зашла о C++, представляю вам хеширование строк на этапе компиляции — это мой четвёртый трюк.
Хотя это не новая концепция, SILVERPICK предлагает некоторые улучшения по сравнению с существующими публичными реализациями.
Во-первых, мы используем 64-битный вариант популярной некриптографической хеш-функции FNV-1a, чтобы снизить вероятность успешной атаки на коллизии хеша.
Во-вторых, мы используем модифицированный параметр хеш-функции для защиты от поиска по предвычисленным хеш-таблицам, например HashDB. Важно: это не меняет свойства хеш-функции.
Для хеширования короткой строки во время выполнения просто используйте макрос HASH_STRING_RUN_TIME.
Для хеширования короткого строкового литерала на этапе компиляции просто используйте макрос HASH_STRING_COMPILE_TIME. Вычисление только на этапе компиляции гарантируется с помощью consteval.
Оказывается, с помощью строковых инструкций x86 можно реализовать целый ряд функций C Runtime Library (CRT). Поэтому, разумеется, мне пришлось реализовать их, используя сочетание встроенных функций компилятора и встроенного ассемблера.
Хотите использовать функцию msvcrt!memset в своём коде? Вместо этого используйте макрос ZERO_MEMORY, который использует инструкцию rep stosb, генерируемую встроенной функцией компилятора.
А как насчёт функций msvcrt!memcpy или msvcrt!memmove, спросите вы? В качестве замены — макрос COPY_MEMORY, который использует инструкцию rep movsb, генерируемую встроенной функцией компилятора.
А как насчёт альтернативы функции msvcrt!memcmp? Оказывается, встроенной функции компилятора для генерации инструкции repe cmpsb не существует. Поэтому мы пишем функцию compare_memory с использованием встроенного ассемблера.
Наконец, если вам нужна замена функции msvcrt!memchr, познакомьтесь с функцией scan_memory, которая опять же использует встроенный ассемблер, поскольку встроенной функции компилятора для генерации инструкции repne scasb не существует.
И да, чуть не забыл упомянуть: вы можете написать собственную более безопасную версию функции msvcrt!strlen, используя процедуру scan_memory, например так:
DWORD_PTR dwptrExportNameLength = std::min(BIT_CAST(DWORD_PTR, scan_memory(strExportName, 0x00, MAX_EXPORTED_SYMBOL_NAME_LEN)) - BIT_CAST(DWORD_PTR, strExportName), MAX_EXPORTED_SYMBOL_NAME_LEN);
Обратите внимание: эти реализации могут не давать самый производительный код в зависимости от микроархитектуры целевого CPU. Однако они гарантированно выполняют поставленную задачу.
Интересуют ещё фокусы?
В Common.h есть множество других небольших макросов, которые абстрагируют некоторые сложности, связанные с «приручением» компилятора.
Реализация функции GetModuleHandle без зависимостей находится в UserModuleBase.cpp. Для удобства использования создан полезный макрос GET_USER_MODULE_BASE.
Аналогично, реализация функции GetProcAddress без зависимостей находится в PEParse.cpp, которая затем обёрнута в удобный макрос с точным названием GET_EXPORTED_SYMBOL_ADDRESS. Кроме того, предоставлены ещё два макроса, помогающих с динамическим связыванием во время выполнения: INITIALIZE_FUNCTION_POINTER для объявления и инициализации указателя на функцию нулём и RESOLVE_FUNCTION_POINTER для разрешения указанного указателя на функцию.
В проект встроена интеграция с Visual Studio Code, чтобы разработчики могли использовать сочетание клавиш Ctrl+Shift+B для простой сборки без лишних хлопот.
В проект также встроена интеграция с GitHub Actions для поддержки CI-сборок.
Проект по праву гордится своей хорошо организованной структурой, а также тщательно прокомментированным и относительно чистым кодом.
Наконец, взгляните на Makefile проекта: в нём подобран отборный набор флагов компилятора и компоновщика, которые генерируют небольшой, безопасный и дружественный к OPSEC код. При этом подробное журналирование и генерируемый файл карты компоновщика обеспечат наглядность процесса сборки и помогут глубже понять инструментарий. Кроме того, каждая единица трансляции также создаёт файл дизассемблирования, глядя на который вы часто будете восклицать что-то вроде «что это компилятор сделал?» и так далее.
Если вас заинтересовал этот фреймворк, в этом разделе описано, как им пользоваться.
Ниже приведён фрагмент из PicMain.cpp:
/// @brief PIC start function
/// @param None
/// @return None
EXTERN_C NO_INLINE VOID __stdcall payload(
VOID
) {
// Init local variables
PVOID pKernel32 = nullptr;
INITIALIZE_FUNCTION_POINTER(LoadLibraryA);
HMODULE hUser32 = nullptr;
STACK_STRING(sstrUser32, "user32.dll");
INITIALIZE_FUNCTION_POINTER(MessageBoxA);
STACK_STRING(sstrText, "an extra long hello world!");
STACK_STRING(sstrCaption, "Demo");
// Get the image base address of kernel32.dll
pKernel32 = GET_USER_MODULE_BASE("kernel32.dll");
if (pKernel32 == nullptr)
goto cleanup;
// Resolve kernel32!LoadLibraryA
RESOLVE_FUNCTION_POINTER(pKernel32, LoadLibraryA);
if (LoadLibraryA == nullptr)
goto cleanup;
// Load User32.dll into the process VAS
hUser32 = LoadLibraryA(sstrUser32.data());
if (hUser32 == nullptr)
goto cleanup;
// Resolve user32!MessageBoxA
RESOLVE_FUNCTION_POINTER(hUser32, MessageBoxA);
if (MessageBoxA == nullptr)
goto cleanup;
// Display a message box
MessageBoxA(nullptr, sstrText.data(), sstrCaption.data(), MB_OK);
// Cleanup
cleanup:
return;
}
Выглядит достаточно просто, не так ли?
При написании PIC на C/C++ с использованием фреймворка SILVERPICK необходимо учитывать следующие правила:
payload так же, как к функции main в традиционной программе, то есть как к (псевдо)точке входа.Windows API или Native API могут использоваться только через динамическое связывание во время выполнения, предварительно убедившись, что прототип функции доступен в соответствующем заголовочном файле.Этот раздел содержит неполный список запланированных улучшений, которые будут интегрированы в будущий проект.
Clang/LLVM.GS.Export Address Filtering (EAF).Ниже приведён список ссылок в хронологическом порядке, которые оказались для меня бесценными во время исследования и послужили источником вдохновения для этого проекта:
Написание шеллкода с помощью компилятора C — Nick Harbour (2010)
Шеллкод с помощью компилятора C — Didier Stevens (2010)
Написание оптимизированного шеллкода для Windows на C — Matt Graeber (2013)
Шеллкод правильным способом, или как просто использовать компилятор — Justin Fisher (2016)
ShellcodeStdio — Jack Ullrich (2016)
Шеллкод: Windows PIC с использованием обмена ключами RSA-2048, AES-256, SHA-3 — Odzhan (2016)
Написание оптимизированного шеллкода для Windows — Dimitri Fourny (2017)
Написание и компиляция шеллкода на C — Aleksandra Doniec и Mantvydas Baranauskas (2021)
Создание шеллкода из любого кода с помощью Visual Studio и C++ — Hamid Memar (2021)
Написание оптимизированного шеллкода для Windows на C — Philip Woldhek (2021)
От C со встроенным ассемблером до шеллкода — Steve Salinas (2023)
Как создать собственный Windows x86/64 Shellcode с помощью Visual Studio — Yazid Benjamaa (2023)
Современный дизайн имплантов: разработка позиционно-независимого вредоносного ПО — Paul Ungur (2024)
Что показалось мне странным, так это классификация VirTool:Win64/Silepesz.A антивирусом Microsoft Defender.
Вот байты, которые покрывает сигнатура:
[+] Target file size: 2560 bytes
[+] Analyzing...
[!] Identified end of bad bytes at offset 0x4CB
000003CB 44 24 2F 32 C6 44 24 30 2E C6 44 24 31 64 C6 44 D$/2�D$0.�D$1d�D
000003DB 24 32 6C C6 44 24 33 6C C6 44 24 35 61 C6 44 24 $2l�D$3l�D$5a�D$
000003EB 36 6E C6 44 24 37 20 C6 44 24 38 65 C6 44 24 39 6n�D$7 �D$8e�D$9
000003FB 78 C6 44 24 3A 74 C6 44 24 3B 72 C6 44 24 3C 61 x�D$:t�D$;r�D$<a
0000040B C6 44 24 3D 20 C6 44 24 3E 6C C6 44 24 3F 6F C6 �D$= �D$>l�D$?o�
0000041B 44 24 40 6E C6 44 24 41 67 C6 44 24 42 20 C6 44 D$@n�D$Ag�D$B �D
0000042B 24 43 68 C6 44 24 44 65 C6 44 24 45 6C C6 44 24 $Ch�D$De�D$El�D$
0000043B 46 6C C6 44 24 47 6F C6 44 24 48 20 C6 44 24 29 Fl�D$Go�D$H �D$)
0000044B 00 C6 44 24 49 77 C6 44 24 4A 6F C6 44 24 4B 72 .�D$Iw�D$Jo�D$Kr
0000045B C6 44 24 4C 6C C6 44 24 4D 64 C6 44 24 4E 21 C6 �D$Ll�D$Md�D$N!�
0000046B 44 24 25 44 C6 44 24 26 65 C6 44 24 27 6D C6 44 D$%D�D$&e�D$'m�D
0000047B 24 28 6F E8 55 00 00 00 48 85 C0 74 4B 48 BA 58 $(o�U...H.AtKH�X
0000048B D0 CC C6 F8 E7 BF 0A 48 89 C1 E8 86 FD FF FF 48 DI�o��.H.A�.y��H
0000049B 85 C0 74 34 48 8D 4C 24 2A FF D0 48 85 C0 74 28 .At4H.L$*�DH.At(
000004AB 48 BA D9 92 FB 55 9A AC 70 E0 48 89 C1 E8 63 FD H�U.�U.�p�H.A�cy
000004BB FF FF 48 85 C0 74 11 48 8D 54 24 35 45 31 C9 4C ��H.At.H.T$5E1�L
Это соответствует следующему исходному коду:
pKernel32 = GET_USER_MODULE_BASE("kernel32.dll");
if (pKernel32 == nullptr)
goto cleanup;
RESOLVE_FUNCTION_POINTER(pKernel32, LoadLibraryA); // mov rdx, 0x0ABFE7F8C6CCD058 (FNV-1a hash of "LoadLibraryA" with modified offset basis)
if (LoadLibraryA == nullptr)
goto cleanup;
hUser32 = LoadLibraryA(sstrUser32.data());
if (hUser32 == nullptr)
goto cleanup;
RESOLVE_FUNCTION_POINTER(hUser32, MessageBoxA); // mov rdx, 0xE070AC9A55FB92D9 (FNV-1a hash of "MessageBoxA" with modified offset basis)
if (MessageBoxA == nullptr)
goto cleanup;
MessageBoxA(nullptr, sstrText.data(), sstrCaption.data(), MB_OK); // arg setup only
Излишне говорить, что это крайне хрупкое обнаружение: оно срабатывает только на точном примере кода, показанном выше. Тем не менее оно подчёркивает важность использования полиморфных хешей API.
От C к шеллкоду (простой способ) — Print3M (2024)
relocatable — Tijme Gommers (2025)
Ускоренный курс по разработке PIC — Raphael Mudge (2025)
scfw — Petr Beneš (2026)