
Verschleiert C/C++ durch LLVM-Pässe: String-Verschlüsselung, Kontrollfluss-Abflachung, MBA-Rewriting und Anti-Analyse, um Reverse Engineering zu vereiteln.
██╗ ███████╗███████╗████████╗
██║ ██╔════╝██╔════╝╚══██╔══╝
██║ █████╗ █████╗ ██║
██║ ██╔══╝ ██╔══╝ ██║
███████╗███████╗███████╗ ██║
╚══════╝╚══════╝╚══════╝ ╚═╝
██████╗ ██████╗ ███████╗██╗ ██╗███████╗ ██████╗ █████╗ ████████╗ ██████╗ ██████╗
██╔═══██╗██╔══██╗██╔════╝██║ ██║██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
██║ ██║██████╔╝█████╗ ██║ ██║███████╗██║ ███████║ ██║ ██║ ██║██████╔╝
██║ ██║██╔══██╗██╔══╝ ██║ ██║╚════██║██║ ██╔══██║ ██║ ██║ ██║██╔══██╗
╚██████╔╝██████╔╝██║ ╚██████╔╝███████║╚██████╗██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
Ein sehr einfacher Obfuskator für C/C++-x64- und x86-Code
Dies ist als LLVM-Fork erstellt worden, du brauchst also den eigentlichen Quellcode, um zu obfuskieren. Es ist kein beliebiger Obfuskator für ausführbare Dateien. Es ist eine modifizierte Version des Compilers.
Wenn du dir die Ergebnisse ansehen möchtest, habe ich mit diesem Tool 2 Crackmes erstellt und obfuskierte. Du findest sie in den Releases zusammen mit dem vorkompilierten Clang-Binary.
All dies wurde mit der -O3-Flagge kompiliert, und alle Screenshots stammen aus IDA Pro 9.4. Ich habe es auch mit Binary Ninja und Ghidra getestet; die Ergebnisse waren entweder gleich oder schlechter.
Verschlüsselt Strings zur Kompilierzeit und fügt bei jeder Verwendung des verschlüsselten Strings eine Entschlüsselungsfunktion ein. Dies deaktiviert vollständig die Möglichkeit, im Binary nach beliebigen Strings zu suchen. Und jeder String hat seinen eigenen eindeutigen Schlüssel, der in der Entschlüsselungsfunktion hartkodiert ist, was das Dumpen und Entschlüsseln erheblich erschwert.
![]() Vor dem Pass |
![]() Danach |
Ersetzt arithmetische Operationen durch ihre MBA-Äquivalente. Es ist praktisch unmöglich zu erkennen, was die ursprüngliche Operation tat, es sei denn, man führt sie zuerst durch einen MBA-Deobfuskator. Natürlich ist diese Obfuskation eher schwach, weil MBA der älteste Trick im Buch ist, daher gibt es viele Tools, die damit umgehen können, zum Beispiel CoBRA – es wird dies erfolgreich zu dem ursprünglichen Ausdruck deobfuskieren:
./cobra-cli --mba "((x^y) - (((x^y)&0xFF)&0xA) + 10) * ((x^y) - ((x^y)|0xA) + 10) + ((x^y) - (((x^y)&0xFF)|0xFFFFFFF5) - 11) * (~(x^y) - (~(x^y)|0xA) + 10)" --bitwidth 32
10 * (x ^ y)
Deshalb habe ich den AAMBA-Pass erstellt.
![]() Vor dem Pass |
![]() Danach |
Ersetzt Operanden von Binäroperationen durch ADC(X, 255) - 255 - CF und SBB(X, 255) + 255 + CF. Natürlich ergibt es immer X, aber es macht den Ausdruck vom Carry-Flag abhängig. Sofern der Decompiler den Zustand von CF nicht verfolgt (was manchmal unmöglich ist), wird er sehr verwirrt und kann diese Ausdrücke nicht zusammenfalten. Es passt sehr gut zu dem vorherigen MBA-Pass, der die Arithmetik noch weiter obfuskierte. Wie du unten sehen kannst, hat der Decompiler einige zusätzliche Variablen erstellt und verwendet viele __PAIR64__- und __CFADD__-Aufrufe, sodass es viel schwieriger wird (wenn auch nicht unmöglich), das in Tools wie CoBRA einzufügen. IDAs gooMBA-Plugin hilft bei der Vereinfachung ebenfalls nicht. Außerdem verfolgt IDAs Decompiler das Carry-Flag bis zu einem gewissen Grad, aber in Kombination mit Control-Flow-Obfuskation wird die Verfolgung ohne Ausführung praktisch unmöglich, da man nicht weiß, was die vorherige Operation war – vielleicht hat sie CF auf 1 gesetzt, vielleicht auch nicht –, sodass spätere Pässe noch mehr zu diesem hier beitragen werden.
![]() Vor dem Pass |
![]() Danach |
Sammelt alle Blöcke innerhalb einer Funktion und macht aus ihnen eine einzige riesige Zustandsmaschine. Es erstellt eine Sprungtabelle am Anfang der Funktion und platziert alle Blockzeiger darin. Anstatt eines normalen Sprungs am Ende jedes Blocks wird dann alles über den Dispatcher geleitet, der indirekte Sprünge verwendet. Diese sind ohne Ausführung fast unmöglich statisch aufzulösen. Es stuft außerdem Register zu Stack herab. Wenn du also einen Block in zwei Hälften teilst, liegen alle Variablen des vorherigen Blocks auf dem Stack, was bedeutet, dass es SEHR viele Variablen in jedem Ausdruck geben wird. Wenn du die Operationen eines einzelnen Blocks durch CoBRA laufen lässt, kann es nicht viel deobfuskieren, da es zu viele unbekannte Variablen gibt.
Erstellt einen Haufen Scheinblöcke, die ungültige Assembly enthalten. Das bringt Disassembler enorm durcheinander, denn wenn der Disassembler auf ein technisch ungültiges Byte stößt, das nie ausgeführt wird, versucht er trotzdem, einen Sinn daraus zu machen. Wenn das Byte unvollständig ist, erzeugt es eine Instruktion aus den Bytes, die zufällig danach kommen, und konsumiert diese im Wesentlichen. Das erzeugt eine Desynchronisation, die jede darauffolgende Instruktion zerstört. Bei Windows-Binaries kann sich IDA davon etwas erholen; in seltenen Fällen kann es einen Graphen erzeugen und decompilieren, was es kann (wenn auch kaputt und unvollständig), während es bei Linux-Binaries die Graph-Ansicht vollständig zerstört und die Dekompilierung deaktiviert. Zusätzlich fügt es RDTSC-Timer-Checks ein; wenn es zu lange dauert (zum Beispiel wenn ein Debugger angehängt ist), stürzt du ab.
![]() Windows |
![]() Linux |
Darüber hinaus: Wenn der Pass eine Instruktion sieht, die mit 0xFF beginnt, fügt er ein einzelnes 0xEB-Byte davor ein. Das erzeugt JMP RIP+1, sodass der Kontrollfluss unverändert bleibt (RIP rückt einfach ein Byte in die ursprüngliche Instruktion vor), aber die Disassembler werden erneut desynchronisiert.
Instruktionen, die mit 0xFF beginnen, sind meistens INC/DEC und indirekte JMP/CALL. Leider sind die meisten gewöhnlichen Calls und Sprünge relativ (0xE8/0xE9/0xEB) und bleiben unbeeinflusst. Aber die Technik ist besonders nützlich mit dem Dispatcher-Pass, da dort alles indirekte Sprünge verwendet. Für Calls ist sie jedoch nicht so nützlich; die einzigen Calls, die davon betroffen sind, sind die indirekten – typischerweise virtuelle Calls, Calls über Funktionszeiger und einige externe/Bibliotheks-Calls.
Wirft alle Stack-Lokalen einer Funktion in einen einzigen großen gemeinsamen Stack-Puffer, auf den die Indizes zur Laufzeit berechnet werden. Auf diese Weise können Decompiler keine Variablen aliassen, wodurch Zugriffe auf dieselbe Variable mehrmals so erscheinen, als würde auf unterschiedliche Werte zugegriffen. Das spielt sehr gut mit dem Dispatcher zusammen, da dieser Register zu Stack herabstuft, sodass es viele dieser Stack-Slots geben wird.
![]() Vor dem Pass |
![]() Danach |
Obfuskieren den Kontrollfluss durch Exceptions. Es ersetzt alle Calls durch int3-Traps. Wenn der Trap ausgelöst wird, geht der Kontrollfluss zum Exception-Handler, der RIP auf den tatsächlichen Call anpasst. Es fügt außerdem direkt nach dem Trap ungültige Bytes ein, um den Disassembler noch weiter zu desynchronisieren.
![]() Vor dem Pass |
![]() Danach |
Durch die Kombination aller Pässe wird die statische Analyse sehr schwierig, ohne zusätzliche Tools, die das deobfuskieren würden. Selbst wenn du irgendwie alle ungültigen Bytes mit NOPs überschreibst und in der Lage bist, dies zu etwas Pseudocode zu decompilieren oder zumindest eine Graph-Ansicht zu bekommen, bleibt dir immer noch die Control-Flow-Obfuskation durch Exceptions und den Dispatcher, und selbst wenn du diese durchbrichst, gibt es einen Berg von redundanten MBAs, Scheinblöcken, Stack-Variablen und String-Verschlüsselung, die die eigentlichen Operationen verschleiern. Hier sind Screenshots des Haupt-Einstiegspunkts eines einfachen Programms, das die einfache XOR-Foo-Funktion von vorhin in allen drei großen Disassemblern enthält. Wie du sehen kannst, können sie nicht viel daraus machen.
|
Ich wollte wirklich ein Ergebnis von Deobfuskator-Versuchen einbauen, die versuchen, daraus schlau zu werden, aber leider konnte ich keine funktionierenden mit symbolischer Ausführung finden, um den tatsächlichen Kontrollfluss zu sehen. Alle, die ich finden konnte, sind entweder stark auf bestimmte Anwendungsfälle begrenzt (wie speziell das Deobfuskieren von VMProtect), zu alt, nicht gewartet und kaputt (fast jedes IDA/BN-Plugin, das ich ausprobiert habe), oder schwere Maschinerie, die zu viel manuelle Führung und Einrichtung über APIs erfordert, mit denen ich einfach nicht vertraut bin (Angr, Triton, IntelPin). Wenn du Deobfuskatoren für beliebige Binaries kennst, die überhaupt irgendetwas extrahieren könnten, lass es mich wissen.
Natürlich wird das Einfügen all dieses Bullshits in das Binary es enorm verlangsamen – bei den Standardeinstellungen im Durchschnitt über 200-mal langsamer:

Es ist jedoch nicht so schlimm, wie es aussieht, aus 2 Gründen: 1. Fast 95% der Leistungseinbußen hier werden durch Nanomites verursacht, denn – nun ja – Interrupts sind einfach langsam. Die Exception muss zum Kernel gehen und zurück zur App kommen; das braucht Zeit. Ohne Nanomites sinkt es auf nur 7,5-mal langsamer:

Daher rate ich dringend, die Funktionen und Calls, die du mit Nanomites obfuskieren möchtest, manuell zu markieren, anstatt es einfach auf alles zu setzen. Jeden einzelnen Call in einem Binary zu obfuskieren ist sinnlos und kostet viel. Und der zweite Grund: Meistens kümmert es dich nicht wirklich, wie performant die Dinge sind, die du verstecken möchtest. Dieser Obfuskator kann selektiv aktiviert und deaktiviert werden. Du kannst ihn also für leistungskritische Abschnitte deines Codes deaktivieren und überall dort aktivieren, wo er tatsächlich benötigt wird. Niemanden interessiert es, ob dein Lizenzcheck 1 ms oder 0,001 ms dauert; es ist für einen Menschen weiterhin unbemerkt.
Für die Konfigurationsoptionen und die vollständige Anleitung siehe das Wiki.
Du kannst auch alle Funktionen, die mit dem Exception-Handler in Leet.h zusammenhängen, mit dem LEET_SKIP-Makro markieren. Auf diese Weise wird der Exception-Handler von den meisten Pässen nicht obfuskierte, was die Kosten der Nanomites halbiert (bei Standardeinstellungen), dich aber auch mit einem nicht obfuskierte Exception-Handler zurücklässt, was ich für schlimmer halte als eine leichte Verlangsamung.
Voraussetzungen:
-DLLVM_USE_LINKER=mold aus dem cmake. Aber mit mold wird es schneller)git clone https://github.com/Zydak/LeetObfuscator.git --recursive
cd LeetObfuscator
mkdir build
cd build
cmake ../leet-llvm-project/llvm -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DLLVM_USE_LINKER=mold -DLLVM_USE_SPLIT_DWARF=ON -DLLVM_ENABLE_ASSERTIONS=ON -DCMAKE_BUILD_TYPE=RelWithDebInfo -DLLVM_ENABLE_PROJECTS=clang -DLLVM_TARGETS_TO_BUILD=X86 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
ninja clang
Der modifizierte Compiler befindet sich in build/bin/. Verwende einfach den, um den Quellcode zu kompilieren, den du obfuskieren möchtest.
Du musst es auch nicht selbst bauen; es gibt ein vorgefertigtes Binary in den Releases. Lade es einfach herunter und entpacke es.
Das Erstellen dieses Projekts für Windows wird derzeit nicht unterstützt. Aber das Cross-Kompilieren mit diesem Projekt nach Windows wird unterstützt. Wenn du es also wirklich möchtest, kannst du das Linux-Binary nehmen und die obfuskierte App von Linux oder WSL aus für Windows cross-kompilieren.
Für die vollständige Anleitung zur genauen Verwendung siehe das Wiki.
Beispielverwendung:
Kopiere Leet.h in dein Projekt, und füge es dann in einer .c/.cpp-Datei ein und definiere LEET_IMPLEMENTATION
Definiere dies nicht in mehreren Modulen!
#define LEET_IMPLEMENTATION
#include "Leet.h"
Kompiliere dann einfach den Quellcode mit dem gebauten Compiler:
./build/bin/clang++ ./test.cpp -o test -fno-exceptions
![]() Binary Ninja Personal 5.2 |
![]() Ghidra 12.1.2 |