Главная / Блог / Реверс инжиниринг для начинающих: как читать ассемблер x86 и понимать логику программы

15 мин.00

Реверс инжиниринг для начинающих: как читать ассемблер x86 и понимать логику программы

Реверс инжиниринг для начинающих: как читать ассемблер x86 и понимать логику программы

Реверс инжиниринг для начинающих: как читать ассемблер x86 и понимать логику программы

На первом CTF я потратил четыре часа на задачу reverse уровня easy — crackme с проверкой пароля в десять строк ассемблера. Не потому что задача была сложной: я просто не понимал, что делает cmp eax, 0x41 и почему после jne программа прыгает мимо строки «Correct». Через полгода практики те же десять строк читались за минуту. Разница не в интеллекте, а в знании паттернов. Эта статья — попытка передать именно это: не справочник по каждой инструкции x86, а способ думать над чужим кодом так, чтобы ассемблер перестал быть набором бессмысленных аббревиатур.

Что видит реверсер при анализе бинарных файлов

Скомпилированная программа — последовательность байтов, машинный код. Процессор понимает их напрямую, человек — нет. Дизассемблер для новичков (Ghidra, IDA Free, Binary Ninja) превращает байты в текстовое представление — ассемблерный листинг. Декомпилятор идёт дальше: пытается восстановить псевдокод на Си. Оба инструмента работают без запуска программы — это статический анализ программ. Подробнее — в нашем руководстве по бинарный анализ уязвимостей.

Статический анализ безопасен: вы не запускаете подозрительный исполняемый файл PE или ELF и не рискуете заразить рабочую машину. По данным Varonis, при анализе вредоносного ПО статический подход через дизассемблер — первый шаг перед запуском в песочнице. Динамический анализ (отладка программ в x64dbg, GDB, запуск в sandbox) дополняет картину, но без понимания ассемблера отладчик бесполезен — вы не поймёте, на что ставить точку останова.

Ключевой навык реверсера — строить гипотезы. Видите набор инструкций — предполагаете «это цикл, который проверяет каждый символ ввода» — прослеживаете поток данных. Ошиблись — корректируете. Чем больше паттернов в голове, тем быстрее гипотезы оказываются верными.

Реверс инжиниринг для начинающих кажется дисциплиной, где нужно запоминать сотни инструкций. На практике 30–40 покрывают абсолютное большинство скомпилированного кода. Ниже — этот необходимый минимум.

Регистры x86 и стек — минимум для чтения ассемблера

Регистры общего назначения

Регистры — сверхбыстрая память внутри процессора. На x86 (32-bit) каждый вмещает 4 байта. Их немного, и у каждого свой «характер»:

  • EAX — аккумулятор. Результат функции почти всегда лежит здесь. Увидели mov eax, 1 перед ret — функция вернула единицу.
  • ECX — счётчик. Исторически привязан к циклам (инструкция loop уменьшает ECX автоматически), но современные компиляторы почти не генерируют loop — она медленная. Циклы собираются через dec ecx + jnz или cmp + условный прыжок.
  • EDX — старшая часть пары EDX:EAX при mul, div, idiv и однооперандном imul. В некоторых calling conventions хранит второй аргумент.
  • EBX — «свободный». Компилятор использует его по ситуации. По конвенции cdecl/stdcall это callee-saved регистр (функция обязана восстановить значение перед возвратом) — как и ESI, EDI, EBP. А вот EAX, ECX, EDX — caller-saved.
  • ESI / EDI — source index и destination index. Классика — копирование блоков памяти через rep movsb, но компиляторы часто задействуют их как обычные регистры.
  • ESP — указатель стека. Всегда показывает на вершину. Менять вручную — путь к краху.
  • EBP — базовый указатель (frame pointer). Фиксирует начало текущего стекового фрейма. Локальные переменные адресуются как [ebp-4], [ebp-8], аргументы функции — как [ebp+8], [ebp+0Ch].
  • EIP — instruction pointer. Адрес выполняемой инструкции. Напрямую через mov его изменить нельзя, но jmp, call и ret делают именно это.

Каждый 32-битный регистр делится: AX — младшие 16 бит EAX, AH и AL — старший и младший байт AX. Когда в листинге встречается mov al, [esi] — программа читает один байт из памяти по адресу в ESI. Типичный паттерн побайтовой обработки строк.

