Analyse des Obfuscateurs Basés sur Machines Virtuelles
Les solutions de protection commerciales telles que VMProtect et Themida utilisent une architecture de machine virtuelle (VM) en boîte noire pour obscurcir le code natif. Lors de l'analyse statique, les fonctions originales sont généralement remplacées par des sauts vers des sections générées dynamiquement, contenant des blocs de code virtualisé complexes.
En examinant le code désassemblé, on identifie rapidement des opérations de manipulation de pile virtuelle. Par exemple, une instruction d'addition virtualisée se traduit par le chargement de deux opérandes depuis la pile, leur addition, et le stockage du résultat. Voici une représentation simplifiée d'un gestionnaire (handler) d'addition :
.vmp_section:0000000140020893 mov eax, [rbx]
.vmp_section:000000014002089C mov edx, [rbx+4]
.vmp_section:00000001400208AE add eax, edx
.vmp_section:00000001400208B3 mov [rbx+8], eax
En pseudo-code de haut niveau, cela correspond à :
int32_t operand1 = v_stack.top(); v_stack.pop();
int32_t operand2 = v_stack.top(); v_stack.pop();
v_stack.push(operand1 + operand2);
L'analyse manuelle de chaque gestionnaire via un débogueur n'est pas viable à grande échelle. Il est nécessaire d'automatiser ce processus via des techniques de compilation.
Transition vers l'Élévation LLVM IR
Les apprcohes initiales basées sur l'émulation dynamique (Unicorn) ou l'analyse symbolique (Triton) présentent des limites en termes de performances et de qualité de la sortie générée. L'alternative la plus robuste consiste à élever le code assembleur vers une représentation intermédiaire (IR), spécifiquement LLVM IR.
L'utilisation de LLVM introduit le format SSA (Single Static Assignment), où chaque varible n'est définie qu'une seule fois. Cela simplifie considérablement le suivi du flux de contrôle et des dépendances de données. Contrairement aux méthodes qui tentent de reconstruire l'état interne de la VM (registres VIP, VSP), une approche générique élève directement les instructions natives vers LLVM IR, en s'appuyant sur les passes d'optimisation standard pour simplifier le code.
Défis Techniques et Résolutions
L'élévation de code virtualisé pose des problèmes spécifiques que LLVM, conçu pour du code compilé standard, ne gère pas nativement :
- Lectures mémoire partielles : Les obfuscateurs manipulent souvent des sous-parties de mots mémoire. Un système d'aliasage personnalisé est requis pour résoudre ces chevauchements.
- Suivi des valeurs et troncature : Le stockage de valeurs avec des bits symboliques suivies de troncatures perturbe l'analyse. L'utilisation de la classe
KnownBitsde LLVM permet de tracker les bits expliciteemnt définis à 0 ou 1, facilitant la concrétisation des valeurs.
Pour éviter les goulots d'étranglement liés à la duplication du module à chaque saut, l'optimisation est appliquée à la volée pendant l'élévation. Cette approche réduit le temps de traitement de plusieurs minutes à quelques millisecondes.
Implémentation d'une VM Jouet et Élévation
Pour illustrer le processus, voici une implémentation réécrite d'une machine virtuelle minimale en C++ :
#include <vector>
#include <cstdint>
enum class OpCode : uint8_t {
NoOp = 0, PushVal, PopVal, AddOp, SubOp, XorOp, Halt
};
struct Instruction {
OpCode type;
int32_t* operand;
};
class VirtualProcessor {
private:
std::vector<int32_t> v_stack;
size_t v_ip;
const std::vector<Instruction>& v_prog;
public:
VirtualProcessor(const std::vector<Instruction>& prog)
: v_prog(prog), v_ip(0) {}
int32_t run() {
while (v_ip < v_prog.size()) {
const auto& inst = v_prog[v_ip++];
switch (inst.type) {
case OpCode::PushVal:
v_stack.push_back(*inst.operand);
break;
case OpCode::PopVal:
if (inst.operand) *inst.operand = v_stack.back();
v_stack.pop_back();
break;
case OpCode::AddOp: {
int32_t op2 = v_stack.back(); v_stack.pop_back();
int32_t op1 = v_stack.back(); v_stack.pop_back();
v_stack.push_back(op1 + op2);
break;
}
case OpCode::XorOp: {
int32_t op2 = v_stack.back(); v_stack.pop_back();
int32_t op1 = v_stack.back(); v_stack.pop_back();
v_stack.push_back(op1 ^ op2);
break;
}
case OpCode::Halt:
return v_stack.empty() ? 0 : v_stack.back();
default:
break;
}
}
return 0;
}
};
int32_t execute_virtualized(int32_t x, int32_t y) {
int32_t out = 0;
std::vector<Instruction> bytecode = {
{OpCode::PushVal, &x},
{OpCode::PushVal, &y},
{OpCode::XorOp, nullptr},
{OpCode::PushVal, &y},
{OpCode::AddOp, nullptr},
{OpCode::PopVal, &out},
{OpCode::Halt, nullptr}
};
VirtualProcessor vm(bytecode);
return vm.run();
}
Après élévation et application des passes d'optimisation -O3, le flux de contrôle aplati est réduit à sa logique fondamentale :
define i64 @virtual_entry(i64 %reg_a, i64 %reg_c, i64 %reg_d) {
entry:
%val_d_32 = trunc i64 %reg_d to i32
%val_c_32 = trunc i64 %reg_c to i32
%xor_res = xor i32 %val_d_32, %val_c_32
%add_res = add i32 %xor_res, %val_d_32
%res_64 = zext i32 %add_res to i64
ret i64 %res_64
}
Comparaison : VMProtect vs Themida
L'application de cette méthodologie sur des protections commerciales révèle des différences architecturales notables. Pour une fonction simple retournant la somme de deux entiers, VMProtect 3.8 génère un flux de contrôle dense mais optimisable :
define i64 @vmp_entry(i64 %reg_a, i64 %reg_c, i64 %reg_d) {
entry:
%op1 = trunc i64 %reg_d to i32
%op2 = trunc i64 %reg_c to i32
%sum = add i32 %op1, %op2
%ret_val = zext i32 %sum to i64
ret i64 %ret_val
}
En revanche, Themida (profil Fish White) introduit une complexité supplémentaire avec de nombreuses écritures mémoire factices et des calculs de drapeaux (flags) excessivement verbeux. Voici un extrait représentatif de l'IR généré avant nettoyage manuel :
define i64 @themida_entry(i64 %reg_a, i64 %reg_c, i64 %reg_d, ptr %mem_block) {
entry:
%op1_32 = trunc i64 %reg_d to i32
%ptr_1 = inttoptr i64 1375416 to ptr
store i32 %op1_32, ptr %ptr_1, align 4
%op2_32 = trunc i64 %reg_c to i32
%ptr_2 = inttoptr i64 1375408 to ptr
store i32 %op2_32, ptr %ptr_2, align 4
; Calculs de drapeaux et écritures mémoire obfusquées
%flag_base = shl nuw nsw i64 %reg_c, 1
%lsb_mask = and i64 %flag_base, 254
%pf_mul = mul nuw i64 %lsb_mask, 72340172838076673
%pf_and = and i64 %pf_mul, -9205322385119247872
%pf_rem = urem i64 %pf_and, 511
%sum_32 = add i32 %op1_32, %op2_32
%cf_check = icmp ult i32 %sum_32, %op2_32
%cf_ext = zext i1 %cf_check to i64
%sum_64 = zext i32 %sum_32 to i64
ret i64 %sum_64
}
Après élimination des écritures mémoire mortes et simplification des calculs de drapeaux inutilisés, l'IR de Themida se réduit à la même logique arithmétique que celle de VMProtect.
Les obfuscateurs basés sur des VM n'offrent pas une résistance significativement supérieure à l'analyse statique par rapport à l'aplatissement du flux de contrôle (Control Flow Flattening), car les deux techniques visent principalement à masquer le flux sans altérer la logique sous-jacente. Les solutions binaires de type VM ajoutent cependant des défis techniques liés à la traduction des instructions, à la reconstruction des tables de saut et à la gestion des exceptions. De plus, les couches d'abstraction supplémentaires entraînent inévitablement une surcharge d'exécution, rendant le programme protégé moins performant.