June 8

Гайд по анпакингу и де-обфускации малвари

Раздел 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 Hollowing
  • IsDebuggerPresent + CheckRemoteDebuggerPresent → anti-debug в stub
  • NtQueryInformationProcess → продвинутый 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 — позволяет действовать когда автоматика ломается.

Порядок действий при неизвестном образце:

  1. DIE → тип паккера/компилятора/энтропия
  2. PEStudio → IAT, строки, индикаторы
  3. HEX → ручная проверка заголовка, embedded PE
  4. x64dbg + ScyllaHide → ESP trick или VirtualAlloc BP
  5. Обход anti-debug (IsDebuggerPresent, RDTSC, TLS)
  6. Дамп через Scylla → правка в PE-bear
  7. Верификация в DIE + PEStudio + Ghidra

Критические точки отказа — если дамп не работает, почти всегда причина в одном из трёх: неправильный момент дампа (IAT не восстановлена), неверный OEP в PE-заголовке, нулевые Raw Size в секциях. Все три решаются в PE-bear вручную.

.NET — отдельный мир — стандартный подход через x64dbg там неприменим. dnSpy + de4dot + ручной поиск дешифраторов строк и embedded PE в ресурсах.

Гайд намеренно избегает скриптовой автоматизации — всё описано на уровне ручных действий, что формирует понимание процесса, а не зависимость от конкретных инструментов.