Intel Assembly x86-64 для реверс-инжиниринга: чтение, понимание и базовая практика

Курс обучает чтению и анализу x86-64 ассемблера (Intel syntax) с упором на задачи реверс-инжиниринга. Включает базовую практику: сборка и запуск Hello World, изучение ключевых инструкций, соглашений о вызовах и типичных компиляторных паттернов.

1. Инструменты и окружение: ассемблер, линковка, отладка, дизассемблер

Инструменты и окружение: ассемблер, линковка, отладка, дизассемблер

Реверс-инжиниринг на x86-64 почти всегда начинается не с написания кода, а с чтения уже скомпилированных бинарников: просмотр секций, дизассемблирование, отладка, понимание соглашений о вызовах и того, что делает компоновщик.

В этой статье соберём минимально достаточный набор инструментов и разберём, как они связаны между собой: от исходника на ассемблере до исполняемого файла, а затем обратно — к дизассемблеру и отладчику.

Что вы будете уметь после статьи

  • Понимать цепочку сборки: ассемблеробъектный файллинковщикисполняемый файл
  • Отличать форматы и роли файлов: .asm, .o, ELF, PE, секции, символы, релокации
  • Пользоваться базовыми утилитами для анализа бинарника: file, readelf, objdump, strings
  • Запускать отладку в gdb и понимать, что именно вы там видите
  • Представлять, где в процесс включаются дизассемблеры и декомпиляторы (Ghidra, IDA, radare2)
  • !Диаграмма показывает путь от исходника до бинарника и обратно к анализу

    Платформа и базовые понятия

    В курсе мы ориентируемся на x86-64 (AMD64). Это архитектура команд процессора. А вот конкретная среда зависит от ОС и формата исполняемых файлов:

  • Linux обычно использует формат ELF
  • Windows обычно использует формат PE
  • macOS обычно использует формат Mach-O
  • Далее примеры будут в первую очередь для Linux, потому что там проще показать все этапы инструментами командной строки. Но концепции (объектный файл, линковка, отладка, дизассемблирование) одинаковые.

    Ассемблер: что это и какой выбрать

    Ассемблер — программа, которая переводит текст с мнемониками (mov, call, ret) в машинный код и упаковывает результат в объектный файл.

    На x86-64 чаще всего вы встретите два синтаксиса:

  • Intel-синтаксис: mov rax, rbx
  • AT&T-синтаксис: mov %rbx, %rax
  • Для реверса важно уметь читать оба, но для практики курса удобнее Intel.

    NASM

    NASM популярен в учебных примерах и небольших проектах.

  • Сайт: NASM
  • Типичный вывод: объектный ELF .o
  • Пример команды сборки объекта:

    GNU assembler (as)

    GNU as обычно идёт в комплекте с GNU binutils и тесно интегрирован с gcc.

  • Документация: Using as (GNU Assembler)
  • Часто as используют через gcc, но можно и напрямую.

    Почему реверсеру важен выбор ассемблера

    Потому что:

  • Синтаксис влияет на то, как вы привыкаете читать инструкции
  • Директивы (описание секций, данных, экспортов) в разных ассемблерах разные
  • Генерируемая отладочная информация и метки может отличаться, а это влияет на удобство отладки
  • Объектный файл: что внутри .o

    Объектный файл — это ещё не программа. Обычно он содержит:

  • секции кода и данных (например, .text, .data, .rodata)
  • таблицу символов (имена функций и меток, если они не удалены)
  • релокации (места, которые линковщик должен “доправить” адресами)
  • иногда отладочную информацию (если вы включали её при сборке)
  • В Linux объектный файл часто имеет формат ELF типа relocatable.

    Полезные команды:

  • file быстро говорит, что это за файл
  • readelf -h показывает заголовок ELF
  • readelf -S показывает список секций
  • readelf -s показывает символы
  • Документация:

  • readelf(1)
  • file(1)
  • Линковка: как из .o получается исполняемый файл

    Линковщик (компоновщик) собирает один или несколько объектных файлов в итоговый бинарник. Он:

  • объединяет секции
  • разрешает внешние символы (например, printf из libc)
  • применяет релокации
  • добавляет динамическую информацию (если сборка динамическая)
  • формирует точку входа (entry point)
  • В Linux вы можете линковать напрямую через ld, но чаще используют gcc, потому что он автоматически добавляет стандартные библиотеки и корректные флаги.

    Линковка через ld

    Это сработает только для программ, которые не требуют libc и правильно оформлены (например, используют системные вызовы напрямую).

    Документация: ld(1)

    Линковка через gcc

    Так проще получить рабочую программу, использующую стандартную библиотеку.

    Статическая и динамическая линковка

  • Динамическая линковка: бинарник использует .so библиотеки во время запуска
  • Статическая линковка: код библиотек вшит внутрь (бинарник обычно больше)
  • Для реверса важно понимать, что динамически связанные вызовы часто проходят через PLT и GOT (в ELF), и это заметно в дизассемблере.

    PIE и ASLR

    Современные дистрибутивы часто собирают исполняемые файлы как PIE (position-independent executable). Это помогает ASLR (рандомизации адресов) и влияет на отладку:

  • адреса в памяти при каждом запуске могут быть разными
  • в дизассемблере вы часто увидите RIP-relative адресацию
  • Проверить тип ELF можно так:

    Полезный термин: entry point — адрес, с которого начинается выполнение.

    Отладка: gdb как инструмент реверса

    Отладчик позволяет исполнять программу под контролем: ставить брейкпоинты, шагать по инструкциям, смотреть регистры и память.

    Установка и запуск

  • Документация: GDB
  • Запуск:

    Минимальный набор команд gdb

  • break main — поставить брейкпоинт на main (если символы доступны)
  • run — запустить
  • si — step instruction, шаг по одной инструкции
  • ni — next instruction, “перешагнуть” вызов call
  • info registers — посмотреть регистры
  • x/16gx $rsp — посмотреть 16 8-байтных слов по адресу стека
  • disassemble — дизассемблировать текущую функцию
  • Подсказка: gdb умеет показывать дизассемблер в Intel-синтаксисе:

    Отладочная информация (DWARF) и флаги

    Если вы компилируете или собираете пример сами, полезно добавить отладочную информацию. В мире C/C++ это обычно флаг -g. Для ассемблера зависит от инструмента.

    Смысл простой:

  • без отладочной информации у вас может не быть имён функций, строк исходника, удобной привязки адресов
  • с отладочной информацией отладка и чтение становятся проще
  • Для реверса “чужих” релизных бинарников отладочной информации обычно нет, поэтому важно уметь работать и без неё.

    Дизассемблирование: objdump и друзья

    Дизассемблер переводит машинный код обратно в текст инструкций. Это не “обратная сборка” исходника: часть информации потеряна (имена переменных, типы, комментарии).

    objdump

  • Документация: objdump(1)
  • Показать дизассемблирование:

    Показать дизассемблирование с символами (если есть):

    Подсказка: чтобы видеть Intel-синтаксис в GNU инструментах, часто используют опцию:

    strings

    Иногда самое полезное — найти строки, по которым можно понять назначение кода.

  • Документация: strings(1)
  • hexdump

    Чтобы увидеть “сырые” байты:

  • Документация: hexdump(1)
  • Статический анализ: Ghidra, IDA, radare2

    Командные утилиты хороши для точечной проверки, но для большого реверса обычно используют полноценные платформы.

    Ghidra

    Ghidra — бесплатная платформа для анализа, включает дизассемблер и декомпилятор.

  • Официальный сайт: Ghidra
  • Что особенно полезно:

  • графы функций и переходов
  • восстановление прототипов и типов (частично)
  • удобная навигация по ссылкам (XREF)
  • IDA

    IDA Pro — индустриальный стандарт (коммерческий продукт).

  • Сайт: Hex-Rays IDA
  • radare2

    Консольный фреймворк для анализа и реверса.

  • Сайт: radare2
  • Динамический анализ: strace

    Иногда проще понять программу не по инструкциям, а по тому, какие системные вызовы она делает.

  • Документация: strace(1)
  • Пример:

    Это особенно полезно для:

  • понимания работы с файлами и сетью
  • поиска точки, где программа падает
  • быстрого определения “что она делает” на уровне ОС
  • Минимальный набор инструментов для курса

    Ниже — практичный набор, которого хватит для старта (Linux).

  • Сборка и линковка:
  • - nasm - gcc (как драйвер линковки) - ld (чтобы понимать линковку напрямую)
  • Инспекция бинарников:
  • - file, readelf, objdump, strings
  • Отладка:
  • - gdb
  • Статический анализ:
  • - Ghidra (рекомендуется)

    Если вы на Windows, концептуально аналогичные роли:

  • ассемблер: MASM или NASM
  • отладчик: WinDbg или x64dbg
  • просмотр PE: различные PE-утилиты
  • дизассемблер: Ghidra или IDA
  • Типичные ошибки новичков и как их избегать

  • Пытаться “понять всё” только по дизассемблеру
  • - Практика: комбинируйте readelf (структура), objdump (инструкции), gdb (поведение)
  • Путать объектный файл и исполняемый
  • - .o может не иметь точки входа и не запускается
  • Не учитывать PIE и ASLR
  • - Адреса “плавают” между запусками, важнее относительные смещения и логика
  • Не различать роли ассемблера и линковщика
  • - Ассемблер делает машинный код локально, линковщик “склеивает” и правит адреса

    Что дальше по курсу

    Следующий шаг — собрать и запустить самый простой пример на x86-64 (например, Hello World), чтобы руками пройти цепочку: ассемблирование, линковка, запуск, дизассемблирование и отладка. После этого мы начнём разбирать базовые инструкции и шаблоны, которые вы постоянно увидите в чужом ассемблере.

    2. Hello World на x86-64: секции, syscalls, формат ELF/PE

    Hello World на x86-64: секции, syscalls, формат ELF/PE

    В прошлой статье мы собрали инструменты (NASM/ld/gcc, readelf, objdump, gdb) и разобрали цепочку исходник → объектный файл → исполняемый файл → дизассемблер/отладчик. Сейчас пройдём эту цепочку руками на самом простом примере — Hello World на x86-64.

    Но цель здесь не “научиться писать приложения на ассемблере”, а получить опору для чтения чужого кода:

  • понять, что такое секции и почему они важны при анализе;
  • увидеть, как выглядит “чистый” бинарник без libc: через syscall;
  • научиться узнавать шаблоны: _start, sys_write, sys_exit, .text, .rodata;
  • связать увиденное с тем, что показывают readelf, objdump, gdb.
  • Что такое Hello World в контексте реверса

    В типичном “учебном” мире Hello World делают через printf. Для реверса это часто плохой старт, потому что вы сразу получаете:

  • вызовы через PLT/GOT (в ELF) и динамический загрузчик;
  • много кода инициализации рантайма;
  • зависимости от libc.
  • Поэтому начнём с варианта, где программа делает ровно два системных вызова:

  • вывести строку в stdout;
  • завершиться.
  • Такой пример короткий, и его легко сопоставить с дизассемблером и регистровыми соглашениями.

    Минимальная программа под Linux: ELF + syscalls

    Ниже пример для Linux x86-64, NASM, Intel-синтаксис. Точка входа — метка _start.

    Где здесь “формат”, а где “ОС”: ELF как контейнер

    Инструкция syscall — это механизм процессора. Но то, какие номера у системных вызовов и что они делают, определяет ОС (Linux).

    ELF — это формат файла, который ОС и загрузчик умеют:

  • распарсить;
  • загрузить сегменты в память;
  • выставить права (код исполняемый, данные только для чтения и т.д.);
  • передать управление на entry point.
  • Поэтому “Hello World под Linux” — это не просто ассемблер, это комбинация:

  • x86-64 инструкции;
  • Linux ABI (правила syscalls);
  • формат ELF.
  • Коротко про PE (Windows): чем отличается подход

    На Windows формат файла обычно PE (Portable Executable). Ключевое практическое отличие для новичка:

  • в Windows редко делают “Hello World” через прямые системные вызовы (они нестабильны между версиями и не предназначены для прикладного кода);
  • обычно вызывают WinAPI (например, WriteFile, ExitProcess) через таблицу импортов.
  • В терминах реверса это означает:

  • вы часто начинаете анализ с Import Table: какие функции импортируются;
  • вместо PLT/GOT (ELF) будет своя модель импорта и разрешения адресов;
  • секции выглядят знакомо, но называются иначе (часто .text, .rdata, .data, .pdata).
  • Ниже — ориентировочная “карта соответствий”, чтобы не теряться при переключении между ОС.

    | Тема | ELF (Linux) | PE (Windows) | |---|---|---| | Формат файла | ELF | PE | | Точка входа | Entry point address в заголовке ELF | AddressOfEntryPoint в Optional Header | | Типичный вывод текста | syscall (write) или libc (printf) | WinAPI (WriteFile, WriteConsoleA) | | Динамические вызовы | часто через PLT/GOT | через Import Address Table | | Частые секции | .text, .rodata, .data, .bss | .text, .rdata, .data, иногда .pdata |

    Если вы хотите сверить детали форматов на первоисточниках:

  • ELF (Wikipedia)
  • Portable Executable (Wikipedia)
  • Что важно запомнить для чтения ассемблера

  • В минимальном ELF-бинарнике без libc исполнение часто начинается в _start, а не в main.
  • Паттерн “подготовить rax и аргументы → syscall” — прямой системный вызов.
  • .text почти всегда код, .rodata часто содержит строки; строки удобно искать через strings, а затем искать ссылки на них в дизассемблере.
  • readelf помогает понять структуру ELF (entry point, секции), а objdump — увидеть инструкции, которые вы затем сопоставляете с поведением в gdb.
  • Что дальше по курсу

    Следующий логичный шаг — разобрать базовые инструкции и “кирпичики” чтения: mov, lea, арифметика, флаги, cmp/test, условные переходы. После этого мы начнём узнавать типичные конструкции компилятора в ассемблере: ветвления, циклы, вызовы функций и соглашения о вызовах.

    3. Регистры, флаги, адресация и модель памяти x86-64

    Регистры, флаги, адресация и модель памяти x86-64

    В предыдущих статьях вы:

  • настроили инструменты (nasm, ld, objdump, readelf, gdb);
  • собрали минимальный Hello World через syscall и увидели, как код и данные лежат в .text и .rodata.
  • Теперь нужен фундамент, без которого чтение дизассемблированного кода превращается в угадайку: регистры, флаги, адресация и базовая модель памяти x86-64.

    Реверс-инжиниринг почти всегда начинается с вопросов вида:

  • что лежит в rdi прямо перед call или syscall;
  • что означает cmp и почему дальше идёт je;
  • это чтение из памяти или просто вычисление адреса (mov против lea);
  • почему доступ к глобальной строке выглядит как [rip+...].
  • Что вы будете уметь после статьи

  • Быстро распознавать роли регистров и их подрегистров (64/32/16/8 бит)
  • Понимать, какие флаги важны для ветвлений (ZF, CF, SF, OF)
  • Читать типовые формы адресации памяти, включая base + index*scale + displacement
  • Узнавать RIP-relative адресацию и понимать, почему она повсеместна в 64-битных бинарниках
  • Представлять модель памяти процесса: где обычно код, данные, куча и стек
  • Регистры x86-64

    Регистр в контексте ассемблера x86-64 это маленькое очень быстрое хранилище внутри процессора. Большая часть инструкций работает именно с регистрами, а память читается и пишется отдельно.

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

    В x86-64 есть 16 основных 64-битных регистров общего назначения.

    | Регистр | Типичная роль при чтении кода | Часто встречается в паттернах | |---|---|---| | rax | аккумулятор, значение результата | возвращаемое значение, номер syscall в Linux | | rbx | сохранённый регистр (часто база) | базовый адрес структуры, реже временный | | rcx | счётчик, временный | сдвиги, циклы, временные значения | | rdx | временный, 3-й аргумент | третий параметр syscall, умножение/деление | | rsi | источник (source) | указатель на буфер, строку | | rdi | назначение (destination) | указатель на объект, 1-й аргумент | | rbp | база кадра стека | доступ к локальным переменным как [rbp-...] | | rsp | указатель стека | push/pop, call/ret, локальные области | | r8-r15 | дополнительные регистры | дополнительные аргументы, временные |

    Важно: “типичная роль” это не жёсткое правило, а частое соглашение, которое помогает быстрее ориентироваться.

    Связь с предыдущей статьёй про syscall

    В Linux x86-64 прямой системный вызов использует регистры так:

  • rax содержит номер системного вызова
  • аргументы идут по порядку в rdi, rsi, rdx, r10, r8, r9
  • Это помогает при реверсе: вы видите подготовку регистров и syscall, значит почти наверняка это прямое обращение к ядру.

    Подрегистры и размеры

    Один и тот же физический регистр можно адресовать разной шириной: 64, 32, 16 и 8 бит. Пример для семейства rax:

    | Имя | Размер | Какая часть регистра | |---|---:|---| | rax | 64 бита | весь регистр | | eax | 32 бита | младшие 32 бита | | ax | 16 бит | младшие 16 бит | | al | 8 бит | младшие 8 бит |

    Есть историческая особенность у первых четырёх регистров (rax, rbx, rcx, rdx): у них существуют отдельные имена для “старшего байта” 16-битной части (ah, bh, ch, dh). В реверсе это встречается, но в современном компиляторном коде обычно реже.

    Ключевое правило, важное для чтения x86-64:

  • запись в 32-битный подрегистр (например, eax) обнуляет старшие 32 бита соответствующего 64-битного регистра (rax).
  • То есть после mov eax, 1 гарантировано rax = 1.

    А вот запись в ax или al меняет только часть регистра, оставляя остальные биты как были. Это частый источник ошибок при ручном анализе.

    Флаги и регистр rflags

    Кроме регистров общего назначения процессор хранит флаги в регистре rflags. Флаг это один бит состояния, который обычно отражает результат последней арифметической или логической операции.

    Для реверса важнее всего несколько флагов:

    | Флаг | Что означает | На что влияет при чтении | |---|---|---| | ZF (zero flag) | результат равен нулю | je/jne и проверки на “равно 0” | | CF (carry flag) | перенос/заём в беззнаковой арифметике | jb/jae и беззнаковые сравнения | | SF (sign flag) | знак результата (старший бит) | signed сравнения вместе с OF | | OF (overflow flag) | переполнение для знаковой арифметики | jl/jg и signed сравнения |

    Флаги почти никогда не “смотрят напрямую” в переменные. Они зависят от последней инструкции, которая эти флаги установила.

    cmp и test как основа ветвлений

    Две инструкции, которые вы будете видеть постоянно:

  • cmp a, b делает вычитание a - b, не сохраняя результат, но обновляя флаги
  • test a, b делает побитовое a & b, тоже не сохраняя результат, но обновляя флаги
  • Типовые паттерны в дизассемблере:

    Смысл: сравнили rdi с нулём, и если ZF=1, то есть rdi был равен 0, прыгнули.

    Смысл: проверили, равен ли rax нулю (побитовое rax & rax), и если не ноль, то прыгнули. Компиляторы любят test reg, reg как компактную проверку на 0.

    Условные переходы

    Условные переходы читаются как “прыжок, если условие по флагам истинно”. Несколько самых полезных для старта:

  • je и jne опираются в первую очередь на ZF
  • jb и jae обычно интерпретируются как беззнаковые сравнения и опираются на CF
  • jl и jg обычно интерпретируются как знаковые сравнения и используют комбинацию SF и OFjg также учитывает ZF)
  • Практический совет для реверса: если вы не уверены, signed или unsigned сравнение, посмотрите на используемый условный переход (jl обычно “signed меньше”, jb обычно “unsigned меньше”).

    Адресация памяти в x86-64

    В ассемблере важно различать:

  • значение в регистре
  • память по адресу, который лежит в регистре
  • В Intel-синтаксисе квадратные скобки [...] означают обращение к памяти.

  • mov rax, rbx копирует значение регистра в регистр
  • mov rax, [rbx] читает 8 байт из памяти по адресу, который лежит в rbx
  • Общая форма адреса

    Часто встречающаяся форма эффективного адреса выглядит так:

    Где:

  • base это базовый регистр, например rbx
  • index это индексный регистр, например rcx
  • scale это множитель 1, 2, 4 или 8 (удобно для массивов int16/int32/int64)
  • displacement это константа-смещение, например 8 или -0x20
  • Пример:

    Чтение для реверса: “в rbx база структуры или массива, в rcx индекс, элемент размером 4 байта, плюс смещение 8 байт”.

    Размер операнда памяти

    Когда вы видите обращение к памяти, важно понимать размер читаемых данных.

  • mov al, [rdi] читает 1 байт
  • mov ax, [rdi] читает 2 байта
  • mov eax, [rdi] читает 4 байта
  • mov rax, [rdi] читает 8 байт
  • В дизассемблере размер обычно уже понятен из целевого регистра. Но в ассемблерном исходнике иногда пишут явные подсказки:

    RIP-relative адресация

    В 64-битных ELF-бинарниках (особенно PIE) вы очень часто увидите доступ к данным как [rip + displacement].

    Примерный вид в дизассемблере:

    Смысл: адрес считается относительно текущей позиции выполнения (от rip, instruction pointer). Это удобно для позиционно-независимого кода, который может быть загружен по разным адресам из-за ASLR.

    Связь с предыдущей статьёй: вы видели строки в .rodata. В реальном компиляторном коде ссылки на .rodata часто выглядят именно как RIP-relative вычисление адреса.

    lea против mov

    Критически важное различие для чтения:

  • mov rax, [rbx+8] читает значение из памяти
  • lea rax, [rbx+8] вычисляет адрес rbx+8 и кладёт его в rax, не читая память
  • Поэтому lea часто встречается не только для указателей, но и как “удобная арифметика” без изменения флагов и без обращения к памяти.

    Порядок байтов: little-endian

    x86-64 использует little-endian порядок байтов: младший байт числа лежит по меньшему адресу.

    Практическое значение для реверса:

  • если вы смотрите память в gdb командой вроде x/8bx или в hex-view в дизассемблере, байты многобайтного числа будут “в обратном порядке” по сравнению с привычной записью числа
  • строка в ASCII лежит “как читается”, потому что строка это последовательность байтов
  • Модель памяти процесса: что где обычно лежит

    Когда ОС запускает ELF/PE, она создаёт для процесса виртуальное адресное пространство. Обычно вы будете мыслить не физической памятью, а тем, что видит процесс.

    Типичная картина (упрощённо):

  • код программы и константы
  • глобальные переменные
  • куча (heap) для динамических выделений
  • отображения файлов и библиотек
  • стек (stack) для вызовов функций и локальных переменных
  • !Упрощённая карта памяти процесса: где искать код, строки, кучу и стек

    Важно: конкретные адреса зависят от ОС, настроек, PIE и ASLR. Но логика областей часто помогает понять, что вы видите в отладчике.

    Стек: rsp, call, ret, локальные переменные

    Стек в x86-64 обычно растёт в сторону меньших адресов. Это означает:

  • push уменьшает rsp и записывает значение по новому адресу
  • pop читает значение по адресу rsp и увеличивает rsp
  • Вызов функции:

  • call target кладёт на стек адрес возврата и прыгает в target
  • ret забирает адрес возврата со стека и прыгает обратно
  • В дизассемблере это помогает распознавать границы вызовов и понимать, почему стек “двигается”.

    Часто локальные переменные лежат по адресам вроде [rbp-0x10] или [rsp+0x20] в зависимости от того, использует ли компилятор rbp как базу кадра.

    Куча и динамическая память

    Куча это область, откуда обычно выделяется память под объекты во время выполнения (например, через malloc в мире C). На уровне ассемблера это обычно выглядит как вызовы функций аллокатора и работа с указателями.

    На ранних этапах реверса вам достаточно помнить:

  • стек обычно связан с вызовами функций и временными данными
  • куча часто хранит долгоживущие структуры и буферы
  • глобальные данные и строки обычно находятся в секциях данных (в ELF часто .rodata, .data, .bss)
  • Расширение и “знаковость”: movzx, movsx, movsxd

    В машинном коде размер имеет значение: байт, 32-битное число и 64-битное число это разные операции чтения и разные правила интерпретации.

    Когда вы видите чтение меньшего размера и перенос в больший регистр, часто встречаются инструкции расширения:

  • movzx делает zero-extend: дополняет старшие биты нулями
  • movsx делает sign-extend: копирует знак (старший бит меньшего значения) в старшие биты
  • movsxd часто используется для расширения 32-битного значения до 64 бит со знаком
  • Это важно при реверсе условий и индексов массивов: один и тот же байт 0xFF может означать 255 (unsigned) или -1 (signed) в зависимости от того, чем его расширили.

    Практика чтения: быстрые ориентиры в дизассемблере

  • xor reg, reg почти всегда означает “обнулить регистр” (и это ещё и быстро)
  • test reg, reg часто означает “проверить на ноль” перед je/jne
  • cmp плюс условный переход почти всегда означает if или проверку границ
  • lea с выражением в скобках часто означает вычисление адреса, индекса, смещения, иногда “умную арифметику”
  • [rip+...] очень часто ведёт к глобальным данным или строкам в .rodata
  • Запись в eax “обнуляет верх” rax, а запись в al/ax нет, и это влияет на дальнейшую логику
  • !Расшифровка типичной инструкции доступа к локальной переменной

    Связь с предыдущими темами и что дальше

    Теперь у вас есть словарь, чтобы “читать глазами” то, что показывают objdump -d -Mintel и gdb disassemble:

  • регистры объясняют, где аргументы и результаты;
  • флаги объясняют, почему происходят переходы;
  • адресация объясняет, что является чтением/записью памяти, а что вычислением адреса;
  • модель памяти подсказывает, где искать строки, глобальные данные и локальные переменные.
  • Следующая логичная ступень курса: базовые инструкции и типовые конструкции компилятора в ассемблере, такие как ветвления, циклы, вызовы функций и соглашения о вызовах, чтобы уверенно восстанавливать псевдокод из дизассемблера.

    4. Основные инструкции: mov/lea, арифметика, логика, сравнения и переходы

    Основные инструкции: mov/lea, арифметика, логика, сравнения и переходы

    В предыдущих статьях вы настроили инструменты и собрали минимальный Hello World через syscall, а затем разобрали фундамент: регистры, флаги, адресацию и модель памяти. Теперь соберём словарь чтения дизассемблера: инструкции, которые вы будете видеть буквально в каждом бинарнике.

    Фокус статьи не на том, чтобы “писать красиво на ассемблере”, а на том, чтобы быстро и надёжно понимать, что делает код в objdump, gdb, Ghidra/IDA.

    Что вы будете уметь после статьи

  • Отличать чтение памяти от вычисления адреса (mov против lea)
  • Понимать, какие инструкции меняют флаги и как это связано с jcc
  • Узнавать типовые паттерны компиляторов: проверка на NULL, проверка диапазона, ветвления, циклы
  • Различать знаковые и беззнаковые сравнения по условным переходам
  • Базовая идея чтения ассемблера

    Чтение дизассемблера почти всегда сводится к цепочке:

  • Какие значения находятся в регистрах прямо сейчас
  • Какие флаги выставлены последней “флаговой” инструкцией (cmp, test, sub, add)
  • Есть ли обращение к памяти ([...]) и какого размера
  • Куда может пойти управление дальше (jcc, jmp, call, ret)
  • Эта статья даёт вам набор наиболее частых “кирпичиков” для такой расшифровки.

    Полезные справочники по инструкциям:

  • Intel® 64 and IA-32 Architectures Software Developer’s Manual
  • x86 instruction reference (Felix Cloutier)
  • mov и lea

    mov и lea часто выглядят похоже, но смысл у них принципиально разный. Для реверса это одна из главных развилок: мы читаем значение или вычисляем адрес.

    mov

    mov копирует данные:

  • регистр → регистр
  • константа → регистр
  • память → регистр
  • регистр → память
  • Примеры:

    Ключевая подсказка из прошлой статьи: квадратные скобки [...] в Intel-синтаксисе означают доступ к памяти.

    Ссылка: MOV

    lea

    lea означает load effective address: вычислить выражение адресации и положить результат в регистр. При этом память не читается.

    lea очень часто встречается в двух ролях:

  • вычисление адреса объекта или поля структуры
  • “арифметика без флагов” (компилятор может умножать/складывать через lea, потому что это не трогает флаги)
  • Ссылка: LEA

    !Сравнение mov и lea: чтение значения из памяти против вычисления адреса

    Частый паттерн: подготовка аргумента-указателя

    Вызовы функций и syscalls часто требуют указатель на данные.

  • lea rdi, [rip+...] или lea rsi, [rbp-...] часто означает “передаём адрес буфера/строки”
  • mov rdi, [rbp-...] часто означает “передаём указатель, который лежал в локальной переменной”
  • Это помогает восстановить псевдокод: передали адрес локального массива vs передали указатель, полученный ранее.

    Арифметика: add/sub, inc/dec, imul

    Арифметические инструкции обычно обновляют флаги. Это важно, потому что сразу после них часто идут jcc.

    add и sub

    Практика реверса:

  • sub rsp, N в прологе функции часто означает “зарезервировать N байт под локальные переменные”
  • add rsp, N в эпилоге возвращает стек обратно
  • Ссылки: ADD, SUB

    inc и dec

    inc и dec увеличивают или уменьшают на 1.

    В современных компиляторах inc/dec могут встречаться реже, чем add/sub, но в “ручном” или старом коде они заметны.

    Ссылки: INC, DEC

    imul

    imul — знаковое умножение. В дизассемблере чаще всего встречается 2-операндный или 3-операндный вариант.

    Подсказка для реверса: умножение на константу часто выдаёт размер элемента массива или размер структуры.

    Ссылка: IMUL

    Про деление

    div/idiv используются реже (деление дорого), компилятор часто заменяет деление на константу более хитрыми трюками. На старте важно знать только, что деление в x86-64 завязано на специальные регистры (обычно rdx:rax) и поэтому выглядит “нестандартно”.

    Ссылка: IDIV

    Логика и битовые операции: and/or/xor/not

    Логические инструкции тоже обычно обновляют флаги и часто используются для масок, проверок битов, выравнивания.

    xor как обнуление

    Классика дизассемблера:

    Почему это важно для реверса:

  • это самый частый способ обнулить регистр
  • часто означает “передаём нулевой аргумент” или “status = 0” (как в exit(0) из Hello World)
  • Ссылка: XOR

    and как маска

    Подсказка: and reg, (2^n-1) часто означает “взять остаток по степени двойки” или “ограничить диапазон битов”.

    Ссылка: AND

    or

    Ссылка: OR

    not

    Ссылка: NOT

    Сдвиги и вращения: shl/shr/sar

    Сдвиги часто встречаются при работе с битовыми полями, упаковкой данных, умножением/делением на степени двойки.

  • shl (shift left) — сдвиг влево, по смыслу часто как умножение на
  • shr (shift right logical) — логический сдвиг вправо, чаще для беззнаковых
  • sar (shift right arithmetic) — арифметический сдвиг вправо, сохраняет знак, чаще для знаковых
  • Примеры:

    Ссылки: SHL/SAL, SHR

    Сравнения: cmp и test

    Ветвления почти всегда строятся как:

  • инструкция, которая выставила флаги
  • условный переход jcc, который прочитал флаги
  • Чаще всего “флаговыми” инструкциями выступают cmp и test.

    cmp

    cmp a, b делает внутреннее вычитание a - b, но результат никуда не сохраняет, только обновляет флаги.

    Ссылка: CMP

    test

    test a, b делает побитовое a & b, тоже без сохранения результата.

    Самые частые паттерны:

    Смысл: проверка на ноль.

    Смысл: проверка младшего бита, часто “нечётность” или “флаг установлен”.

    Ссылка: TEST

    !Как cmp/test устанавливают флаги, а jcc использует их для ветвления

    Переходы: jmp и jcc

    Безусловный переход jmp

    jmp просто меняет поток выполнения.

    Ссылка: JMP

    В реверсе jmp часто встречается:

  • в реализации switch (прыжки по таблице)
  • при оптимизациях компилятора (перестройка блоков)
  • в “хвостовых” вызовах и эпилогах
  • Условные переходы jcc

    Условные переходы читаются как “прыгнуть, если условие по флагам истинно”. Базовый набор:

  • je / jne — равно / не равно (смотрят на ZF)
  • jl / jge — меньше / больше-или-равно (знаковые сравнения)
  • jb / jae — меньше / больше-или-равно (беззнаковые сравнения)
  • Ссылки: Jcc

    Самое важное: signed vs unsigned

    Одна из главных ошибок новичков в реверсе: увидеть cmp и автоматически думать про “меньше/больше” без уточнения знака.

    Практическое правило:

  • переходы jl/jg/jge/jle обычно означают знаковое сравнение
  • переходы jb/ja/jae/jbe обычно означают беззнаковое сравнение
  • То есть одинаковое cmp eax, 1 может означать разную логику в зависимости от того, какой jcc стоит дальше.

    Типовые “фразы” компилятора, которые стоит узнавать

    Ниже несколько коротких фрагментов и как их обычно читать.

    Проверка на NULL

    Чтение: “если указатель в rdi равен 0, перейти в ветку ошибки”.

    Проверка результата функции на ошибку/ноль

    Чтение: “если eax != 0, то успех (или наоборот), иначе другая ветка”. Семантика зависит от соглашения функции.

    Проверка границы массива (один из вариантов)

    Чтение: “если ecx >= edx в беззнаковом смысле, индекс вне диапазона”.

    Индексация массива через scale

    Чтение: “взять int32 по адресу rdi + rsi*4”. Обычно rdi — база массива, rsi — индекс, 4 — размер элемента.

    Адрес строки / глобального объекта (PIE, RIP-relative)

    Чтение: “в rdi кладём адрес строки/данных; вызываем функцию печати”.

    Мини-практика на своих бинарниках (для привязки к инструментам)

    Даже если ваша цель — чтение чужих программ, полезно 1–2 раза увидеть, как компилятор генерирует знакомые паттерны.

    Скомпилировать маленький пример на C и посмотреть ассемблер

    Сборка и дизассемблирование:

    Ищите в выводе:

  • cmp или test
  • je/jne
  • lea как способ посчитать x+5 без изменения флагов
  • objdump справка: objdump(1)

    Посмотреть флаги и переходы в gdb

    В gdb удобно шагать по инструкциям и смотреть, как меняются регистры.

    gdb справка: gdb

    Итоги

  • mov копирует данные, а lea вычисляет адрес или выражение адресации, не читая память
  • Арифметика (add/sub/imul) и логика (and/or/xor/test) часто выставляют флаги, и это напрямую связано с последующим jcc
  • cmp и test — главные “постановщики флагов” перед ветвлениями
  • По jcc можно понять, сравнение было знаковым или беззнаковым
  • Что дальше по курсу

    Следующий шаг — собрать это в более крупные конструкции чтения: вызовы функций и соглашения о вызовах, пролог/эпилог, работа со стеком, а затем типовые формы if/else, циклов и switch в дизассемблере. Это даст вам основу, чтобы уверенно восстанавливать псевдокод из чужого бинарника.

    5. Стек и соглашения о вызовах: prologue/epilogue, аргументы, возврат

    Стек и соглашения о вызовах: prologue/epilogue, аргументы, возврат

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

    В этой статье мы разберём:

  • как работают call и ret на уровне стека;
  • что такое соглашение о вызовах и почему оно определяет “смысл” регистров перед call;
  • как выглядят типовые prologue и epilogue в x86-64;
  • как читать код, где кадр стека построен через rbp, и код, где rbp не используется;
  • базовые различия между Linux (System V AMD64 ABI) и Windows (Microsoft x64).
  • Что вы будете уметь после статьи

  • Узнавать границы функции по прологу и эпилогу в дизассемблере
  • Понимать, где искать аргументы функции и возвращаемое значение
  • Различать caller-saved и callee-saved регистры и использовать это при трассировке значений
  • Понимать, что означает sub rsp, N и почему важна выравненность стека
  • Быстрее восстанавливать псевдокод вида f(a,b,c) из набора mov/lea перед call
  • Стек как основа вызовов

    Стек — это область памяти, которую процессор и компилятор используют для:

  • адресов возврата из функций
  • сохранения регистров
  • локальных переменных
  • передачи части аргументов (когда регистров не хватает)
  • В x86-64 стек обычно растёт в сторону меньших адресов. Главный регистр стека — rsp.

    Что делают call и ret

    Инструкция call target выполняет два действия:

  • кладёт на стек адрес следующей инструкции (адрес возврата)
  • передаёт управление по адресу target
  • Инструкция ret делает обратное:

  • забирает со стека адрес возврата
  • прыгает на него
  • Минимальная модель (упрощённо) выглядит так:

    Практический вывод для реверса:

  • если вы видите ret, это почти всегда конец функции или конец одной из её веток
  • если вы видите call, рядом почти всегда можно найти подготовку аргументов
  • !Иллюстрация, как call кладёт адрес возврата на стек, а ret снимает его

    Соглашение о вызовах: что это и зачем реверсеру

    Соглашение о вызовах — это набор правил, который определяет:

  • где лежат аргументы при входе в функцию
  • где лежит возвращаемое значение
  • какие регистры обязан сохранять вызываемый код, а какие — вызывающий
  • как должен быть выровнен стек
  • Для реверса это критично: один и тот же дизассемблированный фрагмент mov rdi, ...; call ... в Linux и mov rcx, ...; call ... в Windows означает передачу первого аргумента, но через разные регистры.

    Далее основной разбор будет для Linux x86-64, то есть System V AMD64 ABI. Это стандарт для большинства ELF-бинарников на Linux.

    Справочник:

  • System V AMD64 ABI
  • Intel 64 and IA-32 Architectures Software Developer’s Manual
  • System V AMD64 ABI (Linux): аргументы, возврат, сохранение регистров

    Передача аргументов

    В System V AMD64 ABI целочисленные аргументы и указатели передаются так:

  • 1-й аргумент: rdi
  • 2-й аргумент: rsi
  • 3-й аргумент: rdx
  • 4-й аргумент: rcx
  • 5-й аргумент: r8
  • 6-й аргумент: r9
  • Если аргументов больше шести, оставшиеся передаются через стек.

    Аргументы с плавающей точкой (например, double) обычно идут в xmm0xmm7.

    Возвращаемое значение

  • целое/указатель: rax
  • иногда пара значений: rax и rdx (в зависимости от типа)
  • числа с плавающей точкой: xmm0
  • Практически в реверсе чаще всего вы будете пользоваться правилом: результат функции лежит в rax сразу после call.

    Кто сохраняет регистры: caller-saved и callee-saved

    Условно регистры делятся на две группы:

  • caller-saved — вызывающий код не должен рассчитывать, что они сохранятся после call
  • callee-saved — вызываемая функция обязана вернуть их исходные значения (если использовала)
  • Для System V полезная “памятка”:

  • callee-saved: rbx, rbp, r12, r13, r14, r15
  • caller-saved: rax, rcx, rdx, rsi, rdi, r8r11
  • Почему это помогает в реверсе:

  • если вы видите, что функция сохраняет rbx в прологе (push rbx), значит она планирует использовать rbx как долговременное хранилище
  • если значение важно “пережить” call, компилятор либо переложит его в callee-saved регистр, либо сохранит в память (обычно стек)
  • Prologue и epilogue: как компилятор оформляет функцию

    Prologue — начальная часть функции, где готовится стек и сохраняются нужные регистры.

    Epilogue — завершающая часть, где стек и регистры возвращаются в исходное состояние перед ret.

    Классический кадр стека через rbp

    Один из самых узнаваемых шаблонов:

    Что это означает:

  • push rbp сохраняет старое значение rbp
  • mov rbp, rsp фиксирует “базу кадра”
  • sub rsp, 0x20 выделяет 0x20 байт под локальные переменные
  • leave эквивалентен двум инструкциям: mov rsp, rbp и pop rbp
  • ret возвращается к вызывающему
  • Практический эффект для чтения:

  • локальные переменные часто адресуются как [rbp-0x10], [rbp-0x4]
  • аргументы, пришедшие через стек (если они есть), могут быть доступны как [rbp+0x10], [rbp+0x18] (потому что над rbp лежит адрес возврата и другие элементы)
  • !Схема расположения данных относительно rbp в типичном stack frame

    Frame pointer omission: когда rbp не используется

    Современные компиляторы на оптимизациях часто не используют rbp как базу кадра. Тогда вы можете увидеть пролог вида:

    И обращения к локальным данным как [rsp+0x10], [rsp+0x18].

    Что важно при реверсе:

  • rsp в течение функции может двигаться (например, из-за push, вызовов, временных аллокаций), поэтому смещения относительно rsp могут быть менее “стабильными”
  • дизассемблеры часто восстанавливают “виртуальный” кадр стека и подписывают смещения, но понимать механику всё равно необходимо
  • Сохранение регистров в прологе

    Если функция использует callee-saved регистры, она обычно сохраняет их в начале и восстанавливает в конце:

    Для реверса это подсказка: значения в rbx/r12 внутри функции часто являются “долго живущими” указателями, базами структур, контекстом.

    Выравнивание стека: почему рядом с call часто странные sub/add

    Многие ABI требуют выравнивать стек. В System V правило на практике формулируют так:

  • перед выполнением call стек должен быть выровнен на 16 байт так, чтобы после помещения адреса возврата у вызываемой функции сохранялось корректное выравнивание
  • В результате вы часто увидите “лишние” 8 или 0x10 байт в sub rsp, ....

    Зачем это нужно:

  • некоторые инструкции и соглашения (особенно с SIMD и xmm) ожидают определённое выравнивание
  • компилятор обязан соблюдать ABI, иначе вызовы библиотечных функций могут работать некорректно
  • Red zone (только System V)

    В System V есть понятие red zone: небольшая область под текущим rsp, которую функция может использовать без sub rsp, N, если она не делает call (и не использует то, что разрушает эту область).

    Почему это важно в реверсе:

  • вы можете увидеть запись по адресу вроде [rsp-0x8] без видимого выделения стека
  • это не обязательно обфускация: это может быть нормальная оптимизация
  • Как читать подготовку аргументов перед call

    Пример: три аргумента

    Рассмотрим типовой паттерн в Linux:

    Как это читать:

  • rdi получает адрес, значит первый аргумент — указатель
  • esi=5, значит второй аргумент — целое значение 5
  • третий аргумент берётся из eax и копируется в edx
  • После call:

  • результат функции ищите в rax
  • Пример: аргумент — адрес локального буфера

    Как это читать:

  • выделили место на стеке
  • передали в функцию указатель внутрь этого места
  • значит функция, вероятно, заполняет буфер по указателю
  • Стек и локальные переменные: типовые признаки

    Признаки того, что локальная переменная лежит на стеке:

  • доступ вида [rbp-...] или [rsp+...]
  • перед этим было sub rsp, N
  • Признаки массива на стеке:

  • большие выделения вроде sub rsp, 0x100
  • использование индексной адресации к области стека
  • Пример чтения байта из локального массива:

    Как это читать:

  • rcx используется как индекс
  • читается 1 байт
  • movzx расширяет до 32 бит нулями, значит байт трактуется как unsigned
  • Практика в отладчике: что смотреть в gdb

    Полезные команды gdb для темы стека и вызовов:

    ``gdb set disassembly-flavor intel break *addr run si ni info registers x/16gx rsp помогает увидеть адрес возврата и сохранённые значения

  • bt показывает стек вызовов, если отладчик может его восстановить
  • Справочник:

  • GDB Documentation
  • Windows x64: минимум, чтобы не потеряться

    Если вы реверсите Windows PE-бинарники, базовые правила другие.

    Ключевые отличия Microsoft x64 calling convention:

  • первые 4 целочисленных аргумента: rcx, rdx, r8, r9
  • возвращаемое значение: rax
  • вызывающий код обычно выделяет shadow space 32 байта на стеке перед вызовом (под “дом” для аргументов)
  • Справочник:

  • x64 calling convention (Microsoft Learn)
  • Практический вывод для реверса:

  • если вы видите перед call запись в rcx, очень часто это первый аргумент именно в Windows
  • если вы видите sub rsp, 0x20 прямо перед вызовом и add rsp, 0x20 сразу после, это часто не “локальные переменные”, а shadow space
  • Итоги

  • call кладёт на стек адрес возврата, ret забирает его обратно
  • Соглашение о вызовах определяет, где лежат аргументы и результат, и какие регистры должны сохраняться
  • В Linux (System V) первые 6 целочисленных аргументов обычно в rdi/rsi/rdx/rcx/r8/r9, результат в rax
  • Prologue/epilogue помогают выделять границы функции и понимать, где локальные переменные
  • Выравнивание стека и сохранение callee-saved регистров часто объясняют “лишние” sub/add и push/pop вокруг тела функции
  • Что дальше по курсу

    Следующий шаг — научиться узнавать более крупные конструкции компилятора поверх этих правил: if/else, циклы, switch, а также разбор вызовов через PLT/GOT в ELF и типовые паттерны работы со строками и буферами. Это напрямую опирается на понимание стека и соглашений о вызовах из этой статьи.

    6. Чтение кода компилятора: циклы, условия, switch, функции и inlining

    Чтение кода компилятора: циклы, условия, switch, функции и inlining

    В предыдущих статьях вы разобрали регистры, флаги, адресацию, базовые инструкции и соглашения о вызовах. Теперь соберём это в главный практический навык реверсера: читать то, что сгенерировал компилятор, и уверенно восстанавливать конструкции высокого уровня.

    Эта тема важнее, чем запоминание отдельных инструкций, потому что в реальных бинарниках вы почти всегда видите не “ручной” ассемблер, а повторяющиеся шаблоны компиляторов: базовые блоки, ветвления, циклы, switch, вызовы функций и последствия оптимизаций.

    Что вы будете уметь после статьи

  • Узнавать if/else, тернарный оператор и проверки на NULL по cmp/test и jcc
  • Отличать формы циклов while, do-while, for по расположению проверки и переходов
  • Распознавать switch как цепочку сравнений или как таблицу переходов (jump table)
  • Читать подготовку аргументов перед call по соглашению о вызовах и понимать, где искать результат
  • Понимать, как оптимизации меняют “классическую” картину: cmov, setcc, tail call, inlining
  • Базовая техника чтения: блоки, флаги, переходы

    Компилятор генерирует код как набор базовых блоков: куски инструкций без внутренних переходов, которые заканчиваются jcc, jmp, call или ret. Почти любое ветвление или цикл сводится к тому, как блоки соединены переходами.

    Практическая последовательность чтения:

  • Найдите границы функции и её “скелет”
  • - ret как выход - call как границы смысловых операций - пролог/эпилог может быть классическим, а может отсутствовать на оптимизациях
  • Найдите инструкции, выставляющие флаги
  • - чаще всего это cmp, test, иногда sub, add, and
  • Привяжите к ним условные переходы
  • - je/jne часто означает “равно/не равно” или “нулевой/ненулевой” - jl/jg обычно про знаковые сравнения - jb/ja обычно про беззнаковые сравнения
  • Отметьте точки слияния веток
  • - блок, куда приходят оба пути, обычно соответствует “после if/else”

    !Схема графа потока управления помогает увидеть, как if/else и циклы выглядят как переходы между базовыми блоками

    Справочники для сверки поведения переходов и флагов:

  • Intel® 64 and IA-32 Architectures Software Developer’s Manual
  • x86 instruction reference (Felix Cloutier)
  • Условия: if, if/else, тернарный оператор

    Простой if без else

    Частый паттерн: условие проверяется, и если оно ложно, управление перепрыгивает через “then”.

    Пример псевдокода:

    Типовой дизассемблерный скелет:

    Как это читать:

  • test edi, edi выставляет ZF=1, если edi равен 0
  • jne означает “прыжок, если не ноль”, то есть пропускаем work()
  • if/else с точкой слияния

    Псевдокод:

    Одна из типовых форм:

    Ключевой признак if/else: есть jcc в одну ветку и jmp через другую к общему блоку.

    Логические && и || и короткое замыкание

    Короткое замыкание почти всегда видно как две проверки подряд с ранним выходом.

    Псевдокод:

    Типовой скелет:

    Главное, что важно для реверса: первая проверка защищает вторую от обращения к памяти.

    Branchless-формы: cmov и setcc

    На оптимизациях компилятор может убрать ветвление и сделать вычисление “без прыжков”. Это часто ухудшает “читабельность глазами”, но улучшается производительность.

    #### cmovcc

    Смысл: условно перезаписать регистр, не меняя поток управления.

    Псевдокод:

    Возможный скелет:

    Если вы видите cmovcc, ищите ближайший cmp/test перед ним.

    #### setcc

    Смысл: получить 0 или 1 как результат сравнения.

    Это часто соответствует коду вида return (a < b);.

    Циклы: while, do-while, for

    Главный способ отличать виды циклов в дизассемблере: где находится проверка относительно тела.

    while: проверка сверху

    Псевдокод:

    Типовой скелет:

    Признаки while:

  • первый условный переход уводит на выход (.Ldone)
  • в конце тела почти всегда есть jmp обратно к проверке
  • do-while: проверка снизу

    Псевдокод:

    Скелет:

    Признаки do-while:

  • тело выполняется до первой проверки
  • условный переход ведёт назад в тело
  • for: инициализация, проверка, инкремент

    for почти всегда разложен на те же блоки, что и while. Отличие обычно в том, что компилятор выносит инициализацию до цикла, а инкремент стабильно находится в конце итерации.

    Псевдокод:

    Скелет:

    Циклы по указателю вместо индекса

    Компилятор часто заменяет индекс на указатели, особенно при оптимизациях.

    Псевдокод:

    Скелет:

    Подсказка: если в цикле меняется регистр-указатель (inc rdi, add rdi, N), это часто итерация по массиву/буферу.

    switch: цепочки сравнений и таблицы переходов

    switch может компилироваться по-разному. Для реверса важно различать два основных случая: цепочка сравнений и таблица переходов.

    switch как цепочка сравнений

    Обычно так происходит, если кейсов мало или значения “разрежены”.

    Читать это стоит как последовательность if (x==10) ... else if (x==20) ... else ....

    switch как jump table

    Если кейсов много и значения относительно плотные, компилятор часто делает:

  • нормализацию значения (например, x - base)
  • проверку диапазона
  • непрямой прыжок по таблице адресов
  • Типовой скелет:

    Как это читать:

  • sub edi, 5 означает, что исходные значения начинались с 5
  • cmp плюс ja обычно означает беззнаковую проверку “индекс больше максимума”
  • jmp [table + index*8] это переход к нужному кейсу
  • !Иллюстрация связывает арифметику индекса и непрямой jmp с идеей таблицы переходов

    Важно для ELF PIE: адрес таблицы часто берётся RIP-relative (lea rdx, [rip+...]), и это нормально.

    Вызовы функций: как “восстановить” вызов из mov/lea

    Здесь напрямую используется статья про соглашения о вызовах.

    Linux System V: быстрый чек-лист перед call

    Перед call проверьте:

  • 1-й аргумент в rdi
  • 2-й аргумент в rsi
  • 3-й аргумент в rdx
  • 4-й аргумент в rcx
  • 5-й аргумент в r8
  • 6-й аргумент в r9
  • результат после call в rax
  • Источник: System V AMD64 ABI

    Типовой фрагмент:

    Windows x64: не перепутать регистры и shadow space

    Если вы смотрите Windows PE-бинарник, типично:

  • аргументы: rcx, rdx, r8, r9
  • результат: rax
  • перед call часто есть sub rsp, 0x20 как shadow space
  • Источник: Microsoft x64 calling convention

    Tail call: когда call превращается в jmp

    Хвостовой вызов это оптимизация: если функция заканчивается вызовом другой функции и сразу возвращает её результат, компилятор может заменить call на jmp.

    Псевдокод:

    Возможный вид:

    Как это читать:

  • если jmp уходит на другую функцию там, где вы ожидаете call и ret, это может быть tail call
  • это влияет на стек вызовов в отладчике: “промежуточной” функции может не быть в backtrace
  • Inlining: когда функции “исчезают”

    Inlining означает, что компилятор не делает вызов, а вставляет тело маленькой функции прямо в вызывающую.

    Что это даёт для реверса: вы пытаетесь “найти вызов”, а его нет, потому что логика уже встроена.

    Признаки inlining в дизассемблере

  • отсутствует call туда, где по смыслу должна быть маленькая функция
  • повторяется одинаковая последовательность инструкций в нескольких местах
  • исчезает “естественная” граница функции, всё происходит внутри одной большой
  • константы и смещения структур появляются прямо в коде вызывающей функции
  • Что делать реверсеру

  • Выделяйте логические куски сами
  • - группируйте инструкции в “подфункции” по смыслу: подготовка данных, проверка, обработка, выход
  • Ищите повторяющиеся паттерны
  • - если один и тот же фрагмент встречается много раз, это кандидат на inline-функцию
  • Сверяйте с кросс-ссылками
  • - если символов нет, то “маленькой функции” действительно может не существовать как отдельного объекта

    Как оптимизации ломают “учебные” шаблоны

    На -O0 ассемблер часто ближе к исходнику:

  • больше rbp-кадров
  • больше загрузок/сохранений локальных переменных на стек
  • ветвления чаще оформлены “прямолинейно”
  • На -O2/-O3 вы чаще увидите:

  • меньше памяти, больше регистров
  • перестановку блоков и “перевёрнутые” условия
  • cmov, setcc, комбинирование вычислений через lea
  • inlining и tail call
  • Практический совет: если вы тренируетесь на своих примерах, собирайте один и тот же C-код с -O0 и -O2 и сравнивайте дизассемблирование. Это быстро формирует “чувство компилятора”.

    Справка по опциям оптимизации GCC: GCC Optimize Options

    Мини-практика чтения: как превратить дизассемблер в псевдокод

    Ниже фрагмент, который объединяет несколько тем. Попробуйте мысленно восстановить псевдокод.

    Ориентиры:

  • rdi похож на указатель на буфер
  • читается byte ptr [rdi], значит обрабатываются байты
  • сумма накапливается в eax
  • выход либо по rdi == 0, либо по встрече нулевого байта (похоже на терминатор строки)
  • Цель такого упражнения: не угадать “точную” исходную функцию, а научиться строить устойчивую модель: какие данные где лежат и какой контроль потока.

    Итоги

  • Условия почти всегда строятся вокруг cmp/test и jcc, а if/else обычно даёт точку слияния
  • Циклы отличаются расположением проверки: while сверху, do-while снизу; for чаще всего это while с вынесенной инициализацией и инкрементом
  • switch бывает как цепочка сравнений или как jump table с проверкой диапазона и непрямым jmp
  • Вызовы читаются через ABI: аргументы в регистры перед call, результат в rax
  • Оптимизации добавляют cmov/setcc, tail call и inlining, из-за чего “границы” исходника могут исчезать
  • Что дальше по курсу

    Следующий шаг — закрепить чтение на реальных бинарниках: разбор внешних вызовов через динамическую линковку (в ELF это часто PLT/GOT), а также типовые паттерны работы со строками и буферами. Это даст вам практическую связку: структура бинарника + вызовы + контроль потока.

    7. Паттерны реверса: строки, структуры, указатели, буферы и поиск уязвимостей

    Паттерны реверса: строки, структуры, указатели, буферы и поиск уязвимостей

    В предыдущих статьях вы научились читать инструкции, понимать флаги и переходы, разбирать соглашения о вызовах и узнавать конструкции компилятора (условия, циклы, switch, inlining). Теперь добавим практический слой реверса, который чаще всего даёт быстрый результат на реальных бинарниках: поиск строк, восстановление структур данных, понимание указателей и буферов, а также базовый поиск потенциальных уязвимостей по ассемблерным паттернам.

    Важно: в этой статье мы говорим про паттерны, а не про стопроцентные доказательства. Один и тот же дизассемблерный фрагмент может соответствовать разным исходникам. Задача реверсера — построить правдоподобную модель и затем подтвердить её: кросс-ссылками, трассировкой в отладчике, проверкой входных данных.

    Что вы будете уметь после статьи

  • Быстро начинать анализ с поиска строк и переходить от них к коду через XREF
  • Узнавать в ассемблере типичные библиотечные операции над строками и памятью (strlen, strcmp, memcpy, read)
  • Восстанавливать структуры по смещениям и размерам доступов к памяти (byte ptr, dword ptr, qword ptr)
  • Читать указатели и цепочки разыменования (pointer chasing) и отличать указатель от значения
  • Замечать характерные признаки небезопасной работы с буферами и формировать гипотезы об уязвимостях
  • !Диаграмма, как реверсер часто идёт от строки к месту использования и дальше к обработке данных

    Строки как самый быстрый вход в логику программы

    Строки дают контекст: сообщения об ошибках, форматы логов, имена файлов, URL, команды протоколов, ключевые слова конфигов. В ELF- и PE-бинарниках строки чаще всего лежат в секциях данных (в ELF обычно .rodata или .data, в PE часто .rdata).

    Где строки видны и как к ним перейти

    Обычно вы делаете связку:

  • strings или просмотр .rodata в Ghidra/IDA
  • По найденной строке — XREF (кто на неё ссылается)
  • По месту ссылки — чтение ближайшего контекста: какие аргументы готовятся перед call или syscall
  • Команда для первичной разведки:

    Полезно также понимать, что в дизассемблере ссылка на строку в PIE почти всегда выглядит как RIP-relative адресация:

    Чтение по System V ABI (из прошлых статей):

  • rdi — первый аргумент
  • значит puts получает адрес строки
  • Нуль-терминированные строки и длина

    Большинство строк в C — нуль-терминированные: в памяти они заканчиваются байтом 0x00. Это влияет на типовые циклы и на уязвимости.

    Паттерн обхода строки до \0 часто выглядит так:

    Интерпретация:

  • rdi — указатель на текущий символ
  • movzx говорит, что читается один байт и расширяется до 32 бит
  • test al, al проверяет al == 0
  • Узнаваемые вызовы libc по подготовке аргументов

    Даже если символы вырезаны, многие вызовы узнаются по сигнатурам аргументов и поведению.

    | Функция | Смысл | Типовая подготовка аргументов (System V) | Что искать вокруг | |---|---|---|---| | strlen | длина строки до \0 | rdi = s | цикл чтения байтов или вызов, результат в rax | | strcmp | сравнение двух строк | rdi = s1, rsi = s2 | проверка результата test eax,eax + je/jne | | strncmp | сравнение с лимитом | rdi = s1, rsi = s2, rdx = n | отдельная длина/лимит | | memcpy | копирование памяти | rdi = dst, rsi = src, rdx = n | рядом часто расчёт n | | memmove | копирование с перекрытием | то же, что memcpy | часто используется вместо memcpy компилятором |

    Ссылки для сверки сигнатур и семантики:

  • strlen(3)
  • strcmp(3)
  • strncmp(3)
  • memcpy(3)
  • memmove(3)
  • Практический паттерн: ветвление по результату strcmp.

    Чтение:

  • если eax == 0, строки равны (ветка успеха)
  • Указатели в ассемблере: как не перепутать адрес и значение

    В реверсе самая частая ошибка новичка: принять адрес за значение или наоборот. Правило из базовой статьи остаётся главным:

  • mov rax, [rbx] читает значение из памяти по адресу в rbx
  • lea rax, [rbx+8] вычисляет адрес rbx+8, память не читает
  • Признаки того, что регистр содержит указатель

  • регистр используется внутри [...] как база: mov eax, [rdi+4]
  • регистр сравнивают с 0: test rdi, rdi (проверка на NULL)
  • регистр передают как аргумент в функции работы с памятью/строками
  • Типовой защитный паттерн:

    Чтение:

  • сначала проверили rdi != NULL
  • затем разыменовали поле по смещению +8
  • Pointer chasing: цепочки разыменования

    Часто встречается логика вида obj->a->b->c:

    Чтение:

  • каждое mov reg, [reg+off] обычно означает переход по указателю на следующий объект
  • размер чтения (qword/dword) помогает понять тип поля: указатель обычно 8 байт на x86-64
  • Восстановление структур по смещениям

    Компилятор превращает поля структуры в смещения от базового адреса. Если у вас есть указатель на структуру в rdi, то:

  • mov eax, dword ptr [rdi+0x0C] почти всегда означает чтение 32-битного поля по смещению 0x0C
  • mov qword ptr [rdi+0x20], rax означает запись 8-байтного поля
  • Как из смещений получить модель структуры

    Практическая техника:

  • Найдите базовый указатель (часто это аргумент функции в rdi или сохранённый rbx)
  • Выпишите все смещения, которые используются с этой базой
  • Запишите размер доступа в каждом месте (byte/dword/qword)
  • Сопоставьте поля по смыслу: сравнения, проверки на 0, использование как указателя
  • Пример:

    Гипотеза структуры:

    Почему появляется pad: 8-байтный указатель обычно выравнивается, и компилятор кладёт его по +0x08.

    !Иллюстрация, как смещения в инструкциях соответствуют полям структуры

    Массивы внутри структур и scale в адресации

    Если вы видите индексную адресацию со scale, это часто массив.

    Чтение:

  • *4 намекает на элементы по 4 байта (int32)
  • Если это поле структуры, будет смещение:

    Чтение:

  • массив начинается по +0x20 внутри объекта
  • Буферы: стек, куча и границы

    С точки зрения реверса, буфер — это область памяти, куда пишут или из которой читают. Главный вопрос для безопасности и корректности: контролируется ли размер?

    Буфер на стеке

    Признаки:

  • sub rsp, N (выделение места)
  • доступ к [rsp+off] или [rbp-off]
  • передача lea rdi, [rsp+off] в функцию чтения/копирования
  • Пример:

    Сигнал риска:

  • если локальный буфер меньше, чем потенциально записываемый размер, возможен переполнение стека
  • Буфер в куче

    Часто начинается с выделения памяти:

  • call malloc или call calloc
  • затем запись в возвращённый указатель (rax) или сохранение его в структуре
  • Ссылки:

  • malloc(3)
  • free(3)
  • Реверс-ориентир:

  • если результат malloc не проверяется на NULL, это может быть баг надёжности
  • если после free(ptr) вы видите дальнейшее использование ptr, это сигнал на use-after-free
  • Паттерны потенциальных уязвимостей в ассемблере

    Ниже не “эксплойт-гайд”, а практичный чек-лист реверсера: какие места стоит подсветить и проверить входами.

    Переполнение буфера при копировании

    Классический источник — копирование в фиксированный буфер без строгой проверки длины.

    Сигналы:

  • есть memcpy/strcpy/strcat/sprintf или самописная петля копирования
  • размер копирования берётся из входных данных или вычисляется без верхней границы
  • Ссылки:

  • strcpy(3)
  • strcat(3)
  • sprintf(3)
  • Уязвимость по классификации:

  • CWE-120: Buffer Copy without Checking Size of Input
  • Практический ассемблерный паттерн опасного места:

    Чтение:

  • strcpy не знает размер dst
  • если src длиннее 0x40-1, запись выйдет за пределы
  • Off-by-one в циклах копирования

    Сигнал:

  • проверка вида i <= len, а не i < len
  • запись нуль-терминатора \0 тоже требует места
  • В ассемблере это часто проявляется как сравнение и переходы:

  • cmp ecx, edx + jbe (включая равенство) вместо jb
  • Здесь важно не забывать из прошлой статьи про signed/unsigned:

  • jb/jbe/ja/jae обычно про беззнаковое
  • jl/jle/jg/jge обычно про знаковое
  • Ошибки знака и размера (integer truncation)

    Очень частая причина багов в 64-битном коде — непреднамеренное сужение.

    Сигналы:

  • длина/размер приходит в 64-битном регистре, но далее используется только eax/edi (32 бита)
  • встречаются movsxd или наоборот отсутствует знаковое расширение там, где оно нужно
  • Пример тревожного места:

    Чтение:

  • если size больше , он “обрежется” до 32 бит
  • дальше аллокация может быть меньше, чем требуется, и копирование переполнит буфер
  • Уязвимость по классификации:

  • CWE-190: Integer Overflow or Wraparound
  • Форматная строка (format string)

    Сигнал:

  • в printf-подобные функции передают строку, которая выглядит как вход пользователя, а не константа
  • Опорная идея:

  • безопаснее, когда формат — константа из .rodata, а пользовательская строка — отдельный аргумент
  • Пример подозрительного вызова:

    Если rdi указывает на пользовательский буфер, то пользователь контролирует формат.

    Ссылка:

  • CWE-134: Use of Externally-Controlled Format String
  • Use-after-free (признаки на уровне реверса)

    Сигналы:

  • call free на указателе
  • затем этот же указатель снова разыменовывается или передаётся в функции чтения/записи
  • Уязвимость по классификации:

  • CWE-416: Use After Free
  • На практике подтверждение требует динамики: брейкпоинты на free, watchpoints на адрес, попытка воспроизведения.

    Практический мини-воркфлоу: от строки к проверке границ

    Ниже типовой сценарий, который хорошо ложится на инструменты из первых статей.

    Статический этап

  • Найдите подозрительную строку
  • - например, "password", "token", "usage:", "invalid"
  • Посмотрите XREF и ближайшие базовые блоки
  • Идентифицируйте источники данных
  • - read, recv, fgets, argv, чтение файла
  • Идентифицируйте операции над буферами
  • - копирование, форматирование, конкатенация
  • Проверьте, где и как сравнивают длины
  • - cmp len, max + правильный jcc

    Ссылки на системные источники данных:

  • read(2)
  • recv(2)
  • fgets(3)
  • Динамический этап (gdb)

    Полезные приёмы:

  • поставить брейкпоинт на интересующую функцию или адрес
  • перед вызовом посмотреть аргументы по ABI (rdi/rsi/rdx/...)
  • после вызова посмотреть результат в rax
  • посмотреть содержимое буфера: x/s rdi
  • Пример команд:

    Если вы охотитесь за переполнением:

  • полезны watchpoints на адрес буфера, но они дорогие и не всегда удобны
  • часто быстрее ставить брейкпоинты на копирование (memcpy, strcpy, sprintf) и проверять размеры аргументов
  • Итоги

  • Строки и XREF часто дают самый быстрый вход в логику программы
  • RIP-relative lea в 64-битных PIE-бинарниках — нормальный способ взять адрес строки или таблицы
  • Структуры восстанавливаются по смещениям и размерам доступов; указатели обычно читаются как qword ptr
  • Буферы на стеке узнаются по sub rsp, N и lea на область стека; в куче — по malloc/free и дальнейшим разыменованиям
  • Потенциальные уязвимости часто видны как отсутствие проверки длины, неверные сравнения (signed/unsigned), сужение разрядности и небезопасные форматные вызовы
  • Что дальше по курсу

    Следующий шаг — закрепить эти паттерны на реальных бинарниках и добавить ещё один важный слой: внешние вызовы и динамическая линковка (в ELF это PLT/GOT), а также типовые паттерны обработки ввода (файлы, сеть) и построение устойчивых гипотез о данных на границах программы.