Патчим процессы в Linux на лету при помощи GDB

от автора

Техники перехвата функций в Linux хорошо известны и описаны в интернете. Наиболее простой метод заключается в написании динамической библиотеки с «функциями-клонами» и использовании механизма LD_PRELOAD для переопределения таблицы импорта на этапе загрузки процесса.

Недостаток LD_PRELOAD в том что необходимо контролировать запуск процесса. Для перехвата функций в уже работающем процессе или функций отсутствующих в таблице импорта можно использовать «сплайсинг» — запись команды перехода на перехватчик в начало перехватываемой функции.

Также известно, что в Python имеется модуль ctypes позволяющий взаимодействовать с данными и функциями языка Си (т.е. большим числом динамических библиотек имеющих Си интерфейс). Таким образом ничто не мешает перехватить функцию процесса и направить её в Python метод обёрнутый в С-callback с помощью ctypes.

Для перехвата управления и загрузки кода в целевой процесс удобно использовать отладчик GDB, который поддерживает написание модулей расширения на языке Python (https://sourceware.org/gdb/current/onlinedocs/gdb/Python-API.html).

Нюансы

Код примера приведен полностью в конце статьи и состоит из двух файлов:

  • pyinject.py — расширение GDB
  • hook.py — модуль с функциями перехватчиками

Со стороны GDB код удобно оформить в виде пользовательской команды. Новую команду можно создать, наследуя от класса gdb.Command. При использовании команды в GDB будет вызываться метод invoke(argument, from_tty).

Также можно создавать пользовательские параметры наследуя от gdb.Parameter. В примере статьи он используется для задания имени файла с функциями перехвата.

Подключение к работающему процессу PID и загрузку модуля удобно делать сразу при запуске GDB

gdb -ex 'attach PID' -ex 'source pyinject.py' -ex 'set hookfile hook.py'

Поле этого отлаживаемый процесс остановлен и запущена интерактивная командная строка GDB, в которой будет доступна новая команда «pyinject».

Перехват можно условно разделить на три этапа:

  1. Инжектирование интерпретатора Python в адресное пространство целевого процесса
  2. Сбор информации о перехватываемой функции
  3. Собственно перехват

Пункты 1 и 2 проще делать на стороне отладчика, пункт 3 уже внутри целевого процесса.

Инжектирование интерпретатора Python

Большая часть Python интерфейса GDB предназначена для расширения отладочных возможностей. Для всего остального есть gdb.execute(command, from_tty, to_string), которая позволяет выполнить произвольную команду GDB и получить её вывод в виде строки.
Например:

out = gdb.execute("info registers", False, True) 

Также полезна gdb.parse_end_eval(expression), вычисляющая выражение и возвращающая результат в виде gdb.Value.

Первым делом необходимо загрузить библиотеку Python в адресное пространство целевого процесса. Для этого необходимо вызвать dlopen в контексте целевого процесса.
Можно использовать команду call в gdb.execute, либо gdb.parse_and_eval:

# pyinject.py gdb.execute('call dlopen("libpython2.7.so", %d)' % RTLD_LAZY) assert long(gdb.history(0)) handle = gdb.parse_and_eval('dlopen("libpython2.7.so", %d)' % RTLD_LAZY) assert long(handle) 

После этого можно инициализировать интерпретатор

# pyinject.py gdb.execute('call PyEval_InitThreads()') gdb.execute('call Py_Initialize()') 

Первый вызов создает GIL (global interpreter lock), второй подготавливает Python C-API к использованию.

И загрузить модуль с функциями перехвата

# pyinject.py fp = gdb.parse_and_eval('fopen("hook.py", "r")') assert long(fp) != 0 pyret = gdb.parse_and_eval('PyRun_AnyFileEx(%u, "hook.py", 1)' % fp) 

PyRun_AnyFileEx выполняет код из файла в контексте модуля __main__.

Нюансы

Вышеописанное будет работать только если целевой процесс не использует Python (как основной или скриптовый язык). Если это не так, то всё серьёзно усложняется. Основная проблема в том что в процессе остановленном для отладки в случайном месте нельзя использовать никакие функции Python C-API (кроме может быть Py_AddPendingCall).

Модуль hook.py

Модуль hook.py содержит функции перехватчики и класс Hook выполняющий собственно перехват.
Функции перехватчики обозначаются при помощи декоратора. Например для функции open стандартной библиотеки напечатаем её аргументы и вернем результат вызова оригинальной функции, хранящейся в поле orig

# hook.py @hook(symbol='open', ctype=CFUNCTYPE(c_int, c_char_p, c_int)) def python_open(fname, oflag):     print "open: ", fname, oflag     return python_open.orig(fname, oflag) 

Декоратор @hook принимает два параметра:

  • symbol — имя перехватываемого символа (предполагается что символ доступен в GDB из таблиц импорта или отладочной информации, но ничто не мешает перехватывать функции по адресам вместо символов)
  • ctype — класс ctypes задающий тип функции

Декоратор регистрирует функцию в классе Hook и возвращает не изменяя.

# hook.py def hook(symbol, ctype):     def deco(func):         Hook.register(symbol, ctype, func)         return func     return deco 

Метод register создает экземпляр класса и сохраняет его в словаре all_hooks. Таким образом после выполнения файла, благодаря декораторам в Hook.all_hooks будет вся информация о доступных функциях перехватчиках.

# hook.py class Hook(object):     all_hooks = {}     @staticmethod     def register(symbol, *args):         Hook.all_hooks[symbol] = Hook(symbol, *args) 

Чтобы осуществить перехват со стороны GDB вызовом одной функции, удобно определить статический метод в классе Hook, ответственный за перехват

# hook.py class Hook(object):     @staticmethod     def hook(symbol, *args):         h = Hook.all_hooks[symbol]         if h.active:             return         h.install(*args) 

В *args здесь передается дополнительная информация о перехватываемой функции. Какая именно зависит от метода перехвата.

Методы перехвата «сплайсингом»

Сплайсинг глобально делится на два подвида по способу вызова оригинальной функции.

В simple hook вызов оригинальной функции состоит из нескольких шагов:

  1. начало оригинальной функции восстанавливается из сохраненной копии
  2. производится вызов
  3. начало снова затирается инструкцией перехода на перехватчик
Нюансы

Недостаток очевиден, в многопоточной программе нельзя гарантировать, что другой поток не вызовет функцию во время перезаписи её начала. Частично это лечится остановкой других потоков на время вызова оригинальной функции. Но во-первых нет стандартного способа этого достичь, во-вторых можно словить deadlock если неудачно вызвать функцию типа malloc

В trampoline hook начало оригинальной функции копируется в новое место и после него записывается переход в тело оригинальной функции. В этом варианте оригинальная функция всегда доступна по новому адресу.

Trampoline hook работает в многопоточных программах, но гораздо сложнее в установке. Необходимо перезаписывать целое число инструкций, для чего обычно используется дизассемблер. Приход архитектуры x86_64 добавил еще больше проблем из-за повсеместного распространения адресации памяти относительно регистра %rip (адрес текущей команды).

Нюансы

Посмотрим на начало функции open в GDB:

0x7f6cc8aa83e0 <open64+0>:          83 3d ed 33 2d 00 00  cmpl    $0x0,0x2d33ed(%rip) 0x7f6cc8aa83e7 <open64+7>:          75 10                 jne     0x7f6cc8aa83f9 <open64+25> 0x7f6cc8aa83e9 <__open_nocancel+0>: b8 02 00 00 00        mov     $0x2,%eax 0x7f6cc8aa83ee <__open_nocancel+5>: 0f 05                 syscall 

Если мы перепишем первую команду "cmpl $0x0,0x2d33ed(%rip)" по другому адресу, то относительный адрес 0x2d33ed(%rip), который сейчас указывает на 0x7f6cc8d7b7d4, будет указывать в другое место (привет SIGSEGV).

Чтобы сделать trampoline hook этой функции нужно:

  1. определить размер команд в начале функции
  2. выделить память не дальше чем в 2ГБ от целевого адреса команды cmpl (смещение 0x2d33ed(%rip) знаковое 32-битное)
  3. скопировать начало в новое место и пропатчить доступ к памяти относительно %rip в cmpl

В довершение картины, команда перехода должна быть короче 9 байт, т.к. это функция с двумя точками входа и по адресу 0x7f6cc8aa83e9 уже находится __open_nocancel. Это значит, что наш трамплин должен быть не дальше чем в 2ГБ от начала open для возможности 32-битного перехода (все 64-битные переходы длиннее 9 байт).

В принципе, имея всю мощь GDB за спиной (gdb.execute()), ничто не мешает корректно реализовать trampoline hook, но для простоты примера в этой статье будет использоваться simple hook.

В simple hook единственное ограничение это длина инструкции перехода.
Вариантов два (основных):

  • Опкод E9 (5 байт) — относительный 32-битный переход на дополнительно выделенную память (как в trampoline hook) и уже оттуда полноценный 64-битный переход на перехватчик.
    0x7f6cc8aa83e0 <open64+0>:          e9 1b 6c 55 37        jmp     0x7f6cfffff000 

    Переход на 0x7f6cc8aa83e0 + 0x37556c1b + 5 = 0x7f6cfffff000

  • Опкод FF 25 (6 байт) — абсолютный 64-битный переход по адресу в памяти относительно %rip. Для адреса всё равно надо выделять дополнительную память не дальше 2ГБ от начала функции.
    0x00007f6cc8aa83e0 <open64+0>:      ff 25 1a 6c 55 37     jmpq    *0x37556c1a(%rip) 

    Здесь в 0x7f6cc8aa83e0 + 0x37556c1a + 6 = 0x7f6cfffff000 сохранён адрес абсолютного перехода.

В статье используется второй метод

# hook.py class Hook(object):     @staticmethod     def get_indlongjmp(srcaddr, proxyaddr):         s = struct.pack('=BBl', 0xff, 0x25, proxyaddr - srcaddr - 6)         return map(ord, s) 

get_indlongjmp возвращает код для прыжка с адреса srcaddr на адрес сохраненный в QWORD по адресу proxyaddr

Теперь можно наконец написать недостающие методы класса Hook. Метод install получает адрес оригинальной функции address и адрес вспомогательной зоны proxyaddr. После чего переписывает начало функции (предварительно сохранив его в self.code) переходом на перехватчик

# hook.py     def install(self, address, proxyaddr):         self.address = address         self.proxyaddr = proxyaddr         proxymemory = (c_void_p * 1).from_address(self.proxyaddr)         proxymemory[0] = Hook.cast_to_void_p(self.cfunc)         self.jmp = self.get_indlongjmp(self.address, self.proxyaddr)         self.memory = (c_ubyte * len(self.jmp)).from_address(self.address)         self.code = list(self.memory)         self.patchmem(self.jmp)         self.pyfunc.orig = self.origfunc()         self.active = True 

patchmem перезаписывает начало оригинальной функции данными из src

# hook.py     def patchmem(self, src):         for i in range(len(src)):             self.memory[i] = src[i] 

origfunc оборачивает вызов функции в код снимающий и устанавливающий переход на перехватчик.

# hook.py     def origfunc(self):         ofunc = self.ctype(self.address)         def wrap(*args):             self.patchmem(self.code)             val = ofunc(*args)             self.patchmem(self.jmp)             return val         return wrap 

Последние штрихи

Python загружен в адресное пространство, файл hook.py загружен в Python. Осталось вызвать Hook.hook(symbol, address, proxyaddr) cо стороны Python модуля GDB.

Находим адрес функции "open"

line = gdb.execute('info address %s' % "open" False, True) m = re.match(r'.*?(0x[0-9a-f]+)', line) addr = int(m.group(1), 16) 
Нюансы

В общем случае, перед тем как бежать переписывать код остановленного процесса надо убедиться что он не остановлен посередине этого кода (или собирается вернуться в него). Сделать это проще всего, отпарсив вывод gdb.execute("thread apply all backtrace")

Выделяем память поблизости от addr

prot = PROT_READ | PROT_WRITE | PROT_EXEC flags = MAP_PRIVATE | MAP_ANONYMOUS maddr = gdb.parse_and_eval('(void*)mmap(0x%x, %d, %d, %d, -1, 0)\n'                            % (addr | 0x7FFFFFFF, 4096, prot, flags)) maddr = (long(maddr) & 0x00000000FFFFFFFF) | (addr & 0xFFFFFFFF00000000) 
Нюансы

Последняя строка это обход бага в GDB, который съедает старшие биты результата. Аргумент (addr | 0x7FFFFFFF) использует недокументированное свойство mmap выдавать память с адресом меньше занятого желаемого.

Без трюков по-правильному чуть длиннее: надо отпарсить вывод gdb.execute('info proc mappings', False, True), найти ближайшую к addr дырку в адресном пространстве и вывать mmap с MAP_FIXED. Ну и естественно не обязательно выделять по целой странице памяти для каждой перехваченой функции.

Разрешаем перезапись оригинальной функции (иначе SIGSEGV)

gdb.parse_and_eval('mprotect(0x%x, %u, %d)' % (addr & -0x1000, 4096*2, prot)) 

Вызываем Hook.hook через PyRun_SimpleString

pyret = gdb.parse_and_eval('PyRun_SimpleString("Hook.hook(\\"open\\", 0x%x, 0x%x)")'                             % (addr, maddr)) 

Готово! Теперь вызов "open" в целевом процессе будет перехвачен и направлен в python_open из hook.py.

Файлы примеров

Полные файлы примеров (с чуть большим количеством проверок, но без учета многих нюансов)

pyinject.py

# pyinject.py import re import os  RTLD_LAZY = 1 PROT_READ = 0x1 PROT_WRITE = 0x2 PROT_EXEC = 0x4 MAP_PRIVATE = 0x2 MAP_FIXED = 0x10 MAP_ANONYMOUS = 0x20 LIBPYTHON = 'libpython2.7.so'  class ParamHookfile(gdb.Parameter):     instance = None     def __init__(self, default=''):         super(ParamHookfile, self).__init__("hookfile",                                             gdb.COMMAND_NONE, gdb.PARAM_FILENAME)         self.value = default         ParamHookfile.instance = self      def get_set_string(self):         return self.value      def get_show_string(self, svalue):         return svalue  class CmdHook(gdb.Command):     instance = None     def __init__(self):         super(CmdHook, self).__init__("pyinject", gdb.COMMAND_NONE)         self.initialized = False         CmdHook.instance = self      def complete(self, text, word):         matching = [s[4:] for s in dir(self)                      if s.startswith('cmd_')                      and s[4:].startswith(text)]         return matching      def invoke(self, subcmd, from_tty):         self.dont_repeat()         if subcmd.startswith("hook"):             self.cmd_hook(*gdb.string_to_argv(subcmd))         elif subcmd.startswith("unhook"):             self.cmd_unhook(*gdb.string_to_argv(subcmd))         else:             gdb.write('unknown sub-command "%s"' % subcmd)      def cmd_hook(self, *args):         self.initialize()         if not self.initialized:             return          pyret = gdb.parse_and_eval('PyRun_SimpleString("print Hook")')         if long(pyret) != 0:             hookfile = ParamHookfile.instance.value             if not os.path.exists(hookfile):                 gdb.write('Use "set hookfile <path>"\n')                 return             fp = gdb.parse_and_eval('fopen("%s", "r")' % hookfile)             assert long(fp) != 0             pyret = gdb.parse_and_eval('PyRun_AnyFileEx(%u, "%s", 1)' % (fp, hookfile))             if long(pyret) != 0:                 gdb.write('Error loading "%s"\n' % hookfile)                 return          for symbol in args:             try:                 line = gdb.execute('info address %s' % symbol, False, True)                 m = re.match(r'.*?(0x[0-9a-f]+)', line)                 if m:                     addr = int(m.group(1), 16)             except gdb.error:                 continue             prot = PROT_READ | PROT_WRITE | PROT_EXEC             flags = MAP_PRIVATE | MAP_ANONYMOUS # | MAP_FIXED             maddr = gdb.parse_and_eval('(void*)mmap(0x%x, %d, %d, %d, -1, 0)\n'                                        % (addr | 0x7FFFFFFF , 4096, prot, flags))             maddr = (long(maddr) & 0x00000000FFFFFFFF) | (addr & 0xFFFFFFFF00000000)             gdb.write("mmap = 0x%x\n" % maddr)             if maddr == 0:                 continue             gdb.parse_and_eval('mprotect(0x%x, %u, %d)' % (addr & -0x1000, 4096*2, prot))             pyret = gdb.parse_and_eval('PyRun_SimpleString("Hook.hook(\\"%s\\", 0x%x, 0x%x)")'                                        % (symbol, addr, maddr))             if long(pyret) == 0:                 gdb.write('hook "%s" OK\n' % symbol)      def cmd_unhook(self, *args):         for symbol in args:             pyret = gdb.parse_and_eval('PyRun_SimpleString("Hook.unhook(\\"%s\\")")'                                        % (symbol))             if long(pyret) == 0:                 gdb.write('unhook "%s" OK\n' % symbol)      def initialize(self):         if self.initialized:             return         handle = gdb.parse_and_eval('dlopen("%s", %d)' % (LIBPYTHON, RTLD_LAZY))         if not long(handle):             gdb.write('Cannot load library %s\n' % LIBPYTHON)             return         if not long(gdb.parse_and_eval('Py_IsInitialized()')):             gdb.execute('call PyEval_InitThreads()')             gdb.execute('call Py_Initialize()')         self.initialized = True  if __name__ == '__main__':     ParamHookfile()     CmdHook() 

hook.py

# hook.py import struct from ctypes import (CFUNCTYPE, POINTER, c_ubyte, c_int, c_char_p, c_void_p)  class Hook(object):     all_hooks = {}      @staticmethod     def cast_to_void_p(pointer):         return CFUNCTYPE(c_void_p, c_void_p)(lambda x: x)(pointer)      @staticmethod     def register(symbol, *args):         Hook.all_hooks[symbol] = Hook(symbol, *args)      def __init__(self, symbol, ctype, pyfunc):         self.symbol = symbol         self.ctype = ctype         self.pyfunc = pyfunc         self.cfunc = self.ctype(self.pyfunc)         self.address = 0         self.proxyaddr = 0         self.jmp = None         self.memory = None         self.code = None         self.active = False      def install(self, address, proxyaddr):         print "install:", hex(address)         self.address = address         self.proxyaddr = proxyaddr         proxymemory = (c_void_p * 1).from_address(self.proxyaddr)         proxymemory[0] = Hook.cast_to_void_p(self.cfunc)         self.jmp = self.get_indlongjmp(self.address, self.proxyaddr)         self.memory = (c_ubyte * len(self.jmp)).from_address(self.address)         self.code = list(self.memory)         self.patchmem(self.jmp)         self.pyfunc.orig = self.origfunc()         self.active = True      def uninstall(self):         self.patchmem(self.code)         self.active = False      def origfunc(self):         ofunc = self.ctype(self.address)         def wrap(*args):             self.patchmem(self.code)             val = ofunc(*args)             self.patchmem(self.jmp)             return val         return wrap      def patchmem(self, src):         for i in range(len(src)):             self.memory[i] = src[i]      @staticmethod     def get_indlongjmp(srcaddr, proxyaddr):         # 64-bit indirect absolute jump (6 + 8 bytes)         # ff 25 off32     jmpq  *off32(%rip)         try:             s = struct.pack('=BBl', 0xff, 0x25, proxyaddr - srcaddr - 6)             return map(ord, s)         except:             print hex(proxyaddr), hex(srcaddr), hex(proxyaddr - srcaddr - 6)             raise      @staticmethod     def hook(symbol, address, proxyaddr):         h = Hook.all_hooks[symbol]         if h.active:             return         h.install(address, proxyaddr)      @staticmethod     def unhook(symbol):         h = Hook.all_hooks[symbol]         if not h.active:             return         h.uninstall()  def hook(symbol, ctype):     def deco(func):         Hook.register(symbol, ctype, func)         return func     return deco  #int open (const char *__file, int __oflag, ...) @hook(symbol='open', ctype=CFUNCTYPE(c_int, c_char_p, c_int)) def python_open(fname, oflag):     print "open: ", fname, oflag     return python_open.orig(fname, oflag) 

Запуск примера (лучше с абсолютными путями)

gdb -ex 'attach PID' -ex 'source /path/pyinject.py' -ex 'set hookfile /path/hook.py' (gdb) pyinject hook open (gdb) continue

ссылка на оригинал статьи http://habrahabr.ru/post/237575/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *