Перейти к содержимому

Отладка шелл-кода в x64dbg: загрузка PIC-стаба, точка останова на VirtualAlloc и распаковка в памяти

Отладка шелл-кода в x64dbg: загрузка PIC-стаба, точка останова на VirtualAlloc и распаковка в памяти
Время чтения: 11 мин.

PE-файл весом 47 КБ, в таблице импорта — ровно две функции: VirtualAlloc и VirtualProtect. Энтропия секции .data упирается в потолок. Если вы видите такую картину впервые — перед вами портрет лоадера. Он прячет настоящий payload внутри себя и расшифровывает его только в рантайме. По данным исследования Embee Research, именно так устроены загрузчики Cobalt Strike: шелл-код декодируется XOR-циклом с 4-байтным ключом и записывается в буфер, созданный через VirtualAlloc. Статический анализ здесь бессилен — IDA или Ghidra покажут зашифрованный блоб, а не реальные инструкции. Чтобы достать расшифрованный шелл-код, нужно поставить точку останова в x64dbg и поймать момент записи в память. Ниже — полный пошаговый разбор: от открытия образца до сохранённого дампа.

Почему шелл-код нельзя просто открыть в отладчике

Шелл-код — фрагмент машинных инструкций, который работает как PIC (Position-Independent Code, позиционно-независимый код). В отличие от обычного .exe, у него нет PE-заголовков (PE — Portable Executable, стандартный формат исполняемых файлов Windows), нет таблицы импорта, нет секций. Он рассчитан на выполнение с произвольного адреса в памяти — и в этом вся проблема.

x64dbg на входе ожидает валидный PE-файл — с сигнатурой MZ, точкой входа и описанием секций. Загрузите сырой .bin с шелл-кодом — отладчик не поймёт, откуда начинать выполнение, и откажется работать.

В реальных атаках шелл-код попадает в память через лоадер (дроппер, стейджер) — небольшой исполняемый файл, который делает три вещи:

  1. Выделяет блок памяти через WinAPI-функцию VirtualAlloc с правами на выполнение
  2. Расшифровывает или распаковывает шелл-код в этот блок
  3. Передаёт управление на первый байт буфера — через CreateThread, прямой call или jmp

Наша задача — перехватить промежуток между шагами 2 и 3: шелл-код уже расшифрован, но ещё не начал выполняться. Именно в этот момент его можно сдампить из памяти в файл.

Место шелл-кода в цепочке атаки

MITRE ATT&CK — открытая база тактик и техник атак. T-коды (T1027, T1055 и т.п.) — уникальные идентификаторы техник. Если вы только начинаете работать с ATT&CK, воспринимайте их как «номера статей в уголовном кодексе для малвари» — по ним аналитики классифицируют поведение вредоноса. Шелл-код в составе лоадера задействует сразу несколько техник:

  • Software Packing (T1027.002) — payload зашифрован или сжат внутри лоадера, чтобы обойти сигнатурный анализ антивирусов
  • Deobfuscate/Decode Files or Information (T1140) — лоадер расшифровывает шелл-код в рантайме (XOR-цикл, AES, RC4)
  • Native API (T1106) — шелл-код напрямую вызывает WinAPI-функции вместо высокоуровневых обёрток
  • Reflective Code Loading (T1620) — шелл-код загружается и выполняется целиком в памяти, без записи на диск
  • Dynamic API Resolution (T1027.007) — шелл-код находит адреса WinAPI-функций в рантайме, обходя PEB (Process Environment Block — структура, содержащая информацию о загруженных модулях процесса)

Типичная цепочка выглядит так: фишинговое вложение (Initial Access) запускает лоадер → лоадер вызывает VirtualAlloc → расшифровывает шелл-код в буфер → шелл-код устанавливает соединение с C2-сервером (Command and Control — сервер управления, через который атакующий взаимодействует с заражённой машиной). Наша точка перехвата — между «вызывает VirtualAlloc» и «шелл-код начинает исполняться». Если лоадер выполняет также PE-инъекцию в другой процесс (T1055.002 — Portable Executable Injection), шелл-код может быть промежуточным звеном перед внедрением полноценного PE-модуля.

Зачем это знать: когда вы видите в отчёте песочницы строку «T1027.002 + T1140 + T1106» — вы уже понимаете, что внутри лоадер с зашифрованным шелл-кодом, и знаете, где ставить брейкпоинт.

Требования к окружению для динамического анализа вредоносного кода

Параметр Минимум Рекомендуется
ОС Windows 10 x64 (виртуальная машина) Windows 10 22H2 x64 с FlareVM
RAM виртуальной машины 4 ГБ 8 ГБ
x64dbg Актуальный snapshot с github.com/x64dbg/x64dbg (проект активен, коммиты еженедельно) + плагины ScyllaHide, xAnalyzer
Сеть Отключена полностью Host-only adapter для контролируемого C2-трафика
Антивирус Windows Defender Real-time protection: Off Папка с образцами в исключениях
Снимок VM Обязателен до запуска образца Один snapshot «чистый» + один «с загруженным образцом»

FlareVM — преднастроенная среда от Mandiant для анализа вредоносного ПО; включает x64dbg, Ghidra, Python, утилиты для работы с PE-файлами. Если FlareVM нет — хватит чистой Windows 10 в VirtualBox/VMware с установленным x64dbg.

Никогда не запускайте вредоносные образцы на хостовой ОС. Виртуальная машина с отключённой сетью и заранее сделанным snapshot — не рекомендация, а обязательное условие. Один случайный клик без snapshot — и придётся переставлять систему.

Загрузка позиционно-независимого кода в x64dbg

Два рабочих подхода. Выбор зависит от того, в каком виде к вам попал шелл-код.

Конвертация сырых байт в PE через shellcode2exe

Если шелл-код лежит как отдельный .bin-файл (извлечённый из трафика, из макроса, из памяти другого процесса), его нужно обернуть в минимальную PE-структуру. Утилита shellcode2exe (автор Mario Vilas) добавляет заголовок MZ, одну секцию с правами RWX и точку входа на первый байт шелл-кода. Согласно описанию на SecurityBreak.io, команда выглядит так:

shellcode2exe.exe payload.bin output.exe

Результат открывается в x64dbg как обычный .exe. Отладчик остановится на системной точке останова; после нажатия F9 выполнение дойдёт до Entry Breakpoint — это первый байт вашего шелл-кода.

PE-stub лоадер: шелл-код зашит внутри бинаря

Если шелл-код упакован внутри PE-файла (лоадера), конвертация не нужна — загружаем в x64dbg сам лоадер. Задача меняется: перехватить момент, когда лоадер выделит память и запишет туда расшифрованный payload.

Подход Когда использовать Когда не использовать
shellcode2exe Шелл-код в виде сырого .bin/.raw Шелл-код зашит внутри PE-лоадера
Загрузка PE-лоадера Шелл-код распаковывается из исполняемого файла Нет PE-файла, только сырые байты
Собственный лоадер на C Нужен контроль над параметрами VirtualAlloc Быстрый анализ без компиляции

VirtualAlloc: точка останова и пошаговый x64dbg shellcode анализ

Главный практический блок. Разбираем каждый шаг на примере PE-лоадера с зашифрованным шелл-кодом.

Шаг 1. Открываем образец и доходим до точки входа. Запускаем x64dbg (для 64-битных образцов — x64dbg.exe, для 32-битных — x32dbg.exe). File → Open → выбираем лоадер. Отладчик остановится на System Breakpoint — служебной точке внутри ntdll.dll. Нажимаем F9 (Run) — выполнение продолжится до Entry Breakpoint. Ожидаемый результат: в окне дизассемблера (верхняя левая панель) видна первая инструкция лоадера — обычно sub rsp, 28h или аналогичный пролог функции.

Шаг 2. Ставим точку останова на VirtualAlloc. В командной строке x64dbg (нижняя панель) вводим bp VirtualAlloc. Если лоадер использует также VirtualProtect для смены прав памяти на RWX, добавляем bp VirtualProtect. В окне Breakpoints появится строка с адресом функции внутри kernelbase.dll — подтверждение, что брейкпоинт установлен. Почему именно VirtualAlloc: это основная WinAPI-функция для выделения памяти. Лоадеры вызывают её перед записью расшифрованного payload — других вариантов для «честных» лоадеров почти нет.

Шаг 3. Запускаем до срабатывания. F9. Выполнение продолжится до первого вызова VirtualAlloc. Отладчик остановится на входе в функцию. RIP (регистр-указатель на текущую инструкцию; аналог EIP для 64-битного режима) указывает на первую инструкцию VirtualAlloc. В окне регистров (правая панель) смотрим параметры вызова по 64-битному соглашению:

  • RCX — базовый адрес (обычно 0, «система выберет сама»)
  • RDX — размер выделяемого блока (например, 0x10000 = 64 КБ)
  • R8 — тип выделения (MEM_COMMIT = 0x1000)
  • R9 — права доступа

