cours/content/secu_systeme/3_compilation_obfiscation_llvm/index.md

14 KiB

title date tags categories mathjax
Sécurité système : Introduction à la compilation et obfuscation avec llvm 2023-10-12
LLVM
assembleur
Sécurité système
Cours
TD
true

Le but de ce cours est de comprendre ce qui se passe lors de la création de binaire. Nous nous concentrerons sur clang / LLVM dans le cadre de la sécurité, que se soit en défense ou en attaque.

compilation en trois étapes

Il est possible de découper la compilation en 3 étapes avec LLVM:

  1. Le code source passe par un frontend avec l'analyse syntaxique et sémantique;
  2. Le résultat passe par un middle-end qui va procéder aux optimisations;
  3. Et enfin par un backend qui va transformer le code optimisé en code assembleur et réaliser des optimisations spécifique à l'architecture cible.

L'obfuscation du code se fait au niveau du middle-end.

Dans le cadre de la suite d'outils autour de LLVM nous avons plusieurs backends (clang, flang, RetDec) qui transforme le code en entrée en code LLVM (middle-end) sur lequel seront réalisées les optimisations. Et enfin le résultat passe dans un compilateur qui le transforme en code binaire (ARM. x86_64, Webassembly,...).

Nous avons déjà vu cette partie lors [de l'introcution]({{<ref "secu_systeme/1_introduction/index.md#la-compilation">}})

Vie d'un hello world

Voyons maintenant ce qu'il se passe dans le cadre du code suivant:

#include <stdio.h>
int main(int argc, char** argv) {
  puts("Hello, world!\n");
  return 0;
}

l'AST (Frontend)

Voyons l'AST produit avec clang grâce à la commande suivante avec la commande:

clang -Xclang -ast-dump -fsyntax-only main.c

le voici donc:

`-FunctionDecl 0x56028bd93cc0 <main.c:3:1, line:6:1> line:3:5 main 'int (int, char **)'
  |-ParmVarDecl 0x56028bd93b68 <col:10, col:14> col:14 argc 'int'
  |-ParmVarDecl 0x56028bd93be8 <col:20, col:27> col:27 argv 'char **'
  `-CompoundStmt 0x56028bd93e88 <col:33, line:6:1>
    |-CallExpr 0x56028bd93e00 <line:4:3, col:25> 'int'
    | |-ImplicitCastExpr 0x56028bd93de8 <col:3> 'int (*)(const char *)' <FunctionToPointerDecay>
    | | `-DeclRefExpr 0x56028bd93d70 <col:3> 'int (const char *)' Function 0x56028bd8f730 'puts' 'int (const char *)'
    | `-ImplicitCastExpr 0x56028bd93e40 <col:8> 'const char *' <NoOp>
    |   `-ImplicitCastExpr 0x56028bd93e28 <col:8> 'char *' <ArrayToPointerDecay>
    |     `-StringLiteral 0x56028bd93d90 <col:8> 'char[15]' lvalue "Hello, world!\n"
    `-ReturnStmt 0x56028bd93e78 <line:5:3, col:10>
      `-IntegerLiteral 0x56028bd93e58 <col:10> 'int' 0

Langage intermédiaire (Middle-end)

Maintenant, regardons le code LLVM produit par clang:

clang -S -emit-llvm -Xclang -disable-O0-optnone main.c -o 

À ce niveau, le code obtenu n'est pas optimisé:

; ModuleID = '/home/user/code/main.c'
source_filename = "/home/user/code/main.c"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"
@.str = private unnamed_addr constant [15 x i8] c"Hello, world!\0A\00", align 1
; Function Attrs: noinline nounwind uwtable
define dso_local i32 @main(i32 noundef %0, ptr noundef %1) #0 {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca ptr, align 8
  store i32 0, ptr %3, align 4
  store i32 %0, ptr %4, align 4
  store ptr %1, ptr %5, align 8
  %6 = call i32 @puts(ptr noundef @.str)
  ret i32 0
}
declare i32 @puts(ptr noundef) #1

Optimisation (Middle-end)

Voici la commande permettant de lancer les optimisations sur le code intermédiaire LLVM:

opt -S -O2 main.ll

Le code produit contient plus les éléments jugés inutiles comme ici argv et argc :

; ModuleID = '<stdin>'
source_filename = "/home/user/code/main.c"
target datalayout = "e-m:e-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-pc-linux-gnu"
@.str = private unnamed_addr constant [15 x i8] c"Hello, world!\0A\00", align 1
; Function Attrs: nofree noinline nounwind uwtable
define dso_local i32 @main(i32 noundef %0, ptr nocapture noundef readnone %1) local_unnamed_addr #0 {
  %3 = tail call i32 @puts(ptr noundef nonnull @.str)
  ret i32 0
}
; Function Attrs: nofree nounwind
declare noundef i32 @puts(ptr nocapture noundef readonly) local_unnamed_addr #1

