
Offusca C/C++ tramite passi LLVM: crittografia delle stringhe, appiattimento del flusso di controllo, riscrittura MBA e anti-analisi per contrastare il reverse engineering.
██╗ ███████╗███████╗████████╗
██║ ██╔════╝██╔════╝╚══██╔══╝
██║ █████╗ █████╗ ██║
██║ ██╔══╝ ██╔══╝ ██║
███████╗███████╗███████╗ ██║
╚══════╝╚══════╝╚══════╝ ╚═╝
██████╗ ██████╗ ███████╗██╗ ██╗███████╗ ██████╗ █████╗ ████████╗ ██████╗ ██████╗
██╔═══██╗██╔══██╗██╔════╝██║ ██║██╔════╝██╔════╝██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
██║ ██║██████╔╝█████╗ ██║ ██║███████╗██║ ███████║ ██║ ██║ ██║██████╔╝
██║ ██║██╔══██╗██╔══╝ ██║ ██║╚════██║██║ ██╔══██║ ██║ ██║ ██║██╔══██╗
╚██████╔╝██████╔╝██║ ╚██████╔╝███████║╚██████╗██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═════╝ ╚═════╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
A very simple obfuscator for C/C++ x64 and x86 code
È realizzato come fork di LLVM, quindi hai bisogno del codice sorgente vero e proprio per offuscare. Non è un obfuscator per eseguibili arbitrari. È una versione modificata del compilatore.
Se vuoi vedere i risultati, ho creato e offuscato 2 crackme con questo strumento. Puoi trovarli nelle release insieme al binario clang precompilato.
Tutto questo è stato compilato con il flag -O3 e tutti gli screenshot provengono da IDA Pro 9.4. L'ho testato anche con Binary Ninja e Ghidra: i risultati erano gli stessi o peggiori.
Crittografa le stringhe in fase di compilazione e inserisce una funzione di decrittazione a ogni utilizzo della stringa crittografata. Questo disabilita completamente la possibilità di cercare qualsiasi stringa nel binario. Inoltre, ogni stringa ha la propria chiave univoca hardcoded nella funzione di decrittazione, il che rende molto più difficile estrarle e decrittarle.
![]() Prima del pass |
![]() Dopo |
Sostituisce le operazioni aritmetiche con i loro equivalenti MBA. È praticamente impossibile capire cosa facesse l'operazione originale a meno che non la si faccia passare prima da un deobfuscator MBA. Ovviamente questa offuscazione è piuttosto debole, perché MBA è il trucco più vecchio del mondo, quindi ci sono molti strumenti per gestirla; ad esempio CoBRA riesce a deoffuscare con successo questa espressione in quella originale:
./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)
È per questo che ho creato il pass AAMBA.
![]() Prima del pass |
![]() Dopo |
Sostituisce gli operandi delle operazioni binarie con ADC(X, 255) - 255 - CF e SBB(X, 255) + 255 + CF. Ovviamente restituisce sempre X, ma rende l'espressione dipendente dal carry flag. A meno che il decompilatore non tenga traccia dello stato di CF (cosa a volte impossibile), si confonderà parecchio e non riuscirà a semplificare queste espressioni. Si abbina molto bene al precedente pass MBA, offuscando ulteriormente l'aritmetica. Come puoi vedere sotto, il decompilatore ha creato alcune variabili aggiuntive e usa un sacco di chiamate __PAIR64__ e __CFADD__, quindi diventa molto più difficile (anche se non impossibile) incollare tutto questo in strumenti come CoBRA; anche il plugin gooMBA di IDA non aiuta a semplificarlo. Inoltre, il decompilatore di IDA tiene traccia del carry flag in una certa misura, ma combinando questo con l'offuscamento del flusso di controllo, il tracciamento diventa praticamente impossibile senza esecuzione, poiché non sai quale fosse l'operazione precedente: forse ha impostato CF a 1, forse no, quindi i pass successivi si sommeranno ulteriormente a questo.
![]() Prima del pass |
![]() Dopo |
Raccoglie tutti i blocchi all'interno di una funzione e li trasforma in un'unica enorme macchina a stati; crea una jump table all'inizio della funzione e vi inserisce tutti i puntatori ai blocchi. Poi, invece di un normale salto alla fine di ogni blocco, tutto viene instradato attraverso il dispatcher, che usa salti indiretti; questi sono quasi impossibili da risolvere staticamente senza alcuna esecuzione. Inoltre declassa i registri allo stack, quindi se dividi un blocco a metà, tutte le variabili del blocco precedente saranno sullo stack, il che significa che ci saranno MOLTISSIME variabili in ogni espressione. Se fai passare le operazioni di un singolo blocco da CoBRA, non riuscirà a deoffuscarlo più di tanto, perché ci saranno troppe variabili sconosciute.
Crea un mucchio di blocchi fasulli contenenti assembly non valido. Questo confonde enormemente i disassemblatori, perché se il disassemblatore incontra un byte tecnicamente non valido che non viene mai eseguito, tenterà comunque di dargli un senso. Quindi, se il byte è incompleto, creerà un'istruzione dai byte che si trovano subito dopo, di fatto consumandoli. Questo crea una desincronizzazione che distrugge ogni istruzione successiva. Sui binari Windows, IDA riesce in qualche modo a riprendersi; in rari casi riesce a generare un grafo e a decompilare ciò che può (anche se risulterà rotto e incompleto), mentre sui binari Linux rompe completamente la vista a grafo e disabilita la decompilazione. Inoltre inserisce controlli del timer RDTSC: se il tempo è troppo lungo (ad esempio quando è presente un debugger), il programma va in crash.
![]() Windows |
![]() Linux |
Inoltre, se il pass vede un'istruzione che inizia con 0xFF, inserisce un singolo byte 0xEB prima di essa. Questo crea un JMP RIP+1, quindi il flusso di controllo rimane invariato (RIP avanza semplicemente di un byte dentro l'istruzione originale), ma i disassemblatori si desincronizzano di nuovo.
Le istruzioni che iniziano con 0xFF sono per lo più INC/DEC e JMP/CALL indiretti. Purtroppo la maggior parte delle chiamate e dei salti ordinari sono relativi (0xE8/0xE9/0xEB) e restano invariati. Ma la tecnica è particolarmente utile con il pass del dispatcher, dato che lì tutto usa salti indiretti. Non è altrettanto utile per le chiamate: le uniche chiamate interessate sono quelle indirette, tipicamente le chiamate virtuali, le chiamate tramite puntatori a funzione e alcune chiamate esterne/di libreria.
Getta tutte le variabili locali nello stack di una funzione in un unico grande buffer di stack condiviso, per il quale gli indici vengono calcolati a runtime. In questo modo i decompilatori non possono creare alias tra le variabili, il che fa sì che gli accessi ripetuti alla stessa variabile appaiano come accessi a valori diversi. Si sposa molto bene con il dispatcher, poiché declassa i registri allo stack, quindi ci saranno molti di questi slot di stack.
![]() Prima del pass |
![]() Dopo |
Offusca il flusso di controllo tramite le eccezioni. Sostituisce tutte le chiamate con trap int3. Quando la trap viene attivata, il flusso di controllo passa al gestore delle eccezioni, che regola RIP sulla chiamata effettiva. Inserisce inoltre byte non validi subito dopo la trap per desincronizzare ulteriormente il disassemblatore.
![]() Prima del pass |
![]() Dopo |
Combinando tutti i pass, l'analisi statica diventa molto difficile senza strumenti aggiuntivi che deoffuschino il tutto. Anche se in qualche modo fai nop di tutti i byte non validi e riesci a decompilare tutto in uno pseudocodice o almeno a ottenere una vista a grafo, resti comunque con l'offuscamento del flusso di controllo tramite eccezioni e dispatcher; e anche se superi quello, c'è una montagna di MBA ridondanti, blocchi fasulli, variabili di stack e crittografia delle stringhe che offuscano le operazioni reali. Ecco gli screenshot del punto di ingresso principale di un semplice programma contenente quella semplice funzione xor Foo di prima, in tutti e tre i grandi disassemblatori. Come puoi vedere, non riescono a ricavarne granché.
|
Avrei proprio voluto includere qualche risultato di deobfuscator che tentano di dare un senso a tutto questo, ma purtroppo non sono riuscito a trovarne di funzionanti con esecuzione simbolica per vedere il flusso di controllo effettivo. Tutti quelli che sono riuscito a trovare sono molto limitati a casi d'uso specifici (come deoffuscare specificamente VMProtect), troppo vecchi, non mantenuti e rotti (quasi tutti i plugin IDA/BN che ho provato), oppure sono macchine pesanti che richiedono troppo supporto manuale e configurazione tramite API che non conosco (Angr, Triton, IntelPin). Se conosci deobfuscator per binari arbitrari in grado di estrarre qualsiasi cosa, fammi sapere.
Ovviamente inserire tutta questa roba nel binario lo rallenterà enormemente: con le impostazioni predefinite è in media oltre 200 volte più lento:

Anche se non è così grave come sembra, per 2 motivi. 1. quasi il 95% del costo prestazionale qui è causato dalle nanomites, perché beh, gli interrupt sono semplicemente lenti. L'eccezione deve uscire verso il kernel e tornare all'app, e questo richiede tempo. Senza nanomites si scende a sole 7,5 volte più lento:

Quindi consiglio vivamente di contrassegnare manualmente le funzioni e le chiamate che vuoi offuscare con le nanomites, invece di impostarle su tutto. Offuscare ogni chiamata in un binario è inutile e costa molto. E il motivo numero 2: la maggior parte delle volte non ti interessa davvero delle prestazioni delle cose che vuoi nascondere. Questo obfuscator ha la capacità di essere abilitato e disabilitato selettivamente. Quindi puoi disabilitarlo per le sezioni critiche per le prestazioni del tuo codice e abilitarlo dove serve davvero. A nessuno importa se il tuo controllo di licenza richiede 1 ms o 0,001 ms, resta comunque impercettibile per un essere umano.
Per le opzioni di configurazione e la guida completa, fai riferimento alla wiki.
Puoi anche contrassegnare tutte le funzioni relative al gestore delle eccezioni all'interno di Leet.h con la macro LEET_SKIP; in questo modo il gestore delle eccezioni non verrà offuscato dalla maggior parte dei pass, il che dimezza il costo delle nanomites (con le impostazioni predefinite), ma ti lascia anche con un gestore delle eccezioni non offuscato, cosa che ritengo peggiore di un leggero rallentamento.
Requisiti:
-DLLVM_USE_LINKER=mold da cmake. Ma sarà più veloce con mold)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
il compilatore modificato si troverà dentro build/bin/, basta usarlo per compilare il sorgente che vuoi offuscare.
Inoltre non devi compilarlo: c'è un binario precompilato nelle release, basta scaricarlo e decomprimerlo.
La compilazione di questo progetto per Windows non è attualmente supportata. Ma il cross-compiling verso Windows con questo progetto sì. Quindi, se proprio vuoi, puoi prendere il binario Linux e fare il cross-compiling dell'app offuscata per Windows da Linux o WSL.
Per la guida completa su come usarlo esattamente, fai riferimento alla wiki.
Esempio di utilizzo:
Copia Leet.h nel tuo progetto, poi in un file .c/.cpp includilo e definisci LEET_IMPLEMENTATION
non definirlo in più moduli!
#define LEET_IMPLEMENTATION
#include "Leet.h"
poi basta compilare il sorgente con il compilatore compilato:
./build/bin/clang++ ./test.cpp -o test -fno-exceptions
![]() Binary Ninja Personal 5.2 |
![]() Ghidra 12.1.2 |