Урок 1. От исходного кода к машинным байтам: как рождается программа
Договоримся с самого начала, потому что от этого зависит весь курс. Реверс-инжиниринг — это умение понять, что делает программа, когда исходного кода у вас нет. Есть только готовый файл — скомпилированная программа, — и задача: разобраться в её логике и, как правило, достать флаг вида vsosh{...}.
На олимпиаде это выглядит так: вам выдают файл crackme, task, .enc или архив с бинарником, и требуют ввести правильный пароль, восстановить алгоритм или найти спрятанный флаг. Многих это пугает сильнее, чем крипта или web: «там хотя бы понятно, с чего начать, а тут — просто файл, и непонятно, что с ним делать». Весь этот модуль посвящён тому, чтобы это ощущение исчезло. И начнём мы не с инструментов, а с простого вопроса: а что вообще лежит внутри такого файла и откуда он взялся?
Как из кода получается программа
Программу на C (а большинство олимпиадных бинарников — это C или C++) никто не исполняет в том виде, в каком её пишет человек. Прежде чем она станет файлом, который умеет запускать процессор, она проходит несколько превращений. Посмотрите на схему: одна и та же строчка кода показана на четырёх «этажах».
- Исходник (C). То, что писал автор: с именами переменных, комментариями, типами.
if (x == 0x42) win();— человеку сразу понятно, что здесь сравниваютxс числом0x42и, если равно, вызывают функциюwin. - Ассемблер. Компилятор переводит это в инструкции процессора:
cmp eax, 0x42(сравнить),jne skip(если не равно — прыгнуть мимо),call win(вызвать). Это всё ещё текст, но уже на «языке процессора». - Машинные байты. Каждая инструкция — это несколько байт.
cmp eax, 0x42превращается в байты83 F8 42,jne skip— в75 05, и так далее. Вот это уже то, что реально лежит в файле. - ELF. Байты не валяются кучей — они уложены в файл определённого формата. В Linux это ELF (Executable and Linkable Format). Внутри него есть секции:
.text— сам код (инструкции),.rodata— константы и строки только для чтения,.data— изменяемые данные. Про секции подробно будет в уроке 5; пока запомните, что код и строки лежат в файле в разных «ящиках».
Что компилятор выбрасывает — и почему реверс возможен
Ключевой момент, из которого растёт весь реверс: при компиляции теряется почти всё, что удобно человеку.
- Имена переменных и функций (
x,win) чаще всего исчезают — остаются только адреса. Там, где автор писалwin(), в бинаре будетcall 0x401156. - Комментарии выбрасываются целиком — их вообще нет в машинном коде.
- Типы (
int,char, структуры) не хранятся — процессор просто двигает байты, а был это «символ» или «число», решает контекст.
Тогда законный вопрос: если исходник не сохраняется, как вообще можно что-то восстановить? Ответ: логика программы никуда не девается. Сравнение x == 0x42 превратилось в cmp eax, 0x42, но само сравнение осталось — просто записано на языке процессора. Мы не «расшифровываем» файл (там нет секрета и ключа), мы читаем его на более низком уровне и восстанавливаем смысл, который автор вложил в этаж №1. Именно поэтому на схеме есть оранжевая стрелка: компиляция идёт слева направо, а реверс — справа налево, от байтов к смыслу.
Это и есть главный сдвиг в голове, ради которого написан весь модуль. Файл-программа — не непроницаемая стена. Это записанная на понятном процессору языке инструкция, и её можно прочитать. Сначала это будет медленно и по буквам, как чтение на незнакомом языке. К концу курса — почти бегло.
Посмотрим на настоящие байты
Схема выше — не метафора. Вот кусок реального учебного файла из этого урока, как его показывает дизассемблер (objdump -d -M intel l01_sample). Ключ -M intel выбирает форму записи инструкций — ту, которую мы используем весь курс; почему именно её, подробно разберём в уроке 4. Слева — адреса, посередине — те самые машинные байты, справа — инструкции:
Пример разобран на бинарнике 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], то есть «ячейка по такому-то смещению». Ровно то, о чём говорилось выше: компилятор выбросил имена, оставил адреса и смещения.
Почему декомпилятор не вернёт исходник
Раз логика сохраняется, возникает надежда: а нельзя ли просто «раскомпилировать» файл и получить исходный код обратно? Ответ важный, и лучше узнать его сейчас, чем разочароваться позже.
Нельзя. Компиляция — процесс с потерями и «много к одному»: десятки разных исходников дают один и тот же машинный код. По результату невозможно узнать, какой из них был написан.
- Имена и комментарии не восстановить — их физически нет в файле.
- Типы приходится угадывать по тому, как с данными обращаются.
- Оптимизация (
-O2) переставляет вычисления местами, «вклеивает» тело маленькой функции в место вызова, выбрасывает лишние переменные, а иногда заменяет умножение сдвигами. Исходная форма кода теряется.
Декомпилятор (мы будем работать с Ghidra в следующем модуле) делает другое: он строит эквивалентный код на C — такой, который делает то же самое. Выглядеть он будет иначе: переменные назовут local_18 и uVar1, вместо цикла может появиться странная конструкция, а типы окажутся приблизительными. Это нормально и совершенно не мешает: нам нужно понять логику, а не получить копию файла автора.
Правильное ожидание такое: декомпилятор — сильная помощь, а не кнопка «показать исходник». Читать его вывод и проверять свои догадки всё равно придётся вам.
Первый шаг с любым файлом — не дизассемблер, а триаж
Начинающий, впервые получив бинарник, часто делает одну и ту же ошибку: сразу открывает его в дизассемблере и пытается читать код подряд с самого начала. Это как открыть книгу на случайной странице и читать все 300 страниц, чтобы узнать, чем кончилось. Так не делают.
Опытный решатель сначала проводит триаж — быструю разведку, не запуская программу и не читая код. Всего два вопроса:
- Что это за файл? Отвечает команда
file. Она смотрит на первые байты (заголовок) и говорит формат. - Что уже читается открытым текстом? Отвечает команда
strings. Она вытаскивает из файла все последовательности печатных символов — сообщения, имена, иногда прямо флаг.
Покажу на настоящем учебном файле — он же будет вашим первым заданием. Сначала file, затем strings с фильтром по формату флага:
Пример разобран на бинарнике l01_sample.
Смотрим на ответ file. Отсюда мы, ещё не прочитав ни одной инструкции, уже знаем многое: это ELF (значит, программа под Linux), 64-bit x86-64 (архитектура процессора — та самая, которой посвящён курс), dynamically linked (использует системные библиотеки), not stripped (символы — имена функций — не вырезаны, реверсить будет легче). Всё это влияет на то, как мы будем работать дальше.
Теперь вторая команда. strings вытащил из файла все печатные строки, а grep vsosh оставил из них одну — строку с флагом.
Готово. Флаг лежал в секции .rodata (та самая «строки только для чтения»), и strings его просто вытащил — не запуская программу и не читая ни строчки ассемблера. Огромная доля лёгких reverse-задач решается ровно так: сначала посмотри, не отдаёт ли файл ответ по-хорошему. Если да — вы сэкономили полчаса.
Почему нельзя было просто запустить файл, чтобы посмотреть, что он делает? Во-первых, чужой бинарник запускать небезопасно — это может быть что угодно. Во-вторых, он мог и не показать флаг при запуске (в нашем примере вывод флага спрятан в ветке, в которую при обычном запуске программа не заходит). Триаж через
strings/fileбезопасен и часто быстрее. Как запускать бинарники аккуратно и зачем — разберём в модуле про инструменты.
Файл, который проговорился
Скачайте учебный бинарник и достаньте из него флаг, не запуская программу.
Подсказка по методу — в теории урока: флаг лежит в файле открытым текстом. Ответ — флаг целиком, в формате vsosh{...}.
Чем его собрали
Тот же файл l01_sample. Кроме самой программы, компилятор оставляет в бинаре служебные следы — например, свою версию.
Найди в файле строку, которую записал компилятор GCC, и сдай номер версии.
Ответ — три числа через точку, например 9.4.0.
Что запомнить из этого урока
- Реверс — это восстановление смысла программы из скомпилированного файла, без исходного кода.
- Компиляция идёт «сверху вниз»: C → ассемблер → машинные байты → ELF. При этом теряются имена, комментарии и типы, но логика сохраняется в инструкциях.
- Поэтому реверс возможен: мы читаем ту же логику, только на языке процессора.
- Знакомство с любым файлом начинается с триажа:
file(что за файл) иstrings(что уже читается), а не с чтения дизассемблера подряд.