Transformation en assembleur (Backend)

Voici la commande qui permet de transformer le code intermédiaire optimisé en code assembleur:

llc main.ll -o main.s

Le code obtenu sera cette fois dépendant de l'architecture cible. Une fois transformé en code machine, il se sera pas directement exécutable, il manque l'édition de lien.

LLVM

LLVM signifie Low Level Virtual Machine est une spécification d'une représentation intermédiaire (LLVM-IR) accompagnée d'un ensemble d'outils qui communiquent autour de cette représentation.

LLVM se compose de modules, son langage est de type RISC fortement typé et non signé - certaine opération le sont par contre comme div et sdiv.

Prenons comme exemple le code C suivant :

double polynome(float x) {
  return 2*x*x*x + 7*x*x + 9*x + 1234;
} 

Voici la représentation LLVM :

; Function Attrs: mustprogress nofree nosync nounwind readnone willreturn uwtable
define dso_local double @polynome(float noundef %0) local_unnamed_addr #0 {
  %2 = fmul float %0, 2.000000e+00
  %3 = fmul float %2, %0
  %4 = fmul float %0, 7.000000e+00
  %5 = fmul float %4, %0
  %6 = tail call float @llvm.fmuladd.f32(float %3, float %0, float %5)
  %7 = tail call float @llvm.fmuladd.f32(float %0, float 9.000000e+00, float %6)
  %8 = fadd float %7, 1.234000e+03
  %9 = fpext float %8 to double
  ret double %9

Branchements

Il n'y a pas de structure de contrôle comme dans les langages de plus haut niveau, mais certaines instructions permettent des branchements (conditionnels ou non) comme br, ret, switch, invoke, resume ...

Prenons comme exemple :

void then_(int);
void else_(int);
void if_then_else(int a, int b, int c) {
  if(a) then_(b);
  else else_(c);
} 

le code correspondant en représentation intermédiaire:

  %4 = icmp eq i32 %0, 0
  br i1 %4, label %6, label %5

5:                                                ; preds = %3
  tail call void @then_(i32 noundef %1) #2
  br label %7

6:                                                ; preds = %3
  tail call void @else_(i32 noundef %2) #2

Static Single Assignement et PHI-Node

Les instructions LLVM prennent la forme de SSA ce qui signifie principalement qu'une variable peut être assignée une seule fois. C'est ici que les PHI-Nodes entrent en jeu.

Prenons le code C suivant:

a = 1;
if (v < 10)
    a = 2;
b = a;

Le code LLVM-IR correspondant est le suivant:

a1 = 1;
if (v < 10)
    a2 = 2;
b = PHI(a1, a2);

L'instruction b = PHI(a1, a2) permet de faire une affectation conditionnelle de b. Le fonctionnement de phi est le suivant:

%10 = PHI i32 [valeur, label] [valeur, label]

PHI peut faire référence à des variables non déclarées.

Mémoire

LLVM-IR dispose de quelques instructions pour l'accès à la mémoire comme load, store, cmpxchg,

Types complexe

LLVM-IR dispose de plusieurs types complexe comme:

  • les vecteurs sous la forme <4 x i32> représentant 4 entiers de 32 bits;
  • les tableaux sous la forme i32[10]
  • les structures sous la forme my_struct = type { i32, i32}

Les exceptions

LLVM-IR permet la gestion des exceptions, mais nous n;utiliserons pas ces mécanismes dans le cadre de ce cours. LLVM dispose de fonction intrinsèques pour la gestion des exceptions préfixée par llvm.eh. Toutes les fonctions disponibles sont référencées sur cette page

L'obfuscation

L'obfuscation a pour but principal de ralentir au maximum l'opération de reverse engineering. Il est souvent question de protéger les parties les plus sensibles, celle contenant des clés de chiffrement, des algorithmes etc.

Cette protection sera de toutes manières éphémère et elle a un prix, voire même plusieurs:

  • exécution plus lente
  • consommation mémoire alourdie
  • binaire plus volumineux

Il faut alors trouver un compromis. Nous allons voir les techniques utilisées.

Obfusquer les instructions

Il est question de remplacer les instructions élémentaires par des équivalent par exemple:

A + B == (A & B)<<1 + (A ^ B)
A - B == (A & -B)<<1 + (A ^ -B)
A ^ B == A + B - (A & B)<<1

Prédicat opaques

Il existe plusieurs façon d'opacifier certaines partie du code. Il est pas exemple possible d'ajouter du code mort : une condition toujours vérifiée mène au code "correct" :

int predicat = F(x); // F(x) est toujours vrai
if ( predicat ) {
    // donc on exécutera toujours le bon code
    good_code();
}
else {
    // et ça c'est pour perdre le 'reverser'
    useless_insanely_complex_code();
}

