Leçon 4.3 · Désassemblage & analyse de code· 50 min
Reconnaître les idiomes du compilateur
Ramener au C du code x86-64 optimisé : cadres, division par nombre magique, code sans branche, boucles, tables de sauts, structs et vtables.
Objectifs
- Lire les cadres de fonction Windows x64 et System V, y compris l’espace d’ombre, l’alignement et les canaris de pile
- Retraduire les idiomes arithmétiques en code source, et retrouver le diviseur caché dans une multiplication par un nombre magique
- Reconnaître conditions, boucles, tables de sauts de switch, accès aux structs et appels virtuels à -O0 et -O2
- Décider quand une fonction est du code de bibliothèque à identifier plutôt qu’à rétro-ingénierer
Un compilateur ne traduit pas le C ligne à ligne. Il puise dans un répertoire assez restreint de motifs d’instructions, les mêmes quelques dizaines de formes, encore et encore. Une fois ces formes connues, une page de désassemblage cesse d’être une liste d’instructions et devient une liste de constructions : « une table de sauts avec test de bornes », « une division non signée par 7 », « une boucle sur un tableau de structs de 32 octets ».
Les malwares (logiciels malveillants) passent par les mêmes optimiseurs que n’importe quel autre code : cette compétence s’applique donc à tous les échantillons. Fonctions et graphes de flot de contrôle a montré comment un outil regroupe les instructions en fonctions ; ici, nous lisons ce qu’elles contiennent, sur x86-64, d’abord avec l’ABI Windows x64, en signalant System V au passage. Tous les listings ont été produits avec GCC 15 (mingw-w64 pour le PE, un compilateur croisé Linux pour l’ELF), et vous les reproduirez dans le lab.
Le niveau d’optimisation change tout
Une même fonction C prend un aspect très différent selon le niveau d’optimisation :
| Niveau | Ce que vous voyez | Ce que cela signifie pour vous |
|---|---|---|
-O0 / MSVC /Od | Pointeur de cadre, chaque variable en mémoire, chaque argument recopié sur la pile, une instruction C par bloc | Facile à rapprocher du source, mais verbeux |
-O2 / MSVC /O2 | Fonctions sans cadre, valeurs dans les registres, boucles réécrites, appels inlinés ou transformés en sauts | Dense ; c’est ici que les idiomes comptent le plus |
-O3 | -O2 plus vectorisation et déroulement de boucles agressifs | Boucles SIMD suivies d’un code scalaire de « queue » |
-Os / MSVC /O1 | Optimisé pour la taille : séquences courtes, instructions de chaîne rep | Compact, mais avec des choix parfois étranges |
La plupart des malwares sont optimisés, mais des builds de débogage finissent
par fuiter. Un push rbp / mov rbp,rsp dans chaque fonction, avec les
arguments stockés en mémoire juste après le prologue, désigne du code -O0 ou
/Od.
Cadres de fonction
Prologue et épilogue
Une fonction non optimisée construit un cadre classique (voir la pile) :
<sum_array>: ; -O0, mingw-w64
push rbp
mov rbp,rsp
sub rsp,0x10 ; room for two locals
mov QWORD PTR [rbp+0x10],rcx ; home argument 1 (a)
mov DWORD PTR [rbp+0x18],edx ; home argument 2 (n)
...
add rsp,0x10
pop rbp
retÀ -O2, le pointeur de cadre disparaît. La fonction adresse tout relativement à
rsp, ou ne touche pas du tout à la pile. Une fonction qui en appelle d’autres
doit tout de même garder la pile alignée et, sous Windows, réserver de l’espace
pour les fonctions qu’elle appelle :
<count_down>: ; -O2, mingw-w64
push rsi ; save non-volatile registers it will use
push rbx
sub rsp,0x28 ; 0x20 shadow space + 8 bytes of alignment
...
add rsp,0x28
pop rbx
pop rsi
retDeux règles de Windows x64 expliquent ces nombres :
- Espace d’ombre. L’appelant réserve toujours 32 octets (
0x20) juste au-dessus de l’adresse de retour, un emplacement de 8 octets par argument passé en registre. L’appelé peut y stockerrcx,rdx,r8etr9. C’est pourquoi le listing-O0ci-dessus écrit ses arguments en[rbp+0x10]et[rbp+0x18]: avecrbppointant sur lerbpsauvegardé et l’adresse de retour en[rbp+8], ce sont les deux premiers emplacements de l’espace d’ombre. - Alignement.
rspdoit être aligné sur 16 octets à chaquecall. L’appel empile 8 octets ; à l’entrée,rspest donc décalé de 8 octets. Deuxpushplus0x28portent le total à8 + 16 + 40 = 64, un multiple de 16. Quand vous voyez unsub rsp,0x28ou0x38d’apparence bizarre, c’est presque toujours ce calcul.
Les registres empilés sont des registres sauvegardés par l’appelé (rbx, rbp,
rdi, rsi, r12 à r15 sous Windows), empilés uniquement s’ils sont
utilisés ; voir Conventions d’appel.
Arguments et variables locales
| Windows x64 | System V AMD64 (Linux, macOS) | |
|---|---|---|
| Arguments entiers et pointeurs | rcx, rdx, r8, r9, puis la pile | rdi, rsi, rdx, rcx, r8, r9, puis la pile |
| Arguments à virgule flottante | xmm0 à xmm3 (positionnels, partagés avec les emplacements entiers) | xmm0 à xmm7 (décompte indépendant) |
| Valeur de retour | rax / xmm0 | rax (et rdx) / xmm0 |
| Espace réservé par l’appelant | Espace d’ombre de 32 octets | Aucun, mais les fonctions feuilles peuvent utiliser une red zone de 128 octets sous rsp |
| Cinquième argument | [rsp+0x28] à l’entrée | Septième argument entier en [rsp+8] à l’entrée |
Dans une fonction -O2 sans cadre, les variables locales vivent en [rsp+x],
et les arguments qui ne tiennent pas dans les registres vivent au-dessus de
l’espace d’ombre. Dans du code -O0, les variables locales sont à des décalages
négatifs [rbp-x] et les arguments recopiés à des décalages positifs
[rbp+x]. Le moyen le plus rapide de savoir quelle ABI utilise un extrait : ce
qu’il touche en premier est rcx/edx sous Windows, rdi/esi sous System V.
Comparez la même fonction compilée des deux façons :
<get_size>: ; PE, Windows x64 <get_size>: ; ELF, System V
movsxd rdx,edx movsxd rsi,esi
shl rdx,0x5 shl rsi,0x5
mov rax,QWORD PTR [rcx+rdx*1+0x10] mov rax,QWORD PTR [rdi+rsi*1+0x10]
ret retLe movsxd étend le signe d’un index int 32 bits avant qu’il puisse entrer
dans le calcul d’une adresse (Extension de signe).
Rappelez-vous que Windows est LLP64 : long y fait 32 bits.
Canaris de pile
Une fonction dotée d’un tampon local porte généralement un cookie de pile :
une valeur secrète écrite sous les registres sauvegardés dans le prologue et
vérifiée avant le ret. L’option /GS de MSVC (activée par défaut) produit
cette forme. Le listing a été compilé avec clang-cl /GS /O2, qui suit les
conventions de MSVC :
sub rsp,0x68
mov rax,QWORD PTR [rip+0x0] ; __security_cookie
xor rax,rsp ; cookie mixed with the frame address
mov QWORD PTR [rsp+0x60],rax
lea rcx,[rsp+0x20] ; the buffer, just above shadow space
call g
mov rcx,QWORD PTR [rsp+0x60]
xor rcx,rsp
...
call __security_check_cookieLes équivalents de GCC comparent avec __stack_chk_guard (mingw-w64) ou
fs:0x28 (Linux) et appellent __stack_chk_fail. Traitez ce motif comme du
code passe-partout, avec un indice utile : la fonction a un tampon sur sa pile,
et l’emplacement du cookie marque l’endroit où il se termine.
Idiomes arithmétiques
Les compilateurs évitent les instructions lentes et les encodages longs. Le résultat est prévisible une fois que vous connaissez les substitutions :
| Vous voyez | Construction source | Pourquoi |
|---|---|---|
xor eax,eax | x = 0 | Plus court que mov eax,0, casse les dépendances (XOR) |
lea eax,[rcx+rcx*4] puis add eax,eax | x * 10 | Deux opérations bon marché battent imul (LEA) |
shl rdx,0x5 | x * 32 | Une multiplication par une puissance de deux est un décalage (décalages) |
lea eax,[rdi+0x7] | a + 7 dans un nouveau registre | Addition à trois opérandes sans toucher aux flags |
imul par une grande constante impaire, puis shr/sar | x / constante | Multiplication par l’inverse au lieu de div |
Quotient * d, puis sub depuis x | x % constante | x - (x / d) * d |
and eax,0x7 | x % 8 sur une valeur non signée | Un modulo par une puissance de deux est un masque |
sar ecx,0x1f | Signe de x sous forme 0 ou -1 | Sert à corriger la division signée et dans un abs sans branche |
cdq / cqo avant idiv | Division signée par une valeur connue à l’exécution | Étend le signe du dividende dans edx/rdx (DIV / IDIV) |
xor edx,edx avant div | Division non signée par une valeur connue à l’exécution | Met à zéro la moitié haute du dividende |
Notre fonction times10 illustre exactement l’idiome de multiplication :
<times10>:
lea eax,[rcx+rcx*4] ; x*5
add eax,eax ; *2
retDivision par une constante
div et idiv coûtent des dizaines de cycles ; un compilateur qui connaît le
diviseur à l’avance remplace donc la division par une multiplication par un
inverse mis à l’échelle. L’idée : x / d vaut (x * m) >> s quand m vaut à
peu près 2^s / d, arrondi au-dessus, et que s est assez grand pour que
l’erreur d’arrondi ne change jamais le résultat entier. GCC le fait même à
-O0. Voici unsigned mod10(unsigned x) :
<mod10>:
mov edx,0xcccccccd
mov eax,ecx
imul rax,rdx ; 64-bit product x * m
shr rax,0x23 ; >> 35 -> quotient q = x / 10
lea edx,[rax+rax*4] ; q*5
mov eax,ecx
add edx,edx ; q*10
sub eax,edx ; x - q*10 -> remainder
retRetrouver le diviseur. Additionnez tous les décalages à droite appliqués au
produit (ici, 35) et calculez 2^s / m. Le résultat est toujours légèrement
inférieur à un entier, parce que m a été arrondi au-dessus. Arrondissez-le à
l’entier supérieur :
2**35 / 0xcccccccd = 9.9999999994 -> divisor 10Le lea/add/sub final est l’idiome du modulo : reconstruire q * 10 et le
soustraire de x.
Certains diviseurs, dont 7, exigent un nombre magique plus large d’un bit que le
registre. Le compilateur ne stocke alors que les 32 bits de poids faible du
multiplicateur et ajoute une séquence de correction. unsigned div7(unsigned x) :
<div7>:
mov eax,ecx
imul rax,rax,0x24924925
shr rax,0x20 ; t = high 32 bits of x*m
sub ecx,eax ; x - t
shr ecx,1 ; (x - t) >> 1
add eax,ecx ; t + (x - t)/2
shr eax,0x2
retReconnaissez le triplet sub / shr 1 / add. Il calcule le même résultat
qu’une multiplication par la valeur sur 33 bits 2^32 + m. Le décalage total
est 32 + 1 + 2 = 35 :
2**35 / (2**32 + 0x24924925) = 6.9999999994 -> divisor 7La version signée, int sdiv7(int x), ajoute deux idiomes de plus :
<sdiv7>:
movsxd rax,ecx
imul rax,rax,0xffffffff92492493
shr rax,0x20
add eax,ecx ; magic is "negative", so add x back
sar ecx,0x1f ; 0 if x >= 0, -1 if x < 0
sar eax,0x2 ; arithmetic shift keeps the sign
sub eax,ecx ; +1 for negative x: C rounds toward zero
retIci, le décalage total est 32 + 2 = 34, et traiter le nombre magique comme la
valeur non signée sur 32 bits 0x92492493 donne
2^34 / 0x92492493 = 6.99999999... ; le diviseur est donc de nouveau 7. La
paire sar 0x1f / sub est la signature de la division signée : elle fait
arrondir les résultats négatifs vers zéro, comme l’exige le C.
Astuce : Inutile d’apprendre les nombres magiques par cœur. Deux d’entre eux reviennent sans cesse :
0xcccccccd(division par 10, ou par 5 avec un décalage plus petit) et0xaaaaaaab(division par 3). Pour tout le reste, appliquez la formule et confirmez en testant la séquence d’instructions en Python, comme dans le lab. Les décompilateurs de Ghidra et d’IDA replient généralement ces séquences en/et%pour vous.
Conditions et booléens
Un if se compile en un cmp ou un test qui positionne les
flags, suivi d’un saut conditionnel. Le saut mène
généralement au code qui saute le bloc then ; sa condition est donc
l’inverse de la condition source : if (n > 0) { ... } devient
test edx,edx / jle skip. test reg,reg est l’idiome de comparaison à zéro.
Les optimiseurs suppriment les branchements courts quand les deux issues sont bon marché à calculer :
<max_branchless>: <is_nonzero>:
cmp edx,ecx xor eax,eax
mov eax,ecx test ecx,ecx
cmovge eax,edx setne al
ret retLa première est return a > b ? a : b; avec un
déplacement conditionnel. La seconde est une
normalisation booléenne : return x != 0; doit renvoyer exactement 0 ou 1,
et setcc n’écrit qu’un octet, donc le compilateur met
d’abord eax à zéro. À -O0, vous voyez plutôt setne al suivi de
movzx eax,al. Les deux signifient « convertir une condition en int ». Quand
vous voyez un setcc alimenter un add, le source comptait combien de
conditions étaient vraies.
Boucles
À -O0, GCC organise une boucle for ainsi : initialisation, saut vers le test
en bas, corps, incrément, test, saut en arrière si vrai.
mov DWORD PTR [rbp-0x8],0x0 ; i = 0
jmp test
body:
... ; s += a[i]
add DWORD PTR [rbp-0x8],0x1 ; i++
test:
mov eax,DWORD PTR [rbp-0x8]
cmp eax,DWORD PTR [rbp+0x18] ; i < n ?
jl bodyÀ -O2, la même boucle est rotée. Une garde vérifie une seule fois si la
boucle s’exécute, et le corps devient un do/while avec le test en bas.
L’index disparaît souvent lui aussi. Ici, le compilateur a transformé a[i] en
un pointeur qui va de a à a + n :
<sum_array>:
test edx,edx
jle empty ; guard: n <= 0 -> return 0
movsxd rdx,edx
xor eax,eax ; s = 0
lea rdx,[rcx+rdx*4] ; end = a + n
loop:
add eax,DWORD PTR [rcx] ; s += *p
add rcx,0x4 ; p++
cmp rcx,rdx
jne loop
retUne garde suivie d’une boucle testée en bas est donc simplement l’aspect d’une
boucle for ou while après optimisation. Ce n’est pas la preuve que l’auteur
a écrit do/while. Les boucles dont le compteur importe peu sont souvent
retournées pour décompter. while (n-- > 0) calls += puts("tick"); est devenue
une boucle sur ebx = n - 1 qui se termine par sub ebx,0x1 / jae loop : le
flag de retenue signale le moment où le compteur repasse sous zéro.
À -O3 (et dans les boucles critiques des builds -O2 modernes), la boucle peut
être vectorisée. Cherchez des registres xmm ou ymm et des instructions
packed (voir Registres SIMD) :
loop:
movdqu xmm2,XMMWORD PTR [rax] ; load 4 ints
add rax,0x10
paddd xmm0,xmm2 ; 4 running sums at once
cmp r8,rax
jne loop
... ; psrldq/paddd: add the 4 lanes together
movd eax,xmm0
... ; scalar tail: up to 3 leftover elementsUne boucle vectorisée comporte une mise en place, un corps vectoriel et une queue
scalaire. Seul le corps porte l’opération réelle (paddd : addition d’entiers
32 bits packed) ; ignorez le reste. Le déroulement de boucle ressemble à cela,
sans SIMD : le corps est répété avec des décalages +0x4, +0x8, +0xc et le
compteur avance de plusieurs éléments par itération.
Instructions switch
Un switch prend l’une de trois formes, selon la densité de ses valeurs de cas.
Table de sauts. Pour des cas denses, le compilateur vérifie les bornes de la
valeur et saute via une table. Notre dispatch(int cmd) a les cas 0 à 6 :
<dispatch>:
cmp ecx,0x6
ja default ; unsigned: also catches cmd < 0
lea rdx,[rip+0x2afc] ; table at 0x14000402c (.rdata)
mov ecx,ecx
movsxd rax,DWORD PTR [rdx+rcx*4] ; 32-bit signed entry
add rax,rdx ; entry is relative to the table
jmp raxNotez le ja non signé : une seule comparaison rejette à la fois les
valeurs supérieures à 6 et les valeurs négatives, qui deviennent énormes lues
comme non signées. Si le plus petit cas n’est pas zéro, un sub ecx,<lowest>
vient d’abord. Avec des cas de 10 à 15, vous obtenez sub ecx,0xa /
cmp ecx,0x5. Le plus petit cas et la borne vous donnent ensemble la plage des
cas.
Les tables x64 contiennent rarement des adresses absolues. GCC stocke des
décalages 32 bits relatifs à la table, comme ci-dessus ; MSVC stocke
typiquement des RVA 32 bits et ajoute la base de l’image
(lea reg,[__ImageBase]). Quand un désassembleur n’arrive pas à décoder une
table, ce qui est courant dans le code obfusqué, lisez les entrées et ajoutez
vous-même chacune à la base.
Table de correspondance. Quand chaque cas ne fait que produire une valeur,
GCC se passe entièrement de code et indexe un tableau de résultats. classify
(cas 0 à 6 renvoyant 11, 23, 37...) se compile en cmp ecx,0x6 / ja /
mov eax,[rax+rcx*4] sur une table nommée CSWTCH.5, dont les octets dans
.rdata sont exactement ces valeurs de retour. La version -O0 des mêmes
fonctions n’utilise aucune des deux sortes de table, juste une chaîne de
cmp/je/jg.
Arbre de comparaisons. Des cas clairsemés (3, 40, 200, 1000, 4096, 9999) ne peuvent pas utiliser de table sans gaspiller de place ; le compilateur construit donc une recherche binaire :
<sparse>:
cmp ecx,0x3e8 ; 1000: the pivot
je case_1000
jg upper_half
cmp ecx,0x28 ; 40
je case_40
...
upper_half:
cmp ecx,0x1000 ; 4096
...Un je suivi d’un jg sur la même constante marque un point de partage. Les
constantes comparées au registre forment la liste des cas ; dans le répartiteur
de commandes d’une backdoor, ce sont les identifiants de commande.
Structs et tableaux
L’adressage x86, [base + index*scale + disp] (voir
modes d’adressage), correspond directement aux
accès aux données en C :
| Opérande | Source typique |
|---|---|
[rcx+0x10] | p->field au décalage 0x10 |
[rcx+rdx*4] | a[i] pour des éléments de 4 octets |
[rcx+rdx*8+0x10] | p->arr[i] pour des éléments de 8 octets, à partir du décalage 0x10 |
shl rdx,0x5 puis [rcx+rdx*1+0x10] | p[i].field où sizeof(*p) == 32 |
lea rax,[rcx+0x4] | &p->field ou p->array, une adresse et non un chargement |
get_size renvoie r[i].size. Le shl rdx,0x5 donne la taille d’un élément,
32 octets, et le déplacement 0x10 donne le décalage du champ. La largeur du
chargement (QWORD PTR) donne le type du champ : 8 octets. Les facteurs
d’échelle sont limités à 1, 2, 4 et 8 ; les tailles d’élément plus grandes ou
atypiques apparaissent donc d’abord sous forme de décalage ou d’imul sur
l’index.
Pour reconstituer une struct, rassemblez chaque décalage et chaque largeur d’accès utilisés avec le même pointeur de base dans les fonctions qui le reçoivent, puis triez par décalage ; les trous sont des champs inconnus ou du bourrage. La fonction « Auto Create Structure » de Ghidra en fait un premier jet pour vous, et Références croisées et flot de données montre comment suivre le pointeur d’une fonction à l’autre.
Opérations mémoire et chaînes inlinées
Les petits memset, memcpy et copies de structs de taille connue sont
développés en ligne : il n’y a donc aucun appel à trouver. Selon la taille et
les options :
| Cas | Ce que GCC a émis dans nos builds |
|---|---|
memset d’une struct de 32 octets, -O2 | pxor xmm0,xmm0 puis movups [rcx],xmm0 / movups [rcx+0x10],xmm0 |
Copie d’une struct de 200 octets, -O2 | Une série de chargements movdqu et d’écritures movups, de 16 octets chacun |
memset ou copie de 200 octets, -Os | mov ecx,0x32 puis rep stos DWORD PTR [rdi],eax ou rep movs |
Les formes rep (voir opérations sur les chaînes)
explicitent leurs arguments : rdi est la destination, rsi la source, rcx
le nombre d’unités de la taille d’élément. 0x32 unités de 4 octets font
200 octets. Un pxor suivi d’une série de movups vers une même base avec des
décalages croissants est une struct ou un tampon mis à zéro.
Le parcours de chaînes a ses propres formes. Un strlen écrit à la main se
compile en boucle sur les octets : cmp BYTE PTR [rax],0x0 / jne, puis sub
du pointeur de départ. Le code 32 bits plus ancien utilise repne scasb avec
ecx = -1. Les implémentations optimisées du runtime C testent 16 octets à la
fois avec pcmpeqb et pmovmskb, ou emploient des astuces sur les bits qui
cherchent un octet nul dans un mot entier. Quand vous voyez ces constructions
dans une grosse fonction sans logique propre à l’auteur, vous êtes presque
certainement dans du code de bibliothèque.
C++ : this et appels virtuels
Les méthodes C++ reçoivent le pointeur d’objet, this, comme premier argument
caché : rcx sous Windows, rdi sous System V. Un appel virtuel charge le
premier quadword de l’objet (le pointeur de vtable) et appelle via un
emplacement :
<describe(Shape const*)>:
mov rax,QWORD PTR [rcx] ; vtable = s->__vptr
mov rbx,rcx ; keep this across calls
call QWORD PTR [rax+0x18] ; slot 3: area()
mov rcx,rbx ; this again for the next call
mov esi,eax
mov rax,QWORD PTR [rbx]
call QWORD PTR [rax+0x10] ; slot 2: sides()L’indice de l’emplacement est le décalage divisé par 8. Avec l’ABI de GCC
(Itanium), un destructeur virtuel occupe deux emplacements ; sides() et
area() tombent donc en 0x10 et 0x18. MSVC n’utilise qu’un emplacement de
destructeur : la même classe a donc des décalages différents. Pour résoudre
l’appel, trouvez où les objets de cette classe sont construits : le
constructeur écrit l’adresse de la vtable en [rcx], et cette vtable dans
.rdata liste les pointeurs de fonction dans l’ordre des emplacements. Les
informations de type à l’exécution (RTTI), quand elles sont présentes, se
trouvent à côté de la vtable et nomment souvent la classe.
Code de bibliothèque : ne rétro-ingénierez pas memcpy
Un échantillon lié statiquement peut contenir des centaines de fonctions du runtime C, de compression ou de cryptographie. Les rétro-ingénierer à la main fait perdre des heures. Identifiez-les plutôt :
IDA FLIRT compare des motifs d’octets (octets relocalisés masqués) à des
bibliothèques de signatures ; Ghidra Function ID fait de même avec des bases
de hachages ; Binary Ninja fournit ses propres signatures. Choisissez celles qui
correspondent au compilateur identifié dans
Identifier et hacher les fichiers, ou
construisez les vôtres à partir d’une bibliothèque que vous compilez. Sans
signatures, les fonctions de bibliothèque se remarquent quand même : grandes,
nombreux appelants, aucune chaîne propre à l’auteur, SIMD ou astuces sur les
bits. Nommez-les d’après leur comportement (maybe_memcpy) et passez à la
suite.
Attention : Les obfuscateurs cassent délibérément la reconnaissance des idiomes. La substitution d’instructions remplace
x + ypar des expressions équivalentes plus longues, l’arithmétique booléenne mixte cache des constantes dans des identités polynomiales, et l’insertion de code mort entoure les vrais idiomes d’instructions qui ne font rien. Si les formes familières disparaissent d’un binaire par ailleurs normal, cette absence est en soi une découverte.
Aide-mémoire des idiomes
| Motif | Construction source |
|---|---|
push rbp / mov rbp,rsp dans chaque fonction | Build non optimisé ou pointeurs de cadre conservés |
sub rsp,0x28 / 0x38 sans variable locale utilisée | Espace d’ombre plus alignement (Windows x64) |
Écriture de rcx/rdx en [rbp+0x10]/[rbp+0x18] | Recopie des arguments à -O0 |
__security_cookie xor rsp, vérification avant ret | Tampon de pile protégé par /GS |
imul par une constante impaire + décalages | Division par une constante |
sub, shr 1, add après une multiplication à moitié haute | Division nécessitant un nombre magique sur 33 bits |
sar reg,0x1f + sub | Division signée arrondie vers zéro |
test/cmp + setcc (+movzx) | Résultat booléen (==, !=, < utilisé comme valeur) |
cmovcc | ?:, min, max, affectation dans un if simple |
| Garde + boucle testée en bas | N’importe quel for/while à -O2 |
sub reg,1 / jae ou dec / jnz | Boucle à compteur décroissant |
Boucle movdqu/paddd + queue scalaire | Boucle vectorisée sur un tableau |
cmp + ja + movsxd indexé + jmp reg | switch dense (table de sauts) |
cmp + ja + chargement indexé d’une constante | switch qui ne fait que renvoyer des valeurs |
Paires je/jg sur un même registre | switch clairsemé (recherche binaire) |
[reg+disp] avec plusieurs disp fixes sur une même base | Accès aux champs d’une struct |
shl/imul sur l’index puis [base+idx+disp] | Tableau de structs |
Série pxor + movups / rep stos | memset inliné |
mov rax,[rcx] puis call [rax+N] | Appel virtuel C++, emplacement N/8 |
Lab : reconnaître les idiomes
Vous allez compiler un fichier de petites fonctions à -O0 et à -O2, trouver
chaque idiome de cette leçon, décoder une table de sauts à la main et retrouver
un diviseur à partir de son nombre magique. Tout se passe sur votre machine
d’analyse ; rien n’est exécuté.
-
Enregistrez le source sous
idioms.c:c // idioms.c - small benign functions, one compiler idiom each #include <stdio.h> #include <string.h> struct record { int id; /* +0x00 */ char tag[12]; /* +0x04 */ long long size; /* +0x10 */ int flags; /* +0x18 */ }; __attribute__((noinline)) unsigned div7(unsigned x) { return x / 7; } __attribute__((noinline)) int sdiv7(int x) { return x / 7; } __attribute__((noinline)) unsigned mod10(unsigned x) { return x % 10; } __attribute__((noinline)) int times10(int x) { return x * 10; } __attribute__((noinline)) int classify(int op) { switch (op) { case 0: return 11; case 1: return 23; case 2: return 37; case 3: return 41; case 4: return 59; case 5: return 67; case 6: return 71; default: return -1; } } __attribute__((noinline)) const char *dispatch(int cmd) { switch (cmd) { case 0: return "ping"; case 1: return "info"; case 2: return "list"; case 3: return "get"; case 4: return "put"; case 5: return "sleep"; case 6: return "exit"; default: return "unknown"; } } __attribute__((noinline)) long long get_size(struct record *r, int i) { return r[i].size; } __attribute__((noinline)) int sum_array(const int *a, int n) { int s = 0; for (int i = 0; i < n; i++) s += a[i]; return s; } __attribute__((noinline)) int max_branchless(int a, int b) { return a > b ? a : b; } __attribute__((noinline)) int is_nonzero(int x) { return x != 0; } __attribute__((noinline)) void clear_record(struct record *r) { memset(r, 0, sizeof *r); } int main(void) { struct record recs[2] = { {1, "alpha", 100, 0}, {2, "beta", 200, 1} }; int arr[64]; for (int i = 0; i < 64; i++) arr[i] = i; printf("%u %d %u %d\n", div7(100), sdiv7(-100), mod10(1234), times10(9)); printf("%d %s\n", classify(3), dispatch(5)); printf("%lld %d\n", get_size(recs, 1), sum_array(arr, 64)); printf("%d %d\n", max_branchless(3, 9), is_nonzero(5)); clear_record(&recs[0]); printf("%d\n", recs[0].id); return 0; }noinlinegarde chaque fonction séparée ; dans de vrais échantillons, la plupart seraient inlinées dans leurs appelants. -
Compilez aux deux niveaux et désassemblez chaque binaire dans un fichier texte :
bash x86_64-w64-mingw32-gcc -O0 -o idioms_O0.exe idioms.c x86_64-w64-mingw32-gcc -O2 -o idioms_O2.exe idioms.c x86_64-w64-mingw32-objdump -d -M intel --no-show-raw-insn idioms_O0.exe > O0.txt x86_64-w64-mingw32-objdump -d -M intel --no-show-raw-insn idioms_O2.exe > O2.txt awk '/^[0-9a-f]+ <div7>:/,/^$/' O2.txt # print one functionLes binaires ne sont pas strippés : les noms vous servent donc de corrigé. Vos adresses peuvent légèrement différer.
-
Cadres. Comparez
sum_arraydansO0.txtetO2.txt. Trouvez les arguments recopiés en[rbp+0x10]et[rbp+0x18], les deux variables locales en[rbp-0x4](s) et[rbp-0x8](i), et vérifiez que la version-O2n’a aucun cadre. Regardez ensuitemaindansO0.txt: quelle est la taille de sonsub rsp, et pourquoi n’est-elle pas à elle seule un multiple de 16 ? (Comptez lespush.) -
Arithmétique. Faites correspondre
times10,div7,sdiv7etmod10aux listings de cette leçon. Notez quediv7dansO0.txtutilise déjàimul rax,rax,0x24924925: GCC applique la réécriture par nombre magique même à-O0. -
Retrouvez les diviseurs en Python. Enregistrez et lancez
magic.py. Il calcule le diviseur à partir de chaque constante, puis modélise les séquences d’instructions exactes et les compare aux//et%de Python :python # magic.py - recover the divisor behind a multiply-by-reciprocal sequence M10 = 0xcccccccd # mod10: imul by this, then shr rax,0x23 print("mod10 :", 2**0x23 / M10) M7 = 0x24924925 # div7: imul, shr 32, then the sub/shr 1/add/shr 2 fix-up print("div7 :", 2**35 / (2**32 + M7)) def div7_asm(x): # the exact instruction sequence, in Python t = (x * M7) >> 32 # imul rax,rax,0x24924925 ; shr rax,0x20 q = ((x - t) >> 1) + t # sub ecx,eax ; shr ecx,1 ; add eax,ecx return q >> 2 # shr eax,0x2 def mod10_asm(x): q = (x * M10) >> 0x23 # imul rax,rdx ; shr rax,0x23 return x - (q * 5) * 2 # lea edx,[rax+rax*4] ; add edx,edx ; sub eax,edx import random xs = [0, 1, 6, 7, 2**32 - 1] + [random.getrandbits(32) for _ in range(1_000_000)] assert all(div7_asm(x) == x // 7 for x in xs) assert all(mod10_asm(x) == x % 10 for x in xs) print("div7 and mod10 sequences match x//7 and x%10 on", len(xs), "inputs")text mod10 : 9.999999999417923 div7 : 6.99999999938882 div7 and mod10 sequences match x//7 and x%10 on 1000005 inputsLes deux rapports se situent juste sous un entier : les diviseurs sont donc 10 et 7, et les assertions confirment les séquences sur un million d’entrées.
-
Conditions. Trouvez
max_branchlessetis_nonzeroaux deux niveaux. Quelle instruction la version-O0deis_nonzeroajoute-t-elle, et pourquoi ? -
Boucles. Identifiez la garde, la boucle rotée et l’incrément du pointeur dans
sum_arrayen-O2. Recompilez ensuite cette seule fonction à-O3et trouvez le corps enpadddet la queue scalaire :bash x86_64-w64-mingw32-gcc -O3 -c -o idioms_O3.o idioms.c x86_64-w64-mingw32-objdump -d -M intel --no-show-raw-insn idioms_O3.o | awk '/<sum_array>:/,/^$/' -
Décodez la table de sauts. Dans
O2.txt,dispatchcharge l’adresse de la table aveclea rdx,[rip+...]; objdump affiche l’adresse résolue en commentaire (0x14000402cdans notre build). Affichez les octets bruts :bash x86_64-w64-mingw32-objdump -s -j .rdata idioms_O2.exe | head -9text 140004000 70696e67 00696e66 6f006c69 73740067 ping.info.list.g 140004010 65740070 75740073 6c656570 00657869 et.put.sleep.exi 140004020 7400756e 6b6e6f77 6e000000 24d5ffff t.unknown...$... 140004030 14d5ffff 34d5ffff 44d5ffff 64d5ffff ....4...D...d... 140004040 74d5ffff 54d5ffff ...La première entrée,
24d5ffff, est en little-endian0xffffd524, soit-0x2adc. L’ajouter à0x14000402cdonne0x140001550, unlea rax,[rip+...]qui pointe surping. Automatisez avecpefile(pip install pefile) :python # jumptable.py - decode a relative jump table in a PE file import struct, sys import pefile path, table_va, count = sys.argv[1], int(sys.argv[2], 16), int(sys.argv[3]) pe = pefile.PE(path) rva = table_va - pe.OPTIONAL_HEADER.ImageBase raw = pe.get_data(rva, 4 * count) for i, (off,) in enumerate(struct.iter_unpack("<i", raw)): print(f"case {i}: offset {off:#x} -> target {table_va + off:#x}")text $ python3 jumptable.py idioms_O2.exe 0x14000402c 7 case 0: offset -0x2adc -> target 0x140001550 case 1: offset -0x2aec -> target 0x140001540 case 2: offset -0x2acc -> target 0x140001560 ... case 6: offset -0x2aac -> target 0x140001580Le nombre d’entrées, 7, vient du test de bornes
cmp ecx,0x6. Vérifiez que chaque cible charge la bonne chaîne. Trouvez ensuiteCSWTCHdans.rdatapourclassifyet lisez les valeurs de retour directement dans la table. -
Structs. À partir de
get_sizeseulement, notez la taille de la struct, ainsi que le décalage et la largeur du champ lu. Comparez avec les commentaires du source. Que vous apprendclear_recordsur la taille destruct record? -
Comparaison avec System V. Si vous avez un compilateur croisé Linux (ou si vous travaillez sous Linux avec
gcc), compilez un fichier objet et comparez les registres d’arguments :bash x86_64-linux-gnu-gcc -O2 -c -o idioms_O2.o idioms.c x86_64-linux-gnu-objdump -d -M intel --no-show-raw-insn idioms_O2.o | awk '/<get_size>:/,/^$/' -
Facultatif : comparez la sortie du décompilateur de Ghidra pour
div7,mod10etdispatchavec le source, et collezidioms.cdans Compiler Explorer pour comparer GCC, Clang et MSVC.
Questions : Quelles fonctions sont presque identiques à -O0 et à -O2, et
lesquelles changent complètement ? Pourquoi le test de bornes de dispatch
utilise-t-il ja plutôt que jg ? Si vous voyiez un imul par 0xaaaaaaab
suivi d’un shr de 33, quel aurait été le source ? Pourquoi classify
peut-elle utiliser une table de valeurs alors que dispatch a besoin d’une table
de décalages de code ? Dans un binaire strippé, qu’est-ce qui vous indiquerait
que clear_record est un memset inliné plutôt qu’une logique propre à
l’auteur ?
À retenir
- Les compilateurs réutilisent un petit ensemble de motifs ; les lire comme des constructions source est le vrai accélérateur de l’analyse de code.
- Les cadres révèlent l’ABI et le niveau d’optimisation :
rcx/rdx/r8/r9et espace d’ombre sous Windows x64,rdi/rsiet pas d’espace d’ombre sous System V,push rbppartout à-O0. - Une multiplication par une constante impaire suivie de décalages est une
division. Additionnez les décalages, divisez
2^spar le nombre magique (en ajoutant2^32dans la forme corrigée sur 33 bits) et arrondissez à l’entier supérieur pour retrouver le diviseur, puis vérifiez en Python. - Les
switchdenses deviennent des tables de sauts ou de valeurs avec test de bornes, les clairsemés deviennent des arbres de comparaisons. En x64, les tables contiennent des décalages 32 bits, pas des adresses absolues. - L’adressage avec facteur d’échelle révèle la taille des éléments et les
décalages des champs, et un
call [rax+N]après le chargement de[rcx]est un appel virtuel via l’emplacementN/8. - Identifiez le code de bibliothèque avec FLIRT ou Function ID plutôt que de le rétro-ingénierer, et considérez l’absence d’idiomes comme l’indice qu’un obfuscateur est passé par là.