Стек и фреймы функций

Стек — область памяти, организованная по принципу LIFO: последним пришёл — первым ушёл. Две главные инструкции: push кладёт значение на вершину (ESP уменьшается на 4), pop снимает (ESP увеличивается). Адреса растут вниз — контринтуитивно, но привыкаешь за пару дней.

Типичный пролог функции, который вы будете видеть в каждом бинарнике:

push ebp          ; сохраняем старый base pointer
mov  ebp, esp     ; фиксируем текущую вершину стека
sub  esp, 0x10    ; выделяем 16 байт под локальные переменные

Эпилог — зеркальная операция: mov esp, ebp / pop ebp / ret. Инструкция ret снимает адрес возврата со стека и помещает его в EIP — управление возвращается вызывающей функции. По сути call target — это push адрес_следующей_инструкции + jmp target, а ret — это pop eip. Никакой магии, только манипуляция стеком.

Когда в листинге встречается [ebp-4] — это первая локальная переменная. [ebp+8] — первый аргумент функции (между ними лежат сохранённый EBP и адрес возврата, каждый по 4 байта). Привыкнув к этой схеме, вы начнёте мгновенно отличать обращение к аргументу от обращения к локальной переменной — а это уже половина понимания логики программы в ассемблере.

Как читать ассемблер x86: ключевые инструкции и паттерны

Перемещение данных и арифметика

Синтаксис Intel (используется в Ghidra и IDA) читается как «куда, откуда»:

  • mov eax, 5 — записать 5 в EAX (непосредственное значение)
  • mov eax, [ebp-4] — прочитать 4 байта из памяти по адресу ebp-4 и положить в EAX (квадратные скобки — обращение к памяти)
  • mov [ebp-4], eax — записать значение EAX в память
  • lea eax, [ebp-0x20] — загрузить адрес (не содержимое!) ebp-0x20 в EAX. LEA — load effective address, часто используется для вычисления указателей или нестандартной арифметики вроде lea eax, [ecx+ecx*4] (умножение ECX на 5 за одну инструкцию)

Арифметика прямолинейна: add eax, 3 (eax += 3), sub ecx, 1 (ecx -= 1), imul eax, ebx (eax *= ebx). Деление работает через idiv: сначала cdq расширяет EAX в пару EDX:EAX, затем idiv ecx делит пару на ECX — частное попадает в EAX, остаток в EDX.

Первый паттерн, который нужно запомнить: xor eax, eax — обнуление регистра. XOR числа с самим собой всегда даёт ноль. Компиляторы предпочитают это вместо mov eax, 0, потому что инструкция короче (2 байта против 5). Встретили xor eax, eax — читайте как «eax = 0» и двигайтесь дальше.

Условные переходы и циклы в ассемблере

Принятие решений в x86 — всегда пара инструкций: сначала сравнение, потом условный прыжок. cmp eax, 0x41 вычитает 0x41 из EAX, не сохраняя результат, но выставляя флаги (ZF — zero flag, SF — sign flag). test eax, eax — побитовое AND регистра с самим собой: если EAX равен нулю, ZF выставляется в 1. После сравнения идёт переход:

cmp  eax, 0x41    ; сравнить EAX с 'A' (0x41 — ASCII-код)
jne  wrong        ; если не равно — прыгнуть на метку wrong
; код для случая "верно"
jmp  end          ; перепрыгнуть блок wrong
wrong:
; код для случая "неверно"
end:

Основные условные прыжки: je / jne (equal / not equal), jg / jl (greater / less — знаковые), ja / jb (above / below — беззнаковые), jge / jle (greater-or-equal / less-or-equal). Для запоминания: J = jump, остальные буквы — условие.

Цикл for (i = 0; i < 25; i++) компилятор превращает в инициализацию счётчика, тело цикла, инкремент, сравнение с границей и условный прыжок назад. Видите cmp ecx, 0x19 (0x19 = 25) с jl обратно к телу — перед вами цикл на 25 итераций. Этот паттерн характерен для побайтовой обработки строк, массивов и повсеместно встречается в CTF реверс заданиях.

Вызовы функций и передача аргументов

В 32-битном коде аргументы обычно передаются через стек. Две основные конвенции вызова:

cdecl — аргументы кладутся справа налево, стек чистит вызывающая сторона. Стандарт для Си-кода. stdcall — то же, но стек чистит вызываемая функция. Используется в Windows API (MessageBoxA, CreateFileW и прочие WinAPI-функции).

Пример вызова printf("Result: %d", 42) в cdecl:

push 0x2A         ; 42 — второй аргумент (кладётся первым)
push offset fmt   ; адрес строки "Result: %d" — первый аргумент
call printf
add  esp, 8       ; вызывающая сторона чистит: 2 × 4 байта

Возвращаемое значение — в EAX. Встретили call rand и сразу за ним mov [ebp-4], eax — результат rand() сохраняется в локальную переменную.

В 64-битном режиме (x64) первые четыре аргумента передаются через регистры — RCX, RDX, R8, R9 на Windows или RDI, RSI, RDX, RCX, R8, R9 на Linux. Остальные идут через стек. Регистры расширены до 64 бит (RAX, RBX..., R8–R15), добавляется RIP-relative addressing, но базовые принципы те же: cmp + jne, call + ret, push + pop.

Ghidra и IDA Free — дизассемблер для новичков

Требования к окружению и установка

Ghidra (open-source, разработан NSA, выпущен в 2019; активно поддерживается на GitHub, регулярные релизы серии 11.x): - ОС: Windows, Linux, macOS - Java: JDK 21 для Ghidra 11.1+ (JDK 17 для более ранних версий) — проверьте командой java -version и README конкретного релиза - RAM: минимум 4 ГБ, рекомендуется 8 ГБ для комфортного анализа - Диск: ~500 МБ для Ghidra + место под проекты

Установка: на Linux — sudo apt install openjdk-21-jdk (или openjdk-17-jdk для Ghidra до 11.1), затем скачать ZIP с официального сайта, распаковать и запустить ./ghidraRun. На Windows — установить JDK через инсталлятор, запустить ghidraRun.bat.

IDA Free (Hex-Rays, freeware-версия коммерческого дизассемблера): - ОС: Windows, Linux, macOS - RAM: 4 ГБ минимум - Ограничения: только некоммерческое использование; облачный декомпилятор работает с лимитами по архитектурам и количеству запросов

Критерий Ghidra IDA Free
Лицензия Open-source, Apache 2.0 Freeware, закрытый код
Декомпилятор Встроенный, все архитектуры Облачный, только x86/x64, с лимитами
Поддержка архитектур x86, ARM, MIPS, PowerPC, AVR и десятки других Все архитектуры IDA Pro (дизассемблирование)
Скриптинг Java, Python (Jython / Ghidrathon) IDAPython
Командная работа Ghidra Server (встроен) Нет в бесплатной версии
Когда использовать Обучение, командный анализ, мультиплатформенность Быстрый анализ x86, привычный интерфейс
Когда НЕ использовать Нужна минимальная настройка и только x86 Нужна полная локальная декомпиляция или коммерческое использование

Для reverse engineering с нуля я рекомендую Ghidra: бесплатный встроенный декомпилятор, который превращает ассемблер в читаемый псевдо-Си, сильно ускоряет понимание логики. IDA Free — достойная альтернатива с более привычным интерфейсом (многие привыкли к нему ещё со времён IDA 5.0 freeware), но декомпилятор в ней ограничен облачным сервисом. Для полной декомпиляции нужна IDA Pro — или всё та же Ghidra.

Первый бинарный анализ в Ghidra

Открываем Ghidra и создаём проект: File → New Project → Non-Shared Project. Импортируем бинарник через File → Import File — выбираем исполняемый файл (PE для Windows, ELF для Linux). Ghidra предложит запустить автоанализ — соглашаемся. Анализатор определит функции, найдёт строки, восстановит перекрёстные ссылки.

После анализа откроются два ключевых окна. Listing — ассемблерный листинг с адресами, мнемониками и операндами. Decompile — псевдо-Си код, восстановленный из ассемблера. Окна синхронизированы: кликаете на инструкцию в листинге — декомпилятор подсвечивает соответствующую строку в псевдокоде. Удобно до безобразия.

Навигация: двойной клик на имени функции — переход к её определению. Нажатие X на имени — список всех мест, откуда эта функция вызывается (cross-references, или xrefs). Поиск строк: Window → Defined Strings покажет все текстовые строки в бинарнике. Именно с поиска строк начинается дизассемблирование большинства задач — находите «Wrong password» или «Access granted», переходите по xref к функции проверки и читаете её логику.

Если бинарник скомпилирован с отладочной информацией (PDB для MSVC, DWARF для GCC), Ghidra подхватит имена функций и переменных автоматически — анализ упрощается в разы. Без отладочных символов (Stripped Payloads, T1027.008 по MITRE ATT&CK) всё будет называться FUN_00401000, local_4, param_1 — придётся давать имена самостоятельно, опираясь на контекст. Скучно, муторно, но именно так набивается чутьё.

Разбираем crackme: как строить гипотезы о логике программы

Crackme — учебная программа, которая просит ввести пароль или ключ. Задача — найти правильный ввод не подбором, а чтением кода. Этот тип задач стандартен для соревнований в категории reverse и идеально подходит для обучения.

Шаг 1: запускаем и наблюдаем. Запустите crackme в виртуальной машине (незнакомые бинарники из интернета — всегда только в VM, без исключений). Программа выведет «Enter password:», примет строку, напечатает «Wrong!» или «Correct!». Теперь вы знаете две строки, которые точно есть в коде.

Шаг 2: ищем строки в Ghidra. Открываем бинарник, идём в Defined Strings, находим «Correct» или «Wrong». Правая кнопка → References → Show References to Address. Ghidra покажет функцию, которая ссылается на эту строку. Переходим туда — перед нами функция проверки.

Шаг 3: находим условие. В функции проверки ищем условный переход. Где-то будет cmp и jne (или je), который разделяет поток на «верно» и «неверно». Всё что выше ветвления — логика проверки. Декомпилятор Ghidra часто показывает это как обычный if (...) { puts("Correct"); } else { puts("Wrong"); }.

Шаг 4: восстанавливаем алгоритм. Самый частый паттерн в простых crackme — побайтовое сравнение ввода с эталоном. Видите цикл, перебирающий символы ввода и сравнивающий каждый с элементом массива — осталось извлечь массив. Пример: в листинге последовательность mov [ebp+check], 7ABh, mov [ebp+check+4], 3E8h — это заполнение массива числами. Если дальше цикл XOR-ит каждый введённый символ с константой и сравнивает результат с элементом массива — перед вами XOR-шифрование. Чтобы получить пароль, делаете обратную операцию: XOR-ите элементы массива с той же константой.

Шаг 5: проверяем. Вводите восстановленную строку. Crackme пишет «Correct» — задача решена. Если нет — возвращаетесь к шагу 4 и корректируете гипотезу. Иногда проверка сложнее: хеширование, многоступенчатое преобразование, побитовые операции. Но алгоритм мышления тот же — гипотеза, проверка, коррекция.

Декомпилятор в простых случаях покажет псевдо-Си практически идентичный исходному коду. Но слепо доверять ему нельзя: он иногда ошибается в типах переменных и границах структур. Ассемблерный листинг — первоисточник, псевдо-Си — подсказка. Не наоборот.

Ограничения статического анализа программ

Статический анализ — не серебряная пуля. Целый класс техник делает его сложнее или вовсе бесполезным:

Упаковщики (Software Packing, T1027.002 по MITRE ATT&CK). Программа сжимает или шифрует свой код, распаковывая его в памяти при запуске. В дизассемблере вместо логики вы видите код распаковщика и мусор. UPX — простейший пример: штатная команда upx -d packed.exe снимает упаковку. Кастомные пакеры требуют динамического анализа — выполнить бинарник до момента распаковки, затем тупо сдампить код из памяти.

Удаление символов (Stripped Payloads, T1027.008). Без имён функций и переменных листинг превращается в sub_401000, var_4, arg_0. Анализ возможен, но занимает в разы больше времени: приходится вручную давать имена, опираясь на вызовы API, строки и структуру данных.

Антиотладочные приёмы (Debugger Evasion, T1622). Программа вызывает IsDebuggerPresent, проверяет флаг NtGlobalFlag в PEB или замеряет время выполнения через rdtsc. Если детектирует отладчик — меняет поведение или завершается. Эти техники мешают динамическому анализу, но статическому — нет: в Ghidra вы видите вызов IsDebuggerPresent и понимаете, что при отладке результат нужно подменить (патчнуть jne на je — и готово).

