AFFICHER CET ARTICLE EN MODE PAGE ENTIERE

SOMMAIRE

1) Introduction

2) Pourquoi le polymorphisme ?

3) Voyage au centre du Polymorphisme

4) L'assembleur, de l'instruction à l'octet

5) L'assembleur, de l'octet au bit

6) Masquage de bits

7) Rafinement du polymorphisme d'instruction

8) Note concernant Windows

9) Conclusion


 

1) Introduction

Retour dans les années 95. Je lisais beaucoup de documents sur l'assembleur, m'intéressant plus particulièrement aux techniques de furtivité utilisées par les virus ms-dos. Entre autres choses, j'avais trouvé un article qui traitait de ce dont je vais parler. Depuis, je n'ai pas particulièrement entendu reparler de cette technique. Pourtant, je l'a trouve intéressante. Primo car elle peut s'avérer efficace, secundo parce qu'elle entraine encore plus loin dans la compréhension de la machine.

 

 


2) Pourquoi le ploymorphisme

Ah si j'étais invisible... Serais-je un gentil ou un méchant ? Avec les pleins-pouvoirs de l'invisibilité, je pense que je verserais plutôt du côté obscur. Ben quoi ? Et j'aurais beau croiser des policiers ou des douaniers, je n'aurais rien à craindre. Impossible de me reconnaitre. Quelle belle vie pour un méchant !

Dans l'ordinateur, c'est aussi la guerre. Antivirus et moniteurs de comportement y tiennent lieu de policiers et douaniers. Et si M. Phelps se met un bout de caoutchouc sur la figure pour leurer l'adversaire, le programme malicieux peut également recourir au postiche et se grimer. Là est l'art du polymorphisme.

Les antivirus se basent principalement sur des signatures. Une signature, ça peut être des séquences d'octets connues dans le code d'un programme dangeureux. Ca peut également être un CRC opéré sur tel bout du code. Le principe est donc de rendre le code du programme le plus insaisissable possible afin d'empêcher au maximum qu'une chaîne de signature ou un CRC puisse être constitué.

Le polymorphisme est un moyen utilisé pour déguiser le contenu d'un programme. Polymorphe -> plusieurs formes.

L'autre méthode antivirale est l'analyse heuristique. Elle est moins utilisée car plus gourmande en ressources. Raoul veut bien installer Norton sur son PC, mais il ne faut pas que Norton fasse ralentir Need For Speed. Un pc c'est fait pour jouer, bordel ! Dommage car elle est assez efficace. Axée "micro reverse engineering", elle analyse le code d'un programme et peut y déceler des comportements suspects, comme tenter d'écrire sur le secteur de boot du disque dur, ou même détecter une boucle de cryptage implémentée sans discrétion. Du temps de Windows 3.11, je ne sais plus lequel de F-Prot ou TBAV intégrait une analyse heuristique assez efficace. A l'époque, je praticais beaucoup l'assembleur 16 bits et cet anti-virus était pour moi un bon test de discrétion.

Cette méthode génère aussi son lot de faux positifs, et Raoul risquerait de ne plus acheter ses mises à jour d'antivirus si son Norton lui répète tous les jours que Format et Fdisk sont des programmes dangeureux. Raoul, je dois les effacer ou les ignorer ? Heuuuuu... N'oublions pas : fdisk, Raoul il connait pas.

C'est l'universelle loi du marché : incompétence et paresse des milliards de clients sont les ennemis de la performance d'un pauvre petit produit. Au passage, je fais remarquer que ce principe s'applique en politique et en culture également. D'ailleurs qui soutiendrait que culture et politique ne sont pas des marchandises ?


 

3) Voyage au centre du Polymorphisme

Je vous invite à un peu d'histoire. Rapidement, on va passer en revue les grandes familles de techniques de brouillage de code, depuis le cryptage jusqu'au polymorphisme.

Un programme avec une partie de son code brouillé par une fonction de cryptage n'est pas à proprement parler polymorphique : si c'est toujours ce même programme avec cette même boucle de cryptage qui est utilisée, alors cryptage ou pas il n'existe bel et bien qu'une seule forme. Et "un" c'est pas "plusieurs". Sisi j'insiste ;) . C'est un axiome fondamental pour le polymorphisme.

Le cryptage sert pour brouiller des informations, pour les rendre illisibles. Par exemple, crypter les chaines de caractères d'un programme pour éviter que Raoul, armé de Hex Edit, ne s'amuse à mettre son nom partout à la place du votre, le programmeur, avec le même Q.I. qu'un chien qui pisse sur les lampadaires. Pour éviter aussi que Norton n'utilise comme signature pour votre programme malicieux la subtile pensée dont vous êtes si fier et que vous imposez à la vue de vos victimes juste avant leur mort : "je té niké béto chuis mailleur ke toua". Une bien humiliante postérité peut s'éviter avec un simple XOR, sinon le monde entier apprendra dans les pages du Virus Informatique que DeathBringerKingOfRulez écrit aussi mal le français que le C++. Mais passons...

