В текущих реалиях, для перехвата нативных функций существует большое множество библиотек. Но что делать если ни одна из них тебе не нравится?

Конечно же написать свою!

В этой статье я расскажу что лежит внутри библиотек для перехвата x86 кода и почему оно так работает. А также - как искал баги неделями, и что из этого вообще получилось

Сразу же немного обозначу терминологию, используемую в тексте:

Хук — перехват функции, когда перед вызовом какого-то кода выполняется наш коллбэк.

Трамплин — оригинальный код, который нужно вызвать для передачи управления обратно в перехваченную функцию

NOP опкод - инструкция процессора, которая ничего не делает. Фактически это инструкция xchg eax, eax. Записывается в байтах как 0x90

Мотивация

Чтобы начать писать что-то свое, на замену существующим решениям, нужна хорошая мотивация. Итак, для начала разберем, чем нам не нравятся существующие библиотеки:

  1. Неудобный интерфейс. Странные функции, которые должен вызвать программист. Фактически их может вызвать сама библиотека.

  2. «Сишный» интерфейс. Мы не можем писать в стиле C++ используя существующие библиотеки. Появляются костыли в виде глобального состояния, что порождает корявый код.

  3. «Недоделанный» функционал. Где-то ломается цепочка вызовов, при снятии хуков, где-то отсутствует иногда нужный кодген. А где-то безопасности потоков нет.

  4. Работает только на одной платформе (Windows/Unix). Мы ведь прогрессивные разработчики, и пишем только кроссплатформенный код!

Больше всего проблем вызывает «сишность» интерфейса. Из-за этого возникает необходимость работать с единым глобальным состоянием, что вызывает дополнительные сложности при попытке локализовать состояние хуков. Также возникает проблема в использованием C++ коллбэков, что еще больше нас подвигает к использованию глобального состояния.

В конечном итоге хотелось-бы иметь кроссплатформенное решение в рамках одной архитектуры, которое практически независимо от входных данных (OS, implementation defined фичи) предоставляет независимый интерфейс с высоким уровнем абстракции

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

Моделируем библиотеку

Для начала, попробуем построить примерную модель библиотеки, которая будет решать нашу задачу. Пусть это будет первое приближение, но на ней будет видно с чего нам стоит начать.

Так как мы хотим избавится от глобального состояния, нам нужно будет где-то хранить локальное состояние. Также мы хотим предоставлять некоторый интерфейс текущего контекста процессора внутри коллбэка, который будет вызываться при перехвате функции.

Локальное состояние хука будем хранить в объекте этого самого хука. А пробрасывать интерфейс в коллбэк будем через дополнительный аргумент, передаваемый по ссылке. Т.к. внутри имплементаций при вызове функций ссылки представляются как указатели на объект, нам нужно будет добавлять к оригинальной функции указатель на объект нашего хука.

(Передача доп. аргумента по указателю — вероятно самый простой способ передачи доп. аргумента вообще, т.к. мы можем положить значение в регистр, либо положить его на стек, не опасаясь того, что можно что-то сломать )

Изучаем возможности реализации нашей идеи

Далее рассматриваются вопросы, которые непосредственно отражают реализацию, с некоторыми опущениями в теоретической части. Поэтому, если у кого-то возникают вопросы, то вот в этом замечательном документе рассмотрены все соглашения вызовов для Windows / Linux. 

Сначала рассмотрим x86, затем x64

Итак, на x86 у нас есть 4 соглашения о вызове(виндовый __vectorcall пока не рассматриваем). Для реализации идеи, нужно обобщить их все, и свести все к одному общему соглашению вызова. Подкидывать аргумент к функции будем самостоятельно, используя jit ассемблер. В итоге у нас будет две прослойки. Одна генерируется JIT ассемблером и подкидывает один дополнительный аргумент, а вторая обобщает соглашения о вызовах и позволяет выполнять уже любые операции, а компилятор позаботится о сохранении регистров и прочих вещах

Ну чтож, начнем

x86 cdecl

Все аргументы передаются в стеке справа налево. Чтобы докинуть сверху еще один аргумент, просто подкинем в стек наше значение, а в функции прослойке будем принимать его как дополнительный первый аргумент. В конце нужно будет увеличить stack pointer на размер нашего аргумента, чтобы ничего не сломать.

Сигнатура функции прослойки будет примерно такой:

Ret __cdecl relay(HookObject*, Args...)

x86 stdcall

Все аргументы передаются в стеке справа налево. Единственное отличие от cdecl — stack pointer увеличивается в конце функции, и не нужно будет вручную увеличивать stack pointer. Поэтому просто подкидываем в стек наше значение и больше ни о чем не думаем

Сигнатура функции прослойки:

Ret __stdcall relay(HookObject*, Args...)

x86 thiscall

Все аргументы кроме первого integral/pointer type аргумента передаются в стеке справа налево. Первый integral/pointer type(при поиске слева направо) идет в регистр ecx. Фактически мы можем просто подкинуть в стек ecx, подкинуть в стек наш аргумент, и функцию прослойку объявить как stdcall. Единственная проблема — может выстрелить в ногу при создании коллбэка из существующего кода, например такого: void __thiscall foo(std::string, void*)

Для решения проблемы нужно будет пересобрать список аргументов для функции прослойки, чтобы поставить первый integral/pointer type вперед.

В текущей реализации этот момент не учитывается, так как больше интересует перехват функций из стороннего кода. Сигнатуры функций будут собраны из дизассемблера, который сам «пересоберет» для нас список аргументов.

Также можно пойти чуть другим путем: создать структуру с одним членом — указателем на наш аргумент и поставить его самым первым в списке аргументов. Тогда компилятор возьмет его с верхушки стека.

Но второй трюк будет работать только на windows thiscall, поэтому в своей библиотеке я пошел первым путем, поэтому сигнатура функции прослойки будет примерно такой:

Ret __stdcall relay(HookObject*, Args...)

x86 fastcall

В Windows все аргументы кроме первых двух integral/pointer type передаются в стеке справа налево. Первые два intergral/pointer типа берутся так же как в thiscall. Тут я уже воспользовался «недочетом», и создал структуру из одного аргумента, состоящую из одного указателя на аргумент. Все что нам нужно сделать — подкинуть указатель на аргумент в стек. Тогда сигнатура функции будет примерно такой:

struct fastcall_trick {
    HookObject* hook_ptr;
};
Ret __fastcall relay(fastcall_trick esp4, Args...)

Для Linux я не придумал как это можно красиво обернуть, чтобы учесть все случаи, поэтому там стоит просто заглушка.

x64 windows

Первые 4 аргумента передаются в регистрах, как значение, либо указатель на значение, остальные передаются в стеке справа налево. Чтобы подсунуть свой аргумент, нам нужно правильно его передать по порядку. Для этого я разделяю список аргументов на 2 куска: head и tail. head хранит в себе от 0 до 4 типов, tail хранит в себе от 0 до бесконечности типов. Если head меньше 4, значит есть еще один свободный регистр для аргументов, и мы просто кладем наш аргумент в него. Если же в регистрах места не хватает, тогда просто закидываем наш аргумент на стек. Также нельзя забывать о выравнивании, которое нужно учитывать, если мы помещаем аргумент на стек. Т.к. мы положили уже 8 байт, то нам нужно положить еще 8 байт, чтобы правильно выровнять(На выявление этого бага было потрачено более 30 человекочасов, попробуйте и найдите еще, откуда невыровненная память прилетела через сотни инструкций)

Сигнатура коллбэка будет такой, когда аргументов меньше 4:

Ret relay(Head..., HookObject*, Tail...)

И такой, если аргументов больше 3:

Ret relay(Head..., HookObject*, void*, Tail...)

AMD64 SystemV

Несмотря на то, что метапрограммирование в C++ позволяет извлечь много информации о внутреннем преставлении типа еще на этапе компиляции, его все равно недостаточно для эмуляции AMD64 SystemV соглашения о вызове. Но можно эксплуатировать некоторые его особенности, например передавать небольшой аргумента не как указатель, а как большую структуру, которая будет передана через стек. В рамках «большой» структуры можно было бы взять структуру в 16 байт для выравнивания стека, но мои исследования показали, что компиляторы выравнивают размер до 32 байт. Поэтому нам нужно создать структуру в 32 байта, где 8 из них будут хранить полезное значение, и 24 будут служить заглушкой для эксплуатации особенности соглашения о вызове.

Сигнатура коллбэка при этом будет такой:

#ifndef _WIN32
template <typename HookType>
struct SystemVAbiTrick {
		HookType* ptr;
private:
		void *_pad1, *_pad2, *_pad3;
};
#endif
Ret relay(Head..., SystemVAbiTrick, Tail...)

