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

Но для того, чтобы крякнуть программу, нужно понять, что и где патчить, и какая функция отвечает за валидацию лицензии. Для этого и существуют программы вроде IDA Pro. Помимо дизассемблирования они умеют генерировать псевдокод на C, строить графы вызовов и много чего еще.

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

Над чем будем проводить экзекуцию?

Решил написать программку, которую буду анализировать и дорабатывать впоследствии. Отключил оптимизации и CRT, чтобы получилось максимально наглядно.

Пишу небольшой класс, который будет выполнять поиск последовательностей в данных

Создал структуру, которая будет хранить в себе паттерн для поиска:

#define PATTERN_SIZE 128
#define ANY 0xFF00
#define END 0xFFFF

struct BytePattern
{ uint16_t bytes[PATTERN_SIZE]; };

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

Делаю пошаговый поиск. Имеет смысл добавить возможность выбирать направление поиска:

enum class SearchDirection
{
    NONE,

    UP_TO_DOWN,
    DOWN_TO_UP,
};

Сами стадии поиска будут храниться в отдельной структуре:

struct SearchStage
{
    SearchDirection type;

    uint16_t bytes[PATTERN_SIZE];
    int byteCount;
};

Определяю методы и переменные класса:

#define STAGES_COUNT 4

class PatternSearcher
{
public:
    PatternSearcher();
    void addStage(SearchDirection type, const BytePattern bytes);
    void setInfo(void *begin, size_t size);
    void *find();

private:

    bool _performStageSearch(const SearchStage &stage);
    bool _matchAtOffset(const SearchStage &stage, size_t offset);

    SearchStage stages[STAGES_COUNT];
    size_t currentOffset;

    uint8_t *begin;
    size_t size;
};

Через setInfo() задается информация об области поиска, через addStage() добавляются стадии поиска, а после вызывается find(), который возвращает указатель внутри области поиска в случае успеха, либо nullptr.

Добил это всё удобным constexpr парсером, который будет вызываться через лямбду:

#define PATTERN(str) [](){ auto seq = pattern::parse(str); return seq; }()
template <size_t N>
constexpr BytePattern parse(const char(&str)[N])

Полный код:

Взглянем на основной код

Из-за того, что не использую CRT, определил entry следующим образом:

extern "C" void _start()

Выполнять поиск буду внутри строки "Hello World!":

#define SEQUENCE "Hello World!"

const char* sequence = SEQUENCE;
int sequenceSize = sizeof(SEQUENCE);

Искать буду поэтапно:

  1. Поиск от начала к концу строки, последовательность 72 ?? 64 21, "r?d!", где '?' - любой байт

  2. Поиск от результато�� предыдущего поиска к началу, последовательность 65 6C 6C 6F "ello"

  3. На выходе получаю указатель на sequence[1], который указывает на "ello World!"

PatternSearcher searcher;
searcher.setInfo((void*)sequence, sequenceSize);

BytePattern pattern1 = PATTERN("72 ?? 64 21");
searcher.addStage(SearchDirection::UP_TO_DOWN, pattern1);

BytePattern pattern2 = PATTERN("65 6C 6C 6F");
searcher.addStage(SearchDirection::DOWN_TO_UP, pattern2);

void *result = searcher.find();

Вывожу результат как строку в MessageBox, завершаю процесс:

MessageBoxA(NULL, (const char*)result, "Msg", MB_OK);
ExitProcess(0);

Написал небольшой cstd.cpp, в котором реализованы memset и memcpy.

Полный код:

Коротко о сборке

Собираю небольшой CMakeLists.txt, собирать буду через clang-cl, линковать lld-link:

cmake_minimum_required(VERSION 3.20)
project(hideCall LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_COMPILER clang-cl)
set(CMAKE_LINKER lld-link)

add_executable(hideCall main.cpp patternSearcher.cpp cstd.cpp)

Отключил исключения, RTTI, stack-protection, оптимизацию:

target_compile_options(hideCall PRIVATE
    /EHs-
    /EHa-
    /EHsc-
    /GR-
    /GS-
    /Od
)

Выключил все стандартные библиотеки, указал свою entry:

target_link_options(hideCall PRIVATE
    /ENTRY:_start
    /NODEFAULTLIB
    /SUBSYSTEM:WINDOWS
    /MANIFEST:NO
)

И в целом -DCMAKE_BUILD_TYPE=Debug

Полный код:

Опции сборки менять в процессе не буду, только добавлять новые исходные файлы.

Запускаю, работает
Запускаю, работает

Самое интересное

Гружу бинарь в отрыве от .pdb файла, чтобы IDA сама не тянула структуры, классы, имена функций, но подпишу всё это ручками, как делал бы, если бы анализировал какой-то левый бинарь (пусть и с учетом, что об этом бинаре я всё знаю).

Сразу же то, ради чего это всё затевалось

Примерно вот такой call-graph получился
Примерно вот такой call-graph получился

Вижу все вызовы, которые есть в программе, смотрю ближе

В первую очередь меня интересует функция _start, что в ней происходит

Создаются нужные переменные на стеке:

const CHAR *lpText; // [rsp+28h] [rbp-850h]
_BYTE pattern2[256]; // [rsp+30h] [rbp-848h] BYREF
_WORD _pattern2[128]; // [rsp+130h] [rbp-748h] BYREF
_BYTE pattern1[256]; // [rsp+230h] [rbp-648h] BYREF
_WORD _pattern1[128]; // [rsp+330h] [rbp-548h] BYREF
_BYTE searcher[1084]; // [rsp+430h] [rbp-448h] BYREF
int sequenceSize; // [rsp+86Ch] [rbp-Ch]
const char *sequence; // [rsp+870h] [rbp-8h]

Инициализации переменных:

sequence = "Hello World!";
sequenceSize = 13;

Вызов конструктора для searcher:

PatternSearcher::PatternSearcher(searcher);
lea     rcx, [rsp+878h+searcher]
call    PatternSearcher__PatternSearcher

Передача данных в setInfo:

PatternSearcher::setInfo(searcher, sequence, sequenceSize);
movsxd  r8, [rsp+878h+sequenceSize]
mov     rdx, [rsp+878h+sequence]
lea     rcx, [rsp+878h+searcher]
call    PatternSearcher__setInfo

Распаковка constexpr паттерна:

memset(_pattern1, 0, 256);
_pattern1[0] = 114;
_pattern1[1] = -256;
_pattern1[2] = 100;
_pattern1[3] = 33;
_pattern1[4] = -1;
memcpy(pattern1, _pattern1, 256);
lea     rcx, [rsp+878h+_pattern1] ; void *
xor     edx, edx        ; Val
mov     r8d, 100h       ; Size
call    memset
mov     [rsp+878h+_pattern1], 72h ; 'r'
mov     [rsp+878h+var_546], 0FF00h
mov     [rsp+878h+var_544], 64h ; 'd'
mov     [rsp+878h+var_542], 21h ; '!'
mov     [rsp+878h+var_540], 0FFFFh
lea     rcx, [rsp+878h+pattern1] ; void *
lea     rdx, [rsp+878h+_pattern1] ; Src
mov     r8d, 100h       ; Size
call    memcpy

Создание этапа поиска, через addStage:

PatternSearcher::addStage(searcher, 1, pattern1);
lea     rcx, [rsp+878h+searcher]
mov     edx, 1
lea     r8, [rsp+878h+pattern1]
call    PatternSearcher__addStage

Создание паттерна и этапа повторяется два раза.

Вызывается find, результат сохраняется:

lpText = (const CHAR *)PatternSearcher::find(searcher);
lea     rcx, [rsp+878h+searcher]
call    PatternSearcher__find
mov     [rsp+878h+lpText], rax

Конец программы:

MessageBoxA(0, lpText, "Msg", 0);
ExitProcess(0);
mov     rdx, [rsp+878h+lpText] ; lpText
xor     eax, eax
mov     ecx, eax        ; hWnd
lea     r8, Caption     ; "Msg"
xor     r9d, r9d        ; uType
call    cs:__imp_MessageBoxA
xor     ecx, ecx        ; uExitCode
call    cs:__imp_ExitProcess

Ничего необычного, IDA спокойно видит куда ссылаются все инструкции call и без проблем строит от них связи, другого результата тут быть не могло, на это и был расчёт.

Как можно эти связи разорвать?

Я подумал, что не будет ничего проще, чем просто передать указатель на функцию, но в процессе реализации пришло понимание, что не всё так просто:

PatternSearcher();
void addStage(SearchDirection type, const BytePattern bytes);
void setInfo(void *begin, size_t size);
void *find();
bool _performStageSearch(const SearchStage &stage);
bool _matchAtOffset(const SearchStage &stage, size_t offset);

int __stdcall MessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType);
void __stdcall ExitProcess(UINT uExitCode);

У всех функций, которые я использую, разные типы аргументов, их количество, а помимо этого, методы класса PatternSearcher еще и принимают указатель на объект searcher.

PatternSearcher::PatternSearcher(searcher);
PatternSearcher::setInfo(searcher, sequence, sequenceSize);
PatternSearcher::addStage(searcher, 1, pattern1);
PatternSearcher::find(searcher);

Любое значение должно поместиться в uint64_t, будь то указатель, число или bool. Аргументы вызова можно передать структурой, содержащей массив uint64_t значений, каждая функция будет распаковывать её вручную и возвращать будет uint64_t.

#define MAX_CALL_ARGS 6
struct CallArguments
{ uint64_t args[MAX_CALL_ARGS]; };
using Fn = uint64_t (*)(CallArguments args);

Одна общая функция, которая будет выполнять роль диспетчера вызовов:

uint64_t doCall(Fn func, CallArguments args);

Все функции, которые будут вызываться должны иметь одну сигнатуру Fn. Реализовал их в C-подобном стиле, но от конструктора не отказался и ниже покажу почему.

namespace PatternSearcher
{
    struct Object
    {
        Object();

        SearchStage stages[STAGES_COUNT];

        size_t currentOffset;

        uint8_t* begin;
        size_t size;
    };

    uint64_t addStage(dispatch::CallArguments args);
    uint64_t setInfo(dispatch::CallArguments args);
    uint64_t find(dispatch::CallArguments args);

    uint64_t _performStageSearch(dispatch::CallArguments args);
    uint64_t _matchAtOffset(dispatch::CallArguments args);
}

Внутри каждой функции выполняю распаковку аргументов:

namespace PatternSearcher
{
uint64_t addStage(dispatch::CallArguments args)
{
    Object *self = (Object*)args.args[0];
    SearchDirection type = (SearchDirection)args.args[1];
    BytePattern* pattern = (BytePattern*)args.args[2];

    /*
        ...
    */
}
}

И, собственно, вызов:

dispatch::doCall(
    &PatternSearcher::setInfo, 
    dispatch::CallArguments{
        (uint64_t)&searcher,
        (uint64_t)(void*)sequence,
        (uint64_t)sequenceSize
    }
);

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

#define CALL_0(func) doCall(&func, dispatch::CallArguments{})
#define CALL_1(func, a1) doCall(&func, dispatch::CallArguments{ (uint64_t)(a1) })
#define CALL_2(func, a1, a2) doCall(&func, dispatch::CallArguments{ (uint64_t)(a1), (uint64_t)(a2) })
//....
#define CALL(func, ...) PP_CAT(CALL_, PP_NARG(__VA_ARGS__)) (func, __VA_ARGS__)

Получаю что-то такое:

CALL(PatternSearcher::setInfo, &searcher, (void*)sequence, sequenceSize);

Отдельно добавил и обертки для MessageBox и ExitProcess:

uint64_t MessageBoxA_wrapper(dispatch::CallArguments args)
{
    HWND hWnd = (HWND)args.args[0];
    LPCSTR lpText = (LPCSTR)args.args[1];
    LPCSTR lpCaption = (LPCSTR)args.args[2];
    UINT uType = (UINT)args.args[3];

    return (uint64_t)MessageBoxA(hWnd, lpText, lpCaption, uType);
}

uint64_t ExitProcess_wrapper(dispatch::CallArguments args)
{
    UINT uExitCode = (UINT)args.args[0];
    ExitProcess(uExitCode);
    return 0;
}

Как выглядит главная функция:

const char* sequence = SEQUENCE;
int sequenceSize = sizeof(SEQUENCE);

PatternSearcher::Object searcher;
CALL(PatternSearcher::setInfo, &searcher, (void*)sequence, sequenceSize);

BytePattern pattern1 = PATTERN("72 ?? 64 21");
CALL(PatternSearcher::addStage, &searcher, SearchDirection::UP_TO_DOWN, (void*)&pattern1);

BytePattern pattern2 = PATTERN("65 6C 6C 6F");
CALL(PatternSearcher::addStage, &searcher, SearchDirection::DOWN_TO_UP, (void*)&pattern2);

void *result = (void*)CALL(PatternSearcher::find, &searcher);

CALL(MessageBoxA_wrapper, NULL, (const char*)result, "Msg", MB_OK);
CALL(ExitProcess_wrapper, 0);

Также все вызовы внутренних реализаций завернул в CALL.

Полный код:

Собираю, смотрю на разницу

call-graph с doCall
call-graph с doCall

Уже выглядит интересно, прямые связи IDA рисует только к memcpy, memset и конструктору, которые я не обернул в dispatch. В остальном же видно, что doCall вызывается из трёх разных функций, а большая часть функций вообще висит в подвешенном состоянии, словно они есть, но на них никто не ссылается.

В IDA можно включить отображение связей по упоминаниям адресов функций, и тогда картина сильно меняется

call-graph с отображением упоминаний
call-graph с отображением упоминаний

Зашел сравнить дизасм

Конструктор не изменился, что и ожидалось:

PatternSearcher::Object(searcher);
lea     rcx, [rsp+7D8h+searcher]
call    PatternSearcher__Object

Вызов setInfo:

v16[0] = searcher;
v16[1] = sequence;
v16[2] = sequenceSize;
v4 = &v17;
do
  *v4++ = 0;
while ( v4 != (__int64 *)searcher );
doCall(PatternSearcher::setInfo, v16);
lea     rcx, [rsp+7D8h+var_478]
lea     rax, [rsp+7D8h+searcher]
mov     [rsp+7D8h+var_478], rax
mov     rax, [rsp+7D8h+sequence]
mov     [rsp+7D8h+var_470], rax
movsxd  rax, [rsp+7D8h+sequenceSize]
mov     [rsp+7D8h+var_468], rax
mov     rax, rcx
add     rax, 18h
add     rcx, 30h ; '0'
mov     [rsp+7D8h+var_780], rcx
mov     [rsp+7D8h+var_778], rax

loc_1400010EB:
mov     rax, [rsp+7D8h+var_778]
mov     rcx, [rsp+7D8h+var_780]
mov     qword ptr [rax], 0
add     rax, 8
cmp     rax, rcx
mov     [rsp+7D8h+var_778], rax
jnz     short loc_1400010EB

lea     rcx, PatternSearcher__setInfo
lea     rdx, [rsp+7D8h+var_478]
call    doCall

Инструкция call всегда ссылается на функцию doCall, из-за чего прямых связей IDA и не строила, пока я не включил отображение упоминаний. Именно их и видно:

lea     rcx, PatternSearcher__setInfo

Но ведь и это можно исправить?

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

Буду считать хеш имени функций во время компиляции:

consteval uint64_t hash(const char *str);

Заполнять таблицу где-нибудь в начале выполнения:

#define DISPATCH_REGISTER(name) { constexpr uint64_t _hash = hash(#name); dispatch::registerFunction(_hash, &name); }
void registerFunction(const uint64_t hash, Fn func);

Вызывать функции соответственно по хешу:

uint64_t doCall(uint64_t hash, CallArguments args);
#define CALL_0(func) doCall(func, dispatch::CallArguments{})
//....
#define CALL(func, ...) PP_CAT(CALL_, PP_NARG(__VA_ARGS__)) (hash(#func), __VA_ARGS__)

Почему же я оставил конструктор?

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

namespace PatternSearcher
{
Object::Object()
{
    DISPATCH_REGISTER(PatternSearcher::addStage);
    DISPATCH_REGISTER(PatternSearcher::setInfo);
    DISPATCH_REGISTER(PatternSearcher::find);
    DISPATCH_REGISTER(PatternSearcher::_performStageSearch);
    DISPATCH_REGISTER(PatternSearcher::_matchAtOffset);

    for (size_t i = 0; i < STAGES_COUNT; i++)
    {
        this->stages[i].type = SearchDirection::NONE;
    }
}
}

Но врапперы, которые я писал для WinAPI функций, придется всё-таки регистрировать в startup:

void start();
extern "C" void _start()
{
    dispatch::DispatchEntry dispatchTable[DISPATCH_TABLE_SIZE] = {};
    dispatch::setupDispatchTable(dispatchTable);

    DISPATCH_REGISTER(MessageBoxA_wrapper);
    DISPATCH_REGISTER(ExitProcess_wrapper);
    
    start();
}

Основной код переехал в start(), а _start() выполняет роль startup

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

Основной код при этом не изменился, но в коде везде будут хеши, которые просчитываются во время компиляции, а ссылки на функции только в startup и конструкторе

Полный код:

В этот-то раз получилось?

call-graph сразу со связями по упоминаниям
call-graph сразу со связями по упоминаниям

Видно, что start и конструктор ссылаются на registerFunction и на другие функции, но заглянем сразу внутрь main

Конструктор я не скрывал, он вызывается напрямую:

PatternSearcher::Object(searcher);
lea     rcx, [rsp+7D8h+searcher]
call    PatternSearcher__Object

Остальные вызовы идут через doCall, в который передается хеш:

doCall(0xBA7C49E66C136A52uLL, v16); // setInfo
doCall(0x602156B538A2FB49LL, v13); // addStage
result = doCall(0x8FCCE0054447B64DuLL, v8); // find
doCall(0x6C5DF279D37AF158LL, v6); // MessageBoxA_wrapper
doCall(0x8279D977F515DD86uLL, v5); // ExitProcess_wrapper
mov     rcx, 0BA7C49E66C136A52h
lea     rdx, [rsp+7D8h+var_478]
call    doCall

mov     rcx, 602156B538A2FB49h
lea     rdx, [rsp+7D8h+var_5A8]
call    doCall

mov     rcx, 8FCCE0054447B64Dh
lea     rdx, [rsp+7D8h+var_710]
call    doCall
mov     [rsp+7D8h+result], rax

mov     rcx, 6C5DF279D37AF158h
lea     rdx, [rsp+7D8h+var_740]
call    doCall

mov     rcx, 8279D977F515DD86h
lea     rdx, [rsp+7D8h+var_770]
call    doCall

В отрыве от контекста эти хеши ничего не значат. Вместо адреса функции передается непонятное uint64_t значение. Поэтому IDA не строит полноценные связи.

Смотрю start

dispatchTable создается на стеке, передается в глобальный указатель:

_BYTE dispatchTable[4096]; // [rsp+30h] [rbp-1008h] BYREF
_BYTE *v2; // [rsp+1030h] [rbp-8h]

memset(dispatchTable, 0, sizeof(dispatchTable));
v2 = dispatchTable;
g_dispatchTablePtr = dispatchTable;
lea     rcx, [rsp+arg_28] ; void *
xor     edx, edx        ; Val
mov     r8d, 1000h      ; Size
call    memset
lea     rax, [rsp+arg_28]
mov     [rsp+arg_1028], rax
mov     rax, [rsp+arg_1028]
mov     cs:g_dispatchTablePtr, rax

Регистрация функций в хеш-таблице, отсюда IDA и рисует связи:

                      /*hash "MessageBoxA_wrapper"*/
dispatch::registerFunction(0x6C5DF279D37AF158LL, MessageBoxA_wrapper);
                      /*hash "ExitProcess_wrapper"*/
dispatch::registerFunction(0x8279D977F515DD86uLL, ExitProcess_wrapper);
mov     rax, 6C5DF279D37AF158h ; hash "MessageBoxA_wrapper"
mov     [rsp+arg_20], rax
mov     rcx, 6C5DF279D37AF158h ; hash "MessageBoxA_wrapper"
lea     rdx, MessageBoxA_wrapper
call    dispatch__registerFunction
mov     rax, 8279D977F515DD86h ; hash "ExitProcess_wrapper"
mov     [rsp+arg_18], rax
mov     rcx, 8279D977F515DD86h ; hash "ExitProcess_wrapper"
lea     rdx, ExitProcess_wrapper
call    dispatch__registerFunction

То же самое и в конструкторе:

dispatch::registerFunction(0x602156B538A2FB49LL, PatternSearcher::addStage);
dispatch::registerFunction(0xBA7C49E66C136A52uLL, PatternSearcher::setInfo);
dispatch::registerFunction(0x8FCCE0054447B64DuLL, PatternSearcher::find);
dispatch::registerFunction(0x734AD296B2D306D6LL, PatternSearcher::_performStageSearch);
dispatch::registerFunction(0xD6D8F1824447D932uLL, PatternSearcher::_matchAtOffset);
mov     rax, 602156B538A2FB49h
mov     [rsp+68h+var_18], rax
mov     rcx, 602156B538A2FB49h
lea     rdx, PatternSearcher__addStage
call    dispatch__registerFunction
mov     rax, 0BA7C49E66C136A52h
mov     [rsp+68h+var_20], rax
mov     rcx, 0BA7C49E66C136A52h
lea     rdx, PatternSearcher__setInfo
call    dispatch__registerFunction
mov     rax, 8FCCE0054447B64Dh
mov     [rsp+68h+var_28], rax
mov     rcx, 8FCCE0054447B64Dh
lea     rdx, PatternSearcher__find
call    dispatch__registerFunction
mov     rax, 734AD296B2D306D6h
mov     [rsp+68h+var_30], rax
mov     rcx, 734AD296B2D306D6h
lea     rdx, PatternSearcher___performStageSearch
call    dispatch__registerFunction
mov     rax, 0D6D8F1824447D932h
mov     [rsp+68h+var_38], rax
mov     rcx, 0D6D8F1824447D932h
lea     rdx, PatternSearcher___matchAtOffset
call    dispatch__registerFunction

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

Но это все ещё палево

Так что сформирую хеш-таблицу на этапе компиляции и буду хранить в статических данных:

#define DISPATCH_REGISTER(name) { hash(#name), &name }

struct DispatchTable
{ DispatchEntry entries[DISPATCH_TABLE_SIZE]; };

consteval DispatchTable createDispatchTable(DispatchTable table);
#include "dispatch.h"

#include "patternSearcher.h"

extern uint64_t MessageBoxA_wrapper(dispatch::CallArguments args);
extern uint64_t ExitProcess_wrapper(dispatch::CallArguments args);
namespace dispatch
{
dispatch::DispatchTable dispatchTable = createDispatchTable((dispatch::DispatchTable){
    .entries = {
        DISPATCH_REGISTER(PatternSearcher::addStage),
        DISPATCH_REGISTER(PatternSearcher::setInfo),
        DISPATCH_REGISTER(PatternSearcher::find),
        DISPATCH_REGISTER(PatternSearcher::_performStageSearch),
        DISPATCH_REGISTER(PatternSearcher::_matchAtOffset),
        DISPATCH_REGISTER(MessageBoxA_wrapper),
        DISPATCH_REGISTER(ExitProcess_wrapper),
    }
});
}

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

Из-за такого подхода я решил, что и конструктор мне в принципе уже не нужен, код из него перенес в setInfo()

Код внутри start просто передает управление дальше, startup отсутствует:

void start();
extern "C" void _start()
{
    start();
}

Остальной код не изменился

Полный код:

И вот результат

call-graph сразу с отображением упоминаний
call-graph сразу с отображением упоминаний

IDA совсем не видит связей, но вызовы также производятся по хешам.

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

; ....
; ....
; ....
.data:00000001400034D0                 db  4Dh ; M
.data:00000001400034D1                 db 0B6h
.data:00000001400034D2                 db  47h ; G
.data:00000001400034D3                 db  44h ; D
.data:00000001400034D4                 db    5
.data:00000001400034D5                 db 0E0h
.data:00000001400034D6                 db 0CCh
.data:00000001400034D7                 db  8Fh
.data:00000001400034D8                 dq offset PatternSearcher__find
; ....
; ....
; ....
.data:0000000140003520                 db  52h ; R
.data:0000000140003521                 db  6Ah ; j
.data:0000000140003522                 db  13h
.data:0000000140003523                 db  6Ch ; l
.data:0000000140003524                 db 0E6h
.data:0000000140003525                 db  49h ; I
.data:0000000140003526                 db  7Ch ; |
.data:0000000140003527                 db 0BAh
.data:0000000140003528                 dq offset PatternSearcher__setInfo
.data:0000000140003530                 align 80h
.data:0000000140003580                 db  58h ; X
.data:0000000140003581                 db 0F1h
.data:0000000140003582                 db  7Ah ; z
.data:0000000140003583                 db 0D3h
.data:0000000140003584                 db  79h ; y
.data:0000000140003585                 db 0F2h
.data:0000000140003586                 db  5Dh ; ]
.data:0000000140003587                 db  6Ch ; l
.data:0000000140003588                 dq offset MessageBoxA_wrapper
; ....
; ....
; ....
Proximity View
Proximity View

Proximity View показывает на что ссылаются те или иные участки программы, включая функции, переменные, ресурсы. Как видно, связей тут тоже немного.

Конечно, это не панацея

Call Stack в отладчике
Call Stack в отладчике

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

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

Репоз с кодом: GitHub

За вредные советы спасибо комьюнити SenJun - opensource курсов по C++, брейнфаку и другим не менее полезным яп.