Значение R9 = 0x40 означает PAGE_EXECUTE_READWRITE (RWX — Read, Write, Execute). Запомните эту цифру. Лоадер запрашивает память с правами на запись и выполнение одновременно. Легитимный софт так почти не делает — это красный флаг.

Шаг 4. Execute Until Return — получаем адрес буфера. Ctrl+F9 (Execute Until Return). Функция VirtualAlloc отработает целиком, но управление не уйдёт дальше. Затем F8 (Step Over) для выполнения инструкции ret. Ожидаемый результат: регистр RAX (в 64-битном режиме функции возвращают результат именно в RAX) содержит адрес начала выделенного буфера. Запишите этот адрес — сюда лоадер запишет расшифрованный шелл-код.

Шаг 5. Переходим к содержимому буфера. Правой кнопкой по значению RAX в окне регистров — Follow in Dump. В нижнем левом окне (Hex Dump) откроется содержимое буфера. Сейчас там сплошные нули — память выделена, но ещё пуста. Это нормально.

Аппаратные точки останова: трассировка распаковки шелл-кода в памяти

Теперь нужно отследить момент, когда лоадер запишет расшифрованные данные в буфер. Для этого используем аппаратные точки останова (hardware breakpoints).

Шаг 6. Аппаратный брейкпоинт на первый байт. В окне Dump: правая кнопка по первому (нулевому) байту буфера → Breakpoint → Hardware → Access → Byte. Почему аппаратный, а не обычный программный: программный брейкпоинт записывает байт 0xCC (инструкция int3) в память. Это может сломать логику лоадера или быть обнаружено антиотладочным кодом. Аппаратный использует отладочные регистры процессора (DR0–DR3) и не трогает содержимое памяти вообще. Минус — таких регистров всего четыре, но для нашей задачи одного хватит.

Шаг 7. Ловим момент записи. F9. Лоадер продолжит работу: расшифрует payload и начнёт записывать байты в буфер. Как только первый байт будет записан, сработает аппаратный брейкпоинт. Ожидаемый результат: в Dump-окне видно, что первый байт буфера изменился. По данным Embee Research, для шелл-кода типичный первый байт — 0xFC (инструкция cld, Clear Direction Flag). Если на месте нуля появился FC — с высокой вероятностью вы поймали шелл-код.

Шаг 8. Заполняем буфер целиком. Снимаем аппаратный брейкпоинт (окно Breakpoints → удалить hardware breakpoint) и нажимаем Ctrl+F9 (Execute Until Return). Текущая функция расшифровки завершится, заполнив буфер. Ожидаемый результат: Dump-окно показывает данные, которые уже не выглядят случайным мусором. Если шелл-код загружает C2-агент, в буфере могут быть видны ASCII-строки — URL-пути, User-Agent, имена библиотек типа wininet.

Дамп и валидация: анализ шелл-кода вручную

Шаг 9. Проверяем валидность кода. В Dump-окне: правой кнопкой по первому байту → Follow in Disassembler. x64dbg попытается дизассемблировать байты как машинный код.

Признаки валидного шелл-кода: — Инструкции осмысленные — mov, push, call, jmp, а не повторяющийся мусор типа add byte ptr [rax], al — Есть вызовы функций (call) и циклы (loop, jne) — Нет длинных последовательностей nop или int3

Если дизассемблер показывает нормальный код — вы нашли распакованный шелл-код.

Шаг 10. Сохраняем дамп. Правая кнопка по первому байту в Dump → Follow in Memory Map. В карте памяти находим блок с правами ERW или RWX, правая кнопка → Dump Memory to File. Сохраняем как unpacked_shellcode.bin. Этот файл можно загрузить в Ghidra или IDA Free как Raw Binary для статического анализа, проверить YARA-правилами на известные C2-фреймворки, или эмулировать через Speakeasy (инструмент от Mandiant для эмуляции шелл-кода без реального выполнения).

Ограничения: когда VirtualAlloc-брейкпоинт не работает

Описанный подход покрывает простые и среднесложные лоадеры. Вот что может его сломать — и что с этим делать.

Антиотладочные техники (T1622 — Debugger Evasion)

Лоадер проверяет наличие отладчика: — Вызов IsDebuggerPresent() — возвращает TRUE, если процесс под отладкой — Прямое чтение PEB.BeingDebugged (флаг в структуре процесса) — Замер времени через RDTSC — под отладкой инструкции выполняются заметно медленнее

