«Bro, what…?» #1. Первый контакт с crackme на Linux x86-64

от автора

Введение

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

И правда, что мы пытаемся сделать? Пытаемся понять ее.

Перед нами crackme – программа-головоломка, написанная для тренировки навыков обратной разработки. Исходного кода нет, документации нет. Есть только ELF-файл, который хранит свои секреты и издевается над каждым, кто не смог его разгадать. Наша crackme называется Getting started keygen от Mazzotti (файл getting_started_keygen). По названию кажется, что задача вводная, но внутри много сюрпризов, с которыми сталкиваются при разборе реальных C++-программ – оптимизированный пролог, имена переменных декомпилятора, которые значат не то, чем кажутся, скрытые структуры данных и «хитрые» условия, ломающие голову.

Содержание статьи

Введение

Раздел 1. Первичный осмотр файла

Раздел 2. Знакомство с Ghidra: поиск main и первые гипотезы

Раздел 3. Стек, пролог и имена переменных в Ghidra

Раздел 4. Первая гипотеза: что хранится в local_70?

Заключение

Эта статья является первой из трех в цикле. Все три про одну и ту же crackme. В первой – той, что вы читаете – мы познакомимся с файлом: осмотрим его штатными утилитами, загрузим в Ghidra, разберемся со стеком и подтвердим в отладчике первые гипотезы. Во второй статье устроим программе мутационное тестирование, разберем спрятанные оптимизации компилятора и реконструируем скрытую структуру, чтобы декомпилированный код стал выглядеть почти как исходный. В третьей полностью реконструируем функцию, которая вычисляет заветное значение и восстановим алгоритм программы на Python.

Главная тема цикла – метод гибридного анализа. Это когда статика и динамика не конкурируют, а работают в одной связке. Декомпилятор и дизассемблер дают нам гипотезу, отладчик подтверждает или опровергает ее, а мы возвращаемся к декомпилятору с новыми знаниями: поправить типы, имена и структуры. И так по кругу, пока алгоритм не будет восстановлен полностью. Сразу обозначу позицию: мы не будем модифицировать условные переходы для их обхода. Наша цель – восстановить алгоритм так, чтобы воспроизвести его с нуля на Python.

На момент написания этой статьи я не нашел в русскоязычном сегменте ни одного разбора, который бы реконструировал C++-структуры из ELF-бинарника под Linux с применением гибридного анализа. Найденные мной материалы либо ограничиваются перехватом значения в отладчике, либо посвящены Windows. Мы закроем этот пробел. Если вам известны такие публикации – сообщите в комментариях.

Учиться мы будем так, как работает настоящий реверс-инженер: не через чтение «правильного ответа», а через рассуждения. В реверсе нет учебника с ответами в конце – программа не подскажет, правы вы или нет. Единственный способ – думать, выдвигать гипотезы и проверять их экспериментами.

Когда нам встретится незнакомая конструкция, например: конструктор копирования, беззнаковое переполнение, арифметика указателей, мы не будем сразу доверять декомпилятору. Мы напишем небольшую программу на C++, скомпилируем ее и посмотрим, как эта конструкция выглядит в дизассемблере и декомпиляторе. Это «обратная разработка от обратного»: сначала мы своими глазами видим, как компилятор превращает известный нам код в байты, а потом уверенно читаем эти байты в чужом бинарнике.

И последняя философская мысль: у самурая нет цели, есть только путь. В реверсе все наоборот: цель у нас есть – понять алгоритм, но путей к ней много и не так важно, каким именно мы пойдем. Важно, чтобы в конце пути могли объяснить алгоритм программы. Поэтому не удивляйтесь, если иногда мы будем ходить по кругу: ошибка, подтвержденная в отладчике, дороже угаданного правильного ответа.

Все, что понадобится на этом пути: Linux, Ghidra, GDB, базовые знания C/C++ и немного любопытства. Начнем, как всегда, с простого – с первичного осмотра файла.

Раздел 1. Первичный осмотр файла

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

С бинарниками точно так же. У нас есть файл getting_started_keygen. Посмотрим, что файл расскажет о себе.

1.1. Архитектура и точка входа

Первое, что мы делаем с любым неизвестным файлом – проверяем его истинный тип. Расширение файла не влияет на то, как Linux определяет его формат. Система смотрит не на имя файла, а на его сигнатуру – магические байты в начале файла.

$ file getting_started_keygengetting_started_keygen: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=226fff4aea936ab7426bf11dcd1e334c1e053104, for GNU/Linux 3.2.0, stripped

Мы видим, что это 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-файла, чтобы узнать точку входа – адрес, куда операционная система передает управление при запуске:

$ readelf -h getting_started_keygen | grep -E "Entry point address|Адрес точки входа"  Адрес точки входа:                 0x1340

Точка входа – это не функция main, а служебная точка _start, которая подготавливает окружение (загружает библиотеки, инициализирует стек) и только потом вызывает main. Правда, функция main будет называться по-другому, так как бинарник зачищен.

1.2. Строки и символы

Теперь самое интересное – заглянем внутрь файла и посмотрим, какие строки в нем спрятаны. Строки – это первые артефакты. Они могут рассказать, с какими файлами работает программа, какие сообщения выводит, какие URL использует и так далее.

Мы не знаем, что именно искать, поэтому сначала посмотрим все строки. Но вывод strings может быть огромным, поэтому после первого просмотра отфильтруем только подозрительные строки:

$ strings getting_started_keygen...@0H9Enter a string of characters (no spaces):Bro, what are you trying to do?Enter correct number (no spaces):OMG! You did it! :3Send help pls.9*3$"...

Что мы видим? Программа выводит сообщения на английском:

  • "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-контейнер или хотя бы отдельную учетную запись с ограниченными правами.

$ ./getting_started_keygenEnter a string of characters (no spaces):testBro, what are you trying to do?

Что произошло? Программа запросила строку, мы ввели test и программа насмешливо отказала. Почему? Пока нам не хватает информации ответить на этот вопрос.

Давайте попробуем ввести другую строку:

$ ./getting_started_keygenEnter a string of characters (no spaces):exampleEnter correct number (no spaces):5Send help pls.

Теперь интереснее. Программа приняла строку 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.

1.4. Статический анализ: дизассемблеры и декомпиляторы

Консольные утилиты (file, strings, readelf) дали начальное понимание: мы знаем архитектуру, извлекли строки, увидели поведение программы при запуске. Но они не отвечают на главные вопросы:

  • Почему программа принимает строку example, но отвергает test?

  • Что за число она просит ввести и как оно вычисляется?

  • Как устроена логика, которая скрывается за строками?

Мы знаем, «что» программа делает: запрашивает строку, проверяет ее, затем запрашивает число и сравнивает. Но мы не знаем, «как» она это делает.

1.4.1. Как работает компиляция

Напомню, как программа вообще создается. Программист пишет код на языке высокого уровня, например, на C++:

int main() {    int x = 10;    return x + 5;}

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

PUSH       RBPMOV        RBP,RSPMOV        dword ptr [RBP + local_c],0xaMOV        EAX,dword ptr [RBP + local_c]ADD        EAX,0x5POP        RBPRET

Пример скомпилирован без оптимизаций (-O0), поэтому пролог «классический»: PUSH RBP; MOV RBP, RSP. В третьем разделе мы увидим, как тот же код выглядит после оптимизаций и почему это меняет все наше представление о локальных переменных.

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

Представьте, что вы перевели книгу с русского на китайский, а затем с китайского обратно на русский. Даже если переводчик идеален, вы получите совсем другой текст – смысл сохранится, но слова и выражения будут другими. Так и здесь, компилятор выбрасывает имена переменных, перестраивает код, оптимизирует его. Обратное преобразование – декомпиляция – принципиально не может восстановить исходный код в точности.

Компиляция – однонаправленный процесс

Компиляция – однонаправленный процесс

1.4.2 Что делают дизассемблеры и декомпиляторы

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

Декомпиляция пытается восстановить высокоуровневый код (псевдокод на C/C++) по ассемблеру. Это более удобный уровень для анализа, но важно помнить, что декомпилятор не восстанавливает исходный код, а переводит низкоуровневый язык (ассемблер), на более понятный высокоуровневый псевдокод (Си). Декомпилятор может ошибаться, пропускать детали и добавлять лишние конструкции.

1.4.3. Почему нам нужен и тот, и другой

В нашем анализе мы будем использовать оба подхода:

  • Декомпилятор (в Ghidra) – дает нам быстрый обзор и позволяет понять логику на высоком уровне.

  • Дизассемблер (в Ghidra и GDB) – когда декомпилятор ошибается или показывает странности, мы спускаемся на уровень инструкций и проверяем, что на самом деле происходит.

1.4.4. Знакомство с Ghidra

Ghidra (иногда буду называть Гидрой) – это бесплатный инструмент с открытым исходным кодом, разработанный Агентством национальной безопасности США (АНБ). Она умеет и дизассемблировать, и декомпилировать. Ghidra загружает бинарник, анализирует его структуру, находит функции, восстанавливает поток управления и показывает код, который можно читать почти как исходный.

Важно понимать, что Ghidra не может восстановить исходный код. Она не знает, что на самом деле написал программист. Она видит только байты, инструкции, переходы. А смысл и какой алгоритм скрыт за этим кодом – должны понять мы – реверс-инженеры.

В следующем разделе загрузим исследуемую crackme в Ghidra и начнем с поиска функции main – именно с нее начинается вся логика программы.

Раздел 2. Знакомство с Ghidra: поиск main и первые гипотезы

В предыдущем разделе мы осмотрели файл снаружи: узнали архитектуру, извлекли строки, даже запустили программу и увидели ее поведение. Но мы так и не поняли, почему программа по-разному реагирует на test и example.

Теперь наша задача заглянуть внутрь. Для этого мы воспользуемся Гидрой – инструментом, который умеет превращать машинный код в читаемый псевдокод на C/C++.

В этом разделе мы загрузим crackme в Ghidra, найдем main через точку входа, увидим переменные local_78, local_70, local_68, изучим таблицу импортов и разберемся с деманглингом.

Начнем с загрузки файла и знакомства с интерфейсом Ghidra.

2.1. Окна Ghidra

Запускаем Ghidra и создаем новый проект: FileNew ProjectNon-Shared ProjectNextFinish. Затем импортируем файл: FileImport File – выбираем скачанную crackme getting_started_keygen:

Окно импортирования бинарника в проект Ghidra

Окно импортирования бинарника в проект Ghidra

Ghidra покажет окно с информацией о файле – жмем OK. Далее появится окно с краткой информацией о загруженной программе:

Результат импортирования бинарника в проект Ghidra

Результат импортирования бинарника в проект Ghidra

Двойной клик по файлу в окне Active Project открывает CodeBrowser – основное окно анализа.

Вам будет предложено провести базовый анализ файла. Просто нажмите Analyze, сейчас мы не будем вникать в настройки:

Первичный анализ загруженного бинарника

Первичный анализ загруженного бинарника

Вы увидите несколько панелей:

Внешний вид CodeBrowser

Внешний вид CodeBrowser

Познакомимся с нужными для нас:

  • Symbol Tree (слева) – дерево символов. Здесь функции, переменные, импорты. Это наша карта навигации.

  • Decompiler (справа) – декомпилятор. Показывает псевдокод на C. Это то, что мы будем читать большую часть времени. Ghidra пытается восстановить высокоуровневую логику из машинного кода.

  • Listing (по центру) – листинг ассемблера. Сырой машинный код с инструкциями. Когда декомпилятор ошибается или показывает странности, мы смотрим сюда.

  • Console (внизу) – консоль Ghidra. Показывает сообщения анализатора, ошибки, предупреждения.

Если раскрыть раздел Functions в Symbol Tree, то вы не найдете понятных имен, например, main:

Дерево символов крякми getting_started_keygen

Дерево символов крякми getting_started_keygen

Вместо этого имена вроде FUN_001011f0. Это потому что наш бинарник stripped – отладочная информация удалена. Но не волнуйтесь, функцию main мы найдем.

2.2. Поиск main через точку входа

В первом разделе извлекли строки и нашли среди них Enter a string of characters (no spaces):. Теперь давайте найдем функцию, которая их выводит. Но вместо того чтобы искать строки напрямую, пойдем другим путем – от точки входа в программу.

2.2.1. Точка входа в программу

Когда операционная система запускает программу, она не передает управление сразу в функцию main. Сначала выполняется служебная функция _start (в Ghidra она называется entry), которая подготавливает окружение: инициализирует стек, загружает библиотеки, настраивает обработчики сигналов. И только потом _start вызывает main.

2.2.2. Находим entry в Ghidra

В окне Symbol Tree раскрываем раздел Functions и находим функцию entry. Это первая функция, с которой начинается выполнение программы. Двойной клик переносит нас в окно Listing, где мы видим ассемблерный код:

Листинг функции entry нашей crackme

Листинг функции entry нашей crackme

Ассемблер оставим на потом. Посмотрите на код в декомпиляторе:

void processEntry entry(undefined8 param_1,undefined8 param_2){  undefined1 auStack_8 [8];    __libc_start_main(FUN_001011f0,param_2,&stack0x00000008,0,0,param_1,auStack_8);  do {                    /* WARNING: Do nothing block with infinite loop */  } while( true );}

Смотрим на вызов __libc_start_main. Это стандартная функция из библиотеки libc, которая:

  1. Инициализирует стандартные потоки ввода-вывода (stdin, stdout, stderr).

  2. Вызывает конструкторы глобальных объектов C++.

  3. Вызывает функцию main.

  4. Завершает программу с кодом возврата из main.

Первый аргумент __libc_start_main – это указатель на функцию main. В нашем случае это FUN_001011f0.

Двойной клик по FUN_001011f0 переносит нас в тело функции. Смотрим на заголовок в окне – она называется FUN_001011f0.

Правый клик на имени функции – Rename Function (или клавиша L) – вводим main. Теперь в декомпиляторе видим понятное имя.

Изменение имени функции

Изменение имени функции

Смотрим на декомпилированный код функции main:

undefined8 main(void){  int iVar1;  ostream *poVar2;  long in_FS_OFFSET;  int local_7c;  undefined1 *local_78;  long local_70;  undefined1 local_68 [16];  string local_58 [40];  long local_30;    local_30 = *(long *)(in_FS_OFFSET + 0x28);  local_78 = local_68;  local_68[0] = 0;  local_70 = 0;                    /* try { // try from 0010123e to 00101327 has its CatchHandler @ 0010132f */  poVar2 = std::operator<<((ostream *)std::cout,"Enter a string of characters (no spaces): ");  FUN_00101430(poVar2);  std::operator>>((istream *)std::cin,(string *)&local_78);  if (5 < local_70 - 5U) {    std::operator<<((ostream *)std::cout,"Bro, what are you trying to do?");    FUN_00101430();                    /* WARNING: Subroutine does not return */    exit(0);  }    // ...}

Видим знакомую строку "Enter a string of characters (no spaces):" – ту самую, что мы нашли через strings в первом разделе. Видим условие if (5 < local_70 - 5U), которое выводит "Bro, what are you trying to do?" – именно эту фразу видели при запуске с короткой строкой.

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

local_78 = local_68;

Три переменные с похожими именами (local_78, local_70, local_68) подготавливаются к работе строго одна за другой. А сразу после ввода данных переменная local_70 используется в проверке, которая определяет, пропустит ли программа нас дальше.

Выглядит это как единый механизм, но пока мы не знаем, как именно они связаны. Являются ли они независимыми переменными или частями чего-то большего? Запомним этот паттерн. Одно можно сказать точно: local_70 участвует в проверке, очень похожей на проверку длины строки. Но это пока лишь наблюдение.

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

2.3. Таблица импортов: первые подсказки

Чтобы увидеть импорты, откройте Symbol Tree и раскройте раздел Imports. Вот что там видим:

Импорты анализируемой крякми

Импорты анализируемой крякми

Список импортов – это ценный источник информации. Он не показывает, «как» работает программа, но рассказывает, «с чем» она работает.

Посмотрим на группу импортов с приставкой std::. Это стандартная библиотека C++. Мы видим:

  • std::operator<< и std::operator>> – ввод и вывод.

  • std::string::string и std::string::_M_dispose – работа со строками.

  • std::cout и std::cin – стандартные потоки вывода и ввода.

Выводы, которые можно сделать уже сейчас:

  • Программа написана на C++.

  • Она читает ввод пользователя и выводит сообщения.

  • Она использует std::string для работы со строками (об этом говорят std::string::string и Mdispose).

Мы нашли важные артефакты. Например, тот факт, что программа активно использует std::string, наводит на мысль: «А что, если те самые переменные local_78, local_70 и local_68, которые мы видели в main, на самом деле являются полями этой структуры?»

Это лишь гипотеза, построенная на косвенных признаках. Мы проверим ее позже, когда перейдем от статического анализа к динамическому.

2.4. Деманглинг C++ имен

Если вы посмотрели на импорты внимательно, то заметили странные имена вроде _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc. Это не обфускация – это name mangling, стандартный механизм компилятора C++.

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

Например, _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc – это замангленное имя функции std::operator<<.

Для обратного преобразования применяют деманглинг. В Linux для этого есть утилита c++filt:

$ echo "_ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc" | c++filtstd::basic_ostream<char, std::char_traits<char> >& std::operator<< <std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*)

Теперь мы видим, что это оператор вывода C-строки в поток std::ostream.

Ghidra автоматически деманглит C++ имена в декомпиляторе. В окне Listing и таблице импортов видны оригинальные замангленные имена, но в декомпиляторе вы видите понятные std::operator<<, std::string::string и так далее.

Знание деманглинга поможет вам читать имена функций в листинге и таблице импортов, когда Ghidra не смогла автоматически восстановить понятное имя.

2.5. Ghidra уже увидела std::string

Вы могли заметить в функции main странную строку:

std::operator>>((istream *)std::cin,(string *)&local_78);

Ghidra приводит тип (string *)&local_78. Откуда она знает, что это string?

2.5.1. Как Ghidra определяет тип

Ghidra не угадывает типы. Она анализирует таблицу импортов. Например, в crackme есть следующий импорт:

std::operator>> / __ZStrsIcSt11char_traitsIcESaIcEERSt13basic_istreamIT_T0_ES7_RNSt7__cxx1112basic_stringIS4_S5_T1_EE

Это замангленное имя оператора ввода. В C++ оператор >> перегружен для разных типов – есть версия для int, для double, для std::string и других. Каждая перегрузка – это отдельная функция со своей сигнатурой. Ghidra показывает именно ту, которая используется в программе.

Если прогнать это имя через c++filt, получим полную сигнатуру:

// То, что выдал c++filt (полная форма):std::basic_istream<char, std::char_traits<char>>&std::operator>><char, std::char_traits<char>, std::allocator<char>>(    std::basic_istream<char, std::char_traits<char>>&,   // аргумент 1: std::istream& (std::cin)    std::__cxx11::basic_string<char,        std::char_traits<char>, std::allocator<char>>&   // аргумент 2: std::string&);// То же самое в читаемом виде:std::istream& operator>>(std::istream& is, std::string& str);

Не пугайтесь длины: std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char>> – это полное имя типа, который мы привыкли писать как std::string. Деманглер разворачивает алиасы до конца, поэтому подпись выглядит громоздко. Второй аргумент – ссылка на строку.

Именно поэтому Ghidra, встретив вызов std::operator>> с аргументом &local_78, автоматически добавляет приведение типа (string *). Она как бы говорит: «Эта функция ожидает указатель на std::string. Значит, я буду считать local_78 указателем на std::string».

Важно понимать, что это не обязательно является истиной, а всего лишь эвристическая догадка Ghidra, основанная на сигнатуре внешней функции. Ghidra видит не саму переменную local_78, а контекст ее использования.

Мы проверим эту гипотезу экспериментально в следующих разделах, когда заглянем в память с помощью GDB.

2.5.2. Что еще видим в импортах

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

  • std::string::string – конструктор std::string.

  • std::string::_M_dispose – метод освобождения памяти.

Это подтверждает, что программа активно использует std::string.

2.5.3. Пустая структура std::string

Ghidra автоматически создала структуру std::string в окне Data Type Manager (вкладка Data Typesgetting_started_keygenDemanglerstd).

Ghidra добавила структуру std::string в Data Type Manager

Ghidra добавила структуру std::string в Data Type Manager

Если мы откроем ее, то увидим, что она пустая – Ghidra не знает, какие поля у этой структуры.

Что внутри созданной std::string

Что внутри созданной std::string

Ghidra знает, что программа использует std::string, но не знает его внутреннее устройство. Это предстоит выяснить нам.

2.5.4. Что узнали из второго раздела

Кратко подведем итог раздела:

  • Оператор >> перегружен для разных типов. Ghidra показывает конкретную перегрузку – ту, что используется в программе.

  • Ghidra определяет тип переменной по контексту вызова. Если функция ожидает std::string *, Ghidra приводит аргумент к этому типу.

  • В таблице импортов есть несколько записей, связанных со строками: оператор ввода, конструктор, метод освобождения памяти.

  • Ghidra создала пустую структуру std::string, но пока непонятно что с этим делать.

В следующем разделе разберемся, что такое стек и как Ghidra дает имена переменным. Мы проведем динамический анализ в GDB и подтвердим (или опровергнем) гипотезу о том, что local_78, local_70 и local_68 – это поля одной структуры.

Раздел 3. Стек, пролог и имена переменных в Ghidra

В предыдущем разделе мы нашли функцию main, посмотрели на таблицу импортов и увидели в декомпиляторе следующий код:

undefined8 main(void) {   int iVar1;   ostream *poVar2;   long in_FS_OFFSET;   int local_7c;   undefined1 *local_78;   long local_70;   undefined1 local_68[16];   string local_58[40];   long local_30;   local_30 = *(long *)(in_FS_OFFSET + 0x28);   local_78 = local_68;   local_68[0] = 0;   local_70 = 0;      poVar2 = std::operator<<((ostream *)std::cout,"Enter a string of characters (no spaces): ");   FUN_00101430(poVar2);   std::operator>>((istream *)std::cin,(string *)&local_78);      if (5 < local_70 - 5U) {     std::operator<<((ostream *)std::cout,"Bro, what are you trying to do?");     FUN_00101430();     exit(0);   }      // ... }

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

Первая строка настраивает указатель:

local_78 = local_68;

Переменная local_78 теперь указывает на массив local_68. Похоже, что это подготовка хранилища для будущих данных.

Вторая строка обнуляет первый байт этого хранилища:

local_68[0] = 0;

В C++ это часто означает пустую строку. Но пока это только предположение.

Третья строка обнуляет переменную local_70:

local_70 = 0;

Судя по тому, как она используется дальше в условии if (5 < local_70 - 5U), она, возможно, будет хранить длину данных. Но это пока гипотеза.

Взгляните на декомпилированный код функции main еще раз:

undefined8 main(void) {   // ...   // Объявление переменных   undefined1 *local_78;   long local_70;   undefined1 local_68[16];   // ...   // Инициализация переменных   local_78 = local_68; // Теперь local_78 указывает на первый байт local_68   local_68[0] = 0;     // Первый байт local_68 равен нулю   local_70 = 0;        // В local_70 тоже ноль      poVar2 = /* Предложение ввести строку */   // До следующего вызова оператора ввода local_78 все еще указывает на local_68   std::operator>>((istream *)std::cin,(string *)&local_78);   // Оператор ввода сохраняет данные по адресу, на который указывает local_78         // После ввода local_70, судя по всему, перестает быть нулем – это можно   // предположить, глядя на то, как она используется в условии.    // Но это нужно проверить в GDB.   // Возможно, local_70 хранит длину строки? Но это пока только гипотеза.   // А local_68? Что это – буфер? Или что-то другое?      // Мы видим, что local_70 используется в условии.   // Откуда берется ее значение? Скорее всего, оператор ввода ее меняет.   // Но это нужно проверить.   if (5 < local_70 - 5U) {     std::operator<<((ostream *)std::cout,"Bro, what are you trying to do?");     FUN_00101430();     exit(0);   }      // ... }

Когда три разные переменные настраиваются именно в такой строгой последовательности прямо перед операцией ввода – это не совпадение.

Это классический паттерн инициализации объекта в C++. Скорее всего, перед нами не три независимые переменные, а три поля одной структуры (которой, как мы подозреваем из таблицы импортов, является std::string).

Но прежде чем бросимся проверять эту гипотезу в GDB, нужно понять, откуда Ghidra берет эти странные имена с приставкой local_ (78, 70, 68) и познакомиться с особенностями оптимизированного пролога.

3.1. Как Ghidra дает имена переменным

Имена local_78, local_70, local_68 выглядят как смещения: 0x78, 0x70, 0x68. Это может нас сбить с толку. Чтобы понять, откуда они берутся, разберемся с механизмом именования локальных переменных Гидрой, а так же двумя регистрами (RSP и RBP), управляющие стеком.

Стек – это область памяти, организованная по принципу LIFO (Last In, First Out). Представьте стопку свежих номеров журнала «Хакер» на вашем столе: вы кладете новый номер сверху и берете тоже сверху. В архитектуре x86-64 стек растет в сторону уменьшения адресов – новые данные кладутся по меньшим адресам. Где «вверх» (вершина) стека в архитектуре x86-64 мы разберем позднее.

За управление этой «стопкой» в процессоре отвечают два регистра:

  • RSP (Stack Pointer) – указатель вершины стека. Это самый последний добавленный элемент (в терминах адресов – самый младший адрес). Когда мы кладем что-то на стек (push), RSP уменьшается. Когда снимаем (pop), RSP увеличивается. Он меняется постоянно.

  • RBP (Base Pointer) – указатель базы текущего кадра стека. Это «якорь», фиксированная точка отсчета, которая не меняется на протяжении выполнения функции (в неоптимизированном коде).

Чтобы понять, как это работает, напишем простую программу calculate.cpp:

#include <iostream>int calculate(int a, int b) {    int local_sum = a + b;    int local_product = a * b;    return local_sum + local_product;}int main() {    int x, y;    std::cout << "Enter two numbers: ";    std::cin >> x >> y;    int result = calculate(x, y);    std::cout << "Result: " << result << std::endl;    return 0;}

Версия компилятора у меня следующая:

$ g++ --versiong++ (Debian 14.2.0-19) 14.2.0Copyright (C) 2024 Free Software Foundation, Inc.

Скомпилируем ее без оптимизации (-O0) и с небольшой оптимизацией (-O1):

g++ -s -O0 -o calculate_O0 calculate.cppg++ -s -O1 -o calculate_O1 calculate.cpp

Флаг -O0 отключает оптимизацию – компилятор генерирует код, максимально близкий к исходному C++. Большинство переменных размещается на стеке, доступ к ним идет через RBP, код проще анализировать.

Флаг -O1 включает базовые оптимизации – компилятор начинает активнее использовать регистры, применяет встраивание функций и другие техники. В результате код становится быстрее, но сложнее для реверс-инжиниринга: переменные могут «исчезать» из стека, а функции сливаться друг с другом.

3.1.1. Анализ неоптимизированной версии (-O0)

Откроем calculate_O0 в декомпиляторе Ghidra и посмотрим на функцию main:

undefined8 FUN_00101192(void){  istream *this;  ostream *poVar1;  int local_14;    // y  int local_10;    // x  int local_c;     // result    std::operator<<((ostream *)std::cout,"Enter two numbers: ");  this = (istream *)std::istream::operator>>((istream *)std::cin,&local_10);  std::istream::operator>>(this,&local_14);  local_c = FUN_00101169(local_10,local_14);  poVar1 = std::operator<<((ostream *)std::cout,"Result: ");  poVar1 = (ostream *)std::ostream::operator<<(poVar1,local_c);  std::ostream::operator<<(poVar1,std::endl<>);  return 0;}

Здесь все читаемо: local_10 = x, local_14 = y, local_c = result. Каждой переменной из исходного кода соответствует своя переменная в декомпиляторе.

Теперь посмотрим на ассемблерный код этой же функции:

Ассемблерный код функции main неоптимизированной программы calculate_O0

Ассемблерный код функции main неоптимизированной программы calculate_O0

Стандартный пролог

Первые три инструкции подготавливают стек для работы функции. Сохраняем старый RBP на стек, затем делаем RBP равным текущему RSP – с этого момента RBP становится нашим неподвижным якорем. Последняя инструкция выделяет 16 байт под локальные переменные:

00101192    PUSH       RBP00101193    MOV        RBP,RSP00101196    SUB        RSP,0x10

Первый ввод (cin >> x)

Готовим адрес переменной local_10 (это наш x из исходного кода). Инструкция LEA загружает в RAX адрес [RBP + -0x8], то есть смещение 8 байт ниже базы кадра. Ghidra услужливо подсказывает это в аннотации =>local_10:

001011b3    LEA        RAX=>local_10,[RBP + -0x8]001011b7    MOV        RSI,RAX001011ba    LEA        RAX,[std::cin]001011c1    MOV        RDI=>std::cin,RAX001011c4    CALL       <EXTERNAL>::std::istream::operator>>

Второй ввод (cin >> y)

Аналогично готовим адрес переменной local_14 (наш y). Смещение [RBP + -0xc] – 12 байт ниже базы кадра. Ghidra снова подсказывает: =>local_14:

001011c9    MOV        RDX,RAX001011cc    LEA        RAX=>local_14,[RBP + -0xc]001011d0    MOV        RSI,RAX001011d3    MOV        RDI,RDX001011d6    CALL       <EXTERNAL>::std::istream::operator>>

Вызов функции calculate

Читаем значения переменных со стека и кладем их в регистры EDI и ESI (согласно соглашению о вызовах System V ABI). Обратите внимание на то, что Ghidra использует другой синтаксис – [RBP + local_14] вместо [RBP + -0xc]. Это одна и та же переменная, просто Ghidra по-разному показывает ее в разных контекстах:

001011db    MOV        EDX,dword ptr [RBP + local_14]001011de    MOV        EAX,dword ptr [RBP + local_10]001011e1    MOV        ESI,EDX001011e3    MOV        EDI,EAX001011e5    CALL       FUN_00101169

3.1.2. Подведем итог для неоптимизированной программы calculate_O0

Все наше внимание было приковано к использованию регистров RBP и RSP. После анализа неоптимизированной версии программы можно отметить следующее:

  1. Все переменные доступны через RBP. После пролога RBP зафиксирован и компилятор обращается к x, y, result через отрицательные смещения от этого регистра: RBP - 0x8, RBP - 0xc, RBP - 0x4.

  2. Имена local_XX в Ghidra – это детерминированные идентификаторы, формируемые по правилу local_ + шестнадцатеричное смещение от начала стекового фрейма. Ghidra создает переменную в момент первого обращения к данной ячейке стека. В аннотациях ассемблера вы видите =>local_10, =>local_14 – это Ghidra говорит: «по этому адресу лежит переменная, которую будем называть local_10».

  3. Разные синтаксисы Ghidra. Обратите внимание, что в LEA Ghidra пишет [RBP + -0x8] (отрицательное смещение), а в MOV[RBP + local_14] (имя переменной). Это одна и та же адресация, просто Ghidra по-разному показывает ее в разных контекстах.

  4. Связь с исходным кодом. Каждая переменная из исходного C++ кода (x, y, result) имеет свою метку в Ghidra (local_10, local_14, local_c) и свое реальное смещение на стеке (RBP - 0x8, RBP - 0xc, RBP - 0x4).

Имя local_10 (0x10 = 16) не совпадает со смещением от RBP (0x8). Но это не случайность: Ghidra считает смещения от границы стека в момент вызова функции – там, где лежит адрес возврата (это же пишут аннотации Stack[-0x10] в шапке листинга). Между этой границей и RBP находится ровно 8 байт адреса возврата. Поэтому имя на 8 больше абсолютного значения смещения от RBP: local_10RBP-0x8, local_14RBP-0xc, local_cRBP-0x4. Имена – это координаты, просто с другой точкой отсчета. Вывод для практики можно сделать следующий: точку отсчета по имени не угадаешь, единственный источник истины – ассемблерный листинг.

Этот пример идеально показывает, как Ghidra работает с переменными в неоптимизированном коде (-O0): все лежит на стеке, все доступно через RBP, а имена local_XX помогают различать переменные, но их числа привязаны к границе стека в момент вызова, а не к RBP.

Именно поэтому в следующих разделах мы будем проверять смещения по ассемблерному коду, а не по именам local_XX.

3.1.3. Что меняется в оптимизированной версии (-O1)

Теперь рассмотрим оптимизированную версию той же программы calculate. Практически всегда вы будете сталкиваться с оптимизированными версиями программ. Если в -O0 все было предсказуемо и логично, то с включенной оптимизацией компилятор уже сам решает, как будет выглядеть код. Его главная цель – скорость. Он выкидывает все лишнее, сворачивает функции, смешивает данные и выполняет множество других вещей, которые усложняют жизнь исследователю.

Давайте посмотрим, во что превратилась наша функция main после компиляции с флагом -O1:

undefined8 FUN_00101172(void){  istream *this;  ostream *poVar1;  int local_20;  int local_1c [3];    std::operator<<((ostream *)std::cout,"Enter two numbers: ");  this = (istream *)std::istream::operator>>((istream *)std::cin,local_1c);  std::istream::operator>>(this,&local_20);  poVar1 = std::operator<<((ostream *)std::cout,"Result: ");  poVar1 = (ostream *)           std::ostream::operator<<(poVar1,local_20 + local_1c[0] + local_20 * local_1c[0]);  std::endl<>(poVar1);  return 0;}

Куда делась функция calculate? Ее больше нет. Компилятор применил оптимизацию под названием Inline Expansion (встраивание). Вместо того чтобы тратить время на вызов функции, передачу аргументов и возврат, компилятор просто скопировал тело calculate прямо внутрь main.

Давайте теперь заглянем в ассемблерный листинг и разберем его по блокам.

Ассемблерный код функции main оптимизированной программы calculate_O1

Ассемблерный код функции main оптимизированной программы calculate_O1

3.1.3.1. Пролог: RBP больше не якорь

Смотрите, здесь есть PUSH RBP, но нет инструкции MOV RBP, RSP! Компилятор полностью отказался использовать RBP как базу кадра стека – это Frame Pointer Omission:

00101172    PUSH       RBP00101173    PUSH       RBX00101174    SUB        RSP,0x1800101178    LEA        RSI,[s_Enter_two_numbers:_00102004]0010117f    LEA        RBP,[std::cout]  00101186    MOV        RDI=>std::cout,RBP

Почему компилятор так поступил? Потому что современные компиляторы (GCC, Clang) на архитектуре x86-64 используют оптимизацию Frame Pointer Omission (отказ от базового указателя, флаг -fomit-frame-pointer). Компилятор понимает, что держать RBP как «якорь» стека не обязательно – он все равно знает смещения относительно RSP. Это освобождает ценный регистр, который можно использовать для других целей. В нашем случае компилятор сохранил в RBP адрес std::cout, чтобы не загружать его каждый раз заново. Это экономит инструкции и ускоряет код.

Но почему при -O0 этот флаг не сработал? Потому что у оптимизаций есть разные уровни. Флаг -O0 означает минимальный уровень оптимизаций – компилятор генерирует максимально простой код, удобный для отладки. В этом режиме компилятор сохраняет стандартный пролог с push rbp; mov rbp, rsp, чтобы RBP служил стабильной точкой отсчета для локальных переменных. А флаг -O1 и выше активируют базовые оптимизации, включая -fomit-frame-pointer. В зависимости от флага компилятор либо применяет оптимизацию (-O1 и выше), либо нет (-O0).

3.1.3.2. Ввод данных: доступ через RSP

Переменные все еще лежат на стеке, но теперь мы обращаемся к ним через положительные смещения от вершины стека RSP: RSP + 0xc (для первого числа) и RSP + 0x8 (для второго). Никакого RBP здесь нет и в помине:

0010118e    LEA        RSI=>local_1c,[RSP + 0xc]00101193    LEA        RDI,[std::cin]0010119a    CALL       <EXTERNAL>::std::istream::operator>>...001011a2    LEA        RSI=>local_20,[RSP + 0x8]001011a7    CALL       <EXTERNAL>::std::istream::operator>>

3.1.3.3. Математика в регистрах

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

001011ac    MOV        EAX,dword ptr [RSP + local_20]   ; EAX = x001011b0    MOV        EDX,dword ptr [RSP + local_1c]   ; EDX = y001011b4    LEA        EBX,[RAX + RDX*0x1]   ; EBX = x + y001011b7    IMUL       EAX,EDX               ; EAX = x * y001011ba    ADD        EBX,EAX               ; EBX = (x + y) + (x * y)

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

3.1.4. Подведем итог для оптимизированной программы (-O1)

Итак, что мы увидели в оптимизированной версии? Компилятор переписал код так, чтобы он работал быстрее, но стал сложнее для анализа. Вот ключевые изменения:

  1. RBP перестал быть якорем. В прологе нет инструкции mov rbp, rsp. Регистр RBP используется как обычный регистр для хранения данных (в нашем случае – адреса std::cout). Доступ к локальным переменным идет только через RSP + смещение.

  2. Встраивание функций (Inlining). Отдельной функции calculate не существует. Компилятор посчитал, что вызов функции дороже, чем копирование ее тела и встроил код прямо в main.

  3. Промежуточные данные живут в регистрах. Компилятор избегает записи временных значений на стек. Вместо этого он использует регистры (EAX, EDX, EBX) для мгновенных вычислений – это намного быстрее.

  4. Имена local_XX теряют связь со смещениями полностью. Ghidra по-прежнему дает переменным имена вроде local_20 или local_1c, но эти имена совсем не соответствуют реальным смещениям на стеке (0x8 и 0xc).

3.1.5. Что вы должны были понять из этого раздела

Оптимизация стирает грань между исходным C++ кодом и машинными инструкциями. Переменные исчезают, функции сливаются.

Именно с такой оптимизированной реальностью мы столкнемся в нашем крякми. Там тоже нет RBP как якоря и переменные доступны через RSP. Поэтому, когда Ghidra назовет переменную local_70, мы не будем слепо верить, что это RBP - 0x70. Мы заглянем в ассемблер, найдем реальное смещение и будем работать с ним.

Правильное смещение нам понадобится, когда мы дойдем до динамического анализа в отладчике GDB.

3.2. Возвращаемся к crackme: оптимизированный пролог

Посмотрим на начало функции main в листинге Ghidra. В этом разделе мы бегло пробежимся и просто зафиксируем то, что увидим, а в следующих глубоко разберемся со стеком, который формируется для функции main.

Пролог функции main исследуемой crackme

Пролог функции main исследуемой crackme

В crackme видим тот самый оптимизированный пролог, который разобрали ранее в calculate_O1. Компилятор сохраняет на стеке те регистры, которые собирается использовать:

001011f4    PUSH       R14001011f6    LEA        RSI,[s_Enter_a_string...]001011fd    PUSH       R13001011ff    PUSH       R12...00101209    PUSH       RBX...

Затем использует RBP как обычный регистр для хранения адреса std::cout, вместо того чтобы держать его как базу кадра:

00101201    PUSH       RBP00101202    LEA        RBP,[std::cout]...0010120a    MOV        RDI=>std::cout,RBP

Потом выделяется 96 (0x60) байт на стеке для локальных переменных:

0010120d    SUB        RSP,0x60

В результате все локальные переменные доступны через положительные смещения от RSP, а RBP больше не является якорем.

3.3. Находим реальные смещения переменных

В предыдущем подразделе мы выяснили, что в crackme используется оптимизированный пролог: регистр RBP не служит якорем стека, а все локальные переменные доступны через положительные смещения от RSP. Но как именно компилятор расположил наши переменные local_78, local_70, local_68 и другие в памяти? И как перевести странные имена Ghidra (local_70, local_78) в реальные адреса, которые мы сможем использовать в GDB?

Давайте разложим все по полочкам, шаг за шагом.

3.3.1. Рост и сжатие стека в x86-64

В архитектуре x86-64 стек – это область памяти, которая растет в сторону уменьшения адресов. Представьте вертикальную ось координат, где высокие адреса сверху, а низкие – снизу. Регистр RSP всегда указывает на вершину стека – на самый последний добавленный элемент. Последний добавленный элемент – всегда снизу.

Где «вершина» стека

Где «вершина» стека

Когда мы кладем данные на стек (инструкции PUSH, CALL или SUB RSP, N), RSP движется вниз (к меньшим адресам). Мы «строим» стек снизу вверх. Это и есть рост стека – занятие новой памяти.

Что такое «рост» стека

Что такое «рост» стека

Когда мы снимаем данные со стека (инструкции POP, RET или ADD RSP, N), RSP движется вверх (к большим адресам). Мы «разбираем» стек, освобождая память. Это сжатие стека.

Что такое «сжатие» стека

Что такое «сжатие» стека

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

Знакомьтесь, прораб

Прораб контролирует строительство стека. И сейчас он стоит на том месте, куда будет положен первый кирпич. Это и есть вершина стека – последний добавленный элемент. Прораб считает, что стек строится снизу вверх:

Знакомьтесь, прораб

Знакомьтесь, прораб

Прораб успешно заложил первый кирпич

Все идет по плану. RSP (указатель вершины стека) теперь указывает на этот кирпич. Но прораб не знает, что дальше будет сложнее…

Прораб успешно заложил первый кирпич

Прораб успешно заложил первый кирпич

Знакомьтесь, крановщик Вася

Прораб дает указание: «Вася, подними первый кирпич!». Прораб не до конца понимает зачем, но знает, что для следующего кирпича нужно освободить место снизу.

А Вася – профессионал. Он знает, что в компьютере стек растет вниз, поэтому без подъема первого кирпича второй не положить. Он четко выполняет приказ: поднимает кирпич, чтобы под ним появилось место для следующего.

Знакомьтесь, крановщик Вася

Знакомьтесь, крановщик Вася

Прораб успешно заложил второй кирпич

Стек вырос. Вершина стека (RSP) теперь указывает на последний добавленный элемент – самый нижний кирпич.

Прораб доволен – стройка продолжается. А вы теперь знаете, что стек растет вниз и вершина находится внизу.

Прораб успешно заложил второй кирпич

Прораб успешно заложил второй кирпич

Запомните правило: PUSH и SUB – вниз (рост), POP и ADD – вверх (сжатие).

Теперь, когда мы понимаем механику, давайте посмотрим, как именно компилятор «построил» стек функции main в crackme.

3.3.2. Шаг 1. Стек после сохранения регистров

В начале функции main мы видим несколько инструкций PUSH. Компилятор сохраняет на стеке те регистры, которые он планирует использовать в теле функции, чтобы не затереть их значения. Каждый PUSH сохраняет 8-байтный регистр и уменьшает RSP на 8 байт:

001011f4    PUSH       R14          ; RSP -= 8001011fd    PUSH       R13          ; RSP -= 8001011ff    PUSH       R12          ; RSP -= 800101201    PUSH       RBP          ; RSP -= 800101209    PUSH       RBX          ; RSP -= 8

Давайте посчитаем: 5 регистров × 8 байт = 40 байт или 0x28 в шестнадцатеричной системе. После этих пяти инструкций RSP сдвинулся вниз на 0x28 от своей начальной позиции.

Состояние стека после пяти PUSH

Состояние стека после пяти PUSH

Обратите внимание на важную деталь: здесь компилятор сохранил RBP на стеке, но не использует его как базу кадра. Это так называемый callee-saved регистр – функция обязана сохранить его значение, чтобы не сломать работу вызывающего кода. Но после PUSH RBP компилятор тут же переиспользует RBP для своих нужд (в нашем случае для хранения адреса std::cout).

3.3.3. Шаг 2. Выделение места под локальные переменные

После сохранения регистров компилятор выделяет разом место для всех локальных переменных функции:

0010120d    SUB        RSP, 0x60

Инструкция SUB RSP, 0x60 сдвигает указатель стека еще на 96 байт (0x60) в сторону меньших адресов. На нашей схеме это значит, что мы «подкладываем» еще двенадцать кирпичей по 8 байт. Все локальные переменные будут жить именно в этом выделенном блоке, ниже сохраненных регистров.

Состояние стека после выделения места под локальные переменные

Состояние стека после выделения места под локальные переменные

3.3.4. Шаг 3. Как Ghidra видит этот стек

В аннотациях Ghidra переменные обозначены как смещения от предполагаемой базы кадра. Ghidra видит PUSH RBP и думает, что RBP будет использоваться как база. Но на самом деле RBP переиспользуется для хранения адреса std::cout.

Поэтому аннотации Ghidra могут вас сбить с толку (это мы уже обсудили ранее):

Аннотация функции main в Ghidra

Аннотация функции main в Ghidra

Как же нам, реверс-инженерам, перевести имя local_70 в реальный адрес относительно текущего RSP? Давайте выведем простую формулу, а затем проверим ее правильность через листинг ассемблера.

Если возвращаться к нашей аналогии с абсурдным комиксом про строителя, нам нужно вычислить «высоту» построенного здания (общий сдвиг):

  1. Посчитать сдвиг от PUSH: 5 регистров × 8 байт = 0x28.

  2. Прибавить размер выделенной области: 0x60.

  3. Получить общую базу: 0x28 + 0x60 = 0x88.

Теперь на примере local_70 попробуем пересчитать. Ghidra называет переменную local_70, подразумевая смещение 0x70 от некоего базового уровня. Реальное смещение от нового RSP равно:

0x88 (общее смещение) - 0x70 (имя переменной) = 0x18

Звучит сомнительно? Давайте проверим это в ассемблере. Найдем в декомпиляторе инициализацию переменной local_70. Для этого откройте функцию main, найдите следующее выражение и поставьте перед ним курсор:

local_70 = 0;

Ghidra подскажет, где начинается ассемблерная инструкция (голубое выделение и стрелка, указывающая на нужную инструкцию):

Поиск нужной ассемблерной строчки через декомпилятор Ghidra

Поиск нужной ассемблерной строчки через декомпилятор Ghidra

Мы видим инструкцию, выполняющую инициализацию переменной local_70 нулем:

00101235  48 c7 44 24 18 00 00 00 00   MOV qword ptr [RSP + 0x18], 0x0

Как же нам ее прочитать? В следующей врезке разберем теорию и на практике ее применим. А в четвертом шаге составим итоговую таблицу смещений.

3.3.5. Врезка. Анатомия инструкции x86-64: REX, ModRM, SIB

Инструкция в x86-64 – это последовательность байтов, которую процессор читает и интерпретирует как команду. Длина инструкции переменная: от 1 байта (например, RET) до 15 байт. Не все части обязательны, но в нашей инструкции присутствуют практически все ключевые элементы:

[Legacy-префиксы] [REX] [Opcode] [ModRM] [SIB] [Displacement] [Immediate]

Разберем на примере инициализации переменной local_70:

00101235  48 c7 44 24 18 00 00 00 00   MOV qword ptr [RSP + 0x18], 0x0
Соответствие байтов частям инструкции

Соответствие байтов частям инструкции

Схема выше показывает, как каждый байт инструкции соответствует определенному полю. Теперь разберем каждое поле подробно.

3.3.5.1. REX-префикс (1 байт)

Первым байтом в нашей инструкции идет 48 – это REX-префикс.

REX (Register Extension) появился с переходом на 64-битную архитектуру. Его главная задача – расширить возможности процессора. Он позволяет:

  • Использовать 64-битные операнды (вместо 32-битных).

  • Обращаться к дополнительным регистрам R8R15.

REX – это всегда один байт, значения которого лежат в диапазоне 0x400x4F. Внутри этот байт разбит на четыре бита-флага:

Структура REX-префикса: биты W, R, X, B

Структура REX-префикса: биты W, R, X, B

Каждый бит отвечает за свое расширение:

  • W (Word size) – если установлен (1), операция становится 64-битной.

  • R (Register) – расширяет поле reg в ModRM, давая доступ к R8R15.

  • X (indeX) – расширяет поле index в SIB

  • B (Base) – расширяет поле r/m в ModRM или base в SIB

В нашей инструкции байт 48 в двоичном виде – это 0100 1000. Бит W установлен (0100 1 000), а биты R, X, B – нет. Это означает, что мы работаем с 64-битной операцией, но используем стандартные регистры (не R8R15).

Проще говоря: байт 48 говорит процессору: «Все, что будет дальше – это 64-битная операция».

3.3.5.2. Opcode (1–3 байта)

Следующий байт в нашей инструкции – C7. Это opcode (Operation Code) – код операции.

Opcode – это «глагол» инструкции. Он говорит процессору, что именно нужно сделать: сложить (ADD), вычесть (SUB), вызвать функцию (CALL) или, как в нашем случае, переместить данные (MOV).

Opcode может занимать от одного до трех байтов. Простые операции умещаются в один байт, но некоторые инструкции требуют больше. В нашей инструкции один байт (C7) – это код для MOV с непосредственным значением (immediate).

Что значит MOV с immediate?

Это команда процессору: «Взять число, которое идет следом, и записать его по указанному адресу». То есть процессор знает, что после C7 его ждет:

  • Адрес, куда записывать (закодирован в ModRM и SIB).

  • Само число (Immediate).

В нашем случае C7 означает: «Готовься к записи 8-байтового нуля по адресу, который будет указан дальше».

Opcode C7 – MOV с immediate

Opcode C7 – MOV с immediate

3.3.5.3. ModRM байт (1 байт)

Следующий байт в нашей инструкции – 44. Это ModRM (Modifier, Register, Memory) – байт, который рассказывает процессору, «где» находятся данные для операции.

Если Opcode – это «глагол» инструкции, то ModRM – это «подлежащее» и «дополнение». Он описывает:

  1. Откуда брать данные (из регистра или из памяти).

  2. Куда записывать результат (в регистр или в память).

ModRM всегда занимает один байт. Внутри этот байт разбит на три поля:

Структура ModRM байта: mod, reg, r/m

Структура ModRM байта: mod, reg, r/m

mod (2 бита) – определяет режим адресации:

  • 11 – данные лежат в регистрах.

  • 00, 01, 10 – данные лежат в памяти (с разным размером смещения).

reg (3 бита) – номер регистра-источника или расширение Opcode. В нашем случае это расширение для MOV.

r/m (3 бита) – второй регистр или, что важнее, специальное значение 100, которое означает: «Стоп, следующий байт – это SIB, там будет сложная адресация».

В нашей инструкции байт 44 в двоичном виде – это 01 000 100:

  • mod = 01 – память с 8-битным смещением (однобайтовое смещение).

  • reg = 000 – часть Opcode (для MOV).

  • r/m = 100 – сигнал «читай SIB».

Проще говоря, байт 44 говорит процессору: «Данные лежат в памяти, адрес будет вычислен со смещением в один байт, а за подробностями обращайся к следующему байту – SIB».

3.3.5.4. SIB байт (1 байт)

Следующий байт в нашей инструкции – 24. Это SIB (Scale, Index, Base) – байт, который появляется, когда адресация становится сложнее, чем просто [RSP].

Зачем он нужен? В нашей инструкции ModRM уже сказал: «Данные лежат в памяти», но не уточнил, как именно вычисляется адрес. SIB – это уточнение.

SIB – всегда один байт, разбитый на три поля:

Структура SIB байта: scale, index, base

Структура SIB байта: scale, index, base

scale (2 бита) – множитель для индексного регистра:

  • 00 – ×1 (стандартный случай);

  • 01 – ×2;

  • 10 – ×4;

  • 11 – ×8.

index (3 бита) – индексный регистр. Обычно это регистр, который умножается на scale. Кстати, значение 100 (RSP) означает не «используй RSP как индекс», а «индекс отсутствует». Это сделано, чтобы не путать индекс с базой.

base (3 бита) – базовый регистр, к которому прибавляется index * scale + displacement.

В нашей инструкции байт 24 в двоичном виде – это 00 100 100:

  • scale = 00 – множитель ×1;

  • index = 100 – индексный регистр отсутствует;

  • base = 100 – базовый регистр RSP (или R12, если установлен бит REX.B).

Получаем следующую адресацию [RSP + displacement], то есть просто берем значение из RSP и прибавляем к нему смещение.

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

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

3.3.5.5. Displacement (1/2/4 байта)

Следующий байт в нашей инструкции – 18. Это Displacement – смещение. Displacement – это просто число, которое прибавляется к адресу, полученному из SIB (или ModRM).

Давайте вспомним наш стек и заодно разместим local_70 на нем:

Сопоставление Displacement и визуализации стека

Сопоставление Displacement и визуализации стека

Размер смещения определяется полем mod в ModRM:

  • mod = 01 – 1 байт (знакорасширяется до размера операнда: 32 бита без REX.W, 64 бита с REX.W) – наш случай.

  • mod = 10 – 4 байта.

  • mod = 00 + r/m = 101 – 4 байта (RIP-relative адресация).

В нашей инструкции mod = 01, поэтому смещение занимает 1 байт. Значение 0x18 в десятичной системе – это 24.

Проще говоря: «Возьми адрес из RSP, прибавь к нему 24 байта. Это и есть адрес, куда мы будем записывать значение».

3.3.5.6. Immediate (1/2/4/8 байт)

Последние четыре байта в нашей инструкции – 00 00 00 00. Это Immediate – непосредственное значение, константа, которую мы записываем по указанному адресу.

Если Opcode – это «глагол», ModRM и SIB – «подлежащее» и обстоятельства места, то Immediate – это «прямое дополнение». Это само число, которое кладем в память.

Размер Immediate зависит от opcode и может быть:

  • 1 байт (например, PUSH imm8);

  • 2 байта (с префиксом 0x66, который переопределяет размер операнда);

  • 4 байта – самый частый случай в 64-битном коде (наш случай);

  • 8 байт (редко, только для MOV reg, imm64).

В нашей инструкции процессор видит четыре байта 00 00 00 00. Но инструкция – 64-битная (об этом нам сказал REX-префикс 48). Поэтому процессор знакорасширяет это 32-битное значение до 64 бит, получая 0x0000000000000000.

Знакорасширение – это когда старший бит 32-битного числа (бит 31) копируется во все старшие биты 64-битного результата. Для нуля это не имеет значения – в любом случае получается ноль. Но для отрицательных чисел это важно.

Итак, инструкция целиком получается следующей:

  • REX 48 – «Это 64-битная операция»;

  • Opcode C7 – «MOV с immediate»;

  • ModRM 44 – «Данные в памяти, будет SIB»;

  • SIB 24 – «Адрес = RSP + смещение»;

  • Displacement 18 – «Смещение 24 байта»;

  • Immediate 00 00 00 00 – «Записать число 0».

Все вместе: «Записать 8 байт нуля по адресу RSP + 0x18». Именно так компилятор инициализирует переменную local_70 на стеке.

3.3.5.7. Жесткий лимит: правило 15 байт

Аппаратура x86-64 физически не может декодировать инструкцию длиннее 15 байт. Если длина превышает этот лимит, процессор выбросит исключение #UD (Invalid Opcode).

В современных реалиях это создает проблемы даже для разработчиков компиляторов. Например, новые инструкции с 4-байтным EVEX-префиксом (AVX-512/APX) в сочетании с 32-битным смещением и 32-битным immediate уже занимают ровно 15 байт:
4 (EVEX) + 1 (Opcode) + 1 (ModRM) + 1 (SIB) + 4 (Disp) + 4 (Imm) = 15

Если LLVM потребуется добавить к такой инструкции префикс сегмента (например, FS), он не сможет этого сделать. Компилятору придется применять обходной путь, то есть сначала загрузить константу в регистр, а затем выполнить операцию с регистром, чтобы уложиться в лимит.

Для реверс-инженера это означает, что если вы видите странную, «раздутую» последовательность инструкций там, где должна быть одна простая операция, возможно, вы наблюдаете работу компилятора по обходу 15-байтового лимита или результат работы обфускатора.

3.3.5.8. Почему это важно для реверс-инженера

Байты не врут. Если дизассемблер ошибается, вы можете вручную декодировать инструкцию.

Так же не стоит забывать про возможную обфускацию. Вредоносное ПО иногда использует нестандартное кодирование. Понимание структуры помогает распознать такие трюки.

REX.W показывает, работает ли инструкция с 32 или 64 битами – это может быть важным для анализа. А SIB байт раскрывает, как программа обращается к массивам и структурам в памяти.

Запомните, REX говорит о размере операции, ModRM – об операндах, SIB – о сложной адресации, Displacement – о смещении, Immediate – о константе.

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

3.3.6. Шаг 4. Итоговая таблица смещений

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

Аналогично тому, как мы пересчитали смещение для local_70, выполняем пересчет для всех остальных переменных. Вот что у нас получилось:

Где размещены локальные переменные функции main после пересчета смещений

Где размещены локальные переменные функции main после пересчета смещений

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

Посмотрим, как Ghidra интерпретирует каждую переменную на основе ее размера и расположения.

Самая нижняя переменная на стеке – local_7c, она занимает 4 байта по адресу RSP + 0x0c. Ghidra показывает ее как undefined4. Она используется в вызове std::istream::operator>>(std::cin, &local_7c) – скорее всего, это целое число, которое пользователь вводит на втором шаге. Не будем забегать вперед.

По адресу RSP + 0x10, находится local_78 – 8 байт. Это указатель. В коде мы видим, что он настраивается на local_68, а затем передается в std::operator>> для ввода строки. То есть это указатель на область, где будет, как мы предполагаем, храниться строка.

Следом, по адресу RSP + 0x18, лежит local_70 – тоже 8 байт. Она инициализируется нулем, а затем используется в условии if (5 < local_70 - 5U). Похоже, что это какая-то переменная, связанная с длиной строки. В этом нам еще предстоит убедиться.

По адресу RSP + 0x20 находится local_68 – 16 байт. Ghidra показывает ее как undefined1 (всего 1 байт), что явно не соответствует реальному размеру. Эта область используется как буфер, в который копируется указатель local_78 и в нее же записывается ноль в первый байт.

Затем, по адресу RSP + 0x30, лежит local_58 – 40 байт. Ghidra показывает ее как string local_58[40]. Эта переменная используется в конструкторе копирования std::string::string(local_58, &local_78).

И наконец, по адресу RSP + 0x58, находится local_30 – 8 байт. Она инициализируется из FS:[0x28] – это характерный признак стековой канарейки (stack canary), защитного механизма от переполнения буфера.

Обратите внимание на три переменные, которые лежат вплотную друг к другу:

  • local_78 по адресу RSP + 0x10

  • local_70 по адресу RSP + 0x18

  • local_68 по адресу RSP + 0x20

Они расположены строго последовательно, с шагом 8 байт для первых двух и 16 байт для третьей. Это не случайность – так обычно выглядят поля одной структуры в памяти. Но пока это только гипотеза.

Теперь давайте посмотрим на это же в другом формате, как соотносится объявление переменных в декомпиляторе и их расположение на стеке:

Соответствие блока объявления переменных в декомпиляторе и на стеке

Соответствие блока объявления переменных в декомпиляторе и на стеке

Обратите внимание на важную деталь: имена, которые дает Ghidra (local_78, local_70, local_68), не являются прямым указанием на смещения от RSP. Ghidra использует их как метки для различения переменных в декомпиляторе, основываясь на своей эвристике. Чтобы узнать реальное смещение переменной от RSP, мы должны либо пересчитать его по формуле, либо проверить в ассемблерном коде.

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

3.3.7. Выводы подраздела

Мы начали с того, что разобрались с механикой стека и тем, почему в оптимизированном коде переменные доступны через RSP, а не через RBP. Затем мы вывели формулу для пересчета смещений и проверили ее на практике с помощью ассемблерной инструкции MOV qword ptr [RSP + 0x18], 0x0, которая инициализирует local_70.

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

Какие выводы можно сделать:

  • Ghidra дает переменным имена вроде local_XX на основе своей эвристики. Это просто метки, а не указание на реальные смещения.

  • В оптимизированном прологе все переменные доступны через положительные смещения от RSP.

  • Чтобы узнать реальное смещение, нужно либо пересчитать его по формуле, либо проверить в ассемблерном коде.

Теперь, когда мы знаем, где и как лежат переменные, могут возникнуть дополнительные вопросы:

  • Почему local_58 занимает 40 байт, если std::string должна занимать 32? В подразделе 3.4 мы разберем, как Ghidra определяет размер переменных и почему она ошибается в этом случае.

  • Почему local_7c оказалась по адресу RSP + 0x0c, а не RSP + 0x00? В подразделе 3.5 мы посмотрим, как компилятор размещает переменные на стеке и почему образуются «пустые» области.

А затем, в четвертом разделе, перейдем к динамическому анализу в GDB и проверим наши гипотезы о том, что хранится в этих переменных.

3.4. Почему Ghidra ошибается с размером local_58

В предыдущем разделе мы выяснили, что по расчетам Ghidra переменная local_58 занимает 40 байт на стеке – от RSP + 0x30 до RSP + 0x58. Однако, если вы знакомы с внутренним устройством std::string, вы знаете, что эта структура в libstdc++ занимает ровно 32 байта. Возникает закономерный вопрос: откуда взялись лишние 8 байт?

3.4.1. Как Ghidra определяет размер переменной

Исследования алгоритмов работы Ghidra показывают, что декомпилятор определяет размер локальной переменной, вычисляя расстояние между ее началом и началом следующей известной переменной на стеке. Если между двумя переменными есть разрыв (gap), Ghidra предполагает, что переменная с меньшим адресом может быть массивом или неинициализированной структурой.

В нашем случае:

  • local_58 начинается с RSP + 0x30

  • local_30 начинается с RSP + 0x58

  • Расстояние между ними: 0x58 - 0x30 = 0x28 (40 байт)

Ghidra видит этот разрыв и интерпретирует его как string local_58[40].

3.4.2. Что на самом деле

На самом деле 40 байт – это не размер строки, а сумма:

  • 32 байта – структура std::string;

  • 8 байт – неиспользуемое пространство (padding), оставленное компилятором.

Переменная local_30 – это стековая канарейка (stack canary), которая используется для защиты от переполнения буфера:

local_30 = *(long *)(in_FS_OFFSET + 0x28);

Между local_58 и local_30 компилятор оставил 8 байт пространства. Точная причина этого выравнивания может быть разной. Для нас сейчас важно то, что эти 8 байт не являются частью предполагаемой структуры std::string.

3.4.3. Почему Ghidra не видит этого?

Ghidra создала структуру std::string (мы видели это в разделе 2.6), но она пустая – Ghidra не знает ее поля. Без явных обращений к полям структуры декомпилятор не может определить, где заканчивается одна переменная и начинается другая. Поэтому он использует простую эвристику: расстояние до следующей известной переменной.

Метод дает сбой, потому что Ghidra интерпретирует прямые ссылки на стек как отдельные переменные, а не как часть структуры.

Поэтому мы, как реверс-инженеры, не можем однозначно полагаться на Ghidra в определении размеров структур. Эвристику декомпилятора всегда нужно проверять через ассемблерный код.

3.5. Причина смещения local_7c

В предыдущих разделах мы выяснили реальные смещения всех переменных на стеке. Но одна деталь осталась без пояснения: почему local_7c (ввод пользователя) лежит по адресу RSP + 0x0c, а не RSP + 0x00?

3.5.1. Почему local_7c занимает 4 байта?

В листинге ассемблера Ghidra показывает аннотацию:

undefined4        Stack[-0x7c]:4 local_7c

Здесь undefined4 – это тип данных (4-байтовый неопределенный), а :4 – размер в байтах. Но откуда Ghidra знает, что это именно 4 байта?

Ответ кроется в том, как используется local_7c. Если мы посмотрим на декомпилированный код main, то увидим:

std::istream::operator>>(std::cin, &local_7c);

Переменная local_7c передается как второй аргумент в std::istream::operator>>. Чтобы понять, какой тип ожидает эта функция, мы можем проследить путь от вызова к таблице импортов.

Дважды кликнем по std::istream::operator>> в декомпиляторе. Мы попадем в заглушку (thunk function):

thunk undefined __thiscall operator>>(istream * this, int * param_1)

Обратите внимание на сигнатуру: int * param_1 – это означает, что функция ожидает указатель на int в качестве второго аргумента.

В таблице импортов этому вызову соответствует запись:

operator>> / __ZNSirsERi

Если прогнать это замангленное имя через c++filt, получим демангленное имя:

std::istream::operator>>(int&)

То есть существует конкретная перегрузка оператора >>, которая принимает int&. Ghidra видит, что в коде вызывается именно эта перегрузка и на основе этого определяет, что local_7c должна быть типа int.

Этот же механизм мы уже видели ранее, когда Ghidra определяла тип local_78 как std::string на основе импорта std::operator>> для строк.

3.5.2. Что находится в первых 12 байтах стекового кадра?

Анализ ассемблерного кода функции main в Ghidra показывает, что после выделения 96 байт на стеке (SUB RSP, 0x60) компилятор не размещает переменные с самого начала выделенной области.

Первая инструкция, которая обращается к памяти в этой области – это инициализация local_68 по адресу RSP + 0x20:

00101221  48 8d 44 24 20  LEA        RAX=>local_68,[RSP + 0x20]00101226  c6 44 24 20 00  MOV        byte ptr [RSP + local_68],0x00010122b  48 8d 5c 24 10  LEA        RBX=>local_78,[RSP + 0x10]00101230  48 89 44 24 10  MOV        qword ptr [RSP + local_78],RAX00101235  48 c7 44 24 18  MOV        qword ptr [RSP + local_70],0x0

А затем, позже, появляется local_7c по адресу RSP + 0x0c:

001012a1  48 8d 74 24 0c  LEA        RSI=>local_7c,[RSP + 0xc]

Обратите внимание, что адреса RSP + 0x00 и RSP + 0x08 в этом коде не используются для переменных. Первая переменная, которая появляется в выделенной области, – local_7c по адресу RSP + 0x0c.

Это означает, что первые 12 байт стека (от RSP + 0x00 до RSP + 0x0b) содержат временные данные, которые не соответствуют переменным из исходного кода. Это могут быть остатки от предыдущих вычислений или просто неиспользуемое пространство, оставленное для выравнивания.

3.5.3. Почему компилятор разместил переменные именно так?

Компилятор размещает переменные на стеке с учетом нескольких факторов. Первые 8 байт (от RSP + 0x00 до RSP + 0x07) могут использоваться для временных данных – промежуточных значений, которые не соответствуют переменным из исходного кода.

Следующие 4 байта (от RSP + 0x08 до RSP + 0x0b) – это выравнивание: 8-байтовые переменные должны начинаться с адреса, кратного 8, поэтому local_78 размещается не на RSP + 0x0c, а на RSP + 0x10.

В образовавшуюся «щель» между временными данными и выровненной областью компилятор помещает 4-байтовую local_7c – чтобы не тратить место впустую.

Расположение local_7c – наглядный пример того, как компилятор оптимизирует использование стека.

Ghidra правильно определила размер local_7c (4 байта) и ее тип (int) – не по аннотации undefined4, а по контексту использования: вызову std::istream::operator>>(int&). Это еще один пример того, как декомпилятор использует информацию из таблицы импортов для восстановления типов переменных.

3.6. Вывод третьего раздела

В третьем разделе мы проделали путь от полного непонимания того, что такое local_70, local_78 и local_68 и до полной карты стека функции main.

Мы начали с устройства стека в x86-64: как он растет в сторону уменьшения адресов, почему регистр RSP всегда указывает на вершину, а RBP в неоптимизированном коде служит «якорем». В оптимизированном же коде компилятор использует RSP напрямую – именно с этим мы столкнулись в crackme.

Затем разобрались с именами переменных в Ghidra. Имена вроде local_78 или local_70 – это не реальные смещения, а просто метки, которые Ghidra присваивает на основе эвристики. В оптимизированном коде они могут сбивать с толку, потому что не соответствуют смещениям от RSP. Мы вывели формулу для пересчета аннотаций Ghidra в реальные адреса и проверили ее на практике через ассемблерный код.

В результате мы составили полную карту стека функции main и увидели, что три переменные – local_78, local_70, local_68 – лежат вплотную друг к другу. Это навело нас на гипотезу, что они могут быть полями одной структуры.

Кроме того, мы научились читать машинный код: разобрали инструкцию 48 c7 44 24 18 00 00 00 00 по байтам, распознали REX-префикс, Opcode, ModRM, SIB, Displacement и Immediate. Это позволяет нам проверять смещения самостоятельно, не доверяя декомпилятору.

Так же мы увидели, как Ghidra ошибается с размерами: local_58 занимает 40 байт вместо 32, потому что Ghidra включает выравнивание (или другую неопределенную область) в размер переменной, а local_7c оказалась по RSP + 0x0c, а не RSP + 0x00 из-за временных данных и выравнивания.

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

В следующем разделе мы запустим программу в GDB, поставим точки останова и проверим первую гипотезу: действительно ли local_70 хранит длину строки? Мы увидим, как меняется ее значение до и после ввода строки. А вопросы о том, связаны ли local_78, local_70 и local_68 в единую структуру и что хранится в local_68, мы оставим для второй части цикла, где проведем мутационное тестирование.

Раздел 4. Первая гипотеза: что хранится в local_70?

В предыдущем разделе мы разобрались со стеком, прологом функции и тем, как Ghidra дает имена переменным. Мы выяснили, что переменная local_70 находится по адресу RSP + 0x18, а не RSP - 0x70, как могло бы показаться из ее имени.

Взгляните еще раз на декомпилированный код. local_70 инициализируется нулем прямо перед вводом строки. После ввода она никак не изменяется в явном виде, то есть мы не видим ни одной инструкции вроде local_70 = strlen(...). Однако условие if (5 < local_70 - 5U) каким-то образом «знает», сколько символов ввел пользователь. Как это возможно?

Предположим, что оператор ввода (std::operator>>) изменяет local_70, записывая туда длину строки. Это не так очевидно из кода, потому что изменение происходит внутри библиотечной функции.

Если это действительно так, то local_70 хранит длину строки. И мы можем с большей уверенностью предполагать, что local_78, local_70 и local_68 – не три независимые переменные, а три поля одной структуры. Именно так устроен std::string в C++, но пока не будем забегать вперед.

Пора перейти к динамическому анализу и подтвердить (или опровергнуть) нашу гипотезу в GDB.

4.1. Две точки останова: до и после ввода строки

Мы поставим две точки останова (breakpoint) в ключевых местах программы.

Первая точка будет перед тем, как программа выведет приглашение Enter a string of characters (no spaces). В этот момент local_70 должна быть равна нулю, потому что строка еще не введена, а переменная изначально инициализирована нулем:

undefined8 main(void){  // ...    long local_70;    // ...    local_70 = 0;  poVar2 = std::operator<<((ostream *)std::cout,"Enter a string of characters (no spaces): ");  // ...}

Если поставить курсор в декомпиляторе перед poVar2 = ..., то Ghidra подскажет нужный адрес в ассемблере:

Первая точка останова

Первая точка останова

Как мы видим, по адресу 0x0010123e происходит вызов std::operator<< – там, куда поставили курсор. Первая точка останова будет по этому адресу.

Вторая точка будет сразу после того, как пользователь ввел строку. В этот момент local_70 должна содержать, согласно нашей гипотезе, количество введенных символов. Поставим вторую точку перед if, в условии которой используется переменная local_70.

undefined8 main(void){  // ...    if (5 < local_70 - 5U) {    // ...  }}

Аналогично находим адрес в ассемблере. Им оказался 0x0010126a:

Вторая точка останова

Вторая точка останова

Сравним значения local_70 в этих двух точках останова. Если до ввода там 0x0, а после ввода строки test там 0x4 (4 символа), значит наша гипотеза подтверждена: local_70 действительно хранит длину строки.

Чтобы поставить точки останова, нам нужно решить одну техническую проблему: адреса в Ghidra не совпадают с адресами в GDB.

4.2. Пересчет адресов из Ghidra в GDB: ASLR и PIE

Когда мы смотрим на код в Ghidra, мы видим адреса вроде 0x0010123e. Но когда запускаем программу в GDB, эти адреса не работают. Почему?

Причина – два защитных механизма: ASLR (Address Space Layout Randomization) и PIE (Position Independent Executable).

ASLR – это рандомизация адресного пространства. Операционная система загружает программу по случайному базовому адресу. Это защита от эксплойтов: рандомизация адресов затрудняет эксплуатацию уязвимостей.

PIE – это позиционно-независимый исполняемый файл. Программа скомпилирована так, что может работать по любому базовому адресу. Это позволяет ASLR работать эффективно.

В результате Ghidra (статический анализ) не знает реальный адрес загрузки. Для PIE-бинарников она использует фиксированный базовый адрес 0x00100000. Все адреса в Ghidra – это смещения относительно этой статической базы. В тоже время, GDB (динамический анализ) показывает реальные адреса, которые могут меняться при каждом запуске из-за ASLR.

Чтобы поставить точку останова в GDB по адресу из Ghidra, нужно пересчитать его с учетом реального базового адреса по следующей формуле:

Адрес в GDB = $base + (Адрес_из_Ghidra - 0x00100000)

Где $base – это реальный базовый адрес программы в памяти, который мы узнаем в отладчике через info proc mappings.

Давайте посмотрим на практике. Запускаем GDB и программу под отладкой (команда run), чтобы инициализировать карту памяти:

$ gdb ./getting_started_keygen(gdb) runStarting program: /path/to/getting_started_keygen^CProgram received signal SIGINT, Interrupt.

Мы запускаем программу и сразу прерываем ее (Ctrl+C). Это стандартный способ узнать базовый адрес. Чтобы GDB показал карту памяти, вводим команду info proc mappings:

(gdb) info proc mappingsMapped address spaces:Start Addr         End Addr           Size     Offset  Perms File0x0000555555554000 0x0000555555555000 0x1000   0x0     r--p  /path/to/getting_started_keygen0x0000555555555000 0x0000555555556000 0x1000   0x1000  r-xp  /path/to/getting_started_keygen...

Первая строка показывает диапазон памяти, куда загружена наша программа. Начало диапазона – 0x0000555555554000. Это и есть базовый адрес $base.

Сохраняем его в переменную GDB для удобства:

(gdb) set $base = 0x555555554000

Примечание: в дальнейшем я буду использовать приставку (gdb) для команд, которые вводим в отладчике GDB. Это визуальное выделение, а не часть команды, поэтому игнорируйте (gdb).

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

4.3. Установка точки останова с учетом базы и реального адреса

Наши адреса из Ghidra:

  • 0x0010123e – первая точка (до вывода приглашения);

  • 0x0010126a – вторая точка (после ввода строки).

Применяем формулу:

(gdb) break *($base + 0x0010123e - 0x00100000)Breakpoint 1 at 0x55555555523e(gdb) break *($base + 0x0010126a - 0x00100000)Breakpoint 2 at 0x55555555526a

Скобки *(...) нужны, чтобы GDB вычислил выражение как адрес. Без скобок GDB попытается интерпретировать выражение как имя функции или символа и выдаст ошибку.

Теперь запускаем программу:

(gdb) runStarting program: /path/to/getting_started_keygen[Thread debugging using libthread_db enabled]Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".Breakpoint 1, 0x000055555555523e in ?? ()

Программа остановилась на первой точке останова. Давайте убедимся, что мы попали в нужное место, дизассемблировав код вокруг текущей инструкции.

Но дизассемблирование в GDB имеет свои особенности. Если неправильно выбрать диапазон, можно увидеть мусор вместо инструкций. Разберемся в этом перед тем, как продолжить.

4.3.1. Врезка. Особенности дизассемблирования в GDB

При дизассемблировании в GDB есть одна тонкость, о которой важно знать. Если указать диапазон, который начинается с середины инструкции, вы увидите мусор вместо осмысленного кода.

4.3.1.1. Почему так происходит?

Дизассемблер GDB интерпретирует байты как инструкции, начиная с того адреса, который вы ему указали. Если этот адрес не совпадает с началом реальной инструкции, он неправильно распознает все последующие байты. Процессор, в отличие от дизассемблера, всегда знает, где начинается инструкция, потому что он выполняет код последовательно. А GDB просто показывает байты так, как вы его попросили.

4.3.1.2. Как избежать этой ошибки?

Вот несколько советов:

  1. Начинайте с известного адреса. Используйте адрес, который точно является началом инструкции. Обычно это адрес, который вы видите в Ghidra или в точке останова.

  2. Не экономьте на диапазоне. Если вы видите мусор, увеличьте диапазон. GDB с большей вероятностью захватит начало инструкции, если диапазон шире.

  3. Настройте Intel-синтаксис. По умолчанию GDB использует синтаксис AT&T, который многим кажется неудобным. Переключитесь на Intel.

Чтобы переключить GDB на синтаксис Intel, выполните следующую команду:

(gdb) set disassembly-flavor intel

Чтобы GDB всегда использовал Intel-синтаксис, можно добавить эту команду в файл ~/.gdbinit. В этом случае настройка будет применяться автоматически при каждом запуске GDB.

Если видите мусор, то просто увеличьте диапазон или начните с другого адреса. В следующем разделе увидим, как это работает на практике с нашей crackme.

Возвращайтесь в отладку.

4.3.2. Дизассемблирование на первой точке останова

После того как разобрались с особенностями дизассемблирования в GDB, давайте посмотрим на код вокруг первой точки останова. Мы выбрали диапазон -36, +36, потому что он начинается и заканчивается на границах инструкций – это дает чистый дизассемблированный код без мусора.

Выбор правильного диапазона для дизассемблирования

Выбор правильного диапазона для дизассемблирования

Вот результат дизассемблирования с правильно подобранным диапазоном:

Результат дизассемблирования в GDB

Результат дизассемблирования в GDB

Стрелка => показывает текущую инструкцию. Мы видим вызов std::operator<< (вывод строки), что соответствует нашему ожиданию: программа собирается вывести приглашение Enter a string of characters (no spaces):.

Теперь самое интересное – смотрим, что хранится в local_70 по адресу RSP + 0x18.

И снова – врезка. Изучите матчасть и возвращайтесь к отладчику.

4.3.3. Врезка. Команда x в GDB: синтаксис и форматы

Команда x (examine – исследовать) – это главный инструмент для инспекции памяти в GDB. Мы будем использовать его постоянно, чтобы заглядывать в память по указанному адресу и смотреть, что там лежит.

Синтаксис команды гибкий и состоит из четырех частей: количество элементов, формат вывода, размер одного элемента и адрес. В общем виде это выглядит так:

x/<количество><формат><размер> <адрес>

Разберем каждую часть по порядку. Количество указывает, сколько элементов вы хотите увидеть. Если не указать, GDB покажет один элемент.

Формат определяет, как интерпретировать данные: в шестнадцатеричном виде (x), как строку (s), как инструкцию (i) или в десятичном формате (d). Размер задает, сколько байт приходится на один элемент: b для одного байта, h для двух, w для четырех и g для восьми (от англ. giant – 64 бита).

Адрес может быть любым выражением: регистром ($rsp), конкретным адресом (0x555555555000) или арифметическим выражением ($rsp + 0x18).

Посмотрим на несколько примеров, чтобы стало понятнее. Команда x/gx $rsp + 0x18 читает 8 байт (размер g) по адресу RSP + 0x18 и показывает их в шестнадцатеричном формате (x). Это именно то, что нам понадобится для проверки значения local_70.

Если вы хотите прочитать строку по адресу в регистре RDI, используйте x/s $rdi – GDB будет читать байты до первого нулевого и покажет их как текст.

Для дизассемблирования кода прямо в отладчике пригодится команда x/10i $rip – она покажет 10 инструкций, начиная с текущего адреса.

Иногда полезно посмотреть на стек в сыром виде: x/4wx $rsp покажет 4 значения по 4 байта в шестнадцатеричном формате, а x/20bx $rsp – 20 отдельных байт.

4.3.3.1. Разница между x и print

Команда x читает сырую память по адресу и показывает байты в том формате, который вы запросили. Команда print (или сокращенно p) работает иначе – она вычисляет выражение на C/C++, автоматически разыменовывает указатели и может показывать структуры, если известен их тип.

Вот простой пример. Если в регистре $rdi лежит адрес строки test, то команда x/s $rdi прочитает память по этому адресу и покажет test. А команда p $rdi просто покажет сам адрес – 0x55555556bb40.

Запомните простое правило: x – это «загляни в память по адресу», а print – «вычисли выражение». Первое работает с сырыми байтами, второе – с типами данных.

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

4.3.4. Возвращаемся к отладчику и экспериментам с local_70

Возвращаемся к нашей проверке. Мы остановились на первой точке останова – программа вот-вот выведет приглашение. Теперь мы готовы заглянуть в память и посмотреть, что хранится в local_70.

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

(gdb) x/gx $rsp + 0x180x7fffffffdfd8: 0x0000000000000000

Что это за адрес 0x7fffffffdfd8? Это конкретное место в памяти, где в данный момент находится переменная local_70. Он принадлежит области стека – на это указывает характерный префикс 0x7fff.... Обратите внимание, что при следующем запуске программы этот адрес может измениться из-за ASLR. Именно поэтому мы никогда не используем его напрямую, а всегда обращаемся к переменной как $rsp + 0x18. Такой подход универсален и работает при любом раскладе.

Значение 0x0000000000000000 говорит нам, что до ввода строки local_70 равна нулю. Это полностью соответствует нашему ожиданию – в коде мы видели явную инициализацию:

local_70 = 0;

Гипотеза о том, что local_70 хранит длину строки, начинает подтверждаться. Но окончательный ответ мы получим только после ввода данных.

Теперь переходим ко второй точке останова, чтобы увидеть, что изменится.

4.4. Команда continue: переход между точками останова

Когда программа останавливается на точке останова, она замирает – мы можем исследовать регистры, память, стек. Но чтобы пойти дальше, нужно сказать GDB: «Продолжай!». Для этого есть команда continue (или сокращенно c).

Команда continue возобновляет выполнение программы до следующей точки останова, сигнала (например, SIGSEGV при ошибке сегментации) или завершения программы.

Продолжаем выполнение:

(gdb) continueContinuing.Enter a string of characters (no spaces):test

Программа вывела приглашение, после чего мы ввели слово test (4 символа). Программа остановилась на второй точке останова:

Breakpoint 2, 0x000055555555526a in ?? ()

Теперь смотрим, что хранится в local_70 после ввода:

(gdb) x/gx $rsp + 0x180x7fffffffdfd8: 0x0000000000000004

Значение 0x4 в шестнадцатеричной системе равно 4 в десятичной. Мы ввели ровно четыре символа! Нужно выполнить еще несколько экспериментов для подтверждения.

4.5. Подтверждение гипотезы назначения local_70

Давайте еще пару разу запустим программу с разными данными. Для этого введите команду run:

(gdb) runThe program being debugged has been started already.Start it from the beginning? (y or n)

GDB спросит, нужно ли перезапустить программу. Вводим y:

Start it from the beginning? (y or n) yStarting program: /mnt/hgfs/share/crackmes/1. Mazzottis/getting_started_keygen[Thread debugging using libthread_db enabled]Using host libthread_db library "/usr/lib/x86_64-linux-gnu/libthread_db.so.1".Breakpoint 1, 0x000055555555523e in ?? ()

Аналогично продолжаем до второй точки останова:

(gdb) cContinuing.Enter a string of characters (no spaces):

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

  • example0x7 (7 в десятичной)

  • hello_world0xb (11 в десятичной)

  • qwertyuiopasdfghjklzxcvbnm0x1a (26 в десятичной)

Гипотеза подтверждена: local_70 действительно хранит длину введенной строки.

Мы подтвердили, что local_70 – это длина. Но что насчет local_78 и local_68? Мы видели в статическом анализе, что они инициализируются вместе и участвуют в одной операции ввода:

local_78 = local_68;local_68[0] = 0;local_70 = 0;...std::operator>>((istream *)std::cin,(string *)&local_78);

Мы предполагаем, что local_78, local_70, local_68 – это поля одной структуры и, вероятно, именно структуры std::string. Но пока это только догадка. Чтобы подтвердить ее, нам нужно провести серию экспериментов с разной длиной строк и посмотреть, как меняются значения в этих переменных. Это называется мутационное тестирование – методология многократного запуска программы с различными входными данными для выявления скрытых закономерностей.

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

4.6. Возвращение к статическому анализу: что такое local_78?

Мы подтвердили, что local_70 – это длина строки. Теперь посмотрим на local_78:

undefined1 *local_78;...local_78 = local_68;...std::operator>>((istream *)std::cin,(string *)&local_78);

Переменная local_78 объявлена как указатель неизвестного типа (undefined1 *). Ghidra не знает, на что он указывает. Но дальше мы видим:

local_78 = local_68;

Указатель local_78 указывает на ту же область памяти, что и local_68. А именно, local_78 указывает на первый элемент local_68.

Затем следует вызов:

std::operator>>((istream *)std::cin,(string *)&local_78);

Ghidra приводит тип (string *)&local_78. Это явное приведение типа (cast) в стиле C. Ghidra добавляет его не потому, что знает, что local_78 – это std::string, а потому что функция std::operator>> ожидает ссылку на std::string – на уровне ABI это передается как указатель, поэтому Ghidra и пишет (string *). Ghidra подсказывает нам: «Судя по контексту, эта переменная должна быть указателем на std::string».

Ghidra предполагает тип по контексту вызова, но не гарантирует его. Она не знает точный тип local_78, но предполагает его на основе вызова. Это одна из эвристик декомпилятора, которая помогает аналитику.

Это согласуется с тем, что мы видели во втором разделе: таблица импортов уже намекала, что программа работает со std::string (там были std::string::string и Mdispose), а в Data Type Manager Ghidra даже создала пустую структуру с этим именем.

Причем лежат они на стеке вплотную: local_78 (RSP + 0x10), local_70 (RSP + 0x18), local_68 (RSP + 0x20) – единый непрерывный блок из 32 байт. Именно так в памяти выглядит один объект, а не три независимые переменные.

Все эти признаки – импорты, приведение типа, смежность на стеке – указывают на то, что local_78, local_70 и local_68 – это поля одного объекта std::string. Но пока это только гипотеза. Чтобы подтвердить ее, нам нужно понять, как программа управляет памятью при разной длине строк и что на самом деле скрывается за local_68. Этим займемся во второй части цикла, где проведем мутационное тестирование и раскроем Small String Optimization (SSO).

Заключение

Мы начали с того, что запустили программу, ввели test и получили насмешливое «Bro, what are you trying to do?». А заканчиваем с пониманием значения этой фразы.

Давайте зафиксируем, что прошли за эти четыре раздела.

Результаты исследования

Мы начали с осмотра файла снаружи, не заглядывая в декомпилятор: file показал архитектуру, strings вытащил сообщения и дал направление поиска, а readelf указал точку входа. Запуск в изолированной среде показал два режима работы – насмешку и запрос числа. Затем мы загрузили crackme в Ghidra, нашли main через точку входа, изучили таблицу импортов и поняли, что программа написана на C++ и работает со std::string. Деманглинг превратил страшные конструкции _ZStls... в понятные std::operator<<.

Дальше мы разобрались со стеком и оптимизациями. Компилятор применил Frame Pointer Omission – RBP не служит якорем, а переменные доступны через положительные смещения от RSP. Вывели формулу пересчета имен Ghidra в реальные адреса, разобрали анатомию инструкций x86-64 и составили полную карту стека функции main. Это позволило перейти к динамическому анализу: мы поставили две точки останова с учетом ASLR и PIE, заглянули в память через команду x и доказали, что local_70 действительно хранит длину строки. Гипотеза подтверждена: до ввода – ноль, после test – четыре, после example – семь.

Задачи для следующей части

Мы знаем, что local_70 – это длина. Но что такое local_78 и local_68? Мы видели, что они инициализируются вместе, лежат на стеке вплотную друг к другу и передаются в один и тот же оператор ввода. Это три независимые переменные или поля одной структуры?

А главное, почему при вводе строки длиннее определенного количества символов поведение программы изменяется? Мы пока не знаем этого, но интуиция подсказывает: там прячется что-то интересное.

В таблице импортов есть std::string::_M_dispose – метод освобождения памяти. Он вызывается дважды в main. Зачем? Что он освобождает? И как он решает, нужно ли вообще что-то освобождать? Вернемся к этому в следующей части.

Все эти вопросы приведут нас к пониманию внутреннего устройства std::string, его главной оптимизации (Small String Optimization) и к тому, как C++ управляет памятью на низком уровне.

Во второй части цикла мы проведем мутационное тестирование: напишем скрипт на Python, который запустит программу сотню раз с разной длиной строк и автоматически соберет данные из GDB. Мы увидим, как при определенной длине строка неожиданно меняет свое поведение, словно внутри нее срабатывает переключатель. Реконструируем скрытую структуру в Ghidra. Разберемся с условием if (5 < local_70 - 5U) и поймем, почему компилятор записал проверку диапазона через беззнаковое переполнение.

Мы все еще в начале пути. У самурая нет цели, есть только путь. В реверсе все наоборот: цель у нас есть, а путь мы только начали прокладывать. Мы самураи, которые учатся задавать правильные вопросы и которым предстоит прочитать еще две статьи цикла, чтобы стать настоящими реверс-инженерами.

P.S.

Спасибо, что прошли первый этап вместе со мной. Если статья оказалась полезной – добавьте блог в закладки: впереди ещё две части, и с каждой новой материал будет только интереснее.

Я буду рад любой конструктивной критике и дополнениям. Если вы заметили неточность, знаете, как объяснить что-то проще или у вас есть идея для следующего разбора – пишите в комментариях.

Все дополнительные материалы и ссылки можно найти на sutulin.ru. Анонсы новых статей и короткие заметки по реверсу – в Telegram-канале и группе ВКонтакте «Хакерская кузница».

До встречи в следующих статьях.

ссылка на оригинал статьи https://habr.com/ru/articles/1068160/