Также нужно учитывать, что в любом calling convention для возврата нетривиальных, либо больших типов передается скрытый указатель на место, куда нужно вернуть значение первым аргументом скрытым аргументом. В thiscall и fastcall и x64 это будет первый регистр, в stdcall и cdecl это верхушка стека.

Все эти проверки тривиальны, но слишком объемны, чтобы приложить кодом прямо в статью. Если вам интересно как оно работает — реализация

Реализация

После того как мы обобщили все соглашения о вызовах в прослойках, нам нужно передать управление в эту самую прослойку. 

Установка хука

Принцип передачи здесь прост. Если по адресу установки хука лежит инструкция call — мы просто заменяем адрес вызова на нашу прослойку. Это называется Call Hook.

Если же по адресу установки хука лежит не call, мы ставим туда jmp в нашу прослойку.

С jmp хуками возникает дополнительная сложность — они могут включать разные инструкции разного размера. Если просто поставить туда jmp, то часть инструкций может быть затерта. И если мы просто поставим туда jmp, то мы можем затереть часть инструкции. Поэтому подключим дизассемблер и считаем инструкции, пока не найдем нужное количество байт(5). Если у нас вышло больше — устанавливаем jmp, а оставшиеся байты заменяем на nop опкоды, чтобы не оставить инструкции в промежуточном состоянии.

Также, чтобы иметь возможность вернуть все как было, сохраним инструкции которые мы перезаписали.

Итого получаем:

struct {
		std::uint8_t opcode;
		std::uint32_t operand;
} patch;

this->hook_size = detail::detect_hook_size(info.hook_address);
info.original_code = std::make_unique<unsigned char[]>(this->hook_size);

std::memcpy(info.original_code.get(), reinterpret_cast<void*>(info.hook_address), this->hook_size);
uintptr_t relative =
		detail::get_relative_address(reinterpret_caststd::uintptr_t(this->relay_jump), info.hook_address);

std::memcpy(&patch, reinterpret_cast<void*>(info.hook_address), sizeof(patch));
if (patch.opcode != 0xE8) {
		patch.opcode = 0xE9;
}
patch.operand = relative;

std::memcpy(reinterpret_cast<void*>(info.hook_address), &patch, sizeof(patch));
memset(reinterpret_cast<void*>(info.hook_address + sizeof(patch)), 0x90, this->hook_size — sizeof(patch));

Теперь нам нужно уметь передавать управление обратно в перехватываемую функцию.

Создание трамплина

В целом, можно было бы просто использовать те инструкции что мы перезаписали, но возникает проблема — что если по адресу установки хука лежат относительные переходы? Если мы просто выполним их, то адреса будут посчитаны относительно места, куда мы сохранили оригинальный код. Поэтому нам нужно пересобрать оригинальный код так, чтобы все работало как и должно было(трамплин).

Чтобы удобно это сделать и не думать о байтах, используем все тот же JIT ассемблер

Зайдем в reference на все опкоды x86, и поищем все относительные переходы.

Итого имеем:

  1. Короткие прыжки по относительному адресу (JMP SHORT, JNE SHORT/JE SHORT и прочие).

  2. Длинные прыжки по относительному адресу (JMP, JNE/JE/JG/JL и прочие)

  3. Опкоды зацикливания (LOOPNZ/LOOPZ/LOOP/JECXZ)

Условные прыжки и безусловные прыжки отличаются кодированием, поэтому нужно будет рассмотреть их отдельно.

Короткие условные прыжки кодируются как 0b1110000 и в младшие 4 бита записывается какой тип условного оператора используется. Поэтому отрезаем младшие 4 бита и проверяем на 0x70

Длинные условные прыжки кодируются двумя байтами, где первый байт — указатель на то, что используется двухбайтовое кодирование инструкции, а второй байт - указатель на тип условного оператора. Но для кодирования самого опкода прыжка используется уже 0x80, а младшие 4 бита все также указывают на тип прыжка. Также отрежем младшие 4 бита и проверим на 0x80

Безусловные короткие прыжки кодируются также как и условные, но нам нужно различать условные и безусловные прыжки(чуть позже расскажу для чего), поэтому для определения именно безусловных прыжков будем использовать маску 0xFD

