
Zyrox: plugin ofuscador en tiempo de compilación basado en LLVM.
por qué no ¯\_(ツ)_/¯
Uno de mis proyectos más grandes, donde aprendí mucho sobre los internos de LLVM, formatos binarios, ensamblador y técnicas de ofuscación.
Creo que aprender construyendo es la mejor manera de aprender, por eso construí este proyecto para aprender más sobre estos temas.
He escrito 4 blogs explicando los conceptos detrás de Zyrox:
Estas partes profundizan más que este readme, y definitivamente vale la pena leerlas si estás interesado en el tema.
Esto está pensado para quienes quieran probar rápidamente Zyrox, o aprender cómo integrarlo en un proyecto cmake.
Sigue los pasos en el repositorio Zyrox Template.
instala llvm:
sudo apt update
sudo apt install llvm-18 llvm-18-dev clang-18
clona y 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
asegúrate de tener python3 y pip instalados.
# 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
Después de la ofuscación, ejecuta PyPlugin.py para encriptar las tablas de salto:
# 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]
Revisa el repositorio Zyrox Template para un ejemplo de integración con CMake.
Entiendo que es un tema complejo, y este proyecto fue principalmente con fines educativos, además de servir a BSD Brawl. Si tienes alguna pregunta, o simplemente quieres charlar, no dudes en contactarme:
@s.b[email protected] o [email protected]cualquier ayuda, a través de pull requests o issues, es apreciada!
ZyroxPlugin.cpp registra el pase, luego enlaza siphash (más sobre esto después) y llama a StringEncryption para encriptar las cadenas.
La razón por la que encriptamos las cadenas temprano es para que la lógica de descifrado también se ofusque después.
luego llama a ModuleUtils::ExpandCustomAnnotations y QuickConfig::RegisterPasses para parsear todas las expresiones __attribute__((annotate("..."))) y ejecutar la configuración de QuickJs (ubicada en ZyroxConfig.js)
Cada función se ofusca llamando a Zyrox::RunOnFunction ubicada en ZyroxCore.cpp, se proporcionará más documentación sobre esto en el futuro.
los switches crean tablas de salto y los nodos PHI son molestos de manejar, por lo que usamos FunctionUtils y BasicBlockUtils para aplanar (en sentencias if) y degradar estos respectivamente.
oh vaya, por dónde empezar
todos los argumentos del plugin js están en index.d.ts, por lo que no se hablará de ellos en esta documentación.
para documentación de anotaciones, haz clic aquí
Este pase divide y mezcla un bloque básico en otros más pequeños. Supongamos que tenemos esto:
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;
}
que se compila en:
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
}
al usar el Divisor de Bloques Básicos con esta configuración:
z.RegisterPass(ObfuscationType.BasicBlockSplitter, {
PassIterations: 1,
"BasicBlockSplitter.SplitBlockChance": 100,
"BasicBlockSplitter.SplitBlockMinSize": 2,
"BasicBlockSplitter.SplitBlockMaxSize": 5,
});
se convierte en:
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
}
ahora no será muy diferente para una función tan pequeña, pero ¿notaste cómo dividió un bloque básico? Esto es útil combinado con otros pases como Aplanamiento de Flujo de Control
Oh, vaya, este pase tiene la mayor cantidad de características entre todos lol. Empezaré explicando cómo funciona y luego su configuración. supongamos que tenemos este código:
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
cada bloque básico (A, B y C) recibe un estado de despachador único, ejemplo: (simplificado)
states = {
1: LABEL_A,
2: LABEL_B,
3: LABEL_C,
};
luego inyectamos un bloque despachador que controla todo y el código se convierte en:
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
ahora esto tiene algunos defectos que el ofuscador corrige. como ves, dado que tenemos una única variable de despachador, es fácil desofuscar esto ya que sabemos a dónde va un bloque después de establecer el estado. ¡fácil de arreglar!
z.RegisterPass(ObfuscationType.ControlFlowFlattening, {
PassIterations: 1,
"ControlFlowFlattening.UseFunctionResolverChance": 60,
"ControlFlowFlattening.UseGlobalStateVariablesChance": 60,
"ControlFlowFlattening.UseOpaqueTransformationChance": 40,
"ControlFlowFlattening.UseGlobalVariableOpaquesChance": 80,
"ControlFlowFlattening.UseSipHashedStateChance": 40,
"ControlFlowFlattening.CloneSipHashChance": 80,
});
revisemos las opciones una por una:
UseFunctionResolverChance: inyecta una función para verificar el estado, así que en lugar de hacer if (state == expected_state), hace if (injected_resolver(state)). ejemplo:
bool __fastcall cff_resolve_state_check_3585(__int64 a1)
{
return a1 == 0x288A6154F8A5E3E2LL;
}
UseGlobalStateVariablesChance: guarda el valor del estado para ser comparado en una variable global:
bool __fastcall cff_resolve_state_check_506(__int64 a1)
{
return a1 == qword_1B20D8;
}
UseOpaqueTransformationChance: ofusca la verificación en alguna transformación que solo dará verdadero para un estado específico:
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 variable global en lugar de un número al hacer , como notaste en el ejemplo anterior. (, , )supongamos que tenemos este código:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
se transformaría en:
@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]));
// ...
cuando se usa este pase, el plugin generará un archivo zyrox_tables.txt para ser usado por PyPlugin.py.
PyPlugin.py encriptará las tablas de salto y parcheará las entradas de reubicación, luego hará que el reubicador apunte a jump_table[0] de cada tabla. un reubicador básicamente hace esto: target.writePointer(base.add(value)), por lo que al establecer el valor en 0, hacemos que el reubicador nos dé la dirección base y la coloque en la tabla de salto en tiempo de ejecución, y la usamos junto con el goto para generar la dirección en tiempo de ejecución. en modo thumb de arm32, el pase añade automáticamente | 1 después del descifrado.
para usar PyPlugin.py simplemente haz lo siguiente:
python3 PyPlugin.py --in <out_obfuscated_file> --android
pasar --android es importante si estás apuntando a la versión arm64 ya que la versión x86_64 tiene una firma de reubicador diferente.
también puedes pasar --out (por defecto usará el mismo archivo pasado a --in) y puedes pasar --tables (por defecto es zyrox_tables.txt)
aunque la rama indirecta parece genial, también conlleva una penalización de rendimiento ya que descifra punteros en tiempo de ejecución, esta es una versión simple que no afecta el rendimiento, donde esto:
if (x == 2) goto LABEL_A
goto LABEL_B
LABEL_A: do_stuff()
LABEL_B: // ...
se convierte en:
@stack jump_table = {&LABEL_B, &LABEL_A}
goto jump_table[!(x == 2)]
LABEL_A: do_stuff()
LABEL_B: // ...
aunque esto parece simple y fácilmente rompible (estoy de acuerdo), es suficiente para romper IDA y Ghidra sin afectar el rendimiento.
también conocido como MBA Sub (Sustitución de Aritmética Booleana Mixta), convierte operaciones simples en complejas que dan la misma salida. usa un conjunto predefinido. ejemplo:
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
puedes ver la lista completa en Passes/MBASub.cpp si estás interesado.
solo revisa index.d.ts. el analizador de anotaciones usa el mismo orden.
para marcar una función solo haz lo siguiente:
__attribute__((annotate("ibr:1,100"))) void hello_world () {
some_hello ();
}
códigos de anotación:
ejemplo:
en index.d.ts vemos:
{
"BasicBlockSplitter.SplitBlockMinSize"?: number;
"BasicBlockSplitter.SplitBlockMaxSize"?: number;
"BasicBlockSplitter.SplitBlockChance"?: number;
};
ahora aquí está el detalle, el primer argumento y el compartido para todos los pases es PassIterations, por lo que será el primer argumento en las anotaciones.
para anotar algo con bbs hacemos:
__attribute__((annotate("bbs:1,15,30,100"))) void hello_world () {
some_hello ();
}
esto significa: ejecuta Divisor de Bloques Básicos en hello_world 1 vez con tamaño mínimo = 15, tamaño máximo = 30 y probabilidad = 100.
también puedes combinar pases:
__attribute__((annotate("bbs:1,15,30,100 ibr:1,100 sibr:1,100"))) void hello_world () {
some_hello ();
}
esto significa ejecuta Divisor de Bloques Básicos luego Rama Indirecta y luego Rama Indirecta Simple en hello_world.
Se ejecutarán en el orden de definición de izquierda a derecha.
UseOpaqueTransformationChanceqword_1C64D8qword_1A63F0qword_1ACE98UseSipHashedStateChance: usa una pequeña función siphash personalizada para verificar el estado. así que if (state == 23872) se convierte en algo como if (siphash(state) == 3874872081) haciendo más difícil encontrar a dónde salta un bloque. el bloque haría state = 23872 cuando la condición de a dónde va está hasheada. cada llamada siphash usa valores aleatorios para dificultar su emulación.CloneSipHashChance: clona e incluso intenta cuando sea posible en línea la función siphash creando más de un hermano para ella, lo que hace que no sea suficiente con enganchar una sola función. es muy preferible usar esto ya que solo aumenta el tamaño del binario y no afecta el rendimiento.