Функционал GDB существенно сужается, когда приходится иметь дело с файлами, из которых убраны отладочные символы (получаются так называемые «урезанные бинарники»). Функции и имена переменных превращаются в бессмысленные адреса. Для установки контрольных точек приходится отслеживать адреса нужных нам функций из внешнего источника. Также нужно выводить в консоль структурированные значения и после этого корпеть над дампом памяти, пытаясь вычленить, где именно пролегают границы полей.
Вот почему этим летом, работая в Trail of Bits, я расширил Pwndbg — плагин для GDB. Поддерживает его мой наставник Доминик Чарнота. Я добавил в инструмент две фичи, благодаря которым практическая отладка урезанных бинарников сближается с аналогичной работой, знакомой нам из работы с отладчиком в IDE. Теперь в Pwndbg интегрирован инструмент Binary Ninja, позволяющий лучше выяснять специфику GDB+Pwndbg, а также выводить дамп структур Go, чтобы отлаживать бинарники Go стало удобнее.
Интеграция с Binary Ninja
Чтобы качественнее выяснять информацию о взаимодействии GDB+Pwndbg при отладке, я совместил Pwndbg с Binary Ninja. Это популярный декомпилятор с многофункциональным API для скриптинга. Для этого я установил сервер XML-RPC внутри Binary Ninja, а затем стал отправлять на него запросы из Pwndbg. Так Pwndbg получает доступ к базе данных Binary Ninja с аналитической информацией. Эта информация используется для синхронизации символов, сигнатур функций, смещений переменных в стеке и многого другого. Поэтому с практической точки зрения отладка становится гораздо привычнее.
Для декомпиляции я не стал сериализовать токены в текст, а подтянул их из Binary Ninja. Так мы можем заниматься декомпиляцией с подробной подсветкой синтаксиса и конфигурировать её для использования любого из 3 уровней промежуточного языка, применяемых в Binary Ninja. Декомпиляция демонстрируется непосредственно в контексте Pwndbg. Подсвечивается строка, обрабатываемая в настоящий момент – точно как в ассемблерном представлении.
Также я реализовал фичу, позволяющую отображать регистр актуального счётчика команд (PC counter) как стрелку внутри Binary Ninja. Другая моя фича позволяет устанавливать контрольные точки прямо из Binary Ninja, чтобы не так много приходилось переключаться при работе между Binary Ninja и Pwndbg.
Самая нетривиальная часть работы в рамках интеграции – синхронизировать имена переменных стека. Всякий раз, когда адрес из стека фигурирует в Pwndbg, например в представлении регистра, представлении стека или при предпросмотре аргументов функций, механизм интеграции проверяет, есть ли в Binary Ninja такая именованная переменная стека. Если да, то демонстрируется соответствующая метка. Будут проверены даже родительские кадры стека, чтобы переменные от вызывающей стороны также были размечены правильно.
При реализации этой фичи наибольшая сложность заключалась в том, что в Binary Ninja переменные стека сообщаются только в виде смещений относительно базового кадра стека, поэтому также приходится выяснять базовый кадр стека, и на его основе вычислять абсолютные адреса. В большинстве архитектур, например, x86, предусмотрен регистр указателей стека, указывающий на базовый кадр. Но при этом в большинстве архитектур, в том числе, в x86, указатель кадров стека на самом деле не требуется, поэтому компиляторы вольны использовать его как любой другой регистр.
К счастью, в Binary Ninja предусмотрено распространение констант, поэтому можно проверить, имеют ли регистры предсказуемое смещение от базового кадра. Поэтому в моей реализации сначала проверяется, в самом ли деле указатель направлен на базовый кадр. Если нет — проверяется, продвинулся ли указатель стека настолько, насколько следовало ожидать (при работе с современными компиляторами это обычно подтверждается). Иначе переходим к проверке всех прочих регистров общего назначения, пытаемся найти единообразное смещение. Строго говоря, этот подход иногда может не срабатывать, но на практике он почти никогда не отказывает.
Отладка Go
При отладке исполняемых файлов, скомпилированных из иных языков, кроме C, (а иногда и из C) существует общая болевая точка: обычно компоновка этих файлов в памяти слишком сложна, из-за чего затруднён дамп значений. Сравнительно простой пример – дамп среза в Go. В таком случае одна команда должна вывести указатель и длину среза, а другая — проверить его содержимое. С другой стороны, при дампе даже на маленький словарь может потребоваться более десяти команд, а на большие — сотни команд. Для человека такая задача совершенно невыполнима.
Именно поэтому я создал команду go-dump. Взяв для справки исходный код компилятора Go, я реализовал вывод дампа для всех встроенных типов Go, в том числе для целых и комплексных чисел, строк, указателей, срезов, массивов и словарей. У встроенных типов сохраняется точно такая же нотация, как и в Go, поэтому вам не требуется изучать никакого нового синтаксиса — вы и так сможете использовать команду правильно.
Команда go-dump также обеспечивает синтаксический разбор (парсинг) и дамп любых вложенных типов, так что для вывода информации по любому типу достаточно одной команды.
Синтаксический разбор типов Go во время выполнения
Притом, что Go-специфичный подход к дампу гораздо приятнее, чем дамп памяти вручную, некоторые вещи тут по-прежнему делать неудобно. Вам требуется знать полный тип того значения, что вы дампите, а определить этот тип порой бывает сложно, так или иначе приходится угадывать. В особенности актуальна эта проблема, если вы работаете со структурами, в которых в качестве полей содержится множество вложенных структур. Даже если удаётся логически вывести полный тип, некоторые вещи всё равно выяснить невозможно, поскольку они не сказываются на компиляции. Это касается, например, имён полей структур и имён пользовательских типов.
Удобно, что компилятор Go порождает объект времени выполнения для каждого используемого в программе типа (следует использовать с пакетом reflect). В таком объекте содержится информация о компоновке структур произвольной вложенности, имена типов, размер, выравнивание и пр. Такие объекты, соответствующие типам, также можно сопоставлять со значениями этих типов, поскольку в значении интерфейса хранится не только указатель на данные, но и указатель на объект типа. Таким образом, при выделении объектов в куче ссылка на тип такого объекта передаётся в функцию, выделяющую этот объект (обычно runtime.newobject).
Я написал парсер, при помощи которого можно рекурсивно извлекать эту информацию для обработки информации о произвольно вложенных типах. Этот инструмент предоставляется командой go-type, которая отобразит информацию о типе, действующем во время выполнения – достаточно сообщить его адрес. При работе со структурами в эту информацию входят данные о типе, имени и смещении каждого поля.
Здесь открываются два способа для дампа значений. Первый, более простой, применим только со значениями интерфейсов, поскольку указатель типа хранится вместе с указателем данных – в таком случае их извлечение легко автоматизируется. Их можно дампировать, обозначив типом any из Go пустые интерфейсы (такие, в которых нет методов), а типом interface — непустые интерфейсы. При дампе команды будет автоматически извлекаться и разбираться её тип, поэтому дамп пойдёт бесшовно, и вам не потребуется вводить никакой информации о типах.
Второй способ работает со всеми значениями. Но, чтобы им воспользоваться, необходимо найти и задать указатель на тип конкретного значения. Зачастую это совсем несложно – достаточно посмотреть, какой указатель был передан в ту функцию, которая выделила значение. Но при работе с глобальными переменными или такими, операцию выделения которых бывает сложно найти, иногда требуется немного гадать, чтобы выяснить тип. Тем не менее, обычно этот метод всё равно проще, чем пытаться вручную выявить компоновку типа. Кроме того, таким способом можно дампировать даже самые сложные типы. Я проверял этот метод на нескольких крупных структурных типах в урезанной сборке компилятора Go, а это одна из самых больших и сложных опенсорсных баз кода на Go. Дамп проходил без всяких проблем.
Резюме и перспективы
Этим летом я смог доработать Pwndbg так, что теперь он интегрируется с Binary Ninja и открывает доступ к подробной отладочной информации. Ещё я добавил команду go-dump для дампа значений Go. Эти функции уже присутствуют в ветке по разработке Pwndbg и в новейшей версии этого инструмента (2024.08.29).
Предполагаю, что на этом работа по улучшению процесса отладки не заканчивается. Я выполнил интеграцию с Binary Ninja в модульном стиле так, чтобы в будущем было несложно добавить поддержку и для других декомпиляторов. Думаю, было бы интересно добавить полную поддержку Ghidra (в настоящее время при интеграции только синхронизируется декомпиляция), так как Ghidra — свободный и опенсорсный декомпилятор. Пользоваться им могут все желающие.
Что касается отладки Go, можно поработать над тем, чтобы лучше отображались горутины, и работать с ними было удобнее. В настоящее время именно этим силён отладчик Delve (специализированный для работы с Go), выгодно отличающийся от GDB/Pwndbg. Например, Delve может выводить список всех горутин и команду, которая их создала. Также в нём есть команда для переключения между горутинами.
Panzerschrek
Вся соль, как я понял, в компиляторе Go, который вставляет информацию о типах, которая во многом схожа с отладочной информацией. С другими языками так бы не прокатило.