Недостаток 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».
Перехват можно условно разделить на три этапа:
- Инжектирование интерпретатора Python в адресное пространство целевого процесса
- Сбор информации о перехватываемой функции
- Собственно перехват
Пункты 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__
.
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 вызов оригинальной функции состоит из нескольких шагов:
- начало оригинальной функции восстанавливается из сохраненной копии
- производится вызов
- начало снова затирается инструкцией перехода на перехватчик
В 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 этой функции нужно:
- определить размер команд в начале функции
- выделить память не дальше чем в 2ГБ от целевого адреса команды cmpl (смещение
0x2d33ed(%rip)
знаковое 32-битное) - скопировать начало в новое место и пропатчить доступ к памяти относительно
%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)
(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 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 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/
Добавить комментарий