Урок 1. От исходного кода к машинным байтам: как рождается программа

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

На олимпиаде это выглядит так: вам выдают файл crackme, task, .enc или архив с бинарником, и требуют ввести правильный пароль, восстановить алгоритм или найти спрятанный флаг. Многих это пугает сильнее, чем крипта или web: «там хотя бы понятно, с чего начать, а тут — просто файл, и непонятно, что с ним делать». Весь этот модуль посвящён тому, чтобы это ощущение исчезло. И начнём мы не с инструментов, а с простого вопроса: а что вообще лежит внутри такого файла и откуда он взялся?

Как из кода получается программа

Программу на C (а большинство олимпиадных бинарников — это C или C++) никто не исполняет в том виде, в каком её пишет человек. Прежде чем она станет файлом, который умеет запускать процессор, она проходит несколько превращений. Посмотрите на схему: одна и та же строчка кода показана на четырёх «этажах».

Одна строка кода на четырёх этажах: исходник → ассемблер → машинные байты → ELF

  1. Исходник (C). То, что писал автор: с именами переменных, комментариями, типами. if (x == 0x42) win(); — человеку сразу понятно, что здесь сравнивают x с числом 0x42 и, если равно, вызывают функцию win.
  2. Ассемблер. Компилятор переводит это в инструкции процессора: cmp eax, 0x42 (сравнить), jne skip (если не равно — прыгнуть мимо), call win (вызвать). Это всё ещё текст, но уже на «языке процессора».
  3. Машинные байты. Каждая инструкция — это несколько байт. cmp eax, 0x42 превращается в байты 83 F8 42, jne skip — в 75 05, и так далее. Вот это уже то, что реально лежит в файле.
  4. ELF. Байты не валяются кучей — они уложены в файл определённого формата. В Linux это ELF (Executable and Linkable Format). Внутри него есть секции: .text — сам код (инструкции), .rodata — константы и строки только для чтения, .data — изменяемые данные. Про секции подробно будет в уроке 5; пока запомните, что код и строки лежат в файле в разных «ящиках».

Что компилятор выбрасывает — и почему реверс возможен

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

  • Имена переменных и функций (x, win) чаще всего исчезают — остаются только адреса. Там, где автор писал win(), в бинаре будет call 0x401156.
  • Комментарии выбрасываются целиком — их вообще нет в машинном коде.
  • Типы (int, char, структуры) не хранятся — процессор просто двигает байты, а был это «символ» или «число», решает контекст.

Тогда законный вопрос: если исходник не сохраняется, как вообще можно что-то восстановить? Ответ: логика программы никуда не девается. Сравнение x == 0x42 превратилось в cmp eax, 0x42, но само сравнение осталось — просто записано на языке процессора. Мы не «расшифровываем» файл (там нет секрета и ключа), мы читаем его на более низком уровне и восстанавливаем смысл, который автор вложил в этаж №1. Именно поэтому на схеме есть оранжевая стрелка: компиляция идёт слева направо, а реверс — справа налево, от байтов к смыслу.

Это и есть главный сдвиг в голове, ради которого написан весь модуль. Файл-программа — не непроницаемая стена. Это записанная на понятном процессору языке инструкция, и её можно прочитать. Сначала это будет медленно и по буквам, как чтение на незнакомом языке. К концу курса — почти бегло.

Что происходит с именами переменных и комментариями из исходника после компиляции обычной программы на C? · 4 б.

Посмотрим на настоящие байты

Схема выше — не метафора. Вот кусок реального учебного файла из этого урока, как его показывает дизассемблер (objdump -d -M intel l01_sample). Ключ -M intel выбирает форму записи инструкций — ту, которую мы используем весь курс; почему именно её, подробно разберём в уроке 4. Слева — адреса, посередине — те самые машинные байты, справа — инструкции:

Терминал: objdump -d -M intel l01_sample | sed -n '/<main>:/,/ret/p' — функция main от push rbp до ret; обведены три колонки: адрес, машинные байты, инструкция

Пример разобран на бинарнике l01_sample.

Это main целиком: sed в конце команды только вырезает её из длинного вывода. Две инструкции длиной восемь байт не уместились в строку, и objdump перенёс их последний байт 00 на отдельную строку (адреса 40113c и 401144). Разберём, не вдаваясь в детали (многое станет понятно в следующих уроках):

  • push rbp, mov rbp,rsp, sub rsp,0x20 — служебное начало любой функции. Что это такое, подробно в уроке 3.
  • mov DWORD PTR [rbp-0x14],edi — программа прячет свой первый аргумент (argc — сколько слов было в командной строке) в локальную ячейку [rbp-0x14]. Запомните её: именно с ней ниже и сравнивают.
  • mov QWORD PTR [rbp-0x8], 0x402008 — в переменную положили число 0x402008. Это адрес: сама строка лежит в файле по этому адресу. Обратите внимание — в коде нет текста строки, только ссылка на него.
  • mov rax,[rbp-0x8] / mov rdi,rax / call 401030 <puts@plt> — адрес строки перекладывают в rdi (по правилу вызова это первый аргумент, урок 3) и зовут puts — вывод строки на экран.
  • cmp DWORD PTR [rbp-0x14], 0x63 и jle — ту самую ячейку с argc сравнили с числом 0x63 (это 99) и, если меньше или равно, прыгнули дальше, мимо следующего куска.

