Skip to content

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 :

NiveauCe que vous voyezCe que cela signifie pour vous
-O0 / MSVC /OdPointeur de cadre, chaque variable en mémoire, chaque argument recopié sur la pile, une instruction C par blocFacile à rapprocher du source, mais verbeux
-O2 / MSVC /O2Fonctions sans cadre, valeurs dans les registres, boucles réécrites, appels inlinés ou transformés en sautsDense ; c’est ici que les idiomes comptent le plus
-O3-O2 plus vectorisation et déroulement de boucles agressifsBoucles SIMD suivies d’un code scalaire de « queue »
-Os / MSVC /O1Optimisé pour la taille : séquences courtes, instructions de chaîne repCompact, 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) :

text
<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 :

text
<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
  ret

Deux 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 stocker rcx, rdx, r8 et r9. C’est pourquoi le listing -O0 ci-dessus écrit ses arguments en [rbp+0x10] et [rbp+0x18] : avec rbp pointant sur le rbp sauvegardé et l’adresse de retour en [rbp+8], ce sont les deux premiers emplacements de l’espace d’ombre.
  • Alignement. rsp doit être aligné sur 16 octets à chaque call. L’appel empile 8 octets ; à l’entrée, rsp est donc décalé de 8 octets. Deux push plus 0x28 portent le total à 8 + 16 + 40 = 64, un multiple de 16. Quand vous voyez un sub rsp,0x28 ou 0x38 d’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 x64System V AMD64 (Linux, macOS)
Arguments entiers et pointeursrcx, rdx, r8, r9, puis la pilerdi, rsi, rdx, rcx, r8, r9, puis la pile
Arguments à virgule flottantexmm0 à xmm3 (positionnels, partagés avec les emplacements entiers)xmm0 à xmm7 (décompte indépendant)
Valeur de retourrax / xmm0rax (et rdx) / xmm0
Espace réservé par l’appelantEspace d’ombre de 32 octetsAucun, mais les fonctions feuilles peuvent utiliser une red zone de 128 octets sous rsp
Cinquième argument[rsp+0x28] à l’entréeSeptiè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 :

text
<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                                    ret

Le 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 :

text
  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_cookie

Les é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 voyezConstruction sourcePourquoi
xor eax,eaxx = 0Plus court que mov eax,0, casse les dépendances (XOR)
lea eax,[rcx+rcx*4] puis add eax,eaxx * 10Deux opérations bon marché battent imul (LEA)
shl rdx,0x5x * 32Une multiplication par une puissance de deux est un décalage (décalages)
lea eax,[rdi+0x7]a + 7 dans un nouveau registreAddition à trois opérandes sans toucher aux flags
imul par une grande constante impaire, puis shr/sarx / constanteMultiplication par l’inverse au lieu de div
Quotient * d, puis sub depuis xx % constantex - (x / d) * d
and eax,0x7x % 8 sur une valeur non signéeUn modulo par une puissance de deux est un masque
sar ecx,0x1fSigne de x sous forme 0 ou -1Sert à corriger la division signée et dans un abs sans branche
cdq / cqo avant idivDivision signée par une valeur connue à l’exécutionÉtend le signe du dividende dans edx/rdx (DIV / IDIV)
xor edx,edx avant divDivision non signée par une valeur connue à l’exécutionMet à zéro la moitié haute du dividende

Notre fonction times10 illustre exactement l’idiome de multiplication :

text
<times10>:
  lea    eax,[rcx+rcx*4]      ; x*5
  add    eax,eax              ; *2
  ret

Division 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) :

text
<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
  ret

Retrouver 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 :

text
2**35 / 0xcccccccd = 9.9999999994  ->  divisor 10

Le 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) :

text
<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
  ret

Reconnaissez 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 :

text
2**35 / (2**32 + 0x24924925) = 6.9999999994  ->  divisor 7

La version signée, int sdiv7(int x), ajoute deux idiomes de plus :

text
<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
  ret

Ici, 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) et 0xaaaaaaab (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 :

text
<max_branchless>:              <is_nonzero>:
  cmp    edx,ecx                 xor    eax,eax
  mov    eax,ecx                 test   ecx,ecx
  cmovge eax,edx                 setne  al
  ret                            ret

La 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.

text
  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 :

text
<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
  ret

Une 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) :

text
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 elements

Une 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 :

text
<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    rax

Notez 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 :

text
<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érandeSource 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 :

CasCe que GCC a émis dans nos builds
memset d’une struct de 32 octets, -O2pxor xmm0,xmm0 puis movups [rcx],xmm0 / movups [rcx+0x10],xmm0
Copie d’une struct de 200 octets, -O2Une série de chargements movdqu et d’écritures movups, de 16 octets chacun
memset ou copie de 200 octets, -Osmov 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 :

text
<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 + y par 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

MotifConstruction source
push rbp / mov rbp,rsp dans chaque fonctionBuild non optimisé ou pointeurs de cadre conservés
sub rsp,0x28 / 0x38 sans variable locale utiliséeEspace 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 retTampon de pile protégé par /GS
imul par une constante impaire + décalagesDivision par une constante
sub, shr 1, add après une multiplication à moitié hauteDivision nécessitant un nombre magique sur 33 bits
sar reg,0x1f + subDivision 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 basN’importe quel for/while à -O2
sub reg,1 / jae ou dec / jnzBoucle à compteur décroissant
Boucle movdqu/paddd + queue scalaireBoucle vectorisée sur un tableau
cmp + ja + movsxd indexé + jmp regswitch dense (table de sauts)
cmp + ja + chargement indexé d’une constanteswitch qui ne fait que renvoyer des valeurs
Paires je/jg sur un même registreswitch clairsemé (recherche binaire)
[reg+disp] avec plusieurs disp fixes sur une même baseAccès aux champs d’une struct
shl/imul sur l’index puis [base+idx+disp]Tableau de structs
Série pxor + movups / rep stosmemset 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é.

  1. 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;
    }

    noinline garde chaque fonction séparée ; dans de vrais échantillons, la plupart seraient inlinées dans leurs appelants.

  2. 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 function

    Les binaires ne sont pas strippés : les noms vous servent donc de corrigé. Vos adresses peuvent légèrement différer.

  3. Cadres. Comparez sum_array dans O0.txt et O2.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 -O2 n’a aucun cadre. Regardez ensuite main dans O0.txt : quelle est la taille de son sub rsp, et pourquoi n’est-elle pas à elle seule un multiple de 16 ? (Comptez les push.)

  4. Arithmétique. Faites correspondre times10, div7, sdiv7 et mod10 aux listings de cette leçon. Notez que div7 dans O0.txt utilise déjà imul rax,rax,0x24924925 : GCC applique la réécriture par nombre magique même à -O0.

  5. 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 inputs

    Les 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.

  6. Conditions. Trouvez max_branchless et is_nonzero aux deux niveaux. Quelle instruction la version -O0 de is_nonzero ajoute-t-elle, et pourquoi ?

  7. Boucles. Identifiez la garde, la boucle rotée et l’incrément du pointeur dans sum_array en -O2. Recompilez ensuite cette seule fonction à -O3 et trouvez le corps en paddd et 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>:/,/^$/'
  8. Décodez la table de sauts. Dans O2.txt, dispatch charge l’adresse de la table avec lea rdx,[rip+...] ; objdump affiche l’adresse résolue en commentaire (0x14000402c dans notre build). Affichez les octets bruts :

    bash
    x86_64-w64-mingw32-objdump -s -j .rdata idioms_O2.exe | head -9
    text
     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-endian 0xffffd524, soit -0x2adc. L’ajouter à 0x14000402c donne 0x140001550, un lea rax,[rip+...] qui pointe sur ping. Automatisez avec pefile (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 0x140001580

    Le nombre d’entrées, 7, vient du test de bornes cmp ecx,0x6. Vérifiez que chaque cible charge la bonne chaîne. Trouvez ensuite CSWTCH dans .rdata pour classify et lisez les valeurs de retour directement dans la table.

  9. Structs. À partir de get_size seulement, 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 apprend clear_record sur la taille de struct record ?

  10. 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>:/,/^$/'
  11. Facultatif : comparez la sortie du décompilateur de Ghidra pour div7, mod10 et dispatch avec le source, et collez idioms.c dans 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/r9 et espace d’ombre sous Windows x64, rdi/rsi et pas d’espace d’ombre sous System V, push rbp partout à -O0.
  • Une multiplication par une constante impaire suivie de décalages est une division. Additionnez les décalages, divisez 2^s par le nombre magique (en ajoutant 2^32 dans la forme corrigée sur 33 bits) et arrondissez à l’entier supérieur pour retrouver le diviseur, puis vérifiez en Python.
  • Les switch denses 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’emplacement N/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à.