Гайд по анпакингу и де-обфускации малвари
Раздел 1: Пассивная разведка до x64dbg
DIE (Detect It Easy) — чтение всех полей
Открываешь файл в DIE. Главное окно разбито на несколько блоков. Читать их нужно все, а не только строку "Packer".
Строка Packer — название и версия. Версия критична: UPX 3.96 распакуется одной командой, UPX 4.0+ иногда нет. Если написано "UPX (modified)" — заголовок повреждён, нужен HEX-редактор. Если написано "Unknown" — custom packer, надо работать руками.
Строка Compiler — язык написания. Если Golang — IAT работает иначе, там нет стандартного импорта через PE-таблицу, всё линкуется статически. Если Delphi — код выглядит специфично, функции с префиксом @. Если .NET — сразу идёшь в dnSpy, x64dbg здесь неудобен.
Строка Linker — версия компоновщика. Иногда выдаёт реальный возраст сэмпла, даже если TimeDateStamp в PE-заголовке подделан под 1970 или 2037 год.
Вкладка Entropy — визуальный graf по секциям. Красные столбцы выше 7.0 = зашифрованный контент. Смотреть не среднюю энтропию файла, а именно по секциям: если .text имеет 7.8 — там не code, там blob.
Вкладка Heuristic — DIE иногда детектирует одновременно и Packer, и Protector. Например: "UPX" + "VMProtect". Это значит файл сначала упакован UPX, а поверх защищён VMProtect. После распаковки UPX тебя встретит VMProtect — нужно знать об этом заранее.
PEStudio — порядок чтения вкладок
Indicators — открывать первым. Красные строки — это приоритет. Жёлтые — фоновый шум, их можно игнорировать при первом просмотре. Типичные красные индикаторы упакованной малвари: "file contains no meaningful strings", "entropy of section is high", "import table is suspicious".
Libraries — смотреть количество. Нормальный PE имеет 5-15 библиотек. Если видишь только kernel32.dll + ntdll.dll — IAT пустая, всё восстанавливается в рантайме. Если есть только GetProcAddress и LoadLibraryA — это runtime IAT resolve, стандартный паттерн любого паккера.
Functions — список импортируемых функций. Подозрительные комбинации:
VirtualAlloc+VirtualProtect+GetProcAddress→ in-memory распаковкаCreateProcess+WriteProcessMemory+SetThreadContext→ Process HollowingIsDebuggerPresent+CheckRemoteDebuggerPresent→ anti-debug в stubNtQueryInformationProcess→ продвинутый anti-debug, проверяет DebugPort
Strings — нажать на колонку "Blacklist" для сортировки. PEStudio сам выделяет подозрительное красным. Если строк меньше 20 осмысленных — упакован. Нормальный PE содержит сотни строк: пути, сообщения об ошибках, имена функций.
Sections — смотреть четыре параметра на каждую секцию: имя, энтропия, Raw Size, Virtual Size. Если Raw Size = 0 при большом Virtual Size — UPX0-стиль, секция заполнится при распаковке. Если имя секции — мусор или пустая строка — custom packer. RWX-флаги (Execute + Read + Write одновременно) на секции — туда будет писаться и исполняться код.
Раздел 2: HEX-редактор — ручная работа с заголовком
Анатомия PE для ручной работы
Открыть в 010 Editor. Подключить шаблон EXE.bt через Templates → Open Template — он автоматически разберёт и подсветит все поля.
Без шаблона: первые два байта всегда 4D 5A (MZ). По смещению 0x3C — четыре байта, little-endian — это offset до PE-сигнатуры. Например E0 00 00 00 = PE-заголовок начинается с 0xE0. По этому смещению должно быть 50 45 00 00 (PE\0\0).
После PE-сигнатуры идёт COFF Header (20 байт), затем Optional Header. В Optional Header ключевые поля:
+0x10(16 байт от начала Optional Header):AddressOfEntryPoint— RVA точки входа. Это не абсолютный адрес, а смещение отImageBase.+0x1C:ImageBase— куда загружается PE в памяти.+0x68: начало Data Directories. Первые 8 байт = Export Table RVA + Size. Следующие 8 = Import Table RVA + Size. Если Import Table RVA =00 00 00 00— импортов нет, файл упакован.
Восстановление повреждённого UPX-заголовка
Малварь часто затирает блок "UPX!" (байты 55 50 58 21) чтобы сломать upx -d. При этом секции UPX0 и UPX1 остаются — DIE их видит.
Шаг 1 — найти где должен быть блок. В штатном UPX он находится в overlay (после последней секции). Перейти в конец файла. Искать последовательность из ~20 байт где должны быть: версия UPX, размеры, сигнатура.
Шаг 2 — в HxD: Ctrl+F → Hex Values → ввести 55 50 58 30 (это "UPX0" — начало имени первой секции). Так находим секции. Теперь в конце файла искать где был overlay block. Структура: 4 байта compressed size + 4 байта original size + 4 байта filter ID + 4 байта "UPX!".
Шаг 3 — если блок затёрт нулями или мусором: вписать 55 50 58 21 (U P X !) на нужное место. Сохранить копию файла. Пробовать upx -d --force.
Шаг 4 — если upx всё равно не берёт: малварь могла изменить сами заголовки секций чтобы UPX не смог пересчитать размеры. В этом случае единственный путь — ручной ESP trick.
Поиск embedded PE без инструментов
В HEX-редакторе: Ctrl+F → Hex → ввести 4D 5A 90 00 (стандартное начало PE после MZ). Если находит не в начале файла — там embedded PE, дроппер несёт payload в себе. Записать offset. Дальше от этого offset читать как обычный PE: +0x3C → PE offset, и так далее.
Искать также паттерн 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 длиной > 64 байт в середине файла — это padding. Малварь часто хранит зашифрованный конфиг именно в таких "пустых" областях: смотреть на энтропию соседних байт.
Раздел 3: x64dbg — детальная работа
Настройка перед первым запуском
Прежде чем открывать образец: Options → Preferences → Events — убедиться что включены: "System Breakpoint", "Entry Breakpoint", "TLS Callbacks". TLS Callbacks критичны — если они включены в малвари, anti-debug код выполнится до EP, и без остановки на TLS ты пропустишь его.
Plugins → ScyllaHide → Options. Включить весь раздел "General": IsDebuggerPresent, CheckRemoteDebuggerPresent, NtQueryInformationProcess, NtSetInformationThread. Включить раздел "NtApi": NtClose, NtSetDebugFilterState. Сохранить профиль.
Ориентация в интерфейсе
- Верхняя левая — дизасм (код). Колонки: адрес, байты, мнемоника, операнды, комментарии.
- Верхняя правая — регистры. EAX-EDI (x86) или RAX-RDI (x64), флаги, сегментные регистры.
- Нижняя левая — дамп памяти. Следит за адресом, можно следить за любым регистром или адресом.
- Нижняя правая — стек. Показывает текущий ESP и содержимое стека.
Горячие клавиши которые нужны постоянно:
Действие Клавиша Run F9 Step Into F7 Step Over F8 Run to cursor F4 Execute till return Ctrl+F9 Toggle BP F2 Memory Map Alt+M Go to address Ctrl+G Follow in dump Ctrl+D Command line фокус Space в дизасме Restart Ctrl+F2
Чтение стека при остановке на API
Когда x64dbg останавливается на входе в API-функцию (например VirtualAlloc), аргументы находятся на стеке. В x86 они передаются через стек, в x64 — первые четыре через регистры RCX, RDX, R8, R9.
Для x86: в панели Stack смотреть с ESP+4 (первый аргумент после адреса возврата):
[ESP+00] = адрес возврата (куда вернётся функция) [ESP+04] = первый аргумент [ESP+08] = второй аргумент [ESP+0C] = третий аргумент [ESP+10] = четвёртый аргумент
Для VirtualAlloc(lpAddress, dwSize, flAllocationType, flProtect) это [ESP+04], [ESP+08], [ESP+0C], [ESP+10] соответственно. Значение flProtect = 0x40 (PAGE_EXECUTE_READWRITE) — это payload.
Для x64: первые 4 аргумента в RCX, RDX, R8, R9 — читать прямо из регистров в правой панели.
Раздел 4: ESP Trick — полная механика
Что происходит внутри
Stub паккера при старте делает PUSHAD — кладёт на стек значения всех восьми регистров (EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI) в именно таком порядке. ESP уменьшается на 32 байта (8 × 4). После PUSHAD ESP указывает на первое сохранённое значение (EDI).
Потом stub делает свою работу: расшифровывает, распаковывает, перемещает секции. В конце — POPAD: восстанавливает все 8 регистров, записывая их обратно в память по адресу текущего ESP. Именно этот момент записи и перехватывает Hardware Breakpoint.
Почему нужно делать F7 перед Follow in Dump
Если сделать Follow in Dump до F7 (то есть до выполнения PUSHAD), ESP ещё не изменился — ты поставишь HW BP на "неправильный" адрес: туда, где стек был до сохранения регистров, а не туда, куда POPAD будет писать.
После одного F7 (выполнился PUSHAD) ESP уменьшился на 32 байта. Теперь этот адрес — именно то место, куда POPAD запишет сохранённый EDI. HW BP на запись сюда сработает при POPAD.
Что делать если после POPAD несколько инструкций до OEP
Иногда картина не такая прямолинейная:
POPAD LEA EAX, [EBP - 0x4C] ; ← остановились здесь, не JMP CALL EAX ; ← это и есть прыжок на OEP
POPAD ADD EBP, 0x100 JMP [EBP + 0x4] ; косвенный прыжок через таблицу
В таких случаях: F8 несколько раз, наблюдая за EIP. Как только EIP попал в "нормальный" регион (основной модуль, не stub) — ты на OEP. Проверить: правый клик на текущем адресе в дизасме → "Module: [имя файла]" — если видишь имя малвари, а не что-то вроде "MEM_00400000" без имени, то ты в основном коде.
Раздел 5: VirtualAlloc BP — тонкости
Как отличить нужный вызов от служебных
Паккер может вызывать VirtualAlloc несколько раз. Первые вызовы обычно служебные — для структур CRT, стека нового потока, служебных буферов. Нас интересует вызов с PAGE_EXECUTE_READWRITE (0x40) и большим dwSize (> 0x1000).
В x64dbg можно поставить условный BP: правый клик на BP в списке Breakpoints → Conditions. Ввести условие: в x86 это проверка четвёртого аргумента на стеке. В командной строке это записывается так: добавить команду log чтобы логировать каждый вызов без остановки, и потом смотреть лог — найдёшь нужный по dwSize.
После нахождения нового региона
Выйти из VirtualAlloc через Ctrl+F9. EAX содержит базовый адрес нового региона. Этот регион пока пустой — код туда только будет записан. Если поставить BP Execute прямо сейчас, он сработает когда stub начнёт исполнять первую инструкцию из этого региона.
Открыть Alt+M (Memory Map). Найти регион по адресу из EAX. Правый клик → Set Access Breakpoint → Execute. Продолжать F9 — BP сработает на первой инструкции payload.
Если паккер вызывает NtAllocateVirtualMemory напрямую
Некоторые паккеры обходят VirtualAlloc и вызывают ntdll напрямую через syscall. В этом случае BP на VirtualAlloc вообще не срабатывает.
Признак: вызовов VirtualAlloc нет, но в Memory Map появляются новые приватные регионы. Решение: поставить BP на NtAllocateVirtualMemory. Сигнатура отличается — аргументы другие, flProtect это пятый аргумент ([ESP+14] в x86).
Раздел 6: Anti-debug — ручной обход без ScyllaHide
IsDebuggerPresent — патч в дизасме
Найти в дизасме паттерн чтения из PEB. Это выглядит так:
MOV EAX, DWORD PTR FS:[30] ; читает адрес PEB из TEB MOVZX EAX, BYTE PTR [EAX+2] ; читает PEB.BeingDebugged (offset 0x2) TEST EAX, EAX JNZ 0x004012AB ; прыгает если дебаггер есть
Способ 1 — патч JNZ в JMP с инверсией: правый клик на JNZ → Assemble. Ввести JMP SHORT [адрес хорошего пути] — адрес инструкции после JNZ, то есть обойти ветку "есть дебаггер". Нажать OK. x64dbg пропатчит байты.
Способ 2 — нулить EAX в рантайме: поставить BP на TEST EAX, EAX. Когда остановится — в командной строке ввести EAX = 0. Нажать Enter. F9 — продолжить. JNZ теперь не прыгнет. Этот способ не требует патча файла, только меняет состояние регистра в этот конкретный момент.
NtQueryInformationProcess ProcessDebugPort
Продвинутее: вызывает ntdll напрямую. Если возвращает ненулевое значение — дебаггер есть.
Искать в дизасме: PUSH 7 (ProcessDebugPort = 7) перед вызовом NtQueryInformationProcess, или поиск по имени функции через Ctrl+G.
Поставить BP на вызов. При срабатывании смотреть [ESP+0C] — адрес буфера куда функция запишет результат. После Ctrl+F9 (вернуться из функции) — в этом буфере будет значение. Если ненулевое — перейти в Dump на этот адрес, вручную вписать нули. Продолжить.
Timing checks — RDTSC
Паккер вызывает RDTSC дважды и сравнивает разницу. В нормальных условиях разница мала. С дебаггером — большая (ты пошагово ходишь).
В дизасме искать байты 0F 31 — это опкод RDTSC. Найти оба вхождения. Поставить BP на второй RDTSC.
Когда остановился на втором RDTSC: посмотреть значения EDX:EAX с первого замера (они должны быть где-то сохранены в памяти или регистрах). Скопировать их в текущие EDX и EAX командами EDX = значение и EAX = значение в командной строке. Продолжить. Разница будет нулевой, проверка пройдёт.
Альтернатива: найти SUB или CMP после второго RDTSC — там вычисляется разница и сравнивается с порогом. Найти JA или JG после — изменить на JMP на хороший путь.
TLS Callbacks
Выполняются до AddressOfEntryPoint. Если в PEStudio видишь в Data Directories запись "TLS Directory" — там есть callbacks. В x64dbg: Options → Preferences → Events → поставить галочку "TLS Callbacks". Теперь x64dbg остановится на каждом TLS callback до EP. Это место где может быть anti-debug код — анализировать так же, как и EP.
Раздел 7: Process Hollowing — пошагово
Как читать CONTEXT структуру в дампе
При BP на SetThreadContext нужно найти OEP в структуре CONTEXT. Аргумент pContext — это указатель, его значение видно в стеке ([ESP+08] для x86). Перейти в Dump по этому адресу.
Структура CONTEXT для x86 (WinNT.h):
+0x00 ContextFlags (4 байта) +0x04 Dr0-Dr7 (отладочные регистры, 8×4 = 32 байта) +0x24 SegGs, SegFs, SegEs, SegDs (4×4 = 16 байт) +0x34 Edi, Esi, Ebx, Edx, Ecx, Eax (6×4 = 24 байт) +0x4C Ebp +0x50 Eip ← OEP здесь (offset 0x50 от начала CONTEXT) +0x54 SegCs +0x58 EFlags +0x5C Esp +0x60 SegSs
Для x64 CONTEXT гораздо больше. Rip находится на offset 0x080 от начала структуры CONTEXT. Прочитать 8 байт little-endian по этому смещению — это RIP (OEP) x64-кода.
Работа с двумя процессами одновременно
Открыть второй экземпляр x64dbg. Не второй файл — именно второй экземпляр программы. В нём: File → Attach → выбрать процесс-жертву по имени (svchost, notepad). x64dbg приостановит его.
Теперь поставить BP на адрес OEP (который мы нашли в CONTEXT.Eip) в дизасме второго экземпляра. Переключиться обратно в первый экземпляр (malware.exe). Нажать F9 — дать малвари выполниться до ResumeThread. Второй экземпляр x64dbg остановится на OEP payload в жертве.
Теперь из второго экземпляра открывать Scylla и дампить — именно из жертвы, не из оригинальной малвари.
Раздел 8: .NET — ручная работа в dnSpy
Ориентация в структуре сборки
После открытия в dnSpy слева дерево: сборка → namespaces → классы → методы. Искать точку входа: правый клик на сборке → Go to Entry Point. Или Ctrl+Shift+F → поиск строки "Main".
Маркеры что перед нами загрузчик второй стадии:
- Метод принимает
byte[]и возвращаетstring— почти всегда дешифратор строк - Вызов
Assembly.Load(byte[])— загружает embedded PE в память CLR - Вызов
Convert.FromBase64String()+ последующий XOR-цикл — расшифровывает payload - Метод
Module.cctor(статический конструктор модуля) — часто инициализирует дешифратор
Чтение зашифрованных строк без de4dot
Если de4dot не справился с деобфускацией строк: найти метод-дешифратор вручную. Признаки: приватный статический метод, принимает int, возвращает string, содержит byte-массив и XOR или другую арифметику.
В dnSpy перейти в этот метод. Найти последнюю строку перед return — там формируется возвращаемое значение. Кликнуть на эту строку → F9 (Add Breakpoint). Запустить (F5).
При каждой остановке в нижней части dnSpy — вкладка Locals. Там видна локальная переменная содержащая расшифрованную строку. Копировать вручную. Это медленно но работает для ключевых строк (C2, ключи шифрования, пути).
Нахождение embedded PE через ресурсы
В дереве слева: развернуть сборку → найти ветку "Resources". Если там есть записи с бинарными данными или подозрительными именами — двойной клик. dnSpy покажет hex-содержимое. Если первые байты 1F 8B — GZip. Если 4D 5A — прямо PE.
Правый клик на ресурсе → Save Resource → сохранить как файл. Затем открывать в DIE и PEStudio.
Для Costura/Fody: имена ресурсов содержат .dll.compressed. Содержимое: сначала идут несколько байт заголовка Costura, потом GZip-данные. Посмотреть в HEX: найти 1F 8B — это начало GZip-потока. Сохранить всё начиная с 1F 8B как отдельный файл с расширением .gz. Распаковать любым архиватором — получишь DLL.
Раздел 9: Scylla и PE-bear — ручные правки
Диагностика проблем с IAT
Красные записи в Scylla означают что указатель в IAT ведёт на адрес, который Scylla не может разрешить в имя функции. Причины:
Причина 1 — паккер обфусцировал IAT: вместо прямых указателей на API там стоят промежуточные thunk-функции. В дизасме они выглядят как JMP [реальный адрес]. Scylla умеет такие разрешать через кнопку "Fix" — попробовать её.
Причина 2 — паккер восстановил IAT не полностью на момент дампа. Значит ты взял дамп слишком рано. Нужно дать паккеру немного "пробежать" на OEP — несколько F8 — чтобы IAT полностью восстановилась.
Причина 3 — hook-based IAT: некоторые паккеры заменяют указатели в IAT на свои функции-перехватчики. После дампа такие записи красные. Правый клик → Delete на каждую красную запись. Это лучше чем оставить — дамп без этих импортов будет работать нормально для статического анализа.
Ручное исправление ImageBase в PE-bear
Открыть дамп в PE-bear. Левая панель: выбрать "Optional Header". Смотреть поле "Image Base". Оно должно совпадать с реальным адресом загрузки который ты видел в Memory Map x64dbg.
Если они расходятся: двойной клик на поле Image Base → вписать правильное значение → Enter → сохранить через File → Save. Не сохранять в оригинальный файл — всегда в новую копию.
Вычисление RVA OEP вручную
Абсолютный адрес OEP ты знаешь из x64dbg (например 0x00401A30). ImageBase из PE-bear (например 0x00400000). RVA = 0x00401A30 - 0x00400000 = 0x1A30. В PE-bear: Optional Header → Address Of Entry Point → вписать 0x1A30.
Исправление секций с нулевым Raw Size
В PE-bear: вкладка "Section Headers". Каждая секция имеет строку. Колонки: Name, Virtual Size, Virtual Address, Raw Size, Raw Offset, Characteristics.
Если Raw Size = 0 при ненулевом Virtual Size: вписать в Raw Size значение равное Virtual Size, выровненное по FileAlignment (это поле тоже в Optional Header, обычно 0x200 или 0x1000). Формула выравнивания: ((VirtualSize + FileAlignment - 1) / FileAlignment) * FileAlignment.
Пример: Virtual Size = 0x3A40, FileAlignment = 0x200. Выровненный Raw Size = ((0x3A40 + 0x1FF) / 0x200) * 0x200 = 0x3C00.
Раздел 10: Верификация без скриптов
Последовательность проверки
Открыть malware_dump_SCY.exe в DIE. Если не показывает паккер — хорошо. Смотреть энтропию по секциям: .text должна быть ниже 6.5.
Открыть в PEStudio. Вкладка Libraries: должно быть 5+ библиотек с реальными именами. Вкладка Functions: должны быть осмысленные имена API. Вкладка Strings: должны быть читаемые строки — это главный признак успеха.
Открыть в Ghidra. File → Import → выбрать дамп. Формат PE. Analyze → Yes → Auto Analysis. Подождать. После окончания: Window → Functions. Если видишь имена вроде CreateMutexA, InternetOpenUrl — IAT восстановлена правильно и Ghidra видит символы. Если всё FUN_00401000 — IAT пустая или неправильная.
Ручная проверка IOC в PEStudio
Вкладка Strings → нажать на заголовок колонки "Blacklist" чтобы отсортировать по подозрительности. Смотреть красные строки. Что искать:
- URL-паттерны:
http://,https://, IP-адреса видаXXX.XXX.XXX.XXX - Системные пути:
\AppData\,\Temp\,\System32\ - Реестровые ключи:
HKLM\,HKCU\Software\ - Имена мьютексов: одиночные строки без смысла —
{GUID-like},Global\mutex_name - Команды:
cmd.exe /c,powershell -enc,reg add
Вкладка Functions → искать: CreateMutexA (persistence marker), InternetOpenA/WinHttpOpen (network), RegSetValueExA (registry persistence), CreateServiceA (service installation), WriteFile + путь в Temp = дроппер.
Финальный вывод
Гайд охватывает полный цикл анализа упакованной малвари — от пассивной разведки до верификации дампа. Ключевые принципы которые проходят через все разделы:
Методология "слой за слоем" — каждый инструмент закрывает свой этап. DIE и PEStudio дают картину до запуска, HEX-редактор позволяет работать с заголовками вручную, x64dbg вскрывает рантайм, Scylla/PE-bear восстанавливают структуру.
Понимание механики важнее инструментов — знание того, почему ESP Trick работает (PUSHAD/POPAD и момент записи регистров), почему IAT бывает пустой, как CONTEXT структура хранит OEP при Process Hollowing — позволяет действовать когда автоматика ломается.
Порядок действий при неизвестном образце:
- DIE → тип паккера/компилятора/энтропия
- PEStudio → IAT, строки, индикаторы
- HEX → ручная проверка заголовка, embedded PE
- x64dbg + ScyllaHide → ESP trick или VirtualAlloc BP
- Обход anti-debug (IsDebuggerPresent, RDTSC, TLS)
- Дамп через Scylla → правка в PE-bear
- Верификация в DIE + PEStudio + Ghidra
Критические точки отказа — если дамп не работает, почти всегда причина в одном из трёх: неправильный момент дампа (IAT не восстановлена), неверный OEP в PE-заголовке, нулевые Raw Size в секциях. Все три решаются в PE-bear вручную.
.NET — отдельный мир — стандартный подход через x64dbg там неприменим. dnSpy + de4dot + ручной поиск дешифраторов строк и embedded PE в ресурсах.
Гайд намеренно избегает скриптовой автоматизации — всё описано на уровне ручных действий, что формирует понимание процесса, а не зависимость от конкретных инструментов.