Здравствуйте, друзья, это - моя первая статья на Wasm'е, не судите строго. Надеюсь она же станет первой статьей в цикле, в котором я рассмотрю возможности мета-программирования разных ЯП и компиляторов применительно к обфускации кода. Первым пациентом у нас будет великий и могучий, ненавидимый многими программистами по всему миру, прородитель buffer overflow и use after free багов - добрый дядька С++.
Когда то очень давно, а именно в 2013 году, когда в компиляторы С++ начали завозить constexpr, я экспериментировал с применением мета-программирования для обфускации кода и даже сподобился выкатить более менее рабочий вариант (интернет все помнит, если хотите можете погуглить), который в итоге попал в сорсы какого-то несуразного банкинг бота, поэтому позвольте на всякий случай сделать небольшой дисклеймер. Код из этой статьи предоставляется исключительно в целях обучения, пожалуйста не считайте, что я несу ответственность за любое использование этого кода, нарушающее действующие законы вашего государства. Ведь господина Калашникова не судят за убийства из его автомата, а вилки не делают людей жирными. Спасибо.
Как и в прошлый раз для компиляции кода я буду использовать GCC/MinGW, чтобы описанные алгоритмы можно было бы использовать не только на Венде, но и на Линуксе. Поскольку подавляющее большинство из вас сидит на Венде (чисто по статистике), то и примеры мы будем разбирать на Венде. Использовать мы будем версию компилятора 9.2.0, она достаточно свежая на момент написания статьи. Чисто теоретически приведенный ниже код может быть использован так же и в MSVC и Clang, но я не тестил, извиняйте. Под Линуксом соответствующая версия компилятора наверняка есть в списке пакетов вашего дистрибутива (даже если нет, раз вы сидите на Линуксе, я уверен вы в состоянии сами разобраться, как его туда поставить). На Венде можно скачать и поставить его с помощью MSYS2, или tdm-gcc, или mingw-w64-dgn. И это не будет десятки гигабайт трафика и несколько часов потерянного за инсталляцией времени (MSVC, я тебя имею ввиду), я вас уверяю. Ну с преамбулами закончили, пошли скорее кодить.
Начнем с простого хелоу ворлда, чтобы я мог рассказать вам о том, как мы будем все собирать. Итак мы будем собирать код без зависимости от стандартных библиотек сишечки и плюсов, чтобы нам в дизассемблере было сразу видно, что и где находится. Напишем хеллоу ворлд:
Мы будем использовать _printf_l для вывода сообщений в консоль вместо стандартного printf, так как printf переопределен в стандартной библиотеке, а _printf_l доступен в msvcrt.dll. ExitProcess мы вызываем, тк нам нужно корректно завершить процесс (при обычной сборке ExitProcess за нас вызовет стандартная библиотека, но нам в данном случае важнее незагруженность исполняемого файла, и да, мы же не ищем легких путей). Теперь напишем батничек для сборки и тестирования:Код (C++):
#include <windows.h> #include <stdint.h> #include <stdio.h> extern "C" void EntryPoint() { _printf_l("Hello World!\n", 0); ExitProcess(0); }
Первые две и последние две строчки батника должны быть ясны, мы просто удаляем артефакты предыдущей сборки вначале и запускаем новые артефакты сборки в конце для удобства тестирования. Теперь к более сложному, мы используем i686-w64-mingw32-g++ для создания 32-битной версии нашего кода и x86_64-w64-mingw32-g++ соответственно для 64-битной версии. Параметр «-о» указывает название исполняемого файла, который мы хотим собрать. Параметр «-masm=intel» указывает компилятору, что мы хотим использовать ассемблер в интеловском синтаксе (потому, что читать AT&T – адская мука для глаз), в статье нам это не понадобится, но если вы захотите посмотреть, какой ассемблерный код генерирует компилятор или сделать ассемблерную вставочку, то этот параметр окажется полезным. «-O3 -Os» включают оптимизацию, нам же нужно проверить, что компилятор не соптимизировал всю нашу обфускацию в открытый текст. «-fno-exceptions -fno-rtti -fno-ident» отключает использование определенных фич С++, которые требуют стандартных библиотек. «-nostdlib» отключает использование стандартных библиотек. «-e» указывает имя для точки входа исполняемого файла. «-s» вырезает отладочную информацию. «-l» добавляет линковку системной библиотеки Венды, это необходимо, чтобы функции типа ExitProcess появились в таблице импорта.Код (DOS):
del /f /q main32.exe del /f /q main64.exe i686-w64-mingw32-g++ -o main32.exe -masm=intel -O3 -Os -fno-exceptions -fno-rtti -fno-ident main.cpp -nostdlib -e_EntryPoint -s -lkernel32 -lmsvcrt x86_64-w64-mingw32-g++ -o main64.exe -masm=intel -O3 -Os -fno-exceptions -fno-rtti -fno-ident main.cpp -nostdlib -eEntryPoint -s -lkernel32 -lmsvcrt main32.exe main64.exe
Для начала сделаем себе простой макрос для вывода сообщения в консоль, чтобы меньше писать в будущем (я думаю, тут должно быть все понятно, самая обычная сишечка):
Реализуем функцию для простого (читай не криптографического) хеширования строк на этапе компиляции по алгоритму FNV-1 (очень простой и легко реализуемый алгоритм), но сделаем ему возможность передачи произвольного зерна в параметрах, ну и сделаем его constexpr конечно:Код (C++):
#define LOG(F, ...) _printf_l(F, 0, ##__VA_ARGS__)
Теперь добавим вот такой код в основную функцию:Код (C++):
constexpr uint32_t FNVHash(const char* string, uint32_t seed = 0) { uint32_t res = seed; for(int i = 0; string[i] != '\0'; i++) { res = res * 16777619; res = res ^ string[i]; } return res; }
Посмотрим, что у нас получилось в дизассемблере (32-битный исполняемый файл):Код (C++):
LOG("%X\n", FNVHash("Hello World!"));
Как видно из листинга поддержка constexpr в плюсах улучшилась с древних времен, и эта constexpr функция сама по себе делает то, что мы от нее потребовали, то есть хеширует строку на этапе компиляции. И нам с вами теперь не нужно заморачиваться рекурсивной реализацией этой функции, как в прошлый раз. Но моя паранойя в отношении плюсовых компиляторов не дает мне покоя, поэтому таким вот образом мы можем быть уверены, что хеширование все-таки всегда будет происходить на этапе компиляции:Код (ASM):
mov [esp+18h+var_10], 6990D79Dh mov [esp+18h+Locale], 0 ; Locale mov [esp+18h+Format], offset Format ; "%X\n" call ds:_printf_l
Проверяем использование FNVHASH0 макроса вместо FNVHash (64-битный исполняемый файл):Код (C++):
template <uint32_t Const> struct Constantify { enum { Value = Const }; }; #define FNVHASH0(STR) Constantify<FNVHash(STR)>::Value
Так, ну все работает, как надо. Дальше нам каким-то образом нужно получить зерно (мы будем использовать целое 32-битное число) для генераторов псевдослучайных значений, чтобы в последствии при каждой новой сборке проекта наши алгоритмы делали что-то по-разному - ну, например, меняли ключи. Можно передавать это число параметром компиляции или же взять значение констант __DATE__ и __TIME__. Напишем вариант с использованием констант:Код (ASM):
lea rcx, Format ; "%X\n" mov r8d, 6990D79Dh xor edx, edx ; Locale call cs:_printf_l
Проверим написанный макрос и убедимся, что при каждой последующей сборке проекта мы имеем разное зерно, добавив следующий код в основную функцию программы:Код (C++):
#define GET_COMPILE_SEED Constantify<FNVHash(__DATE__ __TIME__)>::Value
Теперь мы можем наконец написать макрос для хеширования строк на этапе компиляции с зависимостью от зерна текущей сборки проекта:Код (C++):
LOG("%X\n", GET_COMPILE_SEED);
Убедившись, что макрос работает правильно, сделаем небольшое лирическое отступление. Некоторые из читателей этой статьи наверное недоумевают, зачем в принципе нужно хешировать строки на этапе сборки проекта? Я расскажу вам несколько use case'ов. Допустим, что вам нужно проверить некоторую строку, которую вы получили из пользовательского ввода, на равенство некой уникальной строке (паролю например), и вы очень не хотите, чтобы мамкины крякеры могли, открыв ваш исполняемый файл дизассемблером, увидеть эту уникальную строку. Так вот хеширование - более менее неплохой выход. Но чтобы не хардкодить хеш-значение, его можно вычислить на этапе компиляции, более того в той реализации, что я уже показал вам, хеш-значение не будет постоянным и будет меняться при каждой пересборке проекта. Другой пример более популярен в малвари, чем в легитимном ПО, но иногда все же используется и там, это - динамический вызов API по хеш-значениям их имен. Как реализовывать такие вызовы на практике выходит за рамки этой статьи, но в интернете есть куча примеров, с этим вопросом пожалуйте в гугл.Код (Text):
#define FNVHASH(STR) Constantify<FNVHash(STR, GET_COMPILE_SEED)>::Value
Теперь напишем генератор псевдослучайных чисел, работающий на этапе компиляции. Важно отметить, что нам собственно не особо важны его свойства с точки зрения математики, нам достаточно, чтобы он генерировал хоть какие-то псевдослучайные числа. Поэтому я не стал запариваться рекурсивной реализацией, как в прошлый раз, и реализовал его через макрос __COUNTER__. Константы взяты из линейного конгруэнтного алгоритма, который использовался раньше в glibc (привет красноглазикам). Ну в общем смотрим код:
Опять же для тех читателей, кто не понимает зачем все это. В частности это можно использовать для генерации мусорного кода, но это уже совсем другая история, я же статью пишу, а не книгу, верно?Код (C++):
constexpr uint32_t Rand(uint32_t cnt) { cnt = cnt + GET_COMPILE_SEED; return cnt * 1103515245 + 12345; } #define RAND() Constantify<Rand(__COUNTER__)>::Value #define RANDOM(MIN, MAX) (MIN + (RAND() % (MAX - MIN + 1)))
Так теперь переходим к самому, наверное, интересному, реализуем шифрование строк на этапе компиляции, тем более, что все нужное для этого у нас уже реализовано. Ну как к шифрованию, в текущем примере мы просто поксорим строку, ну а в продакшн коде само собой надо что-то поинтереснее придумать. Для начала сделаем вычисление длины строки на этапе компиляции, тут все просто:
Далее нам бы хотелось, чтобы весь сгенерированный код для конструирования и расшифровки строки был заинлайнен, поэтому добавим соответствующий макрос (этот макрос для GCC/MinGW, я не в курсе, как аналогичный атрибут называется в MSVC и Clang, возможно forceinline или что-то такое):Код (C++):
constexpr uint32_t Strlen(const char* string) { uint32_t res = 0; for(int i = 0; string[i] != '\0'; i++) { res++; } return res; } #define STRLEN(STR) Constantify<Strlen(STR)>::Value
Далее я сделаю небольшое лирическое отступление. Раньше приходилось сильно изворачиваться, чтобы обходить строку посимвольно на этапе компиляции. Все это потому, что шипко гениальные создатели плюсов сделали язык шаблонов полным по тьюрингу, но чисто функциональным. Это как писать на Haskell, но в еще более отвратительном синтаксисе. То есть этот код чуть более чем полностью должен состоять из чистых функций, не способных хранить состояние. В частности, из-за этого нельзя было реализовать какой-то цикличный алгоритм, а приходилось реализовывать все через рекурсию. К счастью до нас наконец доехали нормальные constexpr и комьюнити все-таки сподобилось придумать более-менее простой способ обхода строки на этапе компиляции, хоть и все равно рекурсивный. Давайте рассмотрим следующий код:Код (C++):
#define ALWAYS_INLINE __attribute__((always_inline))
Знакомьтесь, это – рекурсивный шаблон, который обходит строку с последнего индекса и доходит до индекса 0. Шаблон сверху принимает аргумент – индекс в строке, который нужно зашифровать, и вызывает шаблон с индексом меньше на единицу. Шаблон снизу – это так называемый base case, который нужен, чтобы обработать нулевой символ и остановить рекурсию. Без него рекурсия бы продолжалась бесконечно долго (потому, что после нулевого индекса алгоритм перешел к минус первому). Хотя что я вам объясняю про рекурсию, вы наверняка уже все это знаете.Код (C++):
template <size_t Index> struct Encryptor { ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key) { dst[Index] = src[Index] ^ key; Encryptor<Index - 1>::Encrypt(dst, src, key); } }; template <> struct Encryptor<0> { ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key) { dst[0] = src[0] ^ key; } };
Теперь давайте используем шаблоны Encryptor для шифрования строк. Мы будем оборачивать строки в шаблонный по количеству элементов в строке класс, чтобы компилятор выделил для нас и строки буфер на стеке. Рассмотрим следующий код:
Мы реализуем шифрование строки на этапе компиляции в конструкторе класса, выделяем класс на стеке, и затем вызываем метод Decrypt, который проведет расшифровку буфера и вернет его адрес на стеке. Давайте рассмотрим код макроса, упрощающего это действо (обратите внимание, что у каждой строки будет свой псевдослучайный ключ для нашего "псевдо-шифрования"):Код (C++):
template <size_t Size> class EncryptedString { mutable char _buffer[Size]; const char _key; public: ALWAYS_INLINE constexpr EncryptedString(const char string[Size], char key) : _key { key } { Encryptor<Size - 1>::Encrypt(_buffer, string, _key); } ALWAYS_INLINE const char* Decrypt() { for(int i = 0; i < Size; i++) { _buffer[i] ^= _key; } return _buffer; } };
Протестируем, что у нас получилось следующим кодом:Код (C++):
#define ENCRYPT_STRING(STR) EncryptedString<STRLEN(STR) + 1>(STR, (char)RANDOM(1, 0xFF)).Decrypt()
И посмотрим на результат в дисассемблере:Код (C++):
LOG("%s\n", ENCRYPT_STRING("HELLO"));
Ну что же, выглядит неплохо. Теперь давайте переходить к заключению. Многие после прочтения данной статьи скажут, мол это всё – теребоньканье на плюсовый компилятор, всё это можно было бы легче сделать внешней тулзой, обфускатором каким-нибудь или навесить упаковщик/протектор. И я вам отвечу: да, вы абсолютно правы.Код (ASM):
; 32-битный код: mov [ebp+var_F], 97979E93h lea eax, [ebp+var_F] lea ecx, [ebp+var_9] mov [ebp+var_B], 0DB94h mov edx, eax mov [ebp+var_9], 0DBh loc_40101F: xor byte ptr [eax], 0DBh inc eax cmp eax, ecx jnz short loc_40101F ; 64-битный код: mov [rsp+38h+var_9], 9Fh lea rax, [rsp+38h+var_F] lea rdx, [rsp+38h+var_9] mov [rsp+38h+var_F], 0D3D3DAD7h mov r8, rax mov [rsp+38h+var_B], 9FD0h loc_401025: xor byte ptr [rax], 9Fh inc rax cmp rax, rdx jnz short loc_401025
Чисто теоретически на базе этого кода можно реализовать полноценный обфускатор, практичность этого – весьма спорный вопрос. Кому-то покажется очень удобным обфусцировать код с помощью мета-программирования, а не копаться в парсерах С++ или более низкоуровневых представлениях кода, таких как LLVM. Кому-то окажется проще написать внешний обфускатор и не бороться с ограничениями убогово плюсового мета-программирования. Ну каждому своё, я просто хотел донести до вас интересную на мой взгляд тему для изучения, как минимум я многое узнал о плюсах копаясь в мета-программировании в далеком 2013 году.
Ну и последнее, весь приведенный код работает в сферическом вакууме, то есть на моем компьютере, с указанной версией компилятора, указанными флагами компилятора и тд. Так что если вдруг вы решите делать что-либо на базе этого кода, пожалуйста не забудьте хорошо всё протестировать. Спасибо за ваше внимание и всего вам хорошего!
ЗЫ И вот полный код, если кто-то запутался в словах статьи:
Код (C++):
#include <windows.h> #include <stdint.h> #include <stdio.h> #define LOG(F, ...) _printf_l(F, 0, ##__VA_ARGS__) template <uint32_t Const> struct Constantify { enum { Value = Const }; }; constexpr uint32_t FNVHash(const char* string, uint32_t seed = 0) { uint32_t res = seed; for(int i = 0; string[i] != '\0'; i++) { res = res * 16777619; res = res ^ string[i]; } return res; } #define FNVHASH0(STR) Constantify<FNVHash(STR)>::Value #define GET_COMPILE_SEED Constantify<FNVHash(__DATE__ __TIME__)>::Value #define FNVHASH(STR) Constantify<FNVHash(STR, GET_COMPILE_SEED)>::Value constexpr uint32_t Rand(uint32_t cnt) { cnt = cnt + GET_COMPILE_SEED; return cnt * 1103515245 + 12345; } #define RAND() Constantify<Rand(__COUNTER__)>::Value #define RANDOM(MIN, MAX) (MIN + (RAND() % (MAX - MIN + 1))) constexpr uint32_t Strlen(const char* string) { uint32_t res = 0; for(int i = 0; string[i] != '\0'; i++) { res++; } return res; } #define STRLEN(STR) Constantify<Strlen(STR)>::Value #define ALWAYS_INLINE __attribute__((always_inline)) template <size_t Index> struct Encryptor { ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key) { dst[Index] = src[Index] ^ key; Encryptor<Index - 1>::Encrypt(dst, src, key); } }; template <> struct Encryptor<0> { ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key) { dst[0] = src[0] ^ key; } }; template <size_t Size> class EncryptedString { mutable char _buffer[Size]; const char _key; public: ALWAYS_INLINE constexpr EncryptedString(const char string[Size], char key) : _key { key } { Encryptor<Size - 1>::Encrypt(_buffer, string, _key); } ALWAYS_INLINE const char* Decrypt() { for(int i = 0; i < Size; i++) { _buffer[i] ^= _key; } return _buffer; } }; #define ENCRYPT_STRING(STR) EncryptedString<STRLEN(STR) + 1>(STR, (char)RANDOM(1, 0xFF)).Decrypt() extern "C" void EntryPoint() { LOG("%s\n", ENCRYPT_STRING("HELLO")); ExitProcess(0); }
Мета-программирование С++ и обфускация
Дата публикации 13 июн 2020
| Редактировалось 15 июн 2020