Техники перехвата функций в 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».
Перехват можно условно разделить на три этапа:
- Инжектирование интерпретатора 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%xn" % 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" OKn' % 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" OKn' % 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 %sn' % 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
Автор: Vayun