Реверс — это сканворд. Как я впервые нормально понял Ghidra

от автора

Привет, Хабр.

У меня бывают неожиданные заказы, из неожиданных сфер на фрилансе. Недавно писал про то как прилетел большой проект по классификатору фоток. А теперь пришел запрос на реверс! Не могу вдаваться в подробности проекта — много конфиденциального — но я расскажу про конкретный разбор одного .dll файла. Открыл Ghidra, кликнул на функцию, включил декомпилятор — и передо мной встала стена.

Не метафорическая стена. Прям реально стена!

local_117clocal_1098DAT_10015c50FUN_10005050undefined4 *, указатели, адресная арифметика, какие-то переходы, какие-то глобальные данные, какие-то вызовы в никуда. То есть формально это C-подобный код. Но по ощущению — будто кто-то взял человеческий код, пропустил через мясорубку, потом аккуратно разложил фарш по строкам и сказал: “ну вот, теперь читай”. 

Короче: DLL — это уже не исходники, а бинарник. Внутри машинный код. Ghidra пытается собрать из него C-подобную картинку, но без нормальных имён переменных, типов и авторских комментариев. Поэтому получается не исходник, а его читаемый труп.

Сначала пытаешься читать функцию сверху вниз — ну по человечески. Логично же: вот начало, вот конец, сейчас пойму. Но через пять минут мозг начинает течь, потому что ты читаешь не исходник. Ты читаешь фарш исходника. Там нет нормальных имен и комментариев. Нет структуры проекта. Нет “вот этот модуль отвечает за лицензию”. Есть просто FUN_004016f0, и она почему-то на 500 строк.

И вот пока я эту функцию ковырял, переименовывал переменные, ходил по ссылкам, открывал соседние функции, смотрел строки, в какой-то момент меня щёлкнуло.

Это же сканворд.

Не в смысле “красивая аналогия для статьи”. А прям рабочая модель. Я реально не читаю код. Я решаю сканворд! Хотя сканворды я решал в последний раз лет 8 назад.

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

В реверсе так же.

Только вместо клеток — функции, переменные, строки, импорты и xrefs. Вместо слов — гипотезы. Вместо подсказок — строки, ошибки, известные функции, константы и узнаваемые паттерны. И вот эта мысль реально помогла мне перестать смотреть на Ghidra как на проклятие и начать двигаться по ней как по сетке. 

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

Первое ощущение: я ничего не понимаю

Итак, есть DLL. Внутри функция, которую Ghidra назвала чем-то вроде FUN_004016f0.

Открываю декомпилятор. Вижу примерно такую котлетку:

local_117c = FUN_00405050(&DAT_00415c30, ...);local_1098 = ...;local_1154 = FUN_00408a10(local_117c, local_1098, ...);if (local_1154 == (void *)0x0) {    ...}

Вроде бы код. Вроде бы даже C. Но смысла ноль.

local_117c — это что? Путь? Буфер? Структура? Указатель на строку?
local_1098 — имя файла? Размер? Ошибка?
FUN_00405050 — вообще что делает?
DAT_00415c30 — данные? строка? таблица?

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

А вот и нет! Не пойму.

Потому что это не тот формат. Декомпилированный код без символов — это не обычный исходник. Его нельзя нормально читать линейно. У тебя нет имен, а значит нет смысловых якорей. Мозг тратит почти всю энергию не на понимание логики, а на удержание в голове: “так, local_117c — это вроде то, что пришло из этой функции, а local_1154 — кажется, поток, но я не уверен. БЫТЬ МОЖЕТ».

Через десять минут таких упражнений появляется желание закрыть Ghidra и пойти собирать шкаф. Или наоборот — разобрать шкаф, потому что там хотя бы болты понятнее.

И вот тут помогает сканворд. В сканворде ты не решаешь поле с первой клетки. Ты ищешь лёгкую подсказку. В Ghidra первая легкая подсказка почти всегда — строки.

Первое слово: строки

Я открываю Window → Defined Strings.

Это, наверное, одно из самых приятных окон в Ghidra для новичка. Потому что строки — это куски программы, которые еще не успели полностью превратиться в кашу. Это человеческие фрагменты внутри бинарника. Тут тебе ошибки, пути, названия переменных окружения, имена параметров, URL, режимы открытия файлов, сообщения вроде invalid license file.
Все это — не просто текст. Это подсказки.

В моем случае среди строк нашлась:

SG_LIC_PATH

И вот это уже что-то.

До этого у меня был просто DAT_00415c30. А теперь есть человеческое имя. SG_LIC_PATH похоже на путь к лицензии. Возможно, переменная окружения. Возможно, PHP-константа. Возможно, настройка.

Я еще не знаю точно, но это уже не пустая клетка!

Правый клик по строке → References → Show References to Address.
Ghidra перебрасывает меня внутрь той самой огромной функции. И внезапно эта функция перестает быть полностью абстрактной. Теперь я знаю: где-то здесь используется SG_LIC_PATH. Вписал первое слово. Вся сетка еще пустая, но у соседних слов появились буквы.

Xrefs — это пересечения

Строка SG_LIC_PATH использовалась в вызове FUN_00405050.

Название, конечно, прекрасное. Очень говорящее. Прям сразу понятно, что функция делает. Спасибо, Ghidra. Но в сканворде мы не угадываем слово изолированно. Мы смотрим пересечения. В Ghidra пересечения — это xrefs.

У любой функции можно задавать два вопроса:

Кто ее вызывает?
И кого вызывает она?

Открываю FUN_00405050. Внутри вижу работу со строками, strlen, какие-то штуки из мира PHP, что-то похожее на получение значения по имени. По контексту начинает складываться гипотеза: функция получает значение SG_LIC_PATH откуда-то из окружения или из PHP-констант. Я не уверен на 100%. Но мне и не надо быть уверенным на 100%, чтобы сделать следующий шаг. Это же исследование — тут нужно строить гипотезы.

Я переименовываю функцию.

Не в super_exact_verified_get_environment_variable_from_php_runtime, а в примерно такое, рабочее:

get_env_value

И переменную тоже:

lic_path_env

Теперь кусок кода превращается во что-то такое:

lic_path_env = get_env_value("SG_LIC_PATH", ...);

Вот. Уже можно жить и жевать этот фарш!

Да, может быть, потом я пойму, что это не совсем env, а константа или обертка над другим механизмом. Ну и что? Переименую. Но прямо сейчас код стал читаться. В Ghidra переименование — это не косметика.
Это не “сначала все пойму, потом красиво назову”. Не, тут надо называть чтобы понять. Плохое человеческое имя (Сентиклетий?) лучше, чем идеальный машинный мусор.

Если не уверен — называй с maybe_:

maybe_get_env_valuemaybe_license_pathlooks_like_config_value

Это карандаш в сканворде — потом сотрешь.

Переименование — это вписать букву в клетку

Вот был код:

local_117c = FUN_00405050(s_SG_LIC_PATH);local_1098 = ...;local_1154 = FUN_00408a10(local_117c, local_1098);

И был он нечитаемый.

А стал:

lic_path_env = get_env_value("SG_LIC_PATH");lic_filename = ...;lic_stream = open_license_file(lic_path_env, lic_filename);

Даже если open_license_file пока неточно, глаз уже не спотыкается. Мозг не держит отдельную таблицу соответствий. Ты освободил оперативку в голове. Она нынче дорогая!

А это в реверсе критично.

Потому что сложность там часто не в том, что конкретная строка очень умная. А в том, что строк много, имена мусорные, и контекст распадается. Ты вроде понял кусок, через пять минут ушёл в другую функцию, вернулся — и уже не помнишь, что такое local_1098.

Поэтому я начал переименовывать агрессивно.
Не “когда докажу”. А когда появилась рабочая гипотеза.

local_1098 стал lic_filename.
local_1154 стал lic_stream.
FUN_00405050 стал get_env_value.
Какая-то глобальная строка перестала быть DAT_..., а стала нормальной подсказкой.

И вот после этого код начал вести себя как сканворд: одно вписанное имя делало соседний участок проще.

Следующее слово: открытие файла

После SG_LIC_PATH ниже по коду я увидел вызовы:

php_stream_open_wrapper_ex

Ого. Я этого не писал!

Когда в функции несколько раз встречается php_stream_open_wrapper_ex, не надо быть великим реверсером, чтобы понять: где-то тут открывают файл. Причем через PHP stream API. И теперь можно смотреть не на всю функцию сразу, а на условия вокруг этих вызовов.

Постепенно складывается такая картина.
Три сценария, короче:

  1. если SG_LIC_PATH есть, код берёт этот путь, добавляет к нему имя файла лицензии и пытается открыть файл там.

  2. если SG_LIC_PATH нет, но имя файла выглядит как абсолютный путь — начинается с /\ или содержит что-то вроде :\ — код открывает его напрямую.

  3. если путь относительный, код берёт базовую директорию и начинает искать файл выше по дереву каталогов.

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

“найти файл лицензии”.

Сначала по явно заданному пути, потом как абсолютный путь, потом через поиск вверх.
Достаточно человеческая логика! Просто спрятанная под слоем local_.... И это снова сканворд. Нашёл строку SG_LIC_PATH — получил первое слово. Перешёл по xrefs — понял функцию чтения значения. Увидел php_stream_open_wrapper_ex — понял соседний блок. Переименовал переменные — следующий блок стал легче.

Частые слова: идиомы

Дальше файл открыл и там был цикл примерно такого вида:

do {    if (capacity - size < 0x400) {        capacity <<= 1;        buf = erealloc(buf, capacity, 0);    }    bytes_read = php_stream_read(lic_stream, buf + size, 0x400, ...);    size += bytes_read;} while (bytes_read == 0x400);

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

В реверсе то же самое. Есть паттерны, которые надо просто начать узнавать.

Здесь паттерн такой:

есть буфер, есть текущий размер, есть capacity, если места мало — capacity удваивается, буфер расширяется через realloc, чтение идёт кусками по 0x400, то есть по 1024 байта, цикл продолжается, пока прочитали полный кусок.

Это чтение файла или потока неизвестной длины целиком в память.
После этого можно переименовывать:

buf        -> lic_buffersize       -> lic_sizecapacity   -> lic_capacitybytes_read -> chunk_size

Двигаемся, короче!
Ну и тут важно понять: я не разобрал каждую строку на атомы. Я узнал паттерн.

Реверс — это во многом насмотренность. Один раз увидел динамический буфер с удвоением. Второй раз. Третий. Потом уже не тратишь на это полчаса. Видишь capacity <<= 1 рядом с realloc и такой: ага, понятно, читаем что-то неизвестного размера.

BSWAP: ещё одна буква

После чтения буфера код берёт первые четыре байта и вызывает маленькую функцию FUN_00404d70. Открываю её. Внутри почти ничего нет. Она делает BSWAP для 32-битного значения. То есть меняет порядок байт. Первая мысль: назвать ntohl_32. Потому что часто такое бывает при чтении big-endian значений, network byte order и всё такое.

Но тут надо быть аккуратным. ntohl — это уже конкретная семантика. А я пока вижу просто byteswap. Поэтому более честное имя:

read_be32

или:

byteswap32

Я выбрал имя в духе read_be32, потому что по контексту это выглядело как чтение 32-битных big-endian полей из бинарного формата. Дальше вижу, что таких чтений два подряд. Первые 4 байта — какое-то число, которое потом используется как размер. Следующие 4 байта — какое-то ожидаемое значение, которое позже с чем-то сравнивается.

Гипотеза:

payload_size = read_be32(lic_buffer);expected_sig = read_be32(lic_buffer + 4);payload = lic_buffer + 8;

И вот здесь я специально не тороплюсь называть expected_sig как expected_hmac. Потому что пока я не знаю, HMAC это или нет. Я знаю только, что это ожидаемое контрольное значение из заголовка. И все тут. В реверсе легко самому себя запутать именами. Назвал переменную hmac, и через полчаса уже начинаешь думать, что доказал HMAC. Хотя на самом деле ты просто так назвал.

Поэтому лучше сначала называть по наблюдаемому поведению:

expected_sigexpected_checksumexpected_mac

А уже потом уточнять.
Слово вроде подходит, но пока карандашом.

TLV: структура начала проявляться

После первых 8 байт начинается разбор payload.

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

Примерно так:

while (ptr < end) {    tag = *ptr++;    length = read_length(ptr);    ptr += length_size;    switch (tag) {        case 0:            ...            break;        case 1:            ...            break;        case 2:            ...            break;        case 5:            ...            break;    }    ptr += length;}

Это Type-Length-Value. TLV.

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

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

И когда ты видишь TLV-цикл, то код перестаёт быть просто “каким-то парсером”. Ты понимаешь: таки тут разбирается структурированный объект. Значит, можно идти по case и смотреть, какие поля вытаскиваются.

В моём случае начало вырисовываться примерно так:

case 0:    license_version = ...    // версия должна быть в диапазоне примерно 16–22case 2:    has_extra_data = ...    // флаг наличия дополнительного блокаcase 5:    license_id = ...    // идентификатор лицензииcase 4:    some_date_or_limit = ...    // похоже на дату, срок или числовой параметр

Не всё стало понятно сразу. В реверсе не всегда нужно назвать каждую клетку!

Можно оставить unknown_field_4.
Можно написать maybe_expire_date.
Можно поставить some_limit_value.

То есть — я признаю, что я не знаю. Но главное быть честным с самим собой. Ведь цель реверса обычно не в том, чтобы восстановить идеальный исходник. Цель — понять механизм на достаточном уровне. Если тебе нужно понять, как файл читается и проверяется, то не обязательно прямо сейчас знать смысл каждого TLV-поля.

Init / Update / Final

Дальше начинается ещё более интересная часть.

После разбора TLV код формирует ключ. Есть встроенная строка в base64, которая декодируется в 16 байт. Потом туда, возможно, добавляются или примешиваются данные из TLV.

А потом идут три вызова подряд:

FUN_0040bf40(&ctx, key, key_len);FUN_0040c500(&ctx, payload, payload_size);computed_sig = FUN_0040d3c0(&ctx);

И вот это снова частое слово.

Init / Update / Final.

Это очень знакомая форма: сначала завели контекст, потом скормили данные, потом сняли результат. Хэш? MAC? HMAC? Шифр? Пока не знаю. Но форма уже узнаваемая! Сначала инициализировали контекст. Потом скормили данные. Потом только получили результат. Можно ли сразу сказать “это HMAC”?
Нет. Не надо торопиться. Нужно смотреть детали: размеры, константы, операции, что сравнивается, как строится ключ. Но на уровне функции смысл уже понятен:

инициализировать проверку
прогнать payload
получить вычисленное контрольное значение
сравнить с ожидаемым значением из заголовка.

Переименовываю аккуратно:

mac_init(&ctx, key, key_len);mac_update(&ctx, payload, payload_size);computed_sig = mac_final(&ctx);

И дальше вижу сравнение:

if (computed_sig != expected_sig) {    error("invalid license file");}

Вот теперь предыдущее слово подтвердилось пересечением.

Раньше второе поле заголовка было просто expected_sig. Теперь понятно: оно действительно связано с проверкой целостности payload. Можно уточнить имя до expected_mac или expected_signature.Если потом станет ясно, что это именно HMAC, можно назвать expected_hmac. Но лучше не раньше, так то. 

Ты ждёшь, пока соседние слова подтвердят твою гипотезу.

Второй слой проверки

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

Но УВЫ.

Меня туда привело не озарение, а тупо проверка границ: после payload + payload_size указатель ещё не доходил до конца буфера, и дальше шёл отдельный расчёт/сравнение.

То есть структура файла оказалась примерно такой:

[header][payload][optional_tail]

Где payload проверяется одним MAC-like значением, а optional_tail — ещё одной проверкой. Если бы я смотрел на это в самом начале, то, скорее всего, просто утонул бы.

Но теперь вокруг уже есть нормальные имена:

lic_bufferlic_sizepayload_sizepayloadexpected_maclicense_context

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

Сначала каждый блок стоит дорого. Надо держать всё в голове.
Потом появляются имена.
Потом куски начинают объяснять друг друга.
Потом функция внезапно читается почти как обычный код.

Потому что ты вписал достаточно букв в сетку.

Что получилось в итоге

В начале у меня была функция на 500+ строк, которая выглядела как фарш:

local_1098FUN_0040bf40DAT_00415c30, указатели, какие-то условия, какие-то переходы.

В итоге функция оказалась довольно обычным пайплайном: сначала она ищет файл лицензии — через SG_LIC_PATH, абсолютный путь или подъём по каталогам; потом читает его целиком в буфер; потом разбирает заголовок и TLV-payload; потом собирает ключ, считает MAC-like значение и сравнивает его с тем, что лежит в файле. Если после payload остаётся хвост — проверяет ещё и его. Онли инженерная процедура.

И вот если написать это списком, звучит просто. Ну даже скучно!

Но прикол в том, что в начале этого списка не было. Вообще. Был просто фарш. Он появился из маленьких шагов:

нашёл строку — перешёл по xref — заглянул в соседнюю функцию — переименовал — увидел импорт — узнал идиому — переименовал ещё — нашёл byteswap — предположил заголовок — увидел TLV — узнал init/update/final — проверил гипотезу по сравнению — уточнил имена.

Ура!

Именно поэтому реверс для меня стал похож на сканворд. Потому что каждое подтверждённое место ограничивает соседние.

Почему не надо пытаться понять всё

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

Ну иначе как будто не считается.
Но это ловушка.

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

Нужно понимать достаточно для задачи.

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

Это нормальная инженерная граница.

Какие-то поля можно оставить как:

unknown_field_4maybe_expire_datelooks_like_machine_idsome_feature_flags

И это лучше, чем притвориться, что ты всё понял.

Где сканворд ломается

Аналогия хорошая, но не идеальная.

Сканворд — честная игра. Там есть фиксированная сетка и правильные ответы. Если слово не подходит, оно конфликтует с пересечениями.

Реверс не такой.

  1. там нет гарантии единственного решения. Один и тот же кусок кода может одинаково хорошо объясняться несколькими гипотезами. Поле может быть датой, версией, лимитом, timestamp, флагами — и пока не будет дополнительного контекста, ты не узнаешь точно.

  2. можно долго жить с ошибочной гипотезой. Особенно если ты дал ей слишком уверенное имя. Назвал expected_hmac, а потом всё вокруг начал подгонять под HMAC. Хотя правильнее было бы expected_mac или вообще expected_sig.

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

Ghidra даёт карту. Но иногда надо выйти на местность. В сканворде такого нет. Там не нужно запускать слово под отладчиком.

Хотя было бы весело — идея для игр Zachtronics

Минимальный набор действий в Ghidra

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

Строки:

Window → Defined Strings

Или поиск строк:

Search → For Strings

Потом правый клик по интересной строке:

References → Show References to Address

Потом смотрим функцию, где строка используется.

Потом переименовываем переменные правым кликом, функции, какие аргументы и что возвращают, ходим по ссылкам. На старте этого уже достаточно — не надо сразу становиться шумахером ассемблера. Надо научиться находить опорные клетки.

Какие идиомы стоит начать узнавать

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

Динамический буфер с удвоением:

if (capacity - size < chunk_size) {    capacity <<= 1;    buf = realloc(buf, capacity);}

Скорее всего, читают или собирают данные неизвестного размера.

Чтение big-endian значения:

val = read_be32(ptr);ptr += 4;

Часто встречается в бинарных форматах, сетевых структурах, заголовках.

TLV-цикл:

tag = *ptr++;length = read_length(ptr);switch (tag) {    ...}ptr += length;

Это структурированный объект из полей переменной длины.

Init / Update / Final:

ctx_init(&ctx, key, key_len);ctx_update(&ctx, data, data_len);result = ctx_final(&ctx);

Похоже на хэш, MAC, HMAC, шифр или другую контекстную обработку данных.

Сравнение после вычисления:

if (computed != expected) {    error();}

Почти всегда проверка целостности, подписи, checksum, MAC, пароля или чего-то такого.

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

Почему это вообще успокаивает

Мне кажется, самое сложное в первом контакте с Ghidra — не техника, а ощущение. Ты открываешь функцию и думаешь: “Я ничего не понимаю. Наверное, я тупой. Наверное, реверс не для меня. Наверное, тут нужны какие-то люди, которые в детстве вместо сказок читали Intel Manual (о нет, таких не было)”.

Но нет!!

Ощущение “я ничего не понимаю” — это отличное стартовое состояние! Иначе это не реверс. Ты просто ещё не нашёл первое слово. Как только появляется первая строка, xref, нормальное имя — становится легче. Хаос начал получать форму.

Ты постепенно переписываешь машинный фарш в понятную карту.

Что забрать с собой

Что я бы сказал себе в начале этого разбора:

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

Идиомы тоже не надо учить как теорию. Увидел realloc с удвоением — запомнил. Увидел TLV — запомнил. Увидел init/update/final — запомнил. Через какое-то время ты начинаешь узнавать не отдельные строки, а форму.

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

И не пугайся первой стены. Она всегда такая.
Но с дырочкой.


Реверс психики проходит тут

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