Вот и весь секрет «мёртвой ветки», о которой речь пойдёт ниже: программа печатает второе сообщение, только если некоторое число больше 99. При обычном запуске оно таким не бывает, поэтому вывода вы не увидите — а в файле он лежит.

И главное, ради чего мы сюда смотрели: имён здесь нет. Автор писал note и flag, а в файле — [rbp-0x8] и [rbp-0x10], то есть «ячейка по такому-то смещению». Ровно то, о чём говорилось выше: компилятор выбросил имена, оставил адреса и смещения.

Исходного кода нет — только скомпилированный файл. Почему реверс всё равно возможен? · 4 б.

Почему декомпилятор не вернёт исходник

Раз логика сохраняется, возникает надежда: а нельзя ли просто «раскомпилировать» файл и получить исходный код обратно? Ответ важный, и лучше узнать его сейчас, чем разочароваться позже.

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

  • Имена и комментарии не восстановить — их физически нет в файле.
  • Типы приходится угадывать по тому, как с данными обращаются.
  • Оптимизация (-O2) переставляет вычисления местами, «вклеивает» тело маленькой функции в место вызова, выбрасывает лишние переменные, а иногда заменяет умножение сдвигами. Исходная форма кода теряется.

Декомпилятор (мы будем работать с Ghidra в следующем модуле) делает другое: он строит эквивалентный код на C — такой, который делает то же самое. Выглядеть он будет иначе: переменные назовут local_18 и uVar1, вместо цикла может появиться странная конструкция, а типы окажутся приблизительными. Это нормально и совершенно не мешает: нам нужно понять логику, а не получить копию файла автора.

Правильное ожидание такое: декомпилятор — сильная помощь, а не кнопка «показать исходник». Читать его вывод и проверять свои догадки всё равно придётся вам.

Чего разумно ожидать от декомпилятора вроде Ghidra? · 4 б.

Первый шаг с любым файлом — не дизассемблер, а триаж

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

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

  • Что это за файл? Отвечает команда file. Она смотрит на первые байты (заголовок) и говорит формат.
  • Что уже читается открытым текстом? Отвечает команда strings. Она вытаскивает из файла все последовательности печатных символов — сообщения, имена, иногда прямо флаг.

Покажу на настоящем учебном файле — он же будет вашим первым заданием. Сначала file, затем strings с фильтром по формату флага:

Терминал: file l01_sample — ELF 64-bit LSB executable, x86-64, dynamically linked, not stripped; strings l01_sample | grep vsosh печатает vsosh{str1ngs_v1d3l_flag_b3z_z4pusk4}

Пример разобран на бинарнике l01_sample.

Смотрим на ответ file. Отсюда мы, ещё не прочитав ни одной инструкции, уже знаем многое: это ELF (значит, программа под Linux), 64-bit x86-64 (архитектура процессора — та самая, которой посвящён курс), dynamically linked (использует системные библиотеки), not stripped (символы — имена функций — не вырезаны, реверсить будет легче). Всё это влияет на то, как мы будем работать дальше.

Теперь вторая команда. strings вытащил из файла все печатные строки, а grep vsosh оставил из них одну — строку с флагом.

Готово. Флаг лежал в секции .rodata (та самая «строки только для чтения»), и strings его просто вытащил — не запуская программу и не читая ни строчки ассемблера. Огромная доля лёгких reverse-задач решается ровно так: сначала посмотри, не отдаёт ли файл ответ по-хорошему. Если да — вы сэкономили полчаса.

Почему нельзя было просто запустить файл, чтобы посмотреть, что он делает? Во-первых, чужой бинарник запускать небезопасно — это может быть что угодно. Во-вторых, он мог и не показать флаг при запуске (в нашем примере вывод флага спрятан в ветке, в которую при обычном запуске программа не заходит). Триаж через strings/file безопасен и часто быстрее. Как запускать бинарники аккуратно и зачем — разберём в модуле про инструменты.

Вы впервые получили незнакомый бинарник. С чего разумнее начать? · 4 б.
Скачать учебный бинарник l01_sample
R1

Файл, который проговорился

12 б.

Скачайте учебный бинарник и достаньте из него флаг, не запуская программу.

Подсказка по методу — в теории урока: флаг лежит в файле открытым текстом. Ответ — флаг целиком, в формате vsosh{...}.

R9

Чем его собрали

8 б.

Тот же файл l01_sample. Кроме самой программы, компилятор оставляет в бинаре служебные следы — например, свою версию.

Найди в файле строку, которую записал компилятор GCC, и сдай номер версии.

Ответ — три числа через точку, например 9.4.0.

Что запомнить из этого урока

  • Реверс — это восстановление смысла программы из скомпилированного файла, без исходного кода.
  • Компиляция идёт «сверху вниз»: C → ассемблер → машинные байты → ELF. При этом теряются имена, комментарии и типы, но логика сохраняется в инструкциях.
  • Поэтому реверс возможен: мы читаем ту же логику, только на языке процессора.
  • Знакомство с любым файлом начинается с триажа: file (что за файл) и strings (что уже читается), а не с чтения дизассемблера подряд.