L'obfuscation
Bonsoir à tous.
L'optique de ce tutoriel est de définir et expliquer de manière pratique l'ensemble des méthodes d'obfuscation du code source, c'est à dire comment altérer celui-ci avant l'étape de compilation afin de rendre la lisibilité après décompilation la plus ardue possible, et surtout de complexifier le travail du désobfuscateur.
I. Introduction : les finalités de l'obfuscation
Tout développeur partageant ses applications recherche avant tout la sécurité et la protection de celles-ci. En effet, depuis ces dernières années la tendance à l'analyse systématique des applications a vu naitre une guerre directe entre coding et reverse-engineering (RE). Le RE désigne l'ensemble des procédures techniques visant à décompiler les applications dans le but d'en retrouver le code source, ou, à tout le moins, les suites d'instructions globales permettant d'en reconstituer l'architecture initiale.
Pour s'en protéger, il existe une multitude de méthodes permettant de protéger relativement efficacement le code source des applications distribuées. Ainsi, l'on distingue la protection restrictive (application développée pour une architecture hyper-spécifique), le chiffrement ou crypting (chiffrer le code source via des clés générées par algorithmes), l'exécution de code distant (scinder l'application en deux parties dont une distante) qui fera l'objet d'un autre turoriel, et l'obfuscation que nous traiterons dans ce tutoriel.
II. Techniques d'obfuscation
Nous allons donc étudier les différentes procédures classiques existantes permettant l'obuscation du code source. Ces procédures possèdent chacune un coefficient d'efficacité différent, c'est pour cela qu'elles sont souvent hybridées afin de rechercher une obfuscation maximale.
A. La suppression des commentaires
C'est la base de l'apprentissage du développeur : commenter précisément son code. Cependant, lors de la décompilation, ces commentaires apparaissent bien évidemment en clair et donnent de précieuses informations quant au code. La première étape consiste donc à enlever tous les commentaires du code source.
Avant suppression des commentaires :
Code:
#include <stdio.h> // intégration de la bibliothèque standard #include <math.h> // intégration de la bibliothèque mathématiques int main(int argc char *argv ) // fonction principale { int a, b=10, c[10]; // déclaration des entiers a b, et du tableau c for (a=0;a<10;a++) // boucle sur a qui s'incrémente pendant 10 tours { c[a]=b-a; // remplissage des cases du tableau via le résultat de la soustraction b-a } return 0; // la fonction retourne 0, tout s'est bien passé }
Après suppression des commentaires :
Code:
#include <stdio.h> #include <math.h> int main(int argc char *argv ) { int a, b=10, c[10]; for (a=0;a<10;a++) { c[a]=b-a; } return 0; }
B. Suppression de la structure formelle
La structure formelle, ou style, désigne l'ensemble des règles de développement que respectent les programmeurs pour plus de clarté : indentation, nombre de caractère par ligne, nombre de caractère par colonne... Elle assure une lisibilité et une compréhension optimales du code source. Supprimer ces règles permettent de rendre la lecture plus complexe à la décompilation.
Avant suppression du style :
Code:
#include <stdio.h> #include <math.h> int main(int argc char *argv ) { int a=0; int b=10; int c[10]; for (a=0;a<10;a++) { c[a]=b-a; } return 0; }
Après suppression du style :
Code:
#include <stdio.h> #include <math.h> int main(int argc char *argv ){ int a, b=10, c[10]; for (a=0;a<10;a++) {c[a]=b-a;} return 0;}
C. Suppression des tests de debugging
Afin de pouvoir mener des analyses de performances efficaces, le développement nécessite l'intégration d'instructions de debugging après chaque fonctions « sensibles » dans le but de traquer le plus rapidement possible les erreurs, les bugs, et de fournir à l'utilisateur un rapport explicatif sur les interruptions non-prévues. Ces tests fournissent des informations précieuses lors de la décompilation, notamment en permettant de remonter à l'agencement cohérent des fonctions natives. Les supprimer augmente la difficulté de reconstruction du code source.
Avant suppression des tests de debugging :
Code:
#include <stdio.h> #include <winsock2.h> int main(int argc char *argv ) { int OnStart=WSAStartup(MAKEWORD(2,2),&initialisation_win32); if (OnStart!=0) printf("\nUnable to initialize WSAStartup : %d %d",erreur,WSAGetLastError()); else printf("\nWSA → initialized !"); SOCKET thisSocket=socket(AF_INET,SOCK_STREAM,0); if (thisSocket==INVALID_SOCKET) printf("\nUnable to initialize socket : %d",WSAGetLastError()); else printf("\nSocket → initialized !"); } return 0; }
Après suppression des tests de debugging :
Code:
#include <stdio.h> #include <winsock2.h> int main(int argc char *argv ) { int OnStart=WSAStartup(MAKEWORD(2,2),&initialisation_win32); SOCKET thisSocket=socket(AF_INET,SOCK_STREAM,0); } return 0; }
D. Renommage des variables
Partie primordiale de l'apprentissage de tout développeur : donner des noms clairs et précis aux variables en rapport avec leur finalité. Le renommage des variables, ou refactoring, a pour but de modifier le nom de toutes les variables afin que lors de la décompilation, le lecteur ne puisse pas s'y retrouver. Il existe trois méthodes de refactoring : le reading violation, l'overload et le random processing.
Avant refactoring :
Code:
int calculerAddition(int premierTerme, int deuxiemeTerme) { static int resultat = 0; resultat = premierTerme+deuxiemeTerme; return resultat; }
Après reading violation :
Code:
int #xxx(int ~#yyy, int @#zzz) { static int ~www = 0; ~www = ~#[email protected]#zzz; return ~www; }
Après overload :
Code:
int x(int xx, int xxx) { static int xxxx = 0; xxxx = xx+xxx; return xxxx; }
Après random processing :
Code:
int U98vxZw65hfRtBB2gom(int wIhu74hdfJd09k, int 9Yxc3qzQdaf98jkj) { static int o7hNuyGDR52ewA = 0; o7hNuyGDR52ewA =wIhu74hdfJd09k+9Yxc3qzQdaf98jkj; return o7hNuyGDR52ewA; }
E. Transformation superglobale
Dans un code source bien construit, chaque variable possède une fonction précise et est donc déclarée localement, c'est à dire dans le bloc où elle est utilisée. Afin de perturber la construction du schéma d'intégration du desobfuscateur, l'idée est ici d'élever une majorité de variables locales en superglobales dans le but de complexifier le schéma d'assimilation du desobfuscateur.
Avant transformation superglobale :
Code:
#include <stdio.h> #include <string.h> int calculerFluxBinaire(int byte, int facteur) { int maximum = 500; unsigned int pile = maximum; int upPile = pile+byte; int downPile = pile-byte; int quadraticMoy = (upPile+downPile)*facteur; return quadraticMoy; } void ordonnerTableau() { int periph[2][8]; int iPeriph = 0, jPeriph = 0; for(iPeriph=0;iPeriph<2;iPeriph++) { for (jPPeriph=0;jPeriph<8;jPeriph++) { periph[iPeriph][jPeriph] = (iPeriph+jPeriph)-iPeriph; } } }
Après transformation superglobale :
Code:
#include <stdio.h> #include <string.h> int maximum, pile, upPile, periph[0][0], downPile, quadraticMoy; #define MINT 2 #define MAXT 8 #define MAX 500 int calculerFluxBinaire(int byte, int facteur) { maximum = MAX; pile = maximum; upPile = pile+byte; downPile = pile-byte; quadraticMoy = (upPile+downPile)*facteur; return quadraticMoy; } void ordonnerTableau() { periph[MINT][MAXT]; for(upPile=0;upPile<MINT;upPile++) { for (quadraticMoy=0;quadraticMoy<MAXT;quadraticMoy++) { periph[upPile][quadraticMoy] = (upPile+quadraticMoy)-upPile; } } }
F. Dead instructions
L'intégration de dead instructions, code mort ou encore code silencieux, permet d'augmenter la taille du code, sans réduire la vitesse d'exécution de l'application, mais en gênant très fortement le schéma d'analyse du desobfuscateur, ce qui provoque le plus souvent des erreurs de logique de sa part lors du rendu final. Le code silencieux désigne l'ajout de code complètement inutile au sein des instructions valides.
Avant ajout de code silencieux :
Code:
#include <stdio.h> #include <string.h> int main() { char entryText[50]; char textButton[]="Validate"; int buffer=strlen(textButton); scanf("%s",&entryText); entryText[strlen(entryText); strcat(textButton,entryText); }
Après ajout de code silencieux :
Code:
#include <stdio.h> #include <string.h> int main() { char entryText[50]; entryText[50-5]; unsigned static int coeff=5; char textButton[]="Validate"; entryText[45+coeff]; int buffer=strlen(textButton); buffer+strlen(textButton); scanf("%s",&entryText); buffer=coeff-2; entryText[strlen(entryText)]; strcat(textButton,entryText); }
G. Chiffrement des caractères
Le chiffrement des chaines de caractères, afin qu'elles n'apparaissent pas en clair, permet d'alourdir le schéma d'intégration du desobfuscateur qui souvent fini par tout simplement sauter l'étape de rendu de ces chaines.
Avant chiffrement :
Code:
#include <stdio.h> int main() { char textBox[]="Entrez votre login"; char textInfoBulle[]="Champ de login"; char textCancelButton[]="Annuler"; int rectX, rectY, rectZ; recupererCoord(); if(rectX>5&&rectX<80) activateButton(); return 0; }
Après chiffrement :
Code:
#include <stdio.h> int main() { char textBox[]="äÒÏɬ×Ä"; char textInfoBulle[]="ß¡ÂÓÒÍØÖ"; char textCancelButton[]="ÙÆÈÕÈØäÒÏ"; int rectX, rectY, rectZ; recupererCoord(); if(rectX>5&&rectX<80) activateButton(); return 0; }
H. Boucles noyées
La noyade de boucles consiste à intégrer des étapes supplémentaires sans effet dans la boucle de contrôle principale de la condition afin d'obliger le desobfuscateur à mobiliser sa puissance de calcul dans la fabrication du schéma conditionnel.
Avant noyade :
Code:
#include <stdio.h> int main() { int k,l; for(k=0;k<50;k++) { for(l=0;<49;l++) { indexTab[k][l]=ajouterValeur(k-1,l-1); } } return 0; }
Après noyade :
Code:
#include <stdio.h> int main() { int k,l,K,L; for(K=50;K>1;K--) { for(k=0;k<50;k++) { for(L=50;L>1;L--) { for(l=0;l<L;l++) indexTab[k][l]=ajouterValeur(k-1,l-1); } } } } return 0; }
I. Clonage fonctionnel
Le clonage fonctionnel, ou cloning statement, désigne le fait de créer plusieurs fonctions ayant le même objectif, et d'y faire ensuite appel aléatoirement dans le code. Ceci oblige le desobfuscateur à constamment revoir son schéma d'intégration et fini par créer des incohérence dans le résultat final.
Avant clonage :
Code:
#include <stdio.h> int multiplication(int premierTerme, int deuxiemeTerme) { int resultat; resultat = premierTerme*deuxiemeTerme; return resultat; } int main() { multiplication(5,9); return 0; }
Après clonage :
Code:
int operationParProduit(int terme1, terme2) { int produitFinal; produitFinal=terme2*terme1; return produitFinal; } int terme1foisterme2(int unChiffre, int autreChiffre) { int resultatObtenu; resultatObtenu=unChiffre*autreChiffre; return resultatObtenu; } int unTrucParUnAutreTruc(int nimporteQuoi, int certaineChose) { int solutionDuTruc; solutionDuTruc=certaineChose*nimporteQuoi; return solutionDuTruc; } int main() { unTrucParUnAutreTruc(7,3); … operationParProduit(43,9); … terme1foisterme2(63,11); return 0; }
J. Explosion fonctionnelle
L'explosion fonctionnelle permet d'éclater une fonction principale en plusieurs petites fonctions remplissant chacune une tâche spécifique et subordonnées à une fonction de lancement. Le fait de répartir les opérations oblige le desobfuscateur à établir un schéma de liaison entre les différents appels et complexifie ainsi son rendu final.
Avant explosion :
Code:
#include <stdio.h> void keylogger() { char keysText[]={'A','B','C',D','E','F'}; char keysNum[]={1,2,3,4,5,6,7,8,9,0}; char keysSymb[]={'?','#','~','!','@'}; char mail[]="[email protected]"; char login[]="admin"; char mdp[]="root"; FILE* log=fopen("w","log.txt"); while(1){ if (toucheDown=='A') fprintf(log,'A'); … }
Après explosion :
Code:
#include <stdio.h> void definirTouches() { char keysText[]={'A','B','C',D','E','F'}; char keysNum[]={1,2,3,4,5,6,7,8,9,0}; char keysSymb[]={'?','#','~','!','@'}; } void definirID() { char mail[]="[email protected]"; char login[]="admin"; char mdp[]="root"; } void enregistrerTouches() { FILE* log=fopen("w","log.txt"); while(1){ if (toucheDown=='A') fprintf(log,'A'); … } void keylogger() { definirTouches(); definirID(); enregistrerTouches(); }
K. Parallélisme fonctionnel
La linéarité fonctionnelle, c'est à dire le séquencement logique des instructions, est un facteur de déroulement stable pour l'application et architecture donc de façon cohérente les différents appels fonctionnels. Le fait de paralléliser le séquencement, c'est à dire de répartir les appels au sein de plusieurs fonctions, augmente considérablement la fabrication de schéma d'intégration du désobfuscateur.
Avant parallélisme :
Code:
void keylogger() { char keysText[]={'A','B','C',D','E','F'}; char keysNum[]={1,2,3,4,5,6,7,8,9,0}; char keysSymb[]={'?','#','~','!','@'}; char mail[]="[email protected]"; char login[]="admin"; char mdp[]="root"; FILE* log=fopen("w","log.txt"); while(1){ if (toucheDown=='A') fprintf(log,'A'); … }
Après parallélisme :
Code:
#include <stdio.h> void A_keylogger_1() { char keysText[]={'A','B','C',D','E','F'}; char keysNum[]={1,2,3,4,5,6,7,8,9,0}; char keysSymb[]={'?','#','~','!','@'}; char mail[]="[email protected]"; char login[]="admin"; char mdp[]="root"; } void A_keylogger_2() { FILE* log=fopen("w","log.txt"); while(1){ if (toucheDown=='A') fprintf(log,'A'); … }
Voilà pour les principales méthodes d'obfuscation. Il en existe des dizaines d'autres comme le transtypage, le polymorphisme, l'héritage explosé ect ect.
Le prochaine cours portera sur l'appel de code distant comme protection du code source.
Commentaire