Мета-программирование С++ и обфускация

Дата публикации 13 июн 2020 | Редактировалось 15 июн 2020
Здравствуйте, друзья, это - моя первая статья на Wasm'е, не судите строго. Надеюсь она же станет первой статьей в цикле, в котором я рассмотрю возможности мета-программирования разных ЯП и компиляторов применительно к обфускации кода. Первым пациентом у нас будет великий и могучий, ненавидимый многими программистами по всему миру, прородитель buffer overflow и use after free багов - добрый дядька С++.

Когда то очень давно, а именно в 2013 году, когда в компиляторы С++ начали завозить constexpr, я экспериментировал с применением мета-программирования для обфускации кода и даже сподобился выкатить более менее рабочий вариант (интернет все помнит, если хотите можете погуглить), который в итоге попал в сорсы какого-то несуразного банкинг бота, поэтому позвольте на всякий случай сделать небольшой дисклеймер. Код из этой статьи предоставляется исключительно в целях обучения, пожалуйста не считайте, что я несу ответственность за любое использование этого кода, нарушающее действующие законы вашего государства. Ведь господина Калашникова не судят за убийства из его автомата, а вилки не делают людей жирными. Спасибо.

Как и в прошлый раз для компиляции кода я буду использовать GCC/MinGW, чтобы описанные алгоритмы можно было бы использовать не только на Венде, но и на Линуксе. Поскольку подавляющее большинство из вас сидит на Венде (чисто по статистике), то и примеры мы будем разбирать на Венде. Использовать мы будем версию компилятора 9.2.0, она достаточно свежая на момент написания статьи. Чисто теоретически приведенный ниже код может быть использован так же и в MSVC и Clang, но я не тестил, извиняйте. Под Линуксом соответствующая версия компилятора наверняка есть в списке пакетов вашего дистрибутива (даже если нет, раз вы сидите на Линуксе, я уверен вы в состоянии сами разобраться, как его туда поставить). На Венде можно скачать и поставить его с помощью MSYS2, или tdm-gcc, или mingw-w64-dgn. И это не будет десятки гигабайт трафика и несколько часов потерянного за инсталляцией времени (MSVC, я тебя имею ввиду), я вас уверяю. Ну с преамбулами закончили, пошли скорее кодить.

Начнем с простого хелоу ворлда, чтобы я мог рассказать вам о том, как мы будем все собирать. Итак мы будем собирать код без зависимости от стандартных библиотек сишечки и плюсов, чтобы нам в дизассемблере было сразу видно, что и где находится. Напишем хеллоу ворлд:
Код (C++):
  1. #include <windows.h>
  2. #include <stdint.h>
  3. #include <stdio.h>
  4.  
  5. extern "C" void EntryPoint() {
  6.     _printf_l("Hello World!\n", 0);
  7.     ExitProcess(0);
  8. }
Мы будем использовать _printf_l для вывода сообщений в консоль вместо стандартного printf, так как printf переопределен в стандартной библиотеке, а _printf_l доступен в msvcrt.dll. ExitProcess мы вызываем, тк нам нужно корректно завершить процесс (при обычной сборке ExitProcess за нас вызовет стандартная библиотека, но нам в данном случае важнее незагруженность исполняемого файла, и да, мы же не ищем легких путей). Теперь напишем батничек для сборки и тестирования:
Код (DOS):
  1. del /f /q main32.exe
  2. del /f /q main64.exe
  3. 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
  4. 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
  5. main32.exe
  6. main64.exe
Первые две и последние две строчки батника должны быть ясны, мы просто удаляем артефакты предыдущей сборки вначале и запускаем новые артефакты сборки в конце для удобства тестирования. Теперь к более сложному, мы используем 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 появились в таблице импорта.

Для начала сделаем себе простой макрос для вывода сообщения в консоль, чтобы меньше писать в будущем (я думаю, тут должно быть все понятно, самая обычная сишечка):
Код (C++):
  1. #define LOG(F, ...) _printf_l(F, 0, ##__VA_ARGS__)
Реализуем функцию для простого (читай не криптографического) хеширования строк на этапе компиляции по алгоритму FNV-1 (очень простой и легко реализуемый алгоритм), но сделаем ему возможность передачи произвольного зерна в параметрах, ну и сделаем его constexpr конечно:
Код (C++):
  1. constexpr uint32_t FNVHash(const char* string, uint32_t seed = 0) {
  2.     uint32_t res = seed;
  3.     for(int i = 0; string[i] != '\0'; i++) {
  4.         res = res * 16777619;
  5.         res = res ^ string[i];
  6.     }
  7.  
  8.     return res;
  9. }
Теперь добавим вот такой код в основную функцию:
Код (C++):
  1. LOG("%X\n", FNVHash("Hello World!"));
Посмотрим, что у нас получилось в дизассемблере (32-битный исполняемый файл):
Код (ASM):
  1. mov  [esp+18h+var_10], 6990D79Dh
  2. mov  [esp+18h+Locale], 0 ; Locale
  3. mov  [esp+18h+Format], offset Format ; "%X\n"
  4. call ds:_printf_l
Как видно из листинга поддержка constexpr в плюсах улучшилась с древних времен, и эта constexpr функция сама по себе делает то, что мы от нее потребовали, то есть хеширует строку на этапе компиляции. И нам с вами теперь не нужно заморачиваться рекурсивной реализацией этой функции, как в прошлый раз. Но моя паранойя в отношении плюсовых компиляторов не дает мне покоя, поэтому таким вот образом мы можем быть уверены, что хеширование все-таки всегда будет происходить на этапе компиляции:
Код (C++):
  1. template <uint32_t Const> struct Constantify {
  2.     enum { Value = Const };
  3. };
  4.  
  5. #define FNVHASH0(STR) Constantify<FNVHash(STR)>::Value
Проверяем использование FNVHASH0 макроса вместо FNVHash (64-битный исполняемый файл):
Код (ASM):
  1. lea  rcx, Format     ; "%X\n"
  2. mov  r8d, 6990D79Dh
  3. xor  edx, edx        ; Locale
  4. call cs:_printf_l
Так, ну все работает, как надо. Дальше нам каким-то образом нужно получить зерно (мы будем использовать целое 32-битное число) для генераторов псевдослучайных значений, чтобы в последствии при каждой новой сборке проекта наши алгоритмы делали что-то по-разному - ну, например, меняли ключи. Можно передавать это число параметром компиляции или же взять значение констант __DATE__ и __TIME__. Напишем вариант с использованием констант:
Код (C++):
  1. #define GET_COMPILE_SEED Constantify<FNVHash(__DATE__ __TIME__)>::Value
Проверим написанный макрос и убедимся, что при каждой последующей сборке проекта мы имеем разное зерно, добавив следующий код в основную функцию программы:
Код (C++):
  1. LOG("%X\n", GET_COMPILE_SEED);
Теперь мы можем наконец написать макрос для хеширования строк на этапе компиляции с зависимостью от зерна текущей сборки проекта:
Код (Text):
  1. #define FNVHASH(STR) Constantify<FNVHash(STR, GET_COMPILE_SEED)>::Value
Убедившись, что макрос работает правильно, сделаем небольшое лирическое отступление. Некоторые из читателей этой статьи наверное недоумевают, зачем в принципе нужно хешировать строки на этапе сборки проекта? Я расскажу вам несколько use case'ов. Допустим, что вам нужно проверить некоторую строку, которую вы получили из пользовательского ввода, на равенство некой уникальной строке (паролю например), и вы очень не хотите, чтобы мамкины крякеры могли, открыв ваш исполняемый файл дизассемблером, увидеть эту уникальную строку. Так вот хеширование - более менее неплохой выход. Но чтобы не хардкодить хеш-значение, его можно вычислить на этапе компиляции, более того в той реализации, что я уже показал вам, хеш-значение не будет постоянным и будет меняться при каждой пересборке проекта. Другой пример более популярен в малвари, чем в легитимном ПО, но иногда все же используется и там, это - динамический вызов API по хеш-значениям их имен. Как реализовывать такие вызовы на практике выходит за рамки этой статьи, но в интернете есть куча примеров, с этим вопросом пожалуйте в гугл.

Теперь напишем генератор псевдослучайных чисел, работающий на этапе компиляции. Важно отметить, что нам собственно не особо важны его свойства с точки зрения математики, нам достаточно, чтобы он генерировал хоть какие-то псевдослучайные числа. Поэтому я не стал запариваться рекурсивной реализацией, как в прошлый раз, и реализовал его через макрос __COUNTER__. Константы взяты из линейного конгруэнтного алгоритма, который использовался раньше в glibc (привет красноглазикам). Ну в общем смотрим код:
Код (C++):
  1. constexpr uint32_t Rand(uint32_t cnt) {
  2.     cnt = cnt + GET_COMPILE_SEED;
  3.     return cnt * 1103515245 + 12345;
  4. }
  5.  
  6. #define RAND() Constantify<Rand(__COUNTER__)>::Value
  7. #define RANDOM(MIN, MAX) (MIN + (RAND() % (MAX - MIN + 1)))
Опять же для тех читателей, кто не понимает зачем все это. В частности это можно использовать для генерации мусорного кода, но это уже совсем другая история, я же статью пишу, а не книгу, верно?

Так теперь переходим к самому, наверное, интересному, реализуем шифрование строк на этапе компиляции, тем более, что все нужное для этого у нас уже реализовано. Ну как к шифрованию, в текущем примере мы просто поксорим строку, ну а в продакшн коде само собой надо что-то поинтереснее придумать. Для начала сделаем вычисление длины строки на этапе компиляции, тут все просто:
Код (C++):
  1. constexpr uint32_t Strlen(const char* string) {
  2.     uint32_t res = 0;
  3.     for(int i = 0; string[i] != '\0'; i++)
  4.     { res++; }
  5.  
  6.     return res;
  7. }
  8.  
  9. #define STRLEN(STR) Constantify<Strlen(STR)>::Value
Далее нам бы хотелось, чтобы весь сгенерированный код для конструирования и расшифровки строки был заинлайнен, поэтому добавим соответствующий макрос (этот макрос для GCC/MinGW, я не в курсе, как аналогичный атрибут называется в MSVC и Clang, возможно forceinline или что-то такое):
Код (C++):
  1. #define ALWAYS_INLINE __attribute__((always_inline))
Далее я сделаю небольшое лирическое отступление. Раньше приходилось сильно изворачиваться, чтобы обходить строку посимвольно на этапе компиляции. Все это потому, что шипко гениальные создатели плюсов сделали язык шаблонов полным по тьюрингу, но чисто функциональным. Это как писать на Haskell, но в еще более отвратительном синтаксисе. То есть этот код чуть более чем полностью должен состоять из чистых функций, не способных хранить состояние. В частности, из-за этого нельзя было реализовать какой-то цикличный алгоритм, а приходилось реализовывать все через рекурсию. К счастью до нас наконец доехали нормальные constexpr и комьюнити все-таки сподобилось придумать более-менее простой способ обхода строки на этапе компиляции, хоть и все равно рекурсивный. Давайте рассмотрим следующий код:
Код (C++):
  1. template <size_t Index> struct Encryptor {
  2.     ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key)
  3.     { dst[Index] = src[Index] ^ key; Encryptor<Index - 1>::Encrypt(dst, src, key); }
  4. };
  5.  
  6. template <> struct Encryptor<0> {
  7.     ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key)
  8.     { dst[0] = src[0] ^ key; }
  9. };
Знакомьтесь, это – рекурсивный шаблон, который обходит строку с последнего индекса и доходит до индекса 0. Шаблон сверху принимает аргумент – индекс в строке, который нужно зашифровать, и вызывает шаблон с индексом меньше на единицу. Шаблон снизу – это так называемый base case, который нужен, чтобы обработать нулевой символ и остановить рекурсию. Без него рекурсия бы продолжалась бесконечно долго (потому, что после нулевого индекса алгоритм перешел к минус первому). Хотя что я вам объясняю про рекурсию, вы наверняка уже все это знаете.

Теперь давайте используем шаблоны Encryptor для шифрования строк. Мы будем оборачивать строки в шаблонный по количеству элементов в строке класс, чтобы компилятор выделил для нас и строки буфер на стеке. Рассмотрим следующий код:
Код (C++):
  1. template <size_t Size> class EncryptedString {
  2.     mutable char _buffer[Size];
  3.     const   char _key;
  4.  
  5.     public:
  6.         ALWAYS_INLINE constexpr EncryptedString(const char string[Size], char key) : _key { key }
  7.         { Encryptor<Size - 1>::Encrypt(_buffer, string, _key); }
  8.  
  9.         ALWAYS_INLINE const char* Decrypt() {
  10.             for(int i = 0; i < Size; i++)
  11.             { _buffer[i] ^= _key; }
  12.  
  13.             return _buffer;
  14.         }
  15. };
Мы реализуем шифрование строки на этапе компиляции в конструкторе класса, выделяем класс на стеке, и затем вызываем метод Decrypt, который проведет расшифровку буфера и вернет его адрес на стеке. Давайте рассмотрим код макроса, упрощающего это действо (обратите внимание, что у каждой строки будет свой псевдослучайный ключ для нашего "псевдо-шифрования"):
Код (C++):
  1. #define ENCRYPT_STRING(STR) EncryptedString<STRLEN(STR) + 1>(STR, (char)RANDOM(1, 0xFF)).Decrypt()
Протестируем, что у нас получилось следующим кодом:
Код (C++):
  1. LOG("%s\n", ENCRYPT_STRING("HELLO"));
И посмотрим на результат в дисассемблере:
Код (ASM):
  1. ; 32-битный код:
  2. mov  [ebp+var_F], 97979E93h
  3. lea  eax, [ebp+var_F]
  4. lea  ecx, [ebp+var_9]
  5. mov  [ebp+var_B], 0DB94h
  6. mov  edx, eax
  7. mov  [ebp+var_9], 0DBh
  8. loc_40101F:
  9. xor  byte ptr [eax], 0DBh
  10. inc  eax
  11. cmp  eax, ecx
  12. jnz  short loc_40101F
  13.  
  14. ; 64-битный код:
  15. mov  [rsp+38h+var_9], 9Fh
  16. lea  rax, [rsp+38h+var_F]
  17. lea  rdx, [rsp+38h+var_9]
  18. mov  [rsp+38h+var_F], 0D3D3DAD7h
  19. mov  r8, rax
  20. mov  [rsp+38h+var_B], 9FD0h
  21. loc_401025:
  22. xor  byte ptr [rax], 9Fh
  23. inc  rax
  24. cmp  rax, rdx
  25. jnz  short loc_401025
  26.  
Ну что же, выглядит неплохо. Теперь давайте переходить к заключению. Многие после прочтения данной статьи скажут, мол это всё – теребоньканье на плюсовый компилятор, всё это можно было бы легче сделать внешней тулзой, обфускатором каким-нибудь или навесить упаковщик/протектор. И я вам отвечу: да, вы абсолютно правы.

Чисто теоретически на базе этого кода можно реализовать полноценный обфускатор, практичность этого – весьма спорный вопрос. Кому-то покажется очень удобным обфусцировать код с помощью мета-программирования, а не копаться в парсерах С++ или более низкоуровневых представлениях кода, таких как LLVM. Кому-то окажется проще написать внешний обфускатор и не бороться с ограничениями убогово плюсового мета-программирования. Ну каждому своё, я просто хотел донести до вас интересную на мой взгляд тему для изучения, как минимум я многое узнал о плюсах копаясь в мета-программировании в далеком 2013 году.

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

ЗЫ И вот полный код, если кто-то запутался в словах статьи:
Код (C++):
  1. #include <windows.h>
  2. #include <stdint.h>
  3. #include <stdio.h>
  4.  
  5. #define LOG(F, ...) _printf_l(F, 0, ##__VA_ARGS__)
  6.  
  7. template <uint32_t Const> struct Constantify {
  8.     enum { Value = Const };
  9. };
  10.  
  11. constexpr uint32_t FNVHash(const char* string, uint32_t seed = 0) {
  12.     uint32_t res = seed;
  13.     for(int i = 0; string[i] != '\0'; i++) {
  14.         res = res * 16777619;
  15.         res = res ^ string[i];
  16.     }
  17.  
  18.     return res;
  19. }
  20.  
  21. #define FNVHASH0(STR) Constantify<FNVHash(STR)>::Value
  22.  
  23. #define GET_COMPILE_SEED Constantify<FNVHash(__DATE__ __TIME__)>::Value
  24.  
  25. #define FNVHASH(STR) Constantify<FNVHash(STR, GET_COMPILE_SEED)>::Value
  26.  
  27. constexpr uint32_t Rand(uint32_t cnt) {
  28.     cnt = cnt + GET_COMPILE_SEED;
  29.     return cnt * 1103515245 + 12345;
  30. }
  31.  
  32. #define RAND() Constantify<Rand(__COUNTER__)>::Value
  33. #define RANDOM(MIN, MAX) (MIN + (RAND() % (MAX - MIN + 1)))
  34.  
  35. constexpr uint32_t Strlen(const char* string) {
  36.     uint32_t res = 0;
  37.     for(int i = 0; string[i] != '\0'; i++)
  38.     { res++; }
  39.  
  40.     return res;
  41. }
  42.  
  43. #define STRLEN(STR) Constantify<Strlen(STR)>::Value
  44.  
  45. #define ALWAYS_INLINE __attribute__((always_inline))
  46.  
  47. template <size_t Index> struct Encryptor {
  48.     ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key)
  49.     { dst[Index] = src[Index] ^ key; Encryptor<Index - 1>::Encrypt(dst, src, key); }
  50. };
  51.  
  52. template <> struct Encryptor<0> {
  53.     ALWAYS_INLINE static constexpr void Encrypt(char* dst, const char* src, char key)
  54.     { dst[0] = src[0] ^ key; }
  55. };
  56.  
  57. template <size_t Size> class EncryptedString {
  58.     mutable char _buffer[Size];
  59.     const   char _key;
  60.  
  61.     public:
  62.         ALWAYS_INLINE constexpr EncryptedString(const char string[Size], char key) : _key { key }
  63.         { Encryptor<Size - 1>::Encrypt(_buffer, string, _key); }
  64.  
  65.         ALWAYS_INLINE const char* Decrypt() {
  66.             for(int i = 0; i < Size; i++)
  67.             { _buffer[i] ^= _key; }
  68.  
  69.             return _buffer;
  70.         }
  71. };
  72.  
  73. #define ENCRYPT_STRING(STR) EncryptedString<STRLEN(STR) + 1>(STR, (char)RANDOM(1, 0xFF)).Decrypt()
  74.  
  75. extern "C" void EntryPoint() {
  76.     LOG("%s\n", ENCRYPT_STRING("HELLO"));
  77.     ExitProcess(0);
  78. }

6 7.008
Rel

Rel
Well-Known Member

Регистрация:
11 дек 2008
Публикаций:
2

Комментарии


      1. yashechka 13 июн 2020
        Давай, я тоже за
      2. Rel 13 июн 2020
        > Да пофиг. Сделаем для Рила исключение =) Нужно только его согласие)
        Ну я согласен, если это будет от моего имени, и если статья хоть что-то выиграет, то эти деньги будут направлены в приют для брошенных домашних животных.
      3. yashechka 13 июн 2020
        Да пофиг. Сделаем для Рила исключение =) Нужно только его согласие)
      4. Rel 13 июн 2020
        > Я могу перенести твоё творения на форум и ты будешь участвовать в конкурсе?
        Там у них вроде в правилах указано, что статья должна быть эксклюзивная для их сайта, то есть должна быть опубликована только у них. Так что уже поздно.
      5. ryuk 13 июн 2020
        yashechka ,
        "Размещение статьи - только у нас на XSS.is. Если вы разместили материал еще где-то, предыдущий пункт требований вами уже не выполняется."
      6. yashechka 13 июн 2020
        Я могу перенести твоё творения на форум и ты будешь участвовать в конкурсе?