• [ Регистрация ]Открытая и бесплатная
  • Tg admin@ALPHV_Admin (обязательно подтверждение в ЛС форума)

Статья Обфусцируем код при помощи LLVM

stihl

Moderator
Регистрация
09.02.2012
Сообщения
1,167
Розыгрыши
0
Реакции
510
Deposit
0.228 BTC
stihl не предоставил(а) никакой дополнительной информации.
Прочитав эту статью, ты узнаешь, как работает обфусцирующий компилятор, углубишься в архитектуру LLVM и сможешь писать собственные проходы для обфускации кода. Мы сделаем обфускатор строчек, соберем LLVM из исходников и узнаем, как интегрировать obfuscator-llvm в современный Visual Studio, чтобы собирать твой код с обфускацией.

Что за зверь LLVM​

Проект LLVM стартовал в 2000 году и получил распространение в начале десятых. Изначальная расшифровка — low level virtual machine, хотя сейчас она не отражает суть проекта. LLVM — фреймворк с открытым исходным кодом для создания компиляторов. На базе LLVM можно собрать компилятор для собственного языка программирования. Или «улучшить» существующий, чем мы сегодня и займемся.

Архитектурно LLVM разделен на три части: фронтенд, оптимизация и бэкенд. Фронтенд преобразует исходный код в промежуточное представление (intermediate representation) — универсальный промежуточный код, который используется на следующих этапах для оптимизации кода и сборки файла. Оптимизация удаляет неиспользуемый код, сворачивает арифметические вычисления в готовые константы и заменяет неэффективные конструкции более быстрыми. Бэкенд превращает IR в машинный код.


Если ты создаешь новый язык, достаточно написать фронтенд, генерирующий IR, а остальную работу LLVM возьмет на себя. Точно так же можно работать с кодом (изначально написанным на любом поддерживаемом языке) на уровне IR, меняя его под свои нужды на этапе оптимизации. Или написать свой бэкенд, чтобы собирать старый код под неизвестную процессорную архитектуру.

Необязательно писать фронтенд с нуля. Достаточно модифицировать исходный код LLVM. Оптимизация — более гибкий инструмент, она поддерживает загружаемые плагины, добавляющие проходы — специальные функции, обрабатывающие IR на уровне модуля, функции, цикла или базового блока. Для обфускации кода мы будем использовать именно их.


Сборка LLVM под Windows​

Для начала установим Для просмотра ссылки Войди или Зарегистрируйся и поместим утилиту Для просмотра ссылки Войди или Зарегистрируйся в C:\Program Files\CMake\bin, тем самым сделав ее видимой для CMake при сборке.

Код:
mkdir C:\LLVM && cd C:\LLVM
"C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build\vcvars64.bat"
git clone https://github.com/llvm/llvm-project.git
cd llvm-project
mkdir build && cd build
cmake -G "Ninja" ^
 -DLLVM_ENABLE_PROJECTS="clang" ^
 -DLLVM_ENABLE_ASSERTIONS=ON ^
 -DCMAKE_BUILD_TYPE=Release ^
 -DLLVM_BUILD_LLVM_DYLIB=ON ^
 -DLLVM_ENABLE_RTTI=ON ^
 -DCMAKE_INSTALL_PREFIX=C:/llvm/custom ^
 ../llvm
ninja
ninja install

Создаем папку в корне диска и запускаем vcvars64.bat. Он установит необходимые для сборки переменные окружения, такие как путь до компилятора и подключаемых файлов MSVC. Далее клонируем исходный код LLVM и запускаем сборку. Ждем около часа окончания сборки и получаем новые файлы в C:\LLVM\custom. Среди них утилиты, компиляторы и подключаемые файлы .lib. Они пригодятся при создании собственных проходов для оптимизации.


Сборка проходов​

Создаем проект в Visual Studio и меняем его настройки:

Код:
Configuration Type = Dynamic Library (.dll)
Additional Include Directories = C:\LLVM\include
Additional Library Directories = C:\LLVM\lib
C++ Langauge Standart = ISO C++17 Standard (/std:c++17)
Code Generation → RuntimeLibrary = /MT

Также добавляем список .lib в Additional Dependencies:

Код:
LLVMCore.lib
LLVMSupport.lib
LLVMBitReader.lib
LLVMIRReader.lib
LLVMAnalysis.lib
LLVMPasses.lib
LLVMFrontendOpenMP.lib
LLVMTargetParser.lib
LLVMRemarks.lib
LLVMProfileData.lib
LLVMBinaryFormat.lib
LLVMDemangle.lib
LLVMBitstreamReader.lib

Таким образом собираются все наши проходы.


Проход для анализа покрытия​

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

Код:
#pragma warning(disable : 4146)
#pragma comment(linker, "/export:llvmGetPassPluginInfo")
#define _CRT_SECURE_NO_WARNINGS

#include "llvm/Passes/PassBuilder.h"
#include "llvm/Passes/PassPlugin.h"
#include "llvm/IR/PassManager.h"
#include "llvm/Pass.h"
#include "llvm/IR/Function.h"
#include "llvm/IR/Module.h"
#include "llvm/IR/IRBuilder.h"
#include "llvm/IR/Type.h"
#include "llvm/IR/Instructions.h"
#include "llvm/IR/Constants.h"
#include "llvm/IR/DerivedTypes.h"
#include "llvm/Support/raw_ostream.h"

using namespace llvm;

struct DebugTracePass : PassInfoMixin<DebugTracePass> {
  PreservedAnalyses run(Module& M, ModuleAnalysisManager&) {
    LLVMContext& Ctx = M.getContext();

    // Получаем i8* тип (char*)
    Type* i8Ty = Type::getInt8Ty(Ctx);
    PointerType* i8PtrTy = PointerType::get(i8Ty, 0);

    // Тип функции: void OutputDebugStringA(char*)
    FunctionType* debugFnTy = FunctionType::get(Type::getVoidTy(Ctx), { i8PtrTy }, false);
    FunctionCallee debugFn = M.getOrInsertFunction("OutputDebugStringA", debugFnTy);

    for (Function& F : M) {
      // Пропуск, если это объявление функции
      if (F.isDeclaration()) continue;

      // Строим IR перед первой инструкцией
      Instruction* insertPt = &*F.getEntryBlock().getFirstInsertionPt();
      IRBuilder<> builder(insertPt);

      // Формируем строку
      std::string msg = "Enter: " + F.getName().str();
      Value* msgStr = builder.CreateGlobalString(msg);

      // Вставляем вызов OutputDebugStringA
      builder.CreateCall(debugFn, msgStr);
    }

    return PreservedAnalyses::none();
  }
};

extern "C"
llvm:assPluginLibraryInfo llvmGetPassPluginInfo() {
  return {
    LLVM_PLUGIN_API_VERSION, // Версия API плагинов
    "InjectFunctionCallPass", // Название плагина
    "v0.1", // Версия
    [](llvm:assBuilder& PB) {

      PB.registerPipelineParsingCallback(
        [](llvm::StringRef Name,
           llvm::ModulePassManager& MPM,
           llvm::ArrayRef<llvm:assBuilder:ipelineElement>) {

          if (Name == "debug-trace") {
            MPM.addPass(DebugTracePass());
            return true;
          }

          return false;
        });

    } };
}

Здесь я сначала отключаю раздражающее предупреждение о неверной типизации внутри кода LLVM. Затем прошу экспортировать функцию llvmGetPassPluginInfo. Она должна быть в экспорте плагина, чтобы сообщить при загрузке его название и добавить новый проход debug-trace в список доступных проходов. Проход располагается в методе run класса DebugTracePass.

Внутри прохода получаю ссылку на функцию OutputDebugStringA, предварительно указав ее прототип. Далее перебираю внутри модуля все функции, у которых есть тело, а не только определение. Вызов getEntryBlock отдает первый базовый блок в теле функции, а getFirstInsertionPt возвращает итератор. Преобразуем его в ссылку на первую инструкцию. Теперь создаю объект IRBuilder, который будет генерировать инструкции в заданной точке, то есть перед первой инструкцией. Формирую глобальную строку с именем функции, которая будет располагаться в секции данных. И создаю вызов функции OutputDebugStringA с аргументом в виде только что определенной строки. В конце PreservedAnalyses явно сообщает LLVM, что код модуля изменился и старый анализ модуля неактуален.


Запуск прохода​

Создадим для теста самый простой исходник:

Код:
#include <Windows.h>

int main()
{
    return 0;
}
И соберем из него промежуточный код:

clang.exe -S -emit-llvm test.cpp -o test.ll
Настало время применить новый проход:

opt.exe -load-pass-plugin llvm_pass.dll -passes=debug-trace -S test.ll -o output.ll
Посмотрим на созданный после прохода промежуточный код:

@0 = private unnamed_addr constant [12 x i8] c"Enter: main\00", align 1

; Function Attrs: mustprogress noinline norecurse nounwind optnone uwtable
define dso_local noundef i32 @main() #0 {
entry:
  call void @OutputDebugStringA(ptr @0)
  %retval = alloca i32, align 4
  store i32 0, ptr %retval, align 4
  ret i32 0
}

declare void @OutputDebugStringA(ptr)

Как и ожидалось, в начале функции main появился вызов OutputDebugStringA. Попробуем собрать IR в исполняемый файл.

clang.exe output.ll -o output.exe

Запускаю полученный EXE и в Для просмотра ссылки Войди или Зарегистрируйся вижу строку [8092] Enter: main.


Проход для скрытия строк​

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

Код:
class EncryptStringsPass : public PassInfoMixin<EncryptStringsPass> {
  LLVMContext* Ctx;
  Type* i8Ty;
  PointerType* i8PtrTy;
  Type* i32Ty;
  FunctionCallee DecryptFunc;

  std::string encryptString(const std::string& Original) {
    std::string Encrypted = Original;
    for (char& c : Encrypted) c ^= 0xAA;
    return Encrypted;
  }

  void createDecryptionBuffer(IRBuilder<>& B, Value* TargetPtr, unsigned Len, Value*& OutBufPtr) {
    auto* Buf = B.CreateAlloca(ArrayType::get(i8Ty, Len + 1));
    auto* BufCast = B.CreatePointerCast(Buf, i8PtrTy);
    auto* PtrCast = B.CreatePointerCast(TargetPtr, i8PtrTy);
    B.CreateCall(DecryptFunc, { BufCast, PtrCast, B.getInt32(Len) });
    OutBufPtr = BufCast;
  }

  void handleConstantExprUser(ConstantExpr* CE, const std::string& Original) {
    std::vector<User*> CEUsers(CE->users().begin(), CE->users().end());
    for (User* Use : CEUsers) {
      if (auto* I = dyn_cast<Instruction>(Use)) {
        IRBuilder<> B(I);
        Value* BufPtr = nullptr;
        createDecryptionBuffer(B, CE, Original.size(), BufPtr);
        I->replaceUsesOfWith(CE, BufPtr);
      }
    }
  }

  void handleRegularUser(Value* Ptr, Instruction* InsertPt, const std::string& Original) {
    IRBuilder<> B(InsertPt);
    Value* BufPtr = nullptr;
    createDecryptionBuffer(B, Ptr, Original.size(), BufPtr);

    Ptr->replaceUsesWithIf(BufPtr, [&](Use& U) {
      return dyn_cast<Instruction>(U.getUser()) == InsertPt;
      });
  }

public:
  PreservedAnalyses run(Module& M, ModuleAnalysisManager&) {
    Ctx = &M.getContext();
    i8Ty = Type::getInt8Ty(*Ctx);
    i8PtrTy = PointerType::get(i8Ty, 0);
    i32Ty = Type::getInt32Ty(*Ctx);

    DecryptFunc = M.getOrInsertFunction(
      "decryptStringInto",
      FunctionType::get(Type::getVoidTy(*Ctx), { i8PtrTy, i8PtrTy, i32Ty }, false)
    );

    for (auto& GV : M.globals()) {
      if (!GV.hasInitializer() || !GV.isConstant()) continue;

      auto* CA = dyn_cast<ConstantDataArray>(GV.getInitializer());
      if (!CA || !CA->isString()) continue;

      std::string Original = CA->getAsString().str();
      std::string Encrypted = encryptString(Original);

      auto* ArrayTy = cast<ArrayType>(GV.getValueType());
      if (Encrypted.size() < ArrayTy->getNumElements())
        Encrypted.resize(ArrayTy->getNumElements(), '\0');

      std::vector<uint8_t> Bytes(Encrypted.begin(), Encrypted.end());
      GV.setInitializer(ConstantDataArray::get(*Ctx, Bytes));
      GV.setConstant(false);

      std::vector<User*> Users(GV.users().begin(), GV.users().end());
      for (User* U : Users) {
        if (auto* CE = dyn_cast<ConstantExpr>(U)) {
          if (CE->getOpcode() == Instruction::GetElementPtr) {
            handleConstantExprUser(CE, Original);
          }
          continue;
        }

        Instruction* InsertPt = nullptr;
        Value* Ptr = nullptr;

        if (auto* GEP = dyn_cast<GetElementPtrInst>(U)) {
          Ptr = GEP;
          if (!GEP->user_empty())
            InsertPt = dyn_cast<Instruction>(*GEP->user_begin());
        }
        else if (auto* I = dyn_cast<Instruction>(U)) {
          Ptr = &GV;
          InsertPt = I;
        }

        if (Ptr && InsertPt)
          handleRegularUser(Ptr, InsertPt, Original);
      }
    }

    return PreservedAnalyses::none();
  }
};

Рассмотрим метод run. Код перебирает все глобальные переменные, отбрасывая те, у которых нет значения, и те, что не являются константой. Если константа — это строка, получаем ее значение и шифруем. Далее подменяем оригинальную строку шифрованными байтами. Затем копируем список пользователей этой константы в массив Users. Проходим по списку и вызываем обработчики для разных типов «пользователей». В обоих случаях вставляем перед инструкцией локальный буфер и вызов процедуры расшифровки. Наконец, заменяем ссылку на исходную строку ссылкой на созданный нами буфер.

Код:
#include <windows.h>
#include <stdint.h>
#include <stdlib.h>

extern "C" void decryptStringInto(char* out, const char* enc, int len) {
  char key = 0xAA;
  for (int i = 0; i < len; ++i) {
    out = enc ^ key;
  }
  out[len] = '\0';
}

int main()
{
  DWORD written;
  const char* message = "Xakep.ru\n";
  WriteConsoleA(GetStdHandle(STD_OUTPUT_HANDLE), message, (DWORD)lstrlenA(message), &written, NULL);
  return 0;
}

Я взял простой тестовый код, выводящий строчку в консоль. Проще всего поместить вызываемую обфускатором decryptStringInto в оригинальный код.

Код:
clang.exe -S -emit-llvm test_str.cpp -o test_str.ll
opt.exe -load-pass-plugin llvm_pass.dll -passes=encrypt-strings -S test_str.ll -o output.ll
clang.exe output.ll -o output.exe
Соберем исходник и посмотрим после декомпиляции, что внутри:

int __fastcall main(int argc, const char **argv, const char **envp)
{
  HANDLE StdHandle;
  LPCSTR lpBuffer;
  int nNumberOfCharsToWrite;
  CHAR v7[9];
  LPCSTR lpString;
  DWORD NumberOfCharsWritten[2];

  NumberOfCharsWritten[1] = 0;
  sub_140001000(v7, &unk_14001A000, 8);
  lpString = v7;
  nNumberOfCharsToWrite = lstrlenA(v7);
  lpBuffer = lpString;
  StdHandle = GetStdHandle(0xFFFFFFF5);
  WriteConsoleA(StdHandle, lpBuffer, nNumberOfCharsToWrite, NumberOfCharsWritten, 0);
  return 0;
}

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



OLLVM-16​

Время поговорить о серьезных обфускаторах. Проект obfuscator-llvm (или, как его сокращенно называют, OLLVM) появился в 2010 году как университетский проект по защите кода. Фактически это форк LLVM, встраивающий проходы обфускации в сам компилятор. Он работает с промежуточным кодом, так что теоретически может работать с любыми поддерживаемыми LLVM выходными языком и архитектурой. Последняя версия обфускатора вышла для LLVM-4 (актуальная версия — LLVM-20). Но к счастью, есть современный порт Для просмотра ссылки Войди или Зарегистрируйся, с ним мы и будем работать.



Сборка​

Для начала установим в Visual Studio 2022 пару пакетов для работы с LLVM:

Compilers, build tools, and runtimes
☑
C++ Clang Compiler for Windows (19.1.5)
☑
MSBuild support for LLVM (clang-cl) toolset

Теперь можно начинать сборку.

Код:
git clone -b llvmorg-16.0.6 --depth=1 https://github.com/llvm/llvm-project.git llvmorg-16.0.6
git clone https://github.com/wwh1004/ollvm-16
robocopy ollvm-16/Obfuscation llvmorg-16.0.6/llvm/lib/Obfuscation

Правим файл llvmorg-16.0.6/llvm/lib/CMakeLists.txt и дописываем в него add_subdirectory(Obfuscation).

Код:
cmake -S C:\llvm-16.0.6\llvm -B C:\llvm-16.0.6-build ^
-G "Visual Studio 17 2022" -A x64 -Thost=x64 ^
-DLLVM_ENABLE_PROJECTS="clang;lld" -DCMAKE_BUILD_TYPE=Release ^
-DLLVM_TARGETS_TO_BUILD=X86 -DLLVM_OBFUSCATION_LINK_INTO_TOOLS=ON ^
-DLLVM_INCLUDE_TESTS=OFF -DLLVM_INCLUDE_EXAMPLES=OFF

Запустив CMake, мы получим файл LLVM.sln, из которого запустим сборку Release-версии. Сборка занимает чуть больше часа.



Интеграция в Visual Studio​

По умолчанию Visual Studio использует LLVM-19. Нам нужно сообщить компилятору, что мы хотим использовать другую версию. Для этого в корне проекта создаем файл Directory.build.props:

Код:
<Project>
<PropertyGroup>
<LLVMInstallDir>C:\llvm-16.0.6-build</LLVMInstallDir>
<LLVMToolsVersion>16.0.6</LLVMToolsVersion>
</PropertyGroup>
</Project>

Это изменяет переменные окружения, остальную настройку IDE сделает за нас, надо только выбрать правильный тулсет:

Platform Toolset = LLVM (clang-cl)

Теперь сборка любого кода будет выполняться нашей версией clang-cl со встроенным обфускатором.



Обзор методов обфускации​

Управление обфускацией в нашем компиляторе происходит через специальные ключи. Допиши в Command Line эти строки:

-mllvm -sub -mllvm -sub_loop=3 -mllvm -split -mllvm -split_num=3 -mllvm -fla -mllvm -bcf -mllvm -bcf_loop=3 -mllvm -bcf_prob=40
Рассмотрим, что делает каждый из ключей.

Bogus control flow​

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


    • -bcf — включает этот вид обфускации;
    • -bcf_loop=N — задает, сколько раз применять BCF к одной функции;
    • -bcf_prob=P — задает вероятность (в процентах, от 0 до 100) добавления bogus-блока.
Рассмотрим, как выглядит код до и после обфускации.

AcceleratorsW = LoadAcceleratorsW(hInstance, (LPCWSTR)0x6D);
На основе этого блока кода рядом вставлен цикл с условием, которое всегда будет False:

Код:
AcceleratorsW = LoadAcceleratorsW(a1, (LPCWSTR)0x6D);
v9 = x;
for ( i = y; y >= 10 && (((_BYTE)x * ((_BYTE)x + 1)) & 1) != 0; i = y )
{
    LoadAcceleratorsW(a1, (LPCWSTR)0x6D);
    AcceleratorsW = LoadAcceleratorsW(a1, (LPCWSTR)0x6D);
    v9 = x;
}
В отличие от традиционной вставки мусора, то есть незначащего (или «мертвого») кода, выловить оригинальный код по уникальным WinAPI становится куда сложнее. Кроме того, bogus-блоки усложняют граф исполнения, разделяя базовый блок на несколько частей.



Instruction substitution​

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


    • -sub — включает этот вид обфускации;
    • -sub_loop=N — сколько раз пройти по функции.
Вот что было:

Код:
EndDialog(a1, a3);
И вот во что превратился код после обфускации:

v34 = (~v32 & 0xCE296CF528B2527EuLL | v32 & 0x31D6930AD74DAD81LL)
    ^ (~v33 & 0xCE296CF528B2527EuLL | v33 & 0x31D6930AD74DAD81LL);

v35 = ((v34 | ~(~v32 | ~v33)) ^ 0x45B110EDBA343CE6LL)
    & (v34 | ~(~v32 | ~v33) | 0x45B110EDBA343CE6LL);

EndDialog(hDlg, ~(~(~(v26 ^ ~v35) & (v35 | v26) ^ 0x7B085DB5B30B2D66LL)
    ^ 0x7B085DB5B30B2D66LL | (~v35 & 0x861C6ABB45D8AB39uLL
    | v35 & 0x79E39544BA2754C6LL) ^ 0x79E39544BA2754C6LL));

Простые вычисления вроде X+Y превращаются в длинную цепочку операций. Обработке подвергаются только подходящие участки кода. Иногда в коде этого метода обфускации сильно течет память, из‑за чего компилятор вешает систему. Все‑таки двадцать гигабайт оперативной памяти для сборки пяти килобайт кода слегка избыточно. Припоминаю, что эта проблема была и на первом серьезном релизе в начале десятых.



Control flow flattening​

Этот метод «уплощает» поток управления, превращая легко читаемый граф в подобие виртуальной машины. Например, таким был код до:

Код:
if (x > 0)
  do_positive();
else
  do_negative();

do_final();
Таким стал после:

int label = 0;

while (1) {
  switch (label) {
    case 0:
      if (x > 0)
        label = 1;
      else
        label = 2;
      break;
    case 1:
      do_positive();
      label = 3;
      break;
    case 2:
      do_negative();
      label = 3;
      break;
    case 3:
      do_final();
      return;
  }
}

Логика потока управления перестает считываться на глаз. Чтобы выяснить, что код делает, потребуется трассировка.

Настройки:


    • -fla — включает этот вид обфускации;
    • -split — активирует деление базовых блоков на части;
    • -split_num=N — сколько раз разделить базовый блок.
Ключ split разбивает базовый блок на несколько меньших блоков, соединенных переходами, эта опция хорошо дополняет Flattening, запутывая граф еще сильнее.

До и после обфускации
До и после обфускации


Заключение​

Обфускаторы многократно усложняют реверсерам жизнь. Чтобы снять слой обфускации, приходится писать деобфускатор, ориентированный на конкретную версию защиты. Дописав свои проходы или слегка изменив существующие, можно на порядок увеличить количество человеко‑часов, требующихся для анализа защищаемых алгоритмов. Совместив обфускацию и антиотладочные приемы, ты можешь подарить аналитикам множество незабываемых часов, проведенных в декомпиляторе.
 
Activity
So far there's no one here
Сверху Снизу