Il est aussi possible de remplacer certaines fonctions mathématiques par certaines autres par exemple:

log2(x) == log10(x) - log10(2)

Il est aussi possible des constantes opaque, trouver par exemple pi avec la formule suivante:

pi = 4 * (1 - 1/3 + 1/5 - 1/7 + ... + 1/N);

Après suffisament d'itération, la marge d'erreur est en dessous de 0,2.

Tester ses obfuscations

Il est très important de tester les obfuscations déjà pour ne pas introduire de bugs, mais aussi pour vérifier qu'elles survivent aux optimisations. Il est possible de faire des tests unitaires, des tests par fuzzing, test de reproductibilité.

Il faut savoir que certaines optimisations effectuées par les compilateurs peuvent faire penser à des obfuscations. La division etant très couteuses, elle est remplacée par une série d'opérations plus rapide.

Premiers TP

Il est question ici de modifier le code intermédiaire LLVM en utilisant son API. Nous allons manipuler l'IR. LLVM est capable de faire de l'instrospection par exemple:

// est ce que I est une fonction
isa<CAllInst>(I);

Analyse et obfuscation dynamique

Un "reverver" va à un moment donné analyser un binaire lors de son exécution, le cas le plus simple est l'utilisation d'un debogeur logiciel (gdb ou x64dbg par exemple). Mais il est aussi possible de lancer le binaire dans un émulateur (l'analyste a alors un contrôle total de l'environnement d'exécution).

En tant que défenseur nous voulons essayer d'éviter de détecter les éléments suivants:

  • Les débogueurs;
  • L'instrumentations -- exécutions dans des environnements spécifiques comme les machines virtuelles;
  • Les modifications de codes.

Gdb par exemple utilise ptrace, mais ce dernier ne peut être lancé qu'une seule fois, il est alors possible de détecter s'il est déjà lancé. La détection de points d'arrêts (breakpoint) est plus complexe, surtout sur l'architecture x86 car les instructions sont de taille variable.

Dans un autre registre, il est possible de vérifier l'intégrité du code via des fonction de hashages : on créée un condensat de la fonction que l'on stocke dans le binaire. Au moment de l'exécution on compare le condensa stocké avec celui de la fonction calculé lors de l'exécution. Cette méthode comporte beaucoup de contraintes:

  • Du travail à effectuer au niveau du linker;
  • Il faut gérer la relocation;
  • Le coût au nouveau des ressources est important.

Quand?

Il est important de déterminer le moment opportun pour effectuer les différentes vérification.

Au démarrage par exemple? Il y a alors peu d'impact sur les performances mais les protections sont faciles à détecter (ptrace par exemple).

Périodiquement? Mais il existe un risque d'injecter des vérifications dans du code chaud, ou encore de ne jamais voir le code de vérification s'exécuter.

Réponse à une attaque

Une fois une attaque sur le code détectée que faire?

  • Ralentissement;
  • comportements aléatoires;
  • Crash de l'application, que se soit dès l'attaque ou plus tard histoire de rendre la protection plus difficile à identifier;

Il peut être utile de vider la pile avant de planter complètement l'application, histoire de compliquer le travail du reverser.

Les builtins

Ce sont des vérifications intégrées au binaire. Ces vérifications n'existent pas dans le code source original. En tant que défenseur, il est possible d'écrire du codes dans l'IR, autant dire que c'est long et fastidieux!. Il est aussi possible de faire de la compilation à la volée, cette solution semble idéale mais elle est compliquée : dans le cas de LLVM il faut faire appel à clang dans une passe (LLVM permet dispose d'option de JIT)

Il est aussi possible de passer par des objets pré-générés qui permet une frontière claire entre la bibliothèque de protections et le code à protéger. Il faut aussi faire en fonction du runtime.

*[JIT]: Just In Time

L'exécution symbolique

Il est ici question de se placer du côté du reverser. Ici il faut travailler avec un solveur [z3][z3] par exemple, nous lui fournissons une représentation du code, les résultats que nous voudrions obtenir et il se charge de trouver les entrées.

[binaire] --> [représenation] --> [solveur]

Cette approche gagne en complexité avec les structures de contrôles. Il est alors nécessaire de déplier les boucles par exemple.

Protection par machine virtuelle

Nous avons déjà évoqué ce type de protection, le binaire contient une machine virtuelle qui interprètera un bytecode spécifique. Il est même possible d'ajouter un niveau supplémentaire de protection en chiffrant le bytecode par exemple.

En pratique, ce type de protection est très peu utilisé.

Triton

Triton est un bibliothèque d'analyse de binaire utilisés dans l'ingénierie inverse. Cependant il contient certaines limites : approximations, boucles etc.

Il est utile pour "casser" les machines virtuelles : Triton construit une représentation intermédiaire du binaire (ou de certaines parties) et explore les différents chemins. Il peut même construire du code LLVM-IR.