
Zyrox: плагин-обфускатор на основе LLVM, действующий на этапе компиляции.
почему бы и нет ¯\_(ツ)_/¯
Один из моих самых больших проектов, где я много узнал о внутренностях LLVM, бинарных форматах, ассемблере и техниках обфускации.
Я считаю, что лучший способ учиться — это создавать, поэтому я создал этот проект, чтобы узнать больше об этих темах.
Я написал 4 блога, объясняющих концепции, лежащие в основе Zyrox:
Эти части раскрывают тему глубже, чем этот README, и их определённо стоит прочитать, если вам интересна тема.
Это предназначено для тех, кто хочет быстро протестировать Zyrox или научиться интегрировать его в CMake-проект.
Выполните шаги из репозитория Zyrox Template.
установите llvm:
sudo apt update
sudo apt install llvm-18 llvm-18-dev clang-18
склонируйте и соберите zyrox:
git clone --recurse-submodules https://github.com/PeterHackz/zyrox.git
cd zyrox
cmake -S . -B build -DCMAKE_C_COMPILER=/usr/bin/clang -DCMAKE_CXX_COMPILER=/usr/bin/clang++
cmake --build build --parallel 4
убедитесь, что установлены python3 и pip.
# Create a virtual environment
python3 -m venv .venv
# Activate the env
source .venv/bin/activate
pip install -r requirements.txt
pip install -r requirements.txt
clang -O0 -flto=full -c main.c -o out/main.o
clang -flto=full -fuse-ld=lld -Wl,--load-pass-plugin=./build/libzyrox.so out/main.o -o out/main
После обфускации запустите PyPlugin.py, чтобы зашифровать таблицы переходов:
# if you installed dependencies in a virtual environment, activate it first:
source .venv/bin/activate
# then run with:
python PyPlugin.py --in=<input_file> [--out=<output_file>] [--tables=<zyrox_tables_file>] [--android]
Смотрите репозиторий Zyrox Template, где приведён пример интеграции с CMake.
Я понимаю, что это сложная тема, и этот проект в основном создавался в образовательных целях, а также для BSD Brawl. Если у вас есть вопросы или просто хотите пообщаться, смело обращайтесь ко мне:
@s.b[email protected] или [email protected]Любая помощь в виде pull request'ов или issues приветствуется!
ZyroxPlugin.cpp регистрирует проход, затем подключает siphash (подробнее об этом позже) и вызывает StringEncryption для шифрования строк.
Причина, по которой мы шифруем строки на раннем этапе, в том, чтобы логика дешифрования тоже была обфусцирована позже.
Затем вызываются ModuleUtils::ExpandCustomAnnotations
и QuickConfig::RegisterPasses для разбора всех выражений __attribute__((annotate("..."))) и запуска
QuickJs-конфигурации (расположенной в ZyroxConfig.js)
Каждая функция обфусцируется вызовом Zyrox::RunOnFunction из ZyroxCore.cpp;
в будущем будет добавлено больше документации по этому вопросу.
switch создают таблицы переходов, а PHI-узлы раздражающе сложны в обработке, поэтому мы используем FunctionUtils и BasicBlockUtils,
чтобы соответственно флаттенить (преобразовывать в if-выражения) и понижать их.
ох, с чего бы начать
все аргументы js-плагина описаны в index.d.ts, поэтому в этой документации они не рассматриваются.
документацию по аннотациям см. здесь
Этот проход разбивает базовый блок на несколько меньших и перемешивает их. Предположим, у нас есть такой код:
int __test_fn(int x)
{
if (x == 2) {
printf("x is 2\n");
} else {
printf("x is not 2!, x is: %d\n", x);
}
return x + 4 * x - 2 / 4;
}
который компилируется в:
define internal i32 @__test_fn(i32 noundef %0) #0 !zyrox !8 !obfuscated !11 {
%2 = alloca i32, align 4
store i32 %0, ptr %2, align 4
%3 = load i32, ptr %2, align 4
%4 = icmp eq i32 %3, 2
br i1 %4, label %5, label %7
5: ; preds = %1
%6 = call i32 (ptr, ...) @printf(ptr noundef @.str.1)
br label %10
7: ; preds = %1
%8 = load i32, ptr %2, align 4
%9 = call i32 (ptr, ...) @printf(ptr noundef @.str.2, i32 noundef %8)
br label %10
10: ; preds = %7, %5
%11 = load i32, ptr %2, align 4
%12 = load i32, ptr %2, align 4
%13 = mul nsw i32 4, %12
%14 = add nsw i32 %11, %13
%15 = sub nsw i32 %14, 0
ret i32 %15
}
при использовании Basic Block Splitter с такой конфигурацией:
z.RegisterPass(ObfuscationType.BasicBlockSplitter, {
PassIterations: 1,
"BasicBlockSplitter.SplitBlockChance": 100,
"BasicBlockSplitter.SplitBlockMinSize": 2,
"BasicBlockSplitter.SplitBlockMaxSize": 5,
});
он становится таким:
define internal i32 @__test_fn(i32 noundef %0) #0 !zyrox !8 !obfuscated !11 {
%2 = alloca i32, align 4
store i32 %0, ptr %2, align 4
%3 = load i32, ptr %2, align 4
%4 = icmp eq i32 %3, 2
br i1 %4, label %5, label %14
5: ; preds = %1
%6 = call i32 (ptr, ...) @printf(ptr noundef @.str.1)
br label %7
7: ; preds = %14, %5
%8 = load i32, ptr %2, align 4
%9 = load i32, ptr %2, align 4
%10 = mul nsw i32 4, %9
%11 = add nsw i32 %8, %10
br label %12
12: ; preds = %7
%13 = sub nsw i32 %11, 0
ret i32 %13
14: ; preds = %1
%15 = load i32, ptr %2, align 4
%16 = call i32 (ptr, ...) @printf(ptr noundef @.str.2, i32 noundef %15)
br label %7
}
Для такой маленькой функции разница не будет слишком заметной, но обратите внимание, как разбивается базовый блок. Это полезно в сочетании с другими проходами, такими как Control Flow Flattening
Ох, у этого прохода больше всего возможностей среди всех, лол. Сначала я объясню, как он работает, а затем его конфигурацию. Предположим, у нас есть такой код:
LABEL_A: bool b = x == 2;
IF EQ: goto LABEL_B
goto LABEL_C
LABEL_B do_stuff()
LABEL_C do_other_stuff()
goto LABEL_A
каждому базовому блоку (A, B и C) назначается уникальное состояние диспетчера, например: (упрощённо)
states = {
1: LABEL_A,
2: LABEL_B,
3: LABEL_C,
};
затем мы внедряем блок-диспетчер, который управляет всем, и код становится таким:
int state = 0;
LABEL_D goto LABEL_CA // dispatcher label jumps to first condition block, label condition A
LABEL_CA if state == 1: goto LABEL_A
// if not 1, go to check if it is label B (fallback)
LABEL_CB if state == 2: goto LABEL_B
LABEL_CC if state == 3: goto LABEL_CC
// unreachable
goto LABEL_D
LABEL_A: bool b = x == 2;
// IF EQ: goto LABEL_B
// goto LABEL_C
state = 2 if b else 3 // update state for the block we want and back to dispatcher
goto LABEL_D
LABEL_B do_stuff()
LABEL_C do_other_stuff()
state = 1
goto LABEL_D
У этого подхода есть недостатки, которые исправляет обфускатор. Как видите, поскольку у нас одна переменная-диспетчер, это легко деобфусцировать: мы знаем, куда перейдёт блок после установки состояния. Исправить легко!
z.RegisterPass(ObfuscationType.ControlFlowFlattening, {
PassIterations: 1,
"ControlFlowFlattening.UseFunctionResolverChance": 60,
"ControlFlowFlattening.UseGlobalStateVariablesChance": 60,
"ControlFlowFlattening.UseOpaqueTransformationChance": 40,
"ControlFlowFlattening.UseGlobalVariableOpaquesChance": 80,
"ControlFlowFlattening.UseSipHashedStateChance": 40,
"ControlFlowFlattening.CloneSipHashChance": 80,
});
давайте разберём параметры по одному:
UseFunctionResolverChance: внедряет функцию для проверки состояния, так что вместо
if (state == expected_state) выполняется if (injected_resolver(state)). пример:
bool __fastcall cff_resolve_state_check_3585(__int64 a1)
{
return a1 == 0x288A6154F8A5E3E2LL;
}
UseGlobalStateVariablesChance: сохраняет значение состояния для сравнения в глобальной переменной:
bool __fastcall cff_resolve_state_check_506(__int64 a1)
{
return a1 == qword_1B20D8;
}
UseOpaqueTransformationChance: обфусцирует проверку до некоторого преобразования, которое вернёт true только для конкретного
состояния:
bool __fastcall cff_resolve_state_check_7901(__int64 a1)
{
return ((((a1 ^ 0xEA9E45BB6099BC6ELL) + qword_1C64D8) << qword_1A63F0)
| (((a1 ^ 0xEA9E45BB6099BC6ELL)
+ qword_1C64D8) >> qword_1ACE98)) == qword_1B0B80;
}
UseGlobalVariableOpaquesChance: использует глобальную переменную вместо числа при выполнении UseOpaqueTransformationChance,
как вы заметили в примере выше. (qword_1C64D8, qword_1A63F0, qword_1ACE98)UseSipHashedStateChance: использует небольшую модифицированную функцию siphash для проверки состояния. Таким образом, if (state == 23872)
превращается во что-то вроде if (siphash(state) == 3874872081), что затрудняет определение того, куда переходит блок.
Блок будет выполнять state = 23872, когда условие перехода захэшировано. Каждый вызов siphash использует случайные значения,
чтобы его было сложнее эмулировать.CloneSipHashChance: клонирует и, когда возможно, даже инлайнит функцию siphash, создавая для неё несколько родственных копий,
из-за чего перехвата одной функции становится недостаточно. Этот параметр очень желательно использовать, так как он лишь увеличивает размер
бинарника и не влияет на производительность.Предположим, у нас есть такой код:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
он преобразуется в:
@global jump_table = {0, &LABEL_A, &LABEL_B};
if (x == 2) goto jump_table[0] + @inline(decrypt(jump_table[1]));
goto jump_table[0] + @inline(decrypt(jump_table[2]));
// ...
при использовании этого прохода плагин выведет файл zyrox_tables.txt, который затем используется PyPlugin.py.
PyPlugin.py зашифрует таблицы переходов и пропатчит записи релокаций, а затем заставит релокатор указывать на jump_table[0]
каждой таблицы. Релокатор по сути делает следующее:
target.writePointer(base.add(value)), поэтому, установив value в 0, мы заставляем релокатор выдать нам базовый адрес и поместить его
в таблицу переходов во время выполнения, а мы используем его вместе с goto для генерации адреса во время выполнения. В thumb-режиме arm32
проход автоматически добавляет | 1 после дешифрования.
Чтобы использовать PyPlugin.py, просто сделайте следующее:
(если используете venv, сначала активируйте его)
python3 PyPlugin.py --in <out_obfuscated_file> --android
Передача --android важна, если вы целитесь в версию arm64, поскольку у версии x86_64 другая сигнатура релокатора.
Также можно передать --out (по умолчанию будет использоваться тот же файл, что передан в --in) и --tables (по умолчанию
это zyrox_tables.txt)
Хотя indirect branching выглядит отлично, он также создаёт нагрузку на производительность, поскольку указатели расшифровываются во время выполнения. Это простая версия, которая не влияет на производительность; например, этот код:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
становится:
@stack jump_table = {&LABEL_B, &LABEL_A}
goto jump_table[!(x == 2)]
LABEL_A: do_stuff()
LABEL_B: // ...
Хотя это выглядит просто и легко ломается (согласен), этого достаточно, чтобы сломать IDA и Ghidra, не влияя на производительность.
также известен как MBA Sub (Mixed Boolean Arithmetic Substitution); преобразует простые операции в сложные, дающие тот же результат. Использует предопределённый набор. пример:
a ^ b = (~a & b) | (a & ~b)
b * c = (((b | c) * (b & c)) + ((b & ~c) * (c & ~b)))
r = rand(); c = b + r; a = a + c; a = a - r
Полный список можно посмотреть в Passes/MBASub.cpp, если интересно.
просто посмотрите index.d.ts. Парсер аннотаций использует тот же порядок.
Чтобы пометить функцию, просто сделайте следующее:
__attribute__((annotate("ibr:1,100"))) void hello_world () {
some_hello ();
}
коды аннотаций:
например,
в index.d.ts мы видим:
{
"BasicBlockSplitter.SplitBlockMinSize"?: number;
"BasicBlockSplitter.SplitBlockMaxSize"?: number;
"BasicBlockSplitter.SplitBlockChance"?: number;
};
Теперь важный момент: первый аргумент, общий для всех проходов, — это PassIterations, поэтому он будет первым
аргументом в аннотациях.
Чтобы пометить что-то с помощью bbs, мы пишем:
__attribute__((annotate("bbs:1,15,30,100"))) void hello_world () {
some_hello ();
}
это означает: запустить Basic Block Splitter для hello_world 1 раз с минимальным размером = 15, максимальным размером = 30
и вероятностью = 100.
можно также комбинировать проходы:
__attribute__((annotate("bbs:1,15,30,100 ibr:1,100 sibr:1,100"))) void hello_world () {
some_hello ();
}
это означает: запустить Basic Block Splitter затем Indirect Branching и затем
Simple Indirect Branching для hello_world.
Они будут выполняться в порядке определения слева
направо.