Патчим процессы в 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
    • +34
    • 12,4k
    • 6
    Поделиться публикацией
    Реклама помогает поддерживать и развивать наши сервисы

    Подробнее
    Реклама
    Комментарии 6
    • 0
      Очень классно, спасибо. Проверить, правда, не получилось:
      Python Exception <class 'gdb.error'> No symbol table is loaded.
      • 0
        А GDB грузит символы библиотек? Должен при запуске писать что-то вроде
        Reading symbols from /lib64/libc.so.6...(no debugging symbols found)...done.
        Loaded symbols for /lib64/libc.so.6

        Я проверил в GDB версий 7.6 и 7.8, одна из них не хотела грузить символы автоматом из командной строки, если писать gdb -p PID. Но gdb -ex "attach PID" работал в обоих.

        Можно попробовать запустить просто gdb и ввести команды вручную
        > gdb
        (gdb) attach PID
        (gdb) sharedlibrary .*
        (gdb) source /path/pyinject.py
        (gdb) set hookfile /path/hook.py
        (gdb) pyinject hook open
        (gdb) continue
        
        • 0
          Когда пытаюсь выполнить attach, получаю
          ptrace: Операция не позволена.
          GDB версии 7.8
          • +1
            Скорее всего ядро с CONFIG_SECURITY_YAMA (ptrace_Protection). Надо разрешить ptrace для юзеров
            sudo sysctl -w kernel.yama.ptrace_scope=0
            

            Или запускать gdb от root
            • 0
              а можно на этом месте подробнее описать систему безопасности. Например есть ли возможность дать такие права (на отладку) только конкретному юзеру, не входящему в какие-то спец группы администрирования?
              • 0
                Если защита включена, то для ptrace процесса нужна CAP_SYS_PTRACE capability, которая по-умолчанию есть только у root. Без CAP_SYS_PTRACE можно делать только ptrace child процесса, т.е. gdb ./prog будет работать, а gdb -p PID нет.

                Теоретически можно написать launcher с setuid root, который сбросит все права кроме CAP_SYS_PTRACE и запустит нужный процесс. Но мало смысла это делать на уровне пользователя, проще отключить защиту и пользователь сможет делать ptrace любого своего процесса.

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.