
Zyrox: LLVM based, compile-time obfuscator plugin.
why not ¯\_(ツ)_/¯
Eines meiner größten Projekte, bei dem ich viel über LLVM-Interna, Binärformate, Assembly und Obfuskierungstechniken gelernt habe.
Ich glaube, dass Lernen durch Bauen der beste Weg ist, zu lernen, daher habe ich dieses Projekt gebaut, um mehr über diese Themen zu erfahren.
Ich habe 4 Blogbeiträge geschrieben, die die Konzepte hinter Zyrox erklären:
Diese Teile gehen tiefer als diese README und sind auf jeden Fall lesenswert, wenn Sie sich für das Thema interessieren.
Dies ist für diejenigen gedacht, die Zyrox schnell testen möchten oder lernen möchten, wie man es in ein CMake-Projekt integriert.
Folgen Sie den Schritten im Zyrox Template-Repository.
llvm installieren:
sudo apt update
sudo apt install llvm-18 llvm-18-dev clang-18
Zyrox klonen und kompilieren:
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
Stellen Sie sicher, dass python3 und pip installiert sind.
# 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
Nach der Obfuskierung führen Sie PyPlugin.py aus, um Sprungtabellen zu verschlüsseln:
# 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]
Schauen Sie sich das Zyrox Template-Repository für ein Beispiel einer CMake-Integration an.
Mir ist klar, dass dies ein komplexes Thema ist, und dieses Projekt diente hauptsächlich Bildungszwecken sowie dem BSD Brawl. Wenn Sie Fragen haben oder einfach nur plaudern möchten, können Sie mich gerne kontaktieren:
@s.b[email protected] or [email protected]Jede Hilfe, sei es durch Pull Requests oder Issues, ist willkommen!
ZyroxPlugin.cpp registriert den Pass, verlinkt dann siphash (mehr dazu später) und ruft StringEncryption auf, um Strings zu verschlüsseln.
Der Grund, warum wir Strings früh verschlüsseln, ist, dass die Entschlüsselungslogik später ebenfalls obfuskiert wird.
dann ruft es ModuleUtils::ExpandCustomAnnotations und QuickConfig::RegisterPasses auf, um alle __attribute__((annotate("...")))-Ausdrücke zu parsen und die QuickJs-Konfiguration (in ZyroxConfig.js) auszuführen.
Jede Funktion wird durch Aufruf von Zyrox::RunOnFunction in ZyroxCore.cpp obfuskiert; weitere Dokumentation dazu wird in Zukunft bereitgestellt.
Switch-Anweisungen erzeugen Sprungtabellen und PHI-Knoten sind lästig zu handhaben, daher verwenden wir FunctionUtils und BasicBlockUtils, um diese zu glätten (in if-Anweisungen) bzw. herabzustufen.
oh Mann, wo soll ich anfangen
Alle js-Plugin-Argumente sind in index.d.ts und werden daher in dieser Dokumentation nicht behandelt.
für die Annotationsdokumentation klicken Sie hier
Dieser Pass teilt und mischt einen Basisblock in kleinere Blöcke auf. Angenommen, wir haben Folgendes:
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;
}
was zu Folgendem kompiliert wird:
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
}
bei Verwendung des Basic Block Splitters mit dieser Konfiguration:
z.RegisterPass(ObfuscationType.BasicBlockSplitter, {
PassIterations: 1,
"BasicBlockSplitter.SplitBlockChance": 100,
"BasicBlockSplitter.SplitBlockMinSize": 2,
"BasicBlockSplitter.SplitBlockMaxSize": 5,
});
wird daraus:
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
}
Jetzt wird es für eine so kleine Funktion nicht viel anders aussehen, aber beachten Sie, wie es einen Basisblock aufgeteilt hat? Dies ist hilfreich in Kombination mit anderen Passes wie Control Flow Flattening.
Oh, Mann, dieser Pass hat die meisten Funktionen von allen lol. Ich werde zuerst erklären, wie es funktioniert, dann seine Konfiguration. Angenommen, wir haben diesen Code:
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
jeder Basisblock (A, B und C) erhält einen eindeutigen Dispatcher-Status, Beispiel: (vereinfacht)
states = {
1: LABEL_A,
2: LABEL_B,
3: LABEL_C,
};
dann fügen wir einen Dispatcher-Block ein, der alles steuert, und der Code wird zu:
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
Nun hat dies einige Fehler, die der Obfuskator behebt. Wie Sie sehen, ist es aufgrund einer einzelnen Dispatcher-Variable einfach, dies zu deobfusken, da wir wissen, wohin ein Block geht, nachdem er den Status gesetzt hat. Einfach zu beheben!
z.RegisterPass(ObfuscationType.ControlFlowFlattening, {
PassIterations: 1,
"ControlFlowFlattening.UseFunctionResolverChance": 60,
"ControlFlowFlattening.UseGlobalStateVariablesChance": 60,
"ControlFlowFlattening.UseOpaqueTransformationChance": 40,
"ControlFlowFlattening.UseGlobalVariableOpaquesChance": 80,
"ControlFlowFlattening.UseSipHashedStateChance": 40,
"ControlFlowFlattening.CloneSipHashChance": 80,
});
Gehen wir die Optionen einzeln durch:
UseFunctionResolverChance: injiziert eine Funktion zur Überprüfung des Status, sodass statt if (state == expected_state) nun if (injected_resolver(state)) ausgeführt wird. Beispiel:
bool __fastcall cff_resolve_state_check_3585(__int64 a1)
{
return a1 == 0x288A6154F8A5E3E2LL;
}
UseGlobalStateVariablesChance: speichert den zu vergleichenden Statuswert in einer globalen Variable:
bool __fastcall cff_resolve_state_check_506(__int64 a1)
{
return a1 == qword_1B20D8;
}
UseOpaqueTransformationChance: obfuskiert die Prüfung in eine Transformation, die nur für einen bestimmten Status wahr wird:
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: verwendet eine globale Variable anstelle einer Zahl bei der Verwendung von , wie Sie im obigen Beispiel bemerkt haben (, , ).Angenommen, wir haben diesen Code:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
es würde transformiert werden in:
@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]));
// ...
Wenn dieser Pass verwendet wird, gibt das Plugin eine Datei zyrox_tables.txt aus, die von PyPlugin.py verwendet wird.
PyPlugin.py verschlüsselt die Sprungtabellen und patcht Relocation-Einträge, dann wird der Relocator auf jump_table[0] jeder Tabelle zeigen. Ein Relocator macht im Grunde Folgendes: target.writePointer(base.add(value)), also setzen wir den Wert auf 0, damit der Relocator uns die Basisadresse gibt und sie zur Laufzeit in die Sprungtabelle einfügt, und wir verwenden sie zusammen mit dem goto, um die Laufzeitadresse zu generieren. Im arm32 Thumb-Modus fügt der Pass automatisch | 1 nach der Entschlüsselung hinzu.
Um PyPlugin.py zu verwenden, gehen Sie einfach wie folgt vor:
(If using venv, activate it first)
python3 PyPlugin.py --in <out_obfuscated_file> --android
Die Übergabe von --android ist wichtig, wenn Sie die arm64-Version anvisieren, da die x86_64-Version eine andere Relocator-Signatur hat.
Sie können auch --out übergeben (standardmäßig wird dieselbe Datei wie bei --in verwendet) und --tables (standardmäßig zyrox_tables.txt).
Während Indirect Branching großartig erscheint, geht es auch mit einem Leistungseinbußen einher, da es zur Laufzeit Zeiger entschlüsselt. Dies ist eine einfache Version, die die Leistung nicht beeinträchtigt, bei der Folgendes:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
wird zu:
@stack jump_table = {&LABEL_B, &LABEL_A}
goto jump_table[!(x == 2)]
LABEL_A: do_stuff()
LABEL_B: // ...
Obwohl dies einfach und leicht zu brechen erscheint (da stimme ich zu), reicht es aus, um IDA und Ghidra zu brechen, ohne die Leistung zu beeinträchtigen.
auch bekannt als MBA Sub (Mixed Boolean Arithmetic Substitution), wandelt einfache Operationen in komplexe um, die dieselbe Ausgabe liefern. Es verwendet eine vordefinierte Menge. Beispiel:
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
Die vollständige Liste finden Sie in Passes/MBASub.cpp, wenn Sie interessiert sind.
Schauen Sie einfach in index.d.ts. Der Annotation-Parser verwendet dieselbe Reihenfolge.
Um eine Funktion zu markieren, gehen Sie einfach wie folgt vor:
__attribute__((annotate("ibr:1,100"))) void hello_world () {
some_hello ();
}
Annotationscodes:
Beispiel:
in index.d.ts sehen wir:
{
"BasicBlockSplitter.SplitBlockMinSize"?: number;
"BasicBlockSplitter.SplitBlockMaxSize"?: number;
"BasicBlockSplitter.SplitBlockChance"?: number;
};
Nun kommt der wichtige Punkt: Das erste Argument und das gemeinsame für alle Passes ist PassIterations, daher wird es das erste Argument in den Annotationen sein.
Um etwas mit bbs zu annotieren, machen wir Folgendes:
__attribute__((annotate("bbs:1,15,30,100"))) void hello_world () {
some_hello ();
}
Dies bedeutet: Basic Block Splitter einmal auf hello_world ausführen mit min-Größe = 15, max-Größe = 30 und Chance = 100.
Sie können auch Passes kombinieren:
__attribute__((annotate("bbs:1,15,30,100 ibr:1,100 sibr:1,100"))) void hello_world () {
some_hello ();
}
Dies bedeutet, Basic Block Splitter dann Indirect Branching und dann Simple Indirect Branching auf hello_world ausführen. Sie werden in der Reihenfolge der Definition von links nach rechts ausgeführt.
UseOpaqueTransformationChanceqword_1C64D8qword_1A63F0qword_1ACE98UseSipHashedStateChance: verwendet eine kleine angepasste siphash-Funktion zur Überprüfung des Status. Aus if (state == 23872) wird so etwas wie if (siphash(state) == 3874872081), was es schwieriger macht herauszufinden, wohin ein Block springt. Der Block würde state = 23872 setzen, wenn die Bedingung, wohin er geht, gehasht ist. Jeder siphash-Aufruf verwendet zufällige Werte, um die Emulation zu erschweren.CloneSipHashChance: klont und versucht nach Möglichkeit, die siphash-Funktion zu inlinen, sodass mehr als ein Geschwisterchen davon entsteht, was das Hooking einer einzelnen Funktion unzureichend macht. Es ist sehr zu empfehlen, dies zu verwenden, da es nur die Binärgröße erhöht und die Leistung nicht beeinträchtigt.