Обфускация потока управления (Obfuscated Files or Information, T1027). Замена прямых вызовов на вычисляемые (call [eax] вместо call printf), вставка мусорных инструкций, расщепление функций. Граф вызовов становится нечитаемым. Против продвинутой обфускации помогают символьное выполнение (angr), эмуляция (Unicorn Engine) или ручная деобфускация скриптами Ghidra — но это уже следующий уровень.

Статический и динамический анализ дополняют друг друга. На практике цикл выглядит так: посмотрели код в Ghidra, не поняли поведение — поставили точку останова в x64dbg (Windows, open-source, активно поддерживается) или GDB (Linux) — прогнали и увидели данные в памяти — вернулись в Ghidra, переименовали переменные. Два инструмента, один процесс.

Куда двигаться после первого crackme

Разобрав одну задачу уровня easy, возникает вопрос: как перейти от учебных crackme к реальному бинарному анализу? Маршрут выглядит так:

  1. Больше crackme разного уровня. Платформы вроде crackmes.one или категории reversing на CTF-площадках дают задачи с нарастающей сложностью. Каждая учит новому паттерну: проверка через хеш, XOR с переменным ключом, вычисляемые переходы. Формула на бумаге понятна, но паттерны по-настоящему оседают в голове только когда сам прогоняешь бинарники. Готовые стенды с подборками задач по реверсу есть на HackerLab.pro — категория Reverse, задачи отсортированы по сложности, можно отрабатывать навык в контролируемой среде.

  2. Чтение write-up'ов. После попытки решить задачу самостоятельно (даже неудачной) читайте чужие разборы. Обращайте внимание не на финальный ответ, а на ход мысли: как автор нашёл нужную функцию, почему переименовал переменную, в какой момент переключился на отладчик.

  3. Структура исполняемых файлов. Форматы PE (Windows) и ELF (Linux) — не просто контейнеры для кода. Секции (.text, .data, .rdata), таблицы импорта и экспорта, заголовки — всё это подсказки. CFF Explorer (Windows) или утилита readelf (Linux) покажут структуру до загрузки в дизассемблер.

  4. Язык Си. Компиляторы генерируют ассемблер из Си (или С++). Чем лучше понимаете конструкции Си — указатели, структуры, арифметику адресов — тем быстрее узнаёте их в дизассемблированном коде. Практика: написать простую программу на Си, скомпилировать с -O0 (без оптимизаций), открыть результат в Ghidra и сравнить свой исходник с декомпилированным. Отличное упражнение — видите, как компилятор раскладывает ваш if в cmp + jne.

  5. Скрипты для автоматизации. Когда задачи станут рутинными (переименовать десятки функций по паттерну, извлечь XOR-ключи из массива), скрипты Ghidra на Java/Python экономят часы. Библиотека capstone (Python, версия 5.x) позволяет дизассемблировать байты программно — удобно для построения собственных инструментов анализа.

Большинство советов начинающим реверсерам звучат одинаково: «сначала выучи ассемблер, потом Си, потом берись за дизассемблер». Я видел десятки людей, которые следовали этому маршруту — и бросали через два месяца. Проблема не в лени, а в подходе: изучать ассемблер в вакууме, без контекста и цели — мучительно скучно. Работает обратный порядок: берёте crackme, открываете Ghidra, натыкаетесь на cmp eax, 0x41 — и лезете в справочник ровно за тем, что нужно прямо сейчас. Через три месяца такой практики в голове оседают те самые 30–40 инструкций, которые покрывают 95% реального кода. Остальные сотни ищутся по справочнику — и это нормально даже для опытных аналитиков.

Вторая ловушка — перфекционизм при чтении листинга. Начинающий вцепляется в каждую строку пролога, мучительно разбирает sub esp, 0x28 и забывает, зачем вообще открыл бинарник. Опытный реверсер пропускает обвязку, сканирует до первого cmp или call и фокусируется на смысле. Ассемблер — это паттерны, а не отдельные буквы. Когда начинаете видеть паттерны, скорость растёт экспоненциально.

Настоящий барьер в обучении реверсу — не мнемоники инструкций. Он в отсутствии системной базы: как устроена память процесса, зачем нужны привилегии, что происходит при системном вызове. Без этого контекста каждый новый бинарник — изолированная загадка. Если собирать фундамент по кускам из разрозненных статей — уходят годы. На курсе IB Basics эту базу дают структурированно — не «запомните терминологию», а «как разобраться».

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...