Реверс-инжиниринг — это сканворд. Как я впервые нормально понял Ghidra. Часть 1

Николай Носков 12 мин

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

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

local_117c, local_1098, DAT_10015c50, FUN_10005050, undefined4 *, указатели, адресная арифметика, какие-то переходы, какие-то глобальные данные, какие-то вызовы в никуда. То есть формально это 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_1098 — это то, что я видел на строке 40, а local_117c — это то, что пришло из FUN_00405050…». Через десять минут таких упражнений появляется желание закрыть 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_value
maybe_license_path
looks_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_buffer
size       -> lic_size
capacity   -> lic_capacity
bytes_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_sig
expected_checksum
expected_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–22

case 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 указатель ещё не доходил до конца буфера, и дальше шёл отдельный расчёт/сравнение.

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

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

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

lic_buffer
lic_size
payload_size
payload
expected_mac
license_context

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

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

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

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