Решение: плагин ScyllaHide для x64dbg скрывает присутствие отладчика, подменяя результаты этих проверок. Ставится через менеджер плагинов x64dbg. На практике закрывает 80% антиотладочных трюков без дополнительной настройки.

Обход песочниц (T1497 — Virtualization/Sandbox Evasion)

Лоадер отказывается работать в виртуальной машине: — Проверка MAC-адреса на принадлежность VMware/VirtualBox — Поиск процессов vmtoolsd.exe, VBoxService.exe — Проверка числа ядер CPU и объёма RAM — менее 2 ядер и 4 ГБ вызывают подозрение

Решение: убрать Guest Additions, сменить MAC-адрес на случайный, выделить 4+ ядра и 8 ГБ RAM. Некоторые лоадеры проверяют ещё и разрешение экрана (менее 1024×768 = песочница), так что полноэкранный режим VM тоже не помешает.

Многоступенчатая распаковка

Некоторые лоадеры вызывают VirtualAlloc десятки раз — для промежуточных буферов, строк, конфигурации. Брейкпоинт сработает на каждом вызове, и придётся вручную определять, какой буфер содержит финальный payload.

Подсказка: смотрите на размер в RDX. Блок в 4 КБ — скорее промежуточный буфер для строк или ключей. Блок в 50–500 КБ с правами RWX — более вероятный кандидат на payload. Ещё один маркер: если после записи в буфер лоадер вызывает CreateThread с адресом начала этого буфера — вы нашли финальный шелл-код.

Альтернативные API

Продвинутые лоадеры используют NtAllocateVirtualMemory (низкоуровневый ntdll-эквивалент VirtualAlloc) или маппят секции через NtMapViewOfSection. В таком случае ставим bp NtAllocateVirtualMemory вместо VirtualAlloc. Логика анализа остаётся той же: перехватить возврат адреса буфера, поставить аппаратный брейкпоинт, дождаться заполнения.

Чеклист: пошаговый алгоритм отладки шелл-кода в x64dbg

  1. Подготовить изолированную VM (Windows 10 x64, x64dbg, сеть отключена, snapshot сделан)
  2. Открыть образец в x64dbg, дойти до Entry Breakpoint (F9)
  3. Установить bp VirtualAlloc и bp VirtualProtect
  4. Запустить (F9), дождаться срабатывания на VirtualAlloc
  5. Проверить R9 на значение 0x40 (PAGE_EXECUTE_READWRITE)
  6. Ctrl+F9 (Execute Until Return), затем F8 (Step Over) — адрес буфера в RAX
  7. Follow in Dump — убедиться, что буфер пуст
  8. Аппаратный брейкпоинт: Breakpoint → Hardware → Access → Byte на первый байт
  9. F9 — дождаться записи в буфер (ожидаемый первый байт: 0xFC)
  10. Снять hardware breakpoint, Ctrl+F9 — дать буферу заполниться
  11. Follow in Disassembler — проверить валидность кода
  12. Follow in Memory Map → Dump Memory to File — сохранить для дальнейшего анализа

Автоматические песочницы стали настолько удобными, что часть аналитиков перестала открывать отладчик вообще. Загрузил файл, подождал три минуты, получил отчёт с C2-адресами. Работает — пока лоадер не содержит таймер-бомбу, проверку окружения или многоступенчатую распаковку. На каждый второй такой образец песочница выдаёт чистый вердикт, потому что шелл-код просто не распаковался. Ручная отладка через VirtualAlloc-брейкпоинт — не ретро-подход из эпохи OllyDbg. Это способ увидеть, что происходит внутри лоадера, когда он решает, стоит ли вообще распаковываться. Аппаратный брейкпоинт на буфер не обмануть вызовом IsDebuggerPresent — он работает на уровне процессорных регистров DR0–DR3, а не через модификацию кода. Пара «VirtualAlloc breakpoint + hardware watchpoint на буфер» покрывает большинство лоадеров, которые попадают на стол аналитику. Кто освоил эту связку — тому следующий уровень (многоступенчатая распаковка, обфускация потока управления, ntdll-syscalls) даётся не как потрясение, а как естественное усложнение. Формула на бумаге понятна, но распаковка по-настоящему ощущается только когда сам ставишь брейкпоинт и видишь, как нули в дампе превращаются в живой код; готовый стенд с задачами такого типа есть на HackerLab.pro — российской CTF-платформе экосистемы Codeby с категориями reverse и forensics, где можно отработать навык без риска на реальных образцах.

Эту тему и смежные навыки разбирают на практике в курсе «Профессия Реверс-инженер» Codeby Academy.