Add obfuscation lesson

This commit is contained in:
Yorick Barbanneau 2023-10-24 01:19:10 +02:00
parent aee8afcdf1
commit d129c59be2

View file

@ -0,0 +1,337 @@
---
title: "Sécurité système : Introduction à la compilation et obfuscation avec llvm"
date: 2023-10-12
tags: ["LLVM", "assembleur"]
categories: ["Sécurité système", "Cours", "TD"]
mathjax: 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:
```c
#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:
```shell
clang -Xclang -ast-dump -fsyntax-only main.c
```
le voici donc:
```text
`-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:
```shell
clang -S -emit-llvm -Xclang -disable-O0-optnone main.c -o
```
À ce niveau, le code obtenu n'est pas optimisé:
```llvm
; 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
intermediaire LLVM:
```shell
opt -S -O2 main.ll
```
Le code produit contient plus les élements jugés inutiles comme ici `argv` et
`argc` :
```text
; 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:
```shell
llc main.ll -o main.s
```
Le code obtenu sera cette-fois dépendant de l'architecture cible. Une fois
trasformé 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 rette répré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 :
```c
double polynome(float x) {
return 2*x*x*x + 7*x*x + 9*x + 1234;
}
```
Voici la représenation LLVM :
```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 :
```c
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 intermediaire:
```llvm
%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:
```c
a = 1;
if (v < 10)
a = 2;
b = a;
```
Le code *LLVM-IR* coorespondant est le suivant:
```llvm
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 fonctionement de phi est le suivant:
```llvm
%10 = PHI i32 [valeur, label] [valeur, label]
```
`PHI` peut faire référence à des variables non déclarées.
### Memoire
LLVM-IR dispose de quelques instructions pour l'accès à la mémoire comme `load`,
`store`, `cmpxchg`,
### Types complexe
*LLVM-IR* dispose de plusieurs rypes complexe comme:
* **les vecteurs** sour 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 das 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_eh_exception]
[l_eh_exception]: https://llvm.org/docs/ExceptionHandling.html#exception-handling-intrinsics
## 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 éphemè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:
```text
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 plusiers 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" :
```c
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 mathematiques 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:
```c
pi = 4 * (1 - 1/3 + 1/5 - 1/7 + ... + 1/N);
```
Après suffisement 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 faires 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:
```c
// est ce que I est une fonction
isa<CAllInst>(I);
```