Пишем низкоуровневый отладчик под Linux на Python
Есть отличные отладчики вроде GDB, но иногда контроля над ними недостаточно. В этой серии статей мы напишем свой низкоуровневый отладчик под Linux на Python.
7К открытий7К показов
Существуют отличные отладчики вроде GDB и LLDB. И хотя их можно настраивать с помощью скриптов, порой хочется иметь больше контроля над работой отладчика. В этой серии статей мы попробуем создать свой отладчик с помощью библиотек python-ptrace, pyelftools и distorm3.
Исходники доступны на GitHub. Всё написано и скомпилировано на Linux x86_64.
Прим.перев. В этой статье используется устаревшая версия Python 2.
Подготовка
Чтобы избежать проблем с правами доступа, мы будем запускать отлаживаемый процесс как дочерний:
Здесь используется системный вызов ptrace для присоединения к дочернему процессу и его остановки. Теперь process содержит много удобных методов. Идея была заимствована из примера в документации python-ptrace.
Считываем значения
Начнём с простого. Получаем регистры:
Считываем байты из памяти:
Ассемблерный REPL
Теперь нам нужно научиться запускать ассемблерные инструкции по одной за раз. Давайте соберём нужные составляющие.
Одиночный шаг:
rip — это указатель на инструкцию. Префикс r обозначает длину в 64 бита. Как видите, он действительно смещается вперёд, когда мы делаем шаг.
Продолжаем до тех пор, пока дочерний процесс не получит сигнал (в нашем случае SIGTRAP). Это может привести к ошибке, если процесс завершится или будет получен другой сигнал:
process.singleStep() неблокирующий, поэтому для удобства мы добавим блокирующую версию:
Так делать не стоит, но пусть process пока побудет глобальной переменной.
Пишем в память. В ассемблере выполнение ассемблерной инструкции int3 приводит к тому, что процессу отправляется сигнал SIGTRAP. Её можно записать в виде одного байта 0xCC:
Также мы можем сравнить регистр rip до и после, чтобы проверить, что значение увеличилось ровно на 1.
Устанавливаем регистр:
Теперь у нас есть всё, что нужно, для запуска одной инструкции, переданной в виде байтов:
Здесь мы перезаписываем байты перед указателем на инструкцию с помощью instr, делаем шаг и возвращаем перезаписанные байты и позицию указателя инструкции. Последнюю часть делаем, только если указатель на инструкцию не изменялся (как в случае с jump или call).
При помощи таблицы преобразования ассемблерных инструкций в байты мы можем поместить это в цикл и сделать REPL.
Вызов функции, первая попытка
Что делать, если мы хотим вызвать ассемблерную функцию и приостановить выполнение после возвращения из неё?
Напишем для этого func_call(func_addr) (запустите её пошагово, чтобы посмотреть на промежуточные состояния). Сначала сохраним часть текущего состояния:
Мы могли бы просто использовать run_asm с инструкцией call. Это байт 0xE8, за которым следуют 5 байт little endian, описывающих разницу между текущим и целевым значениями rip.
Чтобы приостановить дочерний процесс после вызова, мы можем записать int3 (байт 0xCC) после инструкций вызова:
Мы можем перепроверить, что вызов был совершён:
Теперь пусть процесс работает, пока не будет получен сигнал SIGTRAP (желательно тот, что мы установили):
А теперь восстановим перезаписанные байты и значения регистра. В некоторых ситуациях они нам могут пригодиться:
Получаем адрес функции
Давайте попробуем вызвать скомпилированные Си-функции, но пока без аргументов и возвращаемого значения. Для этого нам всего лишь нужно найти адрес функции. Мы можем его получить из заголовка с помощью pyelftools:
А теперь сам вызов:
Вообще, этот метод получает не только функции, но и, наверное, все статические переменные. Для библиотек общего пользования мы можем вызвать variables с полным путём к .so-файлу соответствующей библиотеки.
Тем не менее всегда это работать не будет, поскольку фактический регион используемой памяти не всегда начинается с 0 и нам нужно добавлять начало этого региона в качестве смещения.
Пока что мы можем это сделать следующим образом. С регионами памяти и /proc/pid/maps разберёмся чуть позже:
Ставим точки останова
Теперь у нас есть адреса функций и мы можем поставить точку останова, просто написав int3 (байт 0xCC) в начале функции:
И восстановить перезаписанное значение после прохождения точки останова:
Эти функции можно использовать следующим образом:
Вызов функции, вторая попытка
В общем и целом первый подход работает на удивление хорошо, хотя есть некоторые проблемы.
Слишком большое расстояние вызова. call (0xE8) принимает в качестве аргумента только 5 байт, однако для описания адреса (diff) может потребоваться 8 байт. Мы можем либо подождать, пока не окажемся в пределах функции, которую хотим вызвать (это работает только в том случае, если нам не нужно вызывать функцию сразу же), либо поместить целевой адрес в регистр, например, rax, и воспользоваться инструкцией call rax (байты FF D0).
Перезаписанные байты. Так как мы перезаписываем 7 байт (6 для call, один для int) и восстанавливаем их только после возвращения из функции, то в случае попытки их чтения из другого места можно получить неожиданные значения. Например, если мы совершили вызов внутри тела функции и выполнение программы снова доходит до old_rip.
В теории мы могли бы восстановить 6 из 7 байт после одного шага, оставив только 0xCC. Однако это не решает проблему, а только уменьшает её размер.
Ещё мы могли бы вручную создать стековый кадр.
Вместо этого мы зарезервируем новый участок памяти и запишем наши инструкции туда.
Выделяем память
Мы можем использовать системный вызов mmap() (номер вызова 9) для резервирования памяти. Ему требуются некоторые магические константы, часть которых можно найти в ptrace.syscall:
С помощью следующей функции мы можем вызвать mmap. Здесь syscall представлен байтами 0F 05:
Данная стратегия была позаимствована из этого примера. Для справки, вот константы:
Адрес зарезервированной памяти находится в rax после вызова, поэтому мы его извлекаем и возвращаем.
Это позволяет нам изменить вызов функции, сделав его немного безопаснее:
Тем не менее, в этой функции по-прежнему могут возникать ошибки сегментации.
Получаем следующие инструкции
Добавим в наш отладчик функцию, которая говорит нам, какие следующие инструкции. Для этого нам понадобится дизассемблер distorm3, который можно установить с помощью pip.
Воспользуемся методом PtraceProcess.disassemble для получения итератора по следующим десяти инструкциям:
Запуск этой функции даст примерно следующий результат:
Метод PtraceProcess.dumpCode работает похожим образом, но с другим форматированием.
Итог
На этом пока всё. В следующей статье мы разберёмся с чтением/записью Си-переменных, запуском одиночных Си-команд, библиотеками общего пользования, динамической загрузкой и картами памяти (/proc/pid/maps).
7К открытий7К показов



