«Bro, what…?» #1. Первый контакт с crackme на Linux x86-64. Серия 1
Вы нашли странный бинарник в Linux. Запускаете его, а он вместо пароля просит строку и насмехается над вами: «Bro, what are you trying to do?». Знакомая ситуация для любого, кто хоть раз касался реверс-инжиниринга. Это первая серия большого цикла по гибридному анализу crackme на архитектуре x86-64. Начнем с самого базового: первичного осмотра файла, чтения строк и первых экспериментов в изолированной среде.
Примечание
Введение
Вы запускаете программу, о которой ничего не знаете. Она вежливо просит: «Введите строку». Вы вводите что-то и в ответ получаете насмешливое: «Бро, что ты пытаешься сделать?»
И правда, что мы пытаемся сделать? Пытаемся понять ее.
Перед нами crackme – программа-головоломка, написанная для тренировки навыков обратной разработки. Исходного кода нет, документации нет. Есть только ELF-файл, который хранит свои секреты и издевается над каждым, кто не смог его разгадать. Наша crackme называется Getting started keygen от Mazzotti (файл getting_started_keygen). По названию кажется, что задача вводная, но внутри много сюрпризов, с которыми сталкиваются при разборе реальных C++-программ – оптимизированный пролог, имена переменных декомпилятора, которые значат не то, чем кажутся, скрытые структуры данных и «хитрые» условия, ломающие голову.
Эта статья является первой из трех в цикле. Все три про одну и ту же crackme. В первой – той, что вы читаете – мы познакомимся с файлом: осмотрим его штатными утилитами, загрузим в Ghidra, разберемся со стеком и подтвердим в отладчике первые гипотезы. Во второй статье устроим программе мутационное тестирование, разберем спрятанные оптимизации компилятора и реконструируем скрытую структуру, чтобы декомпилированный код стал выглядеть почти как исходный. В третьей полностью реконструируем функцию, которая вычисляет заветное значение и восстановим алгоритм программы на Python.
Главная тема цикла – метод гибридного анализа. Это когда статика и динамика не конкурируют, а работают в одной связке. Декомпилятор и дизассемблер дают нам гипотезу, отладчик подтверждает или опровергает ее, а мы возвращаемся к декомпилятору с новыми знаниями: поправить типы, имена и структуры. И так по кругу, пока алгоритм не будет восстановлен полностью. Сразу обозначу позицию: мы не будем модифицировать условные переходы для их обхода. Наша цель – восстановить алгоритм так, чтобы воспроизвести его с нуля на Python.
На момент написания этой статьи я не нашел в русскоязычном сегменте ни одного разбора, который бы реконструировал C++-структуры из ELF-бинарника под Linux с применением гибридного анализа. Найденные мной материалы либо ограничиваются перехватом значения в отладчике, либо посвящены Windows. Мы закроем этот пробел. Если вам известны такие публикации – сообщите в комментариях.
Учиться мы будем так, как работает настоящий реверс-инженер: не через чтение «правильного ответа», а через рассуждения. В реверсе нет учебника с ответами в конце – программа не подскажет, правы вы или нет. Единственный способ – думать, выдвигать гипотезы и проверять их экспериментами.
Когда нам встретится незнакомая конструкция, например: конструктор копирования, беззнаковое переполнение, арифметика указателей, мы не будем сразу доверять декомпилятору. Мы напишем небольшую программу на C++, скомпилируем ее и посмотрим, как эта конструкция выглядит в дизассемблере и декомпиляторе. Это «обратная разработка от обратного»: сначала мы своими глазами видим, как компилятор превращает известный нам код в байты, а потом уверенно читаем эти байты в чужом бинарнике.
И последняя философская мысль: у самурая нет цели, есть только путь. В реверсе все наоборот: цель у нас есть – понять алгоритм, но путей к ней много и не так важно, каким именно мы пойдем. Важно, чтобы в конце пути могли объяснить алгоритм программы. Поэтому не удивляйтесь, если иногда мы будем ходить по кругу: ошибка, подтвержденная в отладчике, дороже угаданного правильного ответа.
Все, что понадобится на этом пути: Linux, Ghidra, GDB, базовые знания C/C++ и немного любопытства. Начнем, как всегда, с простого – с первичного осмотра файла.
Раздел 1. Первичный осмотр файла
Первым шагом станет внешний осмотр файла. Представьте, что вы нашли подозрительную коробку. Вы не вскрываете ее сразу, а сначала смотрите, что написано на этикетке, какого она размера, есть ли предупреждающие знаки. Только потом принимаете решение.
С бинарниками точно так же. У нас есть файл getting_started_keygen. Посмотрим, что файл расскажет о себе.
1.1. Архитектура и точка входа
Первое, что мы делаем с любым неизвестным файлом – проверяем его истинный тип. Расширение файла не влияет на то, как Linux определяет его формат. Система смотрит не на имя файла, а на его сигнатуру – магические байты в начале файла.
Мы видим, что это ELF-файл (Executable and Linkable Format) – стандартный формат исполняемых файлов в Linux. Ключевые слова:
- 64-bit – архитектура x86-64, значит работаем с 64-битными регистрами (RAX, RBX, RSP и так далее).
- pie executable (Position Independent Executable) – позиционно-независимый исполняемый файл. Это значит, что при каждом запуске программа загружается по случайному адресу в памяти (работает ASLR). Адреса из Ghidra не будут совпадать с адресами в GDB, но об этом позже.
- dynamically linked – программа использует динамические библиотеки (libc, libstdc++). Значит, в таблице импортов будут внешние функции.
- stripped – отладочная информация удалена. Вместо понятных имен функций мы увидим адреса и сгенерированные Ghidra имена вроде FUN_001014b0.
Теперь посмотрим на заголовок ELF-файла, чтобы узнать точку входа – адрес, куда операционная система передает управление при запуске:
Точка входа – это не функция main, а служебная точка _start, которая подготавливает окружение (загружает библиотеки, инициализирует стек) и только потом вызывает main. Правда, функция main будет называться по-другому, так как бинарник зачищен.
1.2. Строки и символы
Теперь самое интересное – заглянем внутрь файла и посмотрим, какие строки в нем спрятаны. Строки – это первые артефакты. Они могут рассказать, с какими файлами работает программа, какие сообщения выводит, какие URL использует и так далее.
Мы не знаем, что именно искать, поэтому сначала посмотрим все строки. Но вывод strings может быть огромным, поэтому после первого просмотра отфильтруем только подозрительные строки:
Что мы видим? Программа выводит сообщения на английском:
- "Enter a string of characters (no spaces): " – просит ввести строку без пробелов.
- "Bro, what are you trying to do?" – насмешливый ответ.
- "Enter correct number (no spaces): " – просит ввести число.
- "OMG! You did it! :3" – сообщение об успехе.
- "Send help pls." – сообщение о неудаче.
Это показывает базовую логику программы: она запрашивает строку и число, вычисляет что-то и на основе этого принимает решение. А может, и нет – пока мы слишком мало знаем, чтобы делать выводы.
Строки – это первые подсказки. Они говорят нам, «что» программа делает, но не «как». Хотя пока непонятно, к чему именно относятся эти строки, они указывают нам нужное направление: необходимо найти место, где они используются. А для этого – загрузить файл в дизассемблер и найти нужный участок кода.
1.3. Запуск в изолированной среде
Теперь самое время запустить программу и посмотреть, как она себя ведет. Но будьте осторожны, если это реальный вредонос, запуск на рабочей машине – плохая идея. Всегда используйте изолированную среду: виртуальную машину, Docker-контейнер или хотя бы отдельную учетную запись с ограниченными правами.
Что произошло? Программа запросила строку, мы ввели test и программа насмешливо отказала. Почему? Пока нам не хватает информации ответить на этот вопрос.
Давайте попробуем ввести другую строку:
Теперь интереснее. Программа приняла строку example, запросила число, мы ввели 5 и программа сказала Send help pls. – значит, что-то мы ввели неправильно.
Обратите внимание на предыдущий вывод команды strings. Две строки из него мы наблюдаем в действии. Фраза Bro, what are you trying to do? появилась при вводе test. Сообщение Send help pls. появилось при вводе example и неправильного числа. А вот OMG! You did it! :3 пока не встретили – это ждет нас впереди, если мы введем правильное число.
Мы узнали из эксперимента, что программа работает по-разному для разных строк. Строка test не прошла проверку, а example – прошла. Мы не знаем от чего зависит принятие решения, но если строка удовлетворяет программу, то будет предложено ввести какое-то число. И пока это все выводы, которые мы можем сделать из наших экспериментов.
Здесь мы упираемся в стену. strings показал нам сообщения, file показал архитектуру, но мы не понимаем алгоритм. Мы видим, что программа делает, но не понимаем, как она это делает.
Чтобы понять алгоритм, нужно заглянуть внутрь – в машинный код. И здесь самое время знакомиться с Ghidra.
Конец первой серии
Консольные утилиты показали нам лишь верхушку айсберга: мы знаем, что делает программа, но совершенно не понимаем, как она принимает решения. Почему строка test ее не устраивает, а example пропускает дальше? Чтобы узнать это, нам придется заглянуть внутрь машинного кода. В следующей серии мы поговорим о том, почему статический анализ не терпит суеты и зачем нам понадобятся дизассемблеры.
Продолжение в следующей серии...
P.S.
Полный текст статьи: