stihl не предоставил(а) никакой дополнительной информации.
Сегодня мы разберемся, как привычная отладочная информация может обернуться неожиданной головной болью при реверсе. Возьмем бинарь Mach-O под macOS ARM64: с виду — символы есть, а по факту — сплошной мусор. Разберем, почему так происходит, как вручную найти настоящую таблицу символов и строк, и напишем простой скрипт, который вернет читаемые имена обратно в твою IDA.
Мы очень быстро привыкаем к полезным мелочам, делающим нашу жизнь удобнее. Настолько привыкаем, что не замечаем их, когда они есть, однако очень болезненно воспринимаем момент, когда они по каким‑то причинам внезапно пропадают. В этом случае приходится заново учиться базовым вещам, горестно вспоминая, как же мы жили без полезных инструментов раньше.
Сегодня мы поговорим об отладочных символах (я думаю, ты уже достаточно глубоко погружен в темы кодинга и реверса, чтобы не нужно было на пальцах объяснять, что это такое, для чего и как используется). Во время отладки программы к ним так привыкаешь, что забываешь убрать их во время финальной компиляции. С другой стороны, хакеры настолько приспособились к тому, что программист забыл убрать отладочную информацию из программы, что воспринимают ее наличие как нечто само собой разумеющееся и временами теряются, если ее не оказывается на месте или с ней что‑то не так.
Давай попробуем разобраться с конкретным случаем, когда отладочная информация в реверсируемом файле вроде как присутствует, но воспользоваться ей напрямую не получается. Для примера мы возьмем некое приложение под macOS формата Mach-O для архитектуры ARM64.
Для просмотра ссылки Войдиили Зарегистрируйся
Мы когда‑то начинали разбирать этот формат в статье «Для просмотра ссылки Войдиили Зарегистрируйся», поэтому сегодня будем сочетать полезное с полезным и продолжим разбор, делая упор уже на символьной информации, содержащейся в таких файлах. Загружаем нашу программу в IDA.
Для просмотра ссылки Войдиили Зарегистрируйся
На первый взгляд, отладочная информация в файле вроде как присутствует, большинство функций имеют имена. Однако при ближайшем рассмотрении эти «имена» оказываются бессмысленным набором символов без конца и начала и совершенно не вписываются в логику программы.
Для просмотра ссылки Войдиили Зарегистрируйся
Можно было бы предположить злонамеренную обфускацию, о которой я говорил в нескольких предыдущих статьях, однако для обфускации имена, наоборот, чересчур осмысленны. Вдобавок по логике проще выкинуть из модуля всю отладочную информацию начисто, чем заморачиваться с ее обфускацией, преследуя непонятную цель.
Соглашусь: последний аргумент несколько слабоват, поскольку за время нашего знакомства нам неоднократно попадались творения с абсолютно нечеловеческой логикой, однако априори я все‑таки стараюсь придерживаться хорошего мнения о людях. Попробуем проанализировать отладочную информацию этого модуля другими средствами. В конце концов, на IDA свет клином не сошелся, функция анализа Debug Symbols Mach-O встроена, например, в тот же Detect It Easy.
Для ее просмотра необходимо нажать слева кнопку «Информация о файле» или кнопку «Mach-O». В открывшемся окне нас интересует расположенная слева вкладка «Команды → LC_SYMTAB → Таблица символов».
Для просмотра ссылки Войдиили Зарегистрируйся
Даже на первый взгляд (а мы вернемся к подробному разбору этой таблицы позднее) там содержится полная каша. Однако, пролистав таблицу чуть ниже, мы натыкаемся на более осмысленную информацию, что дает нам надежду на успешное излечение пациента.
Для просмотра ссылки Войдиили Зарегистрируйся
Строки в правой колонке напоминают неправильно порезанные декорированные имена символов (если ты еще не слышал о таких, Для просмотра ссылки Войдиили Зарегистрируйся, а мы пока не будем отвлекаться на эту тему). Если мы ткнемся во вкладку «LC_SYMTAB → Таблица строк → Строки», то получим и вовсе полностью валидные декорированные имена функций.
Для просмотра ссылки Войдиили Зарегистрируйся
То есть отладочные имена функций в файле все‑таки присутствуют. Попробуем разобраться, что в них не так, почему ни Detect It Easy, ни IDA их правильно не воспринимают, после чего попытаемся руками самостоятельно починить их.
Для этого нам придется снова окунуться в матчасть.
Вспомним Для просмотра ссылки Войдиили Зарегистрируйся. Правда, там шла речь о «жирном» бинарнике, в котором содержался код, предназначенный для нескольких процессоров, наш же случай проще: у нас бинарник «обезжиренный» и в нем содержится только код для процессора ARM64. В двух словах передам суть полезной информации из той статьи для тех, кому лень читать.
Типичный файл Mach-O состоит из трех областей. Заголовок содержит общую информацию о двоичном файле: порядок байтов (магическое число), тип процессора, количество команд загрузки и так далее. Команды загрузки — это своего рода оглавление, которое описывает положение сегментов, таблицу символов, динамическую таблицу символов и тому подобное. Каждая команда загрузки содержит метаданные, такие как тип команды, ее имя, позиция в двоичном файле и прочие полезные сведения.
Третья область — данные — обычно самая большая часть объектного файла. Она содержит код и данные, такие как таблицы символов, которые, собственно, нас и интересуют. Не будем подробно останавливаться на структурах формата Mach-O, ты можешь самостоятельно изучить их в качестве домашнего задания, прочитав Для просмотра ссылки Войдиили Зарегистрируйся или разбирая код и описание к нему Для просмотра ссылки Войди или Зарегистрируйся. Поскольку мы изначально выбрали Detect It Easy в качестве инструмента для анализа, для экономии времени поручим парсинг именно ему. Начнем с заголовка файла.
Для просмотра ссылки Войдиили Зарегистрируйся
Как я уже говорил, наш бинарник «постный», поэтому он сразу начинается с mach_header. Помимо типа процессора, в заголовке мы видим, что за ним следуют 0x2d команды загрузки общим размером 0x16F0 байт. Каждая команда имеет собственную структуру и собственный размер, поэтому, полагаясь на DIE, сразу находим интересующую нас команду LC_SYMTAB, содержащую ссылки на отладочные символы (я упоминал ее в начале статьи).
Для просмотра ссылки Войдиили Зарегистрируйся
Как следует из спецификации, формат у этой команды следующий:
Все это похоже на правду до тех пор, пока мы не попробуем проверить данные по указанным смещениям. Самый наглядный пример — таблица строк, содержащая имена отладочных символов с нулевым разделителем. По смещению 0x5020cc0 и близко такого нет, однако, терпеливо полистав вниз (или вбив в поиск какое‑нибудь имя), мы находим ее начало 0x6000 байтами ниже по адресу 0x5026cc0.
Для просмотра ссылки Войдиили Зарегистрируйся
Итак, мы определили реальное начало таблицы строк, попробуем теперь найти начало таблицы символов. Снова заглянув в спецификацию, узнаём, что таблица символов представляет собой массив, элементами которого являются структуры nlist_64 размером 0x10 байт каждая:
Поля этой структуры соответствуют колонкам таблицы со вкладки «Команды → LC_SYMTAB → Таблица символов», и их назначение весьма непросто для понимания, поэтому мы снова оставим этот урок для самостоятельного изучения по спецификации Mach-O в процитированных выше статьях. Если кратко: нас сейчас интересуют два поля этой структуры — n_strx и n_value. Их смысл весьма нетривиальным образом определяется установкой битовых групп остальных трех полей, но мы очень грубо предположим, что n_strx — это смещение имени символа относительно таблицы символов (в нашем случае это 0x5026cc0), а n_value — соответствующий адрес данного символа.
Разумеется, по смещению из LC_SYMTAB 0x4b78e20 находятся левые данные, никак не напоминающие подобную таблицу. Но у нас есть первая пришедшая в голову тупая гипотеза, что если адрес таблицы строк сдвинут относительно реального на 0x6000, то и адрес таблицы символов надо начинать искать по такому же сдвигу, то есть по адресу 0x4b78e20+0x6000=0x4B7EE20. На удивление, столь дурацкая идея с ходу оказывается верной, и мы действительно обнаруживаем по адресу 0x4B7EE20 таблицу из 1-байтовых элементов, первые 4 байта которых напоминают смещения на текстовые строки относительно 0x5026cc0 (выделено красным), а последние похожи на адреса функций в IDA (выделено желтым).
Для просмотра ссылки Войдиили Зарегистрируйся
Впрочем, даже если бы нам не повезло, таблицу символов почти наверняка можно было бы найти, пролистав код чуть выше начала таблицы строк (обычно она предшествует ей). В конце концов, можно было бы просто поискать в коде адрес любой именованной функции из IDA — он обязательно присутствует в таблице символов, причем, как правило, неоднократно. Правда, искать начало таблицы символов придется глазами, но это дело упорства и займет минут пять.
Итак, мы нашли реальные адреса таблицы символов и таблицы строк, попробуем теперь максимально просто воспользоваться этим знанием для восстановления реальных имен отладочных символов. Поскольку мы изначально выбрали IDA как инструмент для восстановления исходного кода, предлагаю, не мудрствуя лукаво, реализовать эту задачу через простенький скрипт на IDAPython.
Мы уже сталкивались с IDA-скриптингом в предыдущих статьях, это довольно мощный инструмент расширения функциональности дизассемблера, но недооцененный, в основном из‑за слабого документирования. Впрочем, если ты читаешь «Хакер», то наверняка видел статьи на эту тему, например «Для просмотра ссылки Войдиили Зарегистрируйся» или «Для просмотра ссылки Войди или Зарегистрируйся». В общем, берем IDAPython и для начала пробуем просто вывести список имен символов с соответствующими им адресами. Для этого создаем текстовый файл loadnames.py следующего содержания:
Запустив этот скрипт через File → Script file (Alt-F7), в окне Output мы получаем список символов с соответствующими именами и адресами:
И заменяем такой конструкцией:
И снова запускаем наш скрипт. Проигнорировав и отключив пару вышеупомянутых предупреждений, мы получаем код с вполне себе читаемыми символами, причем уже редекорированными.
Для просмотра ссылки Войдиили Зарегистрируйся
Итак, мы разобрали простейший случай восстановления сбойной таблицы отладочных символов. Разумеется, в реальной жизни все может быть намного сложнее — таблицы символов и строк могут быть побиты, фрагментированы и даже перемешаны, но теперь ты хотя бы имеешь представление, как они выглядят, где их искать и на что именно обращать внимание при их восстановлении. Возможно, восстановленная таким образом отладочная информация поможет тебе при реверсе какого‑то особо нетривиального приложения.
Мы очень быстро привыкаем к полезным мелочам, делающим нашу жизнь удобнее. Настолько привыкаем, что не замечаем их, когда они есть, однако очень болезненно воспринимаем момент, когда они по каким‑то причинам внезапно пропадают. В этом случае приходится заново учиться базовым вещам, горестно вспоминая, как же мы жили без полезных инструментов раньше.
Сегодня мы поговорим об отладочных символах (я думаю, ты уже достаточно глубоко погружен в темы кодинга и реверса, чтобы не нужно было на пальцах объяснять, что это такое, для чего и как используется). Во время отладки программы к ним так привыкаешь, что забываешь убрать их во время финальной компиляции. С другой стороны, хакеры настолько приспособились к тому, что программист забыл убрать отладочную информацию из программы, что воспринимают ее наличие как нечто само собой разумеющееся и временами теряются, если ее не оказывается на месте или с ней что‑то не так.
Давай попробуем разобраться с конкретным случаем, когда отладочная информация в реверсируемом файле вроде как присутствует, но воспользоваться ей напрямую не получается. Для примера мы возьмем некое приложение под macOS формата Mach-O для архитектуры ARM64.
Для просмотра ссылки Войди
Мы когда‑то начинали разбирать этот формат в статье «Для просмотра ссылки Войди
Для просмотра ссылки Войди
На первый взгляд, отладочная информация в файле вроде как присутствует, большинство функций имеют имена. Однако при ближайшем рассмотрении эти «имена» оказываются бессмысленным набором символов без конца и начала и совершенно не вписываются в логику программы.
Для просмотра ссылки Войди
Можно было бы предположить злонамеренную обфускацию, о которой я говорил в нескольких предыдущих статьях, однако для обфускации имена, наоборот, чересчур осмысленны. Вдобавок по логике проще выкинуть из модуля всю отладочную информацию начисто, чем заморачиваться с ее обфускацией, преследуя непонятную цель.
Соглашусь: последний аргумент несколько слабоват, поскольку за время нашего знакомства нам неоднократно попадались творения с абсолютно нечеловеческой логикой, однако априори я все‑таки стараюсь придерживаться хорошего мнения о людях. Попробуем проанализировать отладочную информацию этого модуля другими средствами. В конце концов, на IDA свет клином не сошелся, функция анализа Debug Symbols Mach-O встроена, например, в тот же Detect It Easy.
Для ее просмотра необходимо нажать слева кнопку «Информация о файле» или кнопку «Mach-O». В открывшемся окне нас интересует расположенная слева вкладка «Команды → LC_SYMTAB → Таблица символов».
Для просмотра ссылки Войди
Даже на первый взгляд (а мы вернемся к подробному разбору этой таблицы позднее) там содержится полная каша. Однако, пролистав таблицу чуть ниже, мы натыкаемся на более осмысленную информацию, что дает нам надежду на успешное излечение пациента.
Для просмотра ссылки Войди
Строки в правой колонке напоминают неправильно порезанные декорированные имена символов (если ты еще не слышал о таких, Для просмотра ссылки Войди
Для просмотра ссылки Войди
То есть отладочные имена функций в файле все‑таки присутствуют. Попробуем разобраться, что в них не так, почему ни Detect It Easy, ни IDA их правильно не воспринимают, после чего попытаемся руками самостоятельно починить их.
Для этого нам придется снова окунуться в матчасть.
Вспомним Для просмотра ссылки Войди
Типичный файл Mach-O состоит из трех областей. Заголовок содержит общую информацию о двоичном файле: порядок байтов (магическое число), тип процессора, количество команд загрузки и так далее. Команды загрузки — это своего рода оглавление, которое описывает положение сегментов, таблицу символов, динамическую таблицу символов и тому подобное. Каждая команда загрузки содержит метаданные, такие как тип команды, ее имя, позиция в двоичном файле и прочие полезные сведения.
Третья область — данные — обычно самая большая часть объектного файла. Она содержит код и данные, такие как таблицы символов, которые, собственно, нас и интересуют. Не будем подробно останавливаться на структурах формата Mach-O, ты можешь самостоятельно изучить их в качестве домашнего задания, прочитав Для просмотра ссылки Войди
Для просмотра ссылки Войди
Как я уже говорил, наш бинарник «постный», поэтому он сразу начинается с mach_header. Помимо типа процессора, в заголовке мы видим, что за ним следуют 0x2d команды загрузки общим размером 0x16F0 байт. Каждая команда имеет собственную структуру и собственный размер, поэтому, полагаясь на DIE, сразу находим интересующую нас команду LC_SYMTAB, содержащую ссылки на отладочные символы (я упоминал ее в начале статьи).
Для просмотра ссылки Войди
Как следует из спецификации, формат у этой команды следующий:
Код:
struct symtab_command {
// LC_SYMTAB 0x2
uint32_t cmd;
// Размер структуры symtab_command — 0x18
uint32_t cmdsize;
// Смещение к таблице символов — 0x4b78e20
uint32_t symoff;
// Количество символов в таблице символов — 0x4a537
uint32_t nsyms;
// Смещение таблицы строк — 0x5020cc0
uint32_t stroff;
// Размер таблицы строк в байтах — 0xa37f50
uint32_t strsize;
};
Для просмотра ссылки Войди
Итак, мы определили реальное начало таблицы строк, попробуем теперь найти начало таблицы символов. Снова заглянув в спецификацию, узнаём, что таблица символов представляет собой массив, элементами которого являются структуры nlist_64 размером 0x10 байт каждая:
Код:
struct nlist_64 {
union {
// Индекс, обычно это смещение имени символа относительно начала таблицы строк
uint32_t n_strx;
} n_un;
// Тип символа
uint8_t n_type;
// Целое число, указывающее номер раздела, в котором может быть найден данный символ или NO_SECT
uint8_t n_sect;
// Предоставляет дополнительную информацию о природе данного символа для нестандартных символов
uint16_t n_desc;
// Значение данного символа (обычно это адрес символа)
uint64_t n_value;
};
Разумеется, по смещению из LC_SYMTAB 0x4b78e20 находятся левые данные, никак не напоминающие подобную таблицу. Но у нас есть первая пришедшая в голову тупая гипотеза, что если адрес таблицы строк сдвинут относительно реального на 0x6000, то и адрес таблицы символов надо начинать искать по такому же сдвигу, то есть по адресу 0x4b78e20+0x6000=0x4B7EE20. На удивление, столь дурацкая идея с ходу оказывается верной, и мы действительно обнаруживаем по адресу 0x4B7EE20 таблицу из 1-байтовых элементов, первые 4 байта которых напоминают смещения на текстовые строки относительно 0x5026cc0 (выделено красным), а последние похожи на адреса функций в IDA (выделено желтым).
Для просмотра ссылки Войди
Впрочем, даже если бы нам не повезло, таблицу символов почти наверняка можно было бы найти, пролистав код чуть выше начала таблицы строк (обычно она предшествует ей). В конце концов, можно было бы просто поискать в коде адрес любой именованной функции из IDA — он обязательно присутствует в таблице символов, причем, как правило, неоднократно. Правда, искать начало таблицы символов придется глазами, но это дело упорства и займет минут пять.
Итак, мы нашли реальные адреса таблицы символов и таблицы строк, попробуем теперь максимально просто воспользоваться этим знанием для восстановления реальных имен отладочных символов. Поскольку мы изначально выбрали IDA как инструмент для восстановления исходного кода, предлагаю, не мудрствуя лукаво, реализовать эту задачу через простенький скрипт на IDAPython.
Мы уже сталкивались с IDA-скриптингом в предыдущих статьях, это довольно мощный инструмент расширения функциональности дизассемблера, но недооцененный, в основном из‑за слабого документирования. Впрочем, если ты читаешь «Хакер», то наверняка видел статьи на эту тему, например «Для просмотра ссылки Войди
Код:
import idc
import idautils
def _read_long(f):
"""считать из бинарного файла 32-битное целое"""
data = f.read(4)
if len(data) != 4:
raise ParseError("read_long failed")
return struct.unpack("<I", data)[0]
def _read_long64(f):
"""считать из бинарного файла 64-битное целое"""
data = f.read(8)
if len(data) != 8:
raise ParseError("read_long64 failed")
return struct.unpack("<Q", data)[0]
def _read_name(f):
"""считать из бинарного файла текстовую строку с завершающим 0"""
size = 0x1000
bdata = f.read(size)
data=bdata.decode('utf-8', errors='ignore')
if len(data) < size:
return None
endpos = data.find('\0')
if endpos == -1:
return data
elif endpos == 0:
return ""
else:
return data[:endpos]
# Имя текущего файла, загруженного в IDA
path_name=idaapi.get_input_file_path()
# Открываем его как бинарный файл
f = open(path_name, "rb")
# Смещение до начала таблицы символов
pos=0x4B7EE20
# Пока не конец таблицы символов
while pos<0x5024190:
# Переходим на текущую запись nlist_64
f.seek(pos, 0)
# Считываем n_strx, прибавляем к нему смещение до начала таблицы строк и получаем смещение до имени символа
nameofs=_read_long(f) +0x5026CC0
# Пропускаем следующие 4 байта, устанавливаем позицию на n_value
_read_long(f)
# Читаем n_value — адрес текущего символа
funcofs=_read_long64(f)
# Если он не нулевой
if funcofs>0:
f.seek(nameofs, 0)
# Читаем имя символа
name=_read_name(f)
# Печатаем строку
print(hex(pos)+": "+hex(nameofs)+" ("+name+") "+hex(funcofs))
# Переходим к следующей записи
pos+=0x10
Код:
0x4c5a960: 0x578516d (__ZNSt3__16__treeINS_12__value_typeIjPN5Model4Base16BodyRigEvaluator4NodeEEENS_19__map_value_compareIjS7_NS_4lessIjEELb1EEENS_9allocatorIS7_EEE25__emplace_unique_key_argsIjJRKNS_21piecewise_construct_tENS_5tupleIJRKjEEENSJ_IJEEEEEENS_4pairINS_15__tree_iteratorIS7_PNS_11__tree_nodeIS7_PvEElEEbEERKT_DpOT0_) 0x101e00c50
0x4c5a970: 0x578529f (__ZNSt3__16__treeINS_12__value_typeIjPKN5Model4Base20BodyAnimationSamplerEEENS_19__map_value_compareIjS7_NS_4lessIjEELb1EEENS_9allocatorIS7_EEE25__emplace_unique_key_argsIjJRKNS_21piecewise_construct_tENS_5tupleIJRKjEEENSJ_IJEEEEEENS_4pairINS_15__tree_iteratorIS7_PNS_11__tree_nodeIS7_PvEElEEbEERKT_DpOT0_) 0x101e00c50
0x4c5a980: 0x57853d1 (__ZNK5Model4Base16BodyRigEvaluator8EvaluateEf) 0x101e00d1c
0x4c5a990: 0x57853ff (__ZN5Model4Base16BodyRigEvaluator13ComputeJointsERPNS1_4NodeERKN4Core5Types9Composite8Matrix4fEPS8_RKNS7_8Vector3fE) 0x101e00dec
Поскольку мы парсим таблицу символов очень грубо, не обрабатывая поля n_type, n_sect и n_desc, в этом списке полно коллизий — дублирующиеся символы, несколько имен (включая пустые) на один и тот же адрес и прочее. Это, в принципе, приемлемо, просто при назначении уже назначенного имени IDA будет выдавать предупреждение типа «Can’t rename byte as '...' because the name is already used in the program». Это предупреждение можно проигнорировать и вообще отключить. А вот назначение пустого имени хорошо бы отфильтровать, так как оно обнуляет уже назначенное имя символа. В общем, ищем вот эту строку в нашем скрипте:
print(hex(pos)+": "+hex(nameofs)+" ("+name+") "+hex(funcofs))
Код:
if name!="":
idc.set_name(funcofs,name,idc.SN_NOCHECK | idc.SN_PUBLIC)
Для просмотра ссылки Войди
Итак, мы разобрали простейший случай восстановления сбойной таблицы отладочных символов. Разумеется, в реальной жизни все может быть намного сложнее — таблицы символов и строк могут быть побиты, фрагментированы и даже перемешаны, но теперь ты хотя бы имеешь представление, как они выглядят, где их искать и на что именно обращать внимание при их восстановлении. Возможно, восстановленная таким образом отладочная информация поможет тебе при реверсе какого‑то особо нетривиального приложения.