
Zyrox: plugin offuscatore basato su LLVM, a tempo di compilazione.
perché no ¯\_(ツ)_/¯
Uno dei miei progetti più grandi, in cui ho imparato molto sugli internals di LLVM, formati binari, assembly e tecniche di offuscamento.
Credo che imparare costruendo sia il modo migliore per apprendere, quindi ho creato questo progetto per imparare di più su questi argomenti.
Ho scritto 4 blog che spiegano i concetti alla base di Zyrox:
Queste parti approfondiscono più di questo readme e valgono sicuramente una lettura se sei interessato all'argomento.
Questo è pensato per chi vuole testare rapidamente Zyrox, o imparare come integrarlo in un progetto cmake.
Segui i passaggi nel repo Zyrox Template.
installa llvm:
sudo apt update
sudo apt install llvm-18 llvm-18-dev clang-18
clona e compila 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
assicurati di avere python3 e pip installati.
# 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
Dopo l'offuscamento, esegui PyPlugin.py per cifrare le jump table:
# 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]
Dai un'occhiata al repo Zyrox Template per un esempio di integrazione CMake.
Capisco che sia un argomento complesso, e questo progetto è stato principalmente per scopi educativi, oltre che per servire BSD Brawl. Se hai domande, o vuoi semplicemente fare due chiacchiere, sentiti libero di contattarmi:
@s.b[email protected] o [email protected]qualsiasi aiuto, tramite pull request o issue, è apprezzato!
ZyroxPlugin.cpp registra il pass, poi collega siphash (ne parleremo più avanti) e chiama StringEncryption per cifrare le stringhe.
Il motivo per cui cifriamo le stringhe subito è così che anche la logica di decifratura venga offuscata in seguito.
poi chiama ModuleUtils::ExpandCustomAnnotations e QuickConfig::RegisterPasses per fare il parsing di tutte le espressioni __attribute__((annotate("..."))) ed eseguire la config QuickJs (situata in ZyroxConfig.js)
Ogni funzione viene offuscata chiamando Zyrox::RunOnFunction situata in ZyroxCore.cpp; ulteriore documentazione a riguardo verrà fornita in futuro.
gli switch creano jump table e i nodi PHI sono fastidiosi da gestire, quindi usiamo FunctionUtils e BasicBlockUtils per appiattirli (in istruzioni if) e declassarli rispettivamente.
oh cavolo, da dove comincio
tutti gli argomenti del plugin js sono in index.d.ts, quindi non verranno trattati in questa documentazione.
per la documentazione sulle annotazioni, clicca qui
Questo pass divide e mescola un basic block in blocchi più piccoli. Supponiamo di avere questo:
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;
}
che viene compilato in:
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
}
quando si usa Basic Block Splitter con questa config:
z.RegisterPass(ObfuscationType.BasicBlockSplitter, {
PassIterations: 1,
"BasicBlockSplitter.SplitBlockChance": 100,
"BasicBlockSplitter.SplitBlockMinSize": 2,
"BasicBlockSplitter.SplitBlockMaxSize": 5,
});
diventa:
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
}
ora non sarà molto diverso per una funzione così piccola, ma nota come ha diviso un basic block?
questo è utile se combinato con altri pass come Control Flow Flattening
Oh, cavolo, questo pass ha il maggior numero di funzionalità tra tutti lol. Inizierò spiegando come funziona, poi la sua config. Supponiamo di avere questo codice:
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 ogni basic block (A, B e C) viene assegnato uno stato univoco del dispatcher, esempio: (semplificato)
states = {
1: LABEL_A,
2: LABEL_B,
3: LABEL_C,
};
poi iniettiamo un blocco dispatcher che controlla tutto e il codice diventa:
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
ora questo ha alcuni difetti che l'offuscatore corregge. come vedi, dato che abbiamo una singola variabile dispatcher, è facile deoffuscare tutto ciò perché sappiamo dove andrà un blocco dopo aver impostato lo stato. facile da correggere!
z.RegisterPass(ObfuscationType.ControlFlowFlattening, {
PassIterations: 1,
"ControlFlowFlattening.UseFunctionResolverChance": 60,
"ControlFlowFlattening.UseGlobalStateVariablesChance": 60,
"ControlFlowFlattening.UseOpaqueTransformationChance": 40,
"ControlFlowFlattening.UseGlobalVariableOpaquesChance": 80,
"ControlFlowFlattening.UseSipHashedStateChance": 40,
"ControlFlowFlattening.CloneSipHashChance": 80,
});
esaminiamo le opzioni una per una:
UseFunctionResolverChance: inietta una funzione per controllare lo stato, quindi invece di fare
if (state == expected_state), fa if (injected_resolver(state)). esempio:
bool __fastcall cff_resolve_state_check_3585(__int64 a1)
{
return a1 == 0x288A6154F8A5E3E2LL;
}
UseGlobalStateVariablesChance: salva il valore dello stato da confrontare in una variabile globale:
bool __fastcall cff_resolve_state_check_506(__int64 a1)
{
return a1 == qword_1B20D8;
}
UseOpaqueTransformationChance: offusca il controllo in una trasformazione che restituirà true solo per uno stato specifico:
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: usa una variabile globale invece di un numero quando si applica , come hai notato nell'esempio sopra. (, , )supponiamo di avere questo codice:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
verrebbe trasformato 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]));
// ...
quando questo pass viene usato, il plugin produrrà un file zyrox_tables.txt da usare con PyPlugin.py.
PyPlugin.py cifrerà le jump table e patchará le voci di rilocazione, quindi farà puntare il relocator a jump_table[0] di ogni tabella. un relocator fondamentalmente fa questo: target.writePointer(base.add(value)), quindi impostando value a 0, facciamo in modo che il relocator ci dia l'indirizzo di base e lo metta nella jump table a runtime, e lo usiamo insieme al goto per generare l'indirizzo runtime. nella modalità thumb di arm32, il pass aggiunge automaticamente | 1 dopo la decifratura.
per usare PyPlugin.py basta fare quanto segue:
(se usi venv, attivalo prima)
python3 PyPlugin.py --in <out_obfuscated_file> --android
passare --android è importante se stai puntando alla versione arm64, poiché la versione x86_64 ha una firma del relocator diversa.
puoi anche passare --out (di default userà lo stesso file passato a --in) e puoi passare --tables (di default è zyrox_tables.txt)
anche se l'indirect branching sembra ottimo, comporta un calo prestazionale perché decifra i puntatori a runtime; questa è una versione semplice che non influisce sulle prestazioni, dove questo:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
diventa:
@stack jump_table = {&LABEL_B, &LABEL_A}
goto jump_table[!(x == 2)]
LABEL_A: do_stuff()
LABEL_B: // ...
anche se questo sembra semplice e facilmente aggirabile (concordo), è sufficiente per rompere IDA e Ghidra senza influire sulle prestazioni.
noto anche come MBA Sub (Mixed Boolean Arithmetic Substitution), converte operazioni semplici in operazioni complesse che producono lo stesso output. usa un insieme predefinito. esempio:
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
puoi vedere l'elenco completo in Passes/MBASub.cpp se sei interessato.
basta controllare index.d.ts. il parser delle annotazioni usa lo stesso ordine.
per marcare una funzione basta fare quanto segue:
__attribute__((annotate("ibr:1,100"))) void hello_world () {
some_hello ();
}
codici delle annotazioni:
esempio:
in index.d.ts vediamo:
{
"BasicBlockSplitter.SplitBlockMinSize"?: number;
"BasicBlockSplitter.SplitBlockMaxSize"?: number;
"BasicBlockSplitter.SplitBlockChance"?: number;
};
ora, il primo argomento, condiviso da tutti i pass, è PassIterations, quindi sarà il primo argomento nelle annotazioni.
per annotare qualcosa con bbs facciamo:
__attribute__((annotate("bbs:1,15,30,100"))) void hello_world () {
some_hello ();
}
questo significa: esegui Basic Block Splitter su hello_world 1 volta con dimensione minima = 15, dimensione massima = 30 e probabilità = 100.
puoi anche combinare i pass:
__attribute__((annotate("bbs:1,15,30,100 ibr:1,100 sibr:1,100"))) void hello_world () {
some_hello ();
}
questo significa eseguire Basic Block Splitter poi Indirect Branching e poi Simple Indirect Branching su hello_world.
Verranno eseguiti nell'ordine di definizione, da sinistra a destra.
UseOpaqueTransformationChanceqword_1C64D8qword_1A63F0qword_1ACE98UseSipHashedStateChance: usa una piccola funzione siphash personalizzata per controllare lo stato. Quindi if (state == 23872) diventa qualcosa come if (siphash(state) == 3874872081), rendendo più difficile capire verso quale blocco si sta saltando. il blocco farebbe state = 23872 quando la condizione di dove va è sottoposta a hash. ogni chiamata a siphash usa valori casuali per renderne più difficile l'emulazione.CloneSipHashChance: clona e cerca anche, quando possibile, di inlineare la funzione siphash, creando più di una copia, il che rende insufficiente agganciare una singola funzione. è molto preferibile usare questa opzione, perché aumenta solo la dimensione del binario e non influisce sulle prestazioni.