Если в результате применения маски получилось 0xE9 — значит это безусловный прыжок.

Если оригинальный опкод был 0xEB — значит прыжок безусловный короткий, иначе - длинный безусловный.

Ну и операторы зацикливания кодируются как 0xE0, младшие 2 бита указывают на тип оператора зацикливания. Отрезаем младшие 2 бита и проверяем на 0xE0

Теперь когда мы написали код на определение относительных прыжков — просто пересчитываем адреса относительно места вызова в трамплине.

Короткие условные прыжки нужно преобразовать в длинные условные прыжки, иначе мы не сможем правильно сделать прыжок. Именно для этого нам нужно было отделять условные и безусловные прыжки.

Также не отходя от кассы было бы неплохо учесть и инструкции возврата. Если мы по пути до оператора возврата не нашли прыжок за пределы этого самого возврата, значит пересобирать инструкции после оператора возврата не имеет смысла.

Оператор возврата бывает двух видов — retn и ret.
Отличаются они последним битом — для ret он будет выставлен в 1, для retn он будет выставлен в 0. Применяем маску 0xFD и проверяем на 0xC2 - так мы получим нужным нам результат.

В итоге получим таком код:

while (!finished) {
    std::size_t op_copy_size = hde_disasm(reinterpret_cast(current_address), &hs);
    if (hs.flags & F_ERROR) return false;
    op_copy_src = reinterpret_cast<void*>(current_address);
    if (current_address — hook_address >= sizeof(call)) {
        trampoline_gen->jmp(reinterpret_caststd::uint8_t*(current_address));
        break;
    }
    // Relative Call
    else if (hs.opcode == 0xE8) {
    		// recalculate relative address
    }
    // Relative jmp
    else if ((hs.opcode & 0xFD) == 0xE9) {
    		// recalculate relative address and check max jump ref address
    }
    // Conditional relative jmp
    else if (((hs.opcode & 0xF0) == 0x70) || // one byte jump
    				((hs.opcode & 0xFC) == 0xE0) || // LOOPNZ/LOOPZ/LOOP/JECXZ
    				((hs.opcode2 & 0xF0) == 0x80)) { // two byte jump
    		// recalculate relative address and check max jump ref address
    }
    // RET
    else if ((hs.opcode & 0xFE) == 0xC2) {
    		finished = (current_address >= max_jmp_ref);
    }
    trampoline_gen->db(reinterpret_caststd::uint8_t*(op_copy_src), op_copy_size);

    trampoline_size += op_copy_size;
    current_address += hs.len;
}

Не забываем и про то, что поверх нас могут ставить хуки тоже, и если мы просто перезапишем оригинальные инструкции на место хука - мы сломаем цепочку хуков, и все хуки которые были установлены после нас - не отработают. Для этого в начале прослойки сгенерируем прыжок, а сразу же после него - трамплин. Прыжок будет короткий, и его задача - пропускать трамплин. При снятии хука просто убираем прыжок, записав туда 2 nop опкода. Таким образом, при снятии хука у нас продолжится цепочка хуков.

Выглядеть это будет примерно так:

Xbyak::Label UserCode;
jump_gen->jmp(UserCode, Xbyak::CodeGenerator::LabelType::T_NEAR);
jump_gen->nop(3);

detail::create_trampoline(hook_address, jump_gen);
jump_gen->L(UserCode);
// relay code

Примечание к коду выше: в данном случае я устанавливаю прыжок в 5 байт и сразу за ним 3 nop опкода, т.к. JIT ассемблер который я использовал не умеет перезаписывать N байт в сгенерированном ей коде, а только фиксированные куски по 8 байт.

Установка хука на x64

И тут мы вспоминаем, что на x64 используется 64 битная адресация. Но безусловный относительный прыжок умеет переходить только в пределах 2^32-1 байт. Поэтому для x64 используем небольшой хак.

Чтобы иметь возможность прыгнуть на все адресное пространство, нам нужен безусловный direct переход. Такого можно достичь с использованием регистров, но перед прыжком нам нужно сохранить все регистры. Но ведь у нас есть Instruction Pointer регистр, который постоянно меняется, и его не нужно сохранять!

Тогда берем инструкцию jmp[rip] и сразу же за ней записываем адрес, куда нам нужно перейти. (Кстати, интересный факт, ни один из пользовательских отладчиков, кроме конечно же заточенных под реверс инжиниринг, по типу x64dbg, не умеет распознавать такую инструкцию. Из-за чего листинг ассемблера начинает показывать ахинею).

Но тут сразу же возникает проблема. Такой прыжок занимает в памяти 14 байт, что недопустимо для перехвата небольших функций, либо функций, которые сразу же переходят в другие функции.

Тогда будем искать ближайшую к месту установки хука страницу памяти размером 4096 байт (стандартная единица выделения страниц памяти), загружать туда наш код-прослойку, для подкидывания одного аргумента, и уже оттуда делать direct прыжок в прослойку которую создал компилятор.

На Windows мы можем запрашивать информацию о регионе с помощью функции VirtualQuery. И проверять, занята ли страница:

inline std::uintptr_t find_next_free(std::uintptr_t from, std::uintptr_t to, std::uintptr_t granularity) {
    to -= to % granularity; // alignment
    to -= granularity;
    while (from < to) {
        MEMORY_BASIC_INFORMATION mbi;
        if (VirtualQuery(reinterpret_cast<void*>(to), &mbi, sizeof(mbi)) == 0) break;
        if (mbi.State == MEM_FREE) return to;
        if (reinterpret_caststd::uintptr_t(mbi.AllocationBase) < granularity) break;
        to = reinterpret_caststd::uintptr_t(mbi.AllocationBase) - granularity;
    }
    return 0;
}

На linux это можно сделать двумя способами:

  1. Парсить файл /proc/self/maps(/proc/curproc/map в FreeBSD) и доставать оттуда информацию о занятых регионах.

  2. Использовать mmap с флагом MAP_FIXED_NOREPLACE

Но учитывая что мы пока что не использовали ничего linux специфичного, стоит использовать первый способ, чтобы работало на всех unix-like системах.

inline std::uintptr_t find_next_free(std::uintptr_t from, std::uintptr_t to, std::uintptr_t granularity) {
    auto map_infos = parse_proc_maps();
    to -= to % granularity;  // alignment
    to -= granularity;
    while (from < to) {
        bool found = false;
        for (auto& mi : map_infos) {
            if (mi.start <= to && to < mi.end) {
                found = true;
                to = mi.start — granularity;
                if (mi.start < granularity) {
                    return 0;
                }
                break;
            }
        }
        if (!found) {
        		return to;
        }
    }
    return 0;
}

Небольшие дополнения

Основной код уже написан, но для полной работоспособности, стоит внести еще пару правок.

Проверка защиты памяти

Ну для начала, стоит удостовериться, что мы вообще пытаемся установить хук на выполняемую память. Для этого нужно получить данные о защите страницы памяти. На Windows используем VirtualQuery, на unix-like будем парсить /proc/self/maps, благо парсер у нас уже есть:

inline bool check_is_executable(const void* addr) {
#ifdef _WIN32
    MEMORY_BASIC_INFORMATION buffer;
    VirtualQuery(addr, &buffer, sizeof(buffer));
    return buffer.Protect == PAGE_EXECUTE || buffer.Protect == PAGE_EXECUTE_READ ||
    buffer.Protect == PAGE_EXECUTE_READWRITE || buffer.Protect == PAGE_EXECUTE_WRITECOPY;
#else
    auto map_infos = parse_proc_maps();
    std::uintptr_t iaddr = reinterpret_caststd::uintptr_t(addr);
    for (auto& mi : map_infos) {
        if (mi.start <= iaddr && iaddr < mi.end) {
            if (mi.prot & PROT_EXEC) {
            		return true;
            }
        }
    }
    return false;
#endif
}

Снятие защиты памяти

Странно было-бы, если бы мы могли свободно записывать в executable память. Поэтому чтобы правильно установить хук, нам сначала нужно получить права на запись в страницу памяти. Для Windows будем использовать VirtualProtect, на unix-like – mprotect

inline bool set_memory_prot(const void* addr, std::size_t size, MemoryProt protectMode) {
		auto mode = convert_mode_to_system_mode(protectMode);
#if defined(_WIN32)
    DWORD oldProtect;
    return VirtualProtect(const_cast<void*>(addr), size, mode, &oldProtect) != 0;
#elif defined(GNUC)
    size_t pageSize = sysconf(_SC_PAGESIZE);
    size_t iaddr = reinterpret_cast<size_t>(addr);
    size_t roundAddr = iaddr & ~(pageSize - static_cast<size_t>(1));
    return mprotect(reinterpret_cast<void*>(roundAddr), size + (iaddr - roundAddr), mode) == 0;
#else
    return true;
#endif
}

Сброс кеша инструкций процессора

Также было бы неплохо сбрасывать кеш инструкций, чтобы все наши изменения в коде правильно применялись. На Windows еще осталась функция FlushInstructionCache, на unix-like заголовок пометили как obsolete и функции cacheflush в нем больше нет. Ну оставим просто заглушку ¯\_(ツ)_/¯

inline bool flush_intruction_cache(const void* ptr, std::size_t size) {
#ifdef _WIN32
		return FlushInstructionCache(GetCurrentProcess(), ptr, size) != 0;
#else
		return true;
#endif
}

Заморозка потоков

При установке хука, стоит сделать опцию на заморозку потоков, т.к. в многопоточном приложении в этот момент может происходить выполнение кода по адресу хука.

Но перебор и остановка потоков могут занять довольно много времени, поэтому лучше сделать эту возможность опциональной

На Windows у нас есть стандартная функция SuspendThread для управления данным процессом.

Если мы не смогли заморозить поток — просто пропустим его.
Также не стоит забывать что нам нужно замораживать все потоки, кроме текущего. Иначе уйдем в дедлок.

struct frozen_threads {
		std::vector thread_ids;
};
inline bool freeze_threads(frozen_threads& threads) {
    auto enumerate_threads = [](frozen_threads& threads) {
        HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
        if (hSnapshot == INVALID_HANDLE_VALUE) return false;
        auto self_tid = GetCurrentThreadId();
        auto self_pid = GetCurrentProcessId();
        auto offset = FIELD_OFFSET(THREADENTRY32, th32OwnerProcessID) + sizeof(DWORD);
        THREADENTRY32 te;
        te.dwSize = sizeof(THREADENTRY32);
        if (Thread32First(hSnapshot, &te)) {
            do {
                if (te.dwSize >= offset && te.th32OwnerProcessID == self_pid && te.th32ThreadID != self_tid) {
                		threads.thread_ids.push_back(te.th32ThreadID);
                }
                te.dwSize = sizeof(THREADENTRY32);
            } while (Thread32Next(hSnapshot, &te));
        }
        CloseHandle(hSnapshot);
        return true;
    };
    if (!enumerate_threads(threads)) {
      	return false;
    }
    for (auto tid : threads.thread_ids) {
        HANDLE hThread = OpenThread(
        THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION | THREAD_SET_CONTEXT, FALSE, tid);
        if (hThread != NULL) {
            SuspendThread(hThread);
            CloseHandle(hThread);
        }
    }
}

На unix-like системах стандартного способа для таких манипуляций нет. Есть специфичные pthread функции, но к сожалению мы не можем перебрать pthreads у процесса. Поэтому придется привязываться к системному API конкретной unix-like системы.

На linux замораживать потоки будем с помощью обработчиков сигналов + отправки сигнала конкретному потоку.
Парсить ID потоков будем из /proc/self/tasks/

struct frozen_threads {
		struct sigaction oldact1, oldact2;
		std::vector thread_ids;
};
inline bool freeze_threads(frozen_threads& threads) {
    auto signal_callback = [](int signal) {
        switch (signal) {
        case SIGUSR1:
        		pause();
        		break;
        case SIGUSR2:
        		break;
        }
    };
    struct sigaction act;
    if (sigemptyset(&act.sa_mask) != 0) {
    		return false;
    }
    act.sa_flags = 0;
    act.sa_handler = signal_callback;
    if (sigaction(SIGUSR1, &act, &threads.oldact1) != 0) {
   		 return false;
    }
    if (sigaction(SIGUSR2, &act, &threads.oldact2) != 0) {
    		return false;
    }
    auto self_pid = getpid();
    auto self_tid = gettid();
    for (const auto& dir_entry : std::filesystem::directory_iterator{»/proc/self/task»}) {
        if (dir_entry.is_directory()) {
            auto tid_str = dir_entry.path().stem().string();
            int tid;
            std::from_chars(tid_str.c_str(), tid_str.c_str() + tid_str.size(), tid);
            if (tid != self_tid) {
            		tgkill(self_pid, tid, SIGUSR1);
            		threads.thread_ids.push_back(tid);
            }
        }
    }
}

Ну раз мы умеем замораживать, то надо уметь и размораживать:

inline bool unfreeze_threads(frozen_threads& threads) {
#if defined(_WIN32)
    for (auto tid : threads.thread_ids) {
        HANDLE hThread = OpenThread(
        THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_QUERY_INFORMATION | THREAD_SET_CONTEXT, FALSE, tid);
        if (hThread != NULL) {
            ResumeThread(hThread);
            CloseHandle(hThread);
        }
    }
#elif defined(linux)
    auto self_pid = getpid();
    for (auto tid : threads.thread_ids) {
        tgkill(self_pid, tid, SIGUSR2);
    }
    sigaction(SIGUSR1, &threads.oldact1, nullptr);
    sigaction(SIGUSR2, &threads.oldact2, nullptr);
#else
    struct frozen_threads {
    };
#endif
		return true;
}

Ну вот, наша библиотека готова.

Результат

Собираем все это вместе, и получаем кроссплатформенную библиотеку для перехвата кода. Казалось бы, вроде ничего сильно сложного, но узнать про некоторые мелочи можно только если вы уже знаете про устройство процессора, либо посмотрев в другие имплементации. А некоторые трюки, например подкидывание одного аргумента в стек, или заморозка потоков на Linux, требуют самостоятельного изучения и немного смекалки

В тексте сделано несколько допущений в практической части, чтобы не превратить ее в пелену кода, а не текста. Надеюсь было интересно.

Готовую реализацию можно найти тут — kin4stat/kthook (github.com)

Комментарии (9)


  1. zorn-v
    24.07.2022 20:56
    +1

    Парсить файл /proc/self/maps и доставать оттуда информацию о занятых регионах.

    И это не будет работать во FreeBSD

    чтобы работало на всех unix-like системах.

    Так unix-like или все таки linux ?


    1. kin4stat Автор
      25.07.2022 05:03

      Благодарю за внимательность! Обновил парсинг в библиотеке под FreeBSD

      Так unix-like или все таки linux ?

      На момент написания, казалось что оно одинаковое для всех unix-like систем. Оказывается у Linux свое, у FreeBSD свое.


  1. Hint
    24.07.2022 22:31
    +1

    Спасибо за статью. Вспомнил молодость при прочтении, первые шаги с библиотекой AdvApiHook от Ms-Rem, открывшей тогда дорогу в мир хуков )


  1. gogibeet
    25.07.2022 03:27

    За упорство конечно плюс! Но над интерфейсом либы кажется стоит еще поработать. У меня есть большой опыт пользования всяких разных либ и фреймворков для хукинга, но глядя на примеры в гитхабе, волосы начинают шевелиться даже там где их нет.


    1. kin4stat Автор
      25.07.2022 03:29

      Примеры в README к сожалению полуактуальные. В какой-то момент сделал глобальный рефакторинг, но не поправил readme. Слишком объемная работа на тот и в данный момент.
      Наверное лучше в tests смотреть.


  1. Moraiatw
    25.07.2022 10:06
    -1

    Передавать в обработчик хука какие то лишние параметры? Странная идея. Тем более, что, обработчик один, стало быть указатель на обьект будет всегда один и тот же. Смысл его передавать?

    Что бы не приходилось пересобирать инструкции в трамплине и суспендить потоки, лучше использовать Hot Patching в Windows, он для этого и был создан.


    1. kin4stat Автор
      25.07.2022 13:41

      Передавать в обработчик хука какие то лишние параметры? Странная идея. Тем более, что, обработчик один, стало быть указатель на обьект будет всегда один и тот же. Смысл его передавать?

      Вроде в самом начале статьи же про глобальное состояние...

      Что бы не приходилось пересобирать инструкции в трамплине и суспендить потоки, лучше использовать Hot Patching в Windows, он для этого и был создан.

      Ага. А что делать с кодом, к которому нет доступа, и перекомпилировать с включенным Hot Patching нет возможности?
      И даже если так. Писать отдельный велосипед для винды, и отдельный для unix'ов? Мне кажется все же проще один общий велосипед написать, чем два отдельных, зависящих от настроек компиляции кода


      1. Moraiatw
        26.07.2022 08:01

        Вроде в самом начале статьи же про глобальное состояние..

        Это костыль.

        А что делать с кодом, к которому нет доступа, и перекомпилировать с включенным Hot Patching нет возможности?

        Не совсем понимаю о чем вы. Hot pathing присутствует во всех системных библиотеках Windows. Ничего не надо перекомпилировать.


        1. kin4stat Автор
          26.07.2022 16:03

          Это костыль.

          Почему же? Позволяет например, абстрагироваться от конечной "хукаемой" функции, и написать шаблонную лямбду, которая решит все сама. Как пример, хук на все LoadLibrary. Пример конечно абстрактный, можно было-бы полезть в недра kernel32.dll, и найти там функцию LdrLoadDll, и поставить хук на нее, но все же. На практике даже такие функции иногда полезны.

          auto dispatch_load_calls = [](const auto& hook, auto str_ptr, auto&&... args) {
              auto in_cef_module = [](std::uintptr_t addr) {
                  auto dll = reinterpret_cast<std::uintptr_t>(GetModuleHandleA("libcef.dll"));
          
                  auto ntheader = reinterpret_cast<IMAGE_NT_HEADERS*>(
                      dll + reinterpret_cast<IMAGE_DOS_HEADER*>(dll)->e_lfanew);
          
                  if (dll < addr && addr < dll + ntheader->OptionalHeader.SizeOfImage) {
                      return true;
                  }
                  return false;
              };
          
              if (in_cef_module(hook.get_return_address())) {
                  constexpr bool is_single_byte = std::is_same_v<decltype(str_ptr), LPCSTR>;
          
                  using str_view = std::conditional_t<is_single_byte, std::string_view, std::wstring_view>;
                  using str = std::conditional_t<is_single_byte, std::string, std::wstring>;
                  constexpr auto cef_directory = names_choose<is_single_byte>::prefix;
                  constexpr auto& names_array_ref = names_choose<is_single_byte>::value;
          
                  str_view load_name{str_ptr};
          
                  for (auto name : names_array_ref) {
                      if (load_name.find(name) != str_view::npos) {
                          str fname{cef_directory};
          
                          fname += name;
          
                          return hook.get_trampoline()(fname.c_str(), args...);
                      }
                  }
              }
              return hook.get_trampoline()(str_ptr, args...);
          };
          
          load_liba_hook.set_dest(reinterpret_cast<void*>(GetProcAddress(kernel32, "LoadLibraryA")));
          load_libw_hook.set_dest(reinterpret_cast<void*>(GetProcAddress(kernel32, "LoadLibraryW")));
          load_libexa_hook.set_dest(reinterpret_cast<void*>(GetProcAddress(kernel32, "LoadLibraryExA")));
          load_libexw_hook.set_dest(reinterpret_cast<void*>(GetProcAddress(kernel32, "LoadLibraryExW")));
          
          load_liba_hook.set_cb(dispatch_load_calls);
          load_libw_hook.set_cb(dispatch_load_calls);
          load_libexa_hook.set_cb(dispatch_load_calls);
          load_libexw_hook.set_cb(dispatch_load_calls);

          Если бы не было абстракции от глобального состояния, пришлось бы коллбэк под каждую функцию писать. Также уход от глобального состояния позволяет делать проект модульным, и внутри разных модулей использовать хуки на одну и ту же конечную функцию. Чего, например, нельзя сделать используя MinHook, или PolyHook

          Не совсем понимаю о чем вы. Hot pathing присутствует во всех системных библиотеках Windows. Ничего не надо перекомпилировать.

          Процитирую себя еще раз, и дополню:

          Ага. А что делать с кодом, к которому нет доступа, и перекомпилировать с включенным Hot Patching нет возможности?И даже если так. Писать отдельный велосипед для винды, и отдельный для unix'ов? Мне кажется все же проще один общий велосипед написать, чем два отдельных, зависящих от настроек компиляции кода

          Зачем писать разный код для выполнения одного и того же действия на разных платформах. Гораздо проще описать общий принцип, нет?

          И все еще остается проблема с перехватами стороннего кода, вне стандартных библиотек Windows, или например на Linux. Или например мне захочется добавить функциональность к существующей функции игры(гейммоддинг). Игры с Hot Patching'ом никто не компилирует. Предлагаете сидеть с разбитым корытом, из-за того, что библиотека прикручена к функции Hot patching?

          Также, не у всех системных библиотек и их функций есть HotPatching.
          Как пример - d3d9.dll, а именно функция IDirect3DDevice9::Present.