Routine de décryptage
Partie cryptée

 

 

Une des premières améliorations a été de transmettre, par exemple à chaque nouvelle infection d'un virus, une version du programme crypté avec une nouvelle valeur. Les variantes sont multiples comme le compteur de millisecondes tiré de l'horloge de l'ordinateur. Le code crypté change bien d'apparence à chaque fois. On a obtenu un polymorphisme de clé de cryptage.

Routine de décryptage
Clé variable
Partie cryptée

 

Puis on s'est rendu compte que la routine de décryptage suffisait à établir une signature. Se faire trahir par ses propres armes, quel coup du sort ! On a donc fourni au programme un stock de 4 ou 5 routines de décryptage parfaitement compatibles entre elles. Lors d'une nouvelle infection, on change la clé de cryptage mais on extrait du programme une nouvelle fonction de décryptage. Ainsi, cette fonction ne peut plus être utilisée pour établir une signature. On a du polymorphisme de fonction.

+-------------------------------------+
| Une des routines de décryptage _____|
+-------------------------------------+
| Clé variable _______________________|
+-------------------------------------+
+-------------------------------------+
| Partie cryptée _____________________|
| +----------------------------------+
| | Stock crypté des autres routines |
| | de décryptage ___________________|
+--+---------------------------------+

 

Certains antivirus ont alors décidés de mettre 4 ou 5 signatures pour un même programme dangeureux, une par routine de décryptage. La stratégie a donc été de brouiller les routines. Toutes les deux ou trois instructions assembleur, le programme comporte quelques octets en plus. A chaque réplication, le programme y écrit les valeurs d'instructions qui ne perturbent pas le cours de a routine, comme un NOP, différentes à chaque fois. Le polymorphisme descend au niveau de l'instruction. La fonction elle-même change d'apparence tout en restant opérationnelle.

Un exemple de routine de décryptage :

+---------------+......
| Zoom _________|......
| +-------------+......
| | valide _____|......
| | remplissage |......+-------------------------------------+
| | valide _____|......| Une des routines de décryptage _____|
| | valide _____|......+-------------------------------------+
| | remplissage |......| Clé variable _______________________|
| | valide _____|......+-------------------------------------+
| | remplissage |......+-------------------------------------+
| | remplissage |......| Partie cryptée ____________________|
| | ________... | .... | +----------------------------------+
| | ________... | .... | | Stock crypté des autres routines |
+--+------------+..... | | de décryptage ___________________|
_______________________+-+----------------------------------+

Pour finir cette fuite vers la finesse des altérations polymorphiques, reste le sujet de ce papier : le polymorphisme au niveau sub-instruction, par motif de bits.


 

 

4) L'assembleur, de l'instruction à l'octet

Voici un exemple de code assembleur. Court et simple, il se contente de pousser la chaîne MessageBoxA dans un stack frame. L'exemple en soi est stupide, mais c'est sur celui là que j'ai commencé à travailler donc je continue avec. A côté des instructions assembleur, j'ai inscris les valeurs des octets correspondants.

mov eax,'sseM' B8 4D 65 73 73
mov [ebp-0x1c], eax 89 45 E4
mov eax,'Bega' B8 61 67 65 42
mov [ebp-0x18], eax 89 45 E8
mov ax,'xo' 66 B8 6F 78
mov [ebp-0x14], ax 66 89 45 EC
xor ax,ax 66 33 C0
mov al,'A' B0 41
mov [ebp-0x12], ax 66 89 45 EE

Les octets des mnémoniques assembleur sont lues et décodées par le processeur. Basiquement, une instruction assembleur se décompose de la manière suivante :

OPERATION
mode d'adressage
parametre 1
parametre 2

On peut décomposer la première ligne d'instruction de l'exemple. B8 contient, aux yeux du processeur, les informations disant qu'ici commence une instruction assembleur qui charge un registre 32 bits avec une valeur immédiate, et que ce registre est EAX.

OPERATION
mode d'adressage
parametre 1
parametre 2
MOV EAX valeur immédiate
'sseM'
__rien
B8
4D 65 73 73
 

Maintenant, voici la même routine mais quatre fois de suite. A chaque coup, je change le registre de travail utilisé par la routine. Dans sa grande majorité, les octets codant les opérations sont similaires. On décèle juste quelques petites variations, dont on vient de voir l'explication.
Entre un mov eax et un mov ebx, seul change de quoi dire au processeur quel est le registre ciblé. J'ai mis entre parenthèses les différences à observer :

//Travail avec EAX
mov eax,'sseM' (B8) 4D 65 73 73
mov [ebp-0x1c], eax 89 (45) E4
mov eax,'Bega' (B8) 61 67 65 42
mov [ebp-0x18], eax 89 (45) E8
mov ax,'xo' 66 (B8) 6F 78
mov [ebp-0x14], ax 66 89 (45) EC
xor ax,ax 66 33 (C0)
mov al,'A' (B0) 41
mov [ebp-0x12], ax 66 89 (45) EE

//Travail avec EBX
mov ebx,'sseM' (BB) 4D 65 73 73
mov [ebp-0x1c], ebx 89 (5D) E4
mov ebx,'Bega' (BB) 61 67 65 42
mov [ebp-0x18], ebx 89 (5D) E8
mov bx,'xo' 66 (BB) 6F 78
mov [ebp-0x14], bx 66 89 (5D) EC
xor bx,bx 66 33 (DB)
mov bl,'A' (B3) 41
mov [ebp-0x12], bx 66 89 (5D) EE

//Travail avec ECX
mov ecx,'sseM' (B9) 4D 65 73 73
mov [ebp-0x1c], ecx 89 (4D) E4
mov ecx,'Bega' (B9) 61 67 65 42
mov [ebp-0x18], ecx 89 (4D) E8
mov cx,'xo' 66 (B9) 6F 78
mov [ebp-0x14],cx 66 89 (4D) EC
xor cx,cx 66 33 (C9)
mov cl,'A' (B1) 41
mov [ebp-0x12], cx 66 89 (4D) EE

//Travail avec EDX
mov edx,'sseM' (BA) 4D 65 73 73
mov [ebp-0x1c], edx 89 (55) E4
mov edx,'Bega' (BA) 61 67 65 42
mov [ebp-0x18], edx 89 (55) E8
mov dx,'xo' 66 (BA) 6F 78
mov [ebp-0x14], dx 66 89 (55) EC
xor dx,dx 66 33 (D2)
mov dl,'A' (B2) 41
mov [ebp-0x12], dx 66 89 (55) EE

Seul un octet de chaque instruction est modifié. L'idée au coeur du polymorphisme sub-instruction est de modifier chaque octet sensible afin que toute une routine se mette à travailler avec EAX au lieu de EBX, puis EDX lors de la prochaine infection. La routine reste 100% fonctionnelle, sauf que son registre de travail principal change, et donc sa représentation en octet devient variable.

Le souci est que pour chaque instruction il faut stocker les 4 octets à utiliser : dans le cas de EAX, EBX, ECX et EDX. Rien que dans le court exemple, 4 octets sont modifiés lorsqu'on change de registre, multipliés par 4 registres, c'est un jeu de 16 octets qu'il faut avoir sous le coude en guise de maquillage. Plus un bout de code pour veiller à la mutation de la routine.

On arrive à cette modélisation du corps du programme :

+-------------------------------------+
| Une des routines de décryptage _____|
+-------------------------------------+
| Clé variable _______________________|
+-------------------------------------+
+-------------------------------------+
| Partie cryptée ____________________|
| +----------------------------------+
| | Stock crypté des autres routines |
| | de décryptage ___________________|
| +----------------------------------+
| | Moteur de motation polymorphique |
| | avec octets de remplacement et __|
| | adresse des emplacements ________|
| | à modifier ______________________|
+--+---------------------------------+

 

5) L'assembleur, de l'octet au bit

Pour éviter de se trimbaler une énorme base de données des octets par fonction et par registre, un coup de microscope va s'avérer payant. Voici un détail des 4 octets correspondant aux 4 registres utilisés dans la fonction d'exemple, ainsi que leurs équivalents en binaire.

EAX
ECX
_EDX
EBX
VARIATIONS
B8 10111000
45 01000101
C0 11000000
B0 10110000
B9 10111001
4D 01001101
C9 11001001
B1 10110001
BA 10111010
55 01010101
D2 11010010
B2 10110010
BB 10111011
5D 01011101
DB 11011011
B3 10110011
2 bits : 101110xx
2 bits : 010xx101
2x2 Bits : 110xx0xx
2 Bits : 101100xx
bits à 00
bits à 01
bits à 10
bits à 11

 

Là, révélation. Les motifs binaires montrent bien que seuls deux bits sont utilisés pour coder l'un des quatre registres qui est utilisé.

On a vu comme une instruction assembleur était structurée en éléments que le processeur reconnait et décode. On a vu comme ces parties étaient parfois représentées par le même octet. Maintenant, en entrant au coeur du motif binaire de l'instruction, on retombe sur nos pieds et on arrive à identifier un élément en particulier dans un octet. Au coeur de l'octet représentant l'instruction, on se retrouve tout simplement en face d'un bête compteur sur deux bits, un véritable index du registre à utiliser.

6) Masquage de bits

A ce stade, il est facile de repérer par quelles manipulations logiques on peut transformer une opération sur un registre en la même opération sur un registre différent. Dans le cas qui nous concerne pour chaque registre il y a deux formules distinctes et un cas où les deux sont à utiliser à la suite.


Quelques rappels de logique binaire, avant tout :
Mise à zéro d'un bit : ? and 0 -> 0
Mise à un d'un bit : ? or 1 -> 1


Armé de ces rappels, effectuer la translation de registre d'une instruction, et par extension d'une routine, ne pose plus réellement de problème. A partir de n'importe quelle valeur initiale parmi les quatre, il est possible d'obtenir n'importe quelle autre de ces valeurs au moyen de masquages de bits. Le tableau suivant récapitule les masquages à faire, et souligne les deux formules qu'on peut alors utiliser :

EAX
ECX
EDX
EBX
Fonct.
Formule
and FC
and FD or 1
and FE or 2
or 3
mov reg64, "xxxx"
1
and (FC<<3)
or (1<<3)
and (FD<<3)
or (2<<3)
and (FE<<3)
or (3<<3)
mov [ebx+x], reg
2
and FC
and (FC<<3)
and FD or 1
and (FD<<3)
or (1<<3)
and FE or 2
and (FE<<3)
or (2<<3)
or 3
or (3<<3)
xor reg16, reg16
Formule 1 + 2
1 + 2
and FC
and FD or 1
and FE or 2
or 3
Mov reg8, "x"
1

En appliquant le bon lot de formules selon la translation de registre souhaitée, on modifie le registre utilisé par le bout de code. Il faut juste se souvenir qu'à tel offset du code de la routine il faut utiliser la formule 1, 2, ou bien les deux simultanément. Pour cela, une petite table permet de dire qu'à tel offset de la routine on doit appliquer telles formules.

Offset
Formule(s)
0d
6d
8d
14d
17d
22d
26d
27d
31d
1
2
1
2
1
2
1+2
1
2

 

 

 

7) Rafinement du polymorphisme d'instruction

On a vu que des fonctions peuvent incorporer des instructions ne servant à rien, seulement à brouiller d'avantage les signatures. Avec la méthode du motif de bit, on gagne encore plus de flexibilité.

Le moteur de mutation doit alors s'assurer de deux paramètres : _-.Les instructions "réelles" de la fonction doivent subir un masquage de bits afin de fonctionner avec un registre principal "actif" qui servira pour le bon déroulement.

_ç_________________________________________________________________________.-.Les instructions "pipeau" de la fonction doivent subir un masquage de bits afin de fonctionner avec un des trois autres registres principaux, pouvant ainsi faire n'importe quoi sans altérer le registre de travail "actif".

8) Note concernant Windows

Entre DOS et Windows, que de changements. L'un d'entre eux concerne directement ce domaine. Sous DOS, rien n'empêche de manipuler le code comme s'il s'agissait de données, et donc de modifier directement les octets du segment de code. Cette possibilité permettait une grande souplesse dans la mutation de code ainsi que de mettre en place des stratégies anti-débuggage jouant sur le prefetch des processeurs, assez répandus sur les 486.

Sous Windows NT, le code est le code, et les données sont les données. On ne mélange pas les torchons et les serviettes, pas vrai ? Le gestionnaire de pages de mémoire virtuelle de Windows NT permet de bloquer l'accès en lecture ou écriture sur certains segments. Et il est de coutume que le segment de code soit protégé contre l'écriture.

Deux solutions : soit penser à déverrouiller la page de code au moyen des API adaptées, soit travailler dans des buffer contenus dans le segment de données, stack ou heap.

9) Conclusion

Le polymorphisme sub-instruction s'avère assez aisé à mettre en place. Combiné avec les autres niveaux où peut intervenir le polymorphisme, les résultats sont très satisfaisants. Mon but n'est pas de donner un outil clef en main, et dont la compréhention est en option. Donc pas plus de code que ça.

Intel a publié un pdf nommé "instruction set reference". Je m'attendais à y trouver facilement les motifs de bits des instructions en fonction des registres mis en jeu, mais son usage n'est franchement pas très aisé. La méthode empirique reste encore plus facile.


J'espère que cette plongée au coeur de l'instruction vous aura rappellé l'Aventure Intérieure !

 

 

BY TOLWIN

Copyright © 2004 ARENHACK - DHS


HAUT DE PAGE