Dépackage statique d’UPX : reconstruire le PE d’origine sans l’exécuter

Malware Research Team 25 min de lecture

UPX est sans doute le packer d’exécutables le plus répandu dans l’écosystème malveillant. Il est open source, gratuit, rapide, et produit un exécutable fonctionnel souvent deux fois plus petit que l’original. Pour l’analyste, cela signifie aussi que ce qui arrive dans la file de triage n’est pas le malware lui-même, mais un petit stub de décompression posé sur un bloc opaque à forte entropie.

Malva.RE prend désormais en charge les exécutables Windows packés avec UPX de bout en bout : détection des binaires UPX x86 et x64, décompression statique et reconstruction du fichier PE d’origine, qui est ensuite réinjecté dans le reste de la chaîne d’analyse. À aucun moment l’échantillon n’est exécuté, émulé ou placé en sandbox : le fichier packé est traité uniquement comme une suite d’octets.

Cet article explique comment fonctionne le format UPX sous Windows, ce qu’il faut défaire pour retrouver le programme d’origine, quels pièges un dépackeur statique doit éviter, et comment les auteurs de malwares modifient UPX pour casser l’outil officiel upx -d.


Pourquoi c’est important pour l’analyste

Quand un échantillon est packé avec UPX, presque tous les signaux statiques disparaissent :

Les contournements habituels consistent à exécuter l’échantillon en sandbox puis à le dumper en mémoire, ou à lancer upx -d localement. La première option exige une infrastructure d’exécution et pose ses propres problèmes d’évasion. La seconde échoue souvent sur les malwares, précisément parce que les attaquants altèrent les métadonnées UPX pour la faire échouer.

Avec le dépackage statique, le PE reconstruit devient un sous-fichier dans l’arbre du rapport Malva.RE, typé comme exécutable ou DLL Windows. Il suit alors exactement la même analyse que n’importe quel binaire soumis : analyse de la structure PE, YARA, collecteurs spécifiques aux familles, extraction de configuration, agrégation des IOC. Le fichier packé et le payload décompressé sont tous deux visibles, avec le lien qui les unit.


Pourquoi les malwares utilisent UPX (et le modifient)

UPX a été conçu comme un outil légitime de réduction de taille, et il reste largement utilisé par des logiciels légitimes. C’est justement l’une des raisons de son attrait pour les attaquants :

L’inconvénient évident pour l’attaquant est que upx -d restaure le fichier d’origine en une commande. Une part importante des malwares packés UPX est donc volontairement modifiée après le packing, avec des changements qui laissent le stub fonctionnel (il ignore la plupart des métadonnées) mais cassent le décompresseur officiel :

Le programme packé fonctionne toujours, car le stub n’utilise que des constantes embarquées dans son propre code. Mais l’outil officiel s’appuie sur ces métadonnées et refuse de décompresser. Un dépackeur statique qui se veut utile sur de vrais malwares doit savoir faire face à ces situations.


Anatomie d’un PE packé avec UPX

Les sections, avant et après

UPX réécrit entièrement la table des sections. Les sections d’origine sont fusionnées dans une seule région virtuelle que le stub remplit à l’exécution.

 PE d'origine                        PE packé UPX
 ┌──────────────────────┐            ┌──────────────────────────────┐
 │ En-tête DOS + stub   │            │ En-tête DOS + stub           │
 │ En-têtes NT          │            │ En-têtes NT (réécrits)       │
 │ Table des sections   │            │ Table des sections (3)       │
 │                      │            │ Version "x.yz" + PackHeader  │
 ├──────────────────────┤            ├──────────────────────────────┤
 │ .text                │            │ UPX0  taille brute = 0       │
 │ .rdata               │            │       taille virtuelle =     │
 │ .data                │   ─────►   │       toutes les sections    │
 │ .rsrc                │            ├──────────────────────────────┤
 │ .reloc               │            │ UPX1  image compressée       │
 │ ...                  │            │       + stub de décompression│
 └──────────────────────┘            │       (point d'entrée ici)   │
                                     ├──────────────────────────────┤
                                     │ UPX2 / .rsrc  imports du     │
                                     │       stub, ressources non   │
                                     │       compressées            │
                                     └──────────────────────────────┘

Propriétés clés de cette disposition :

Le PackHeader

Dans un PE Windows, UPX stocke ses métadonnées dans une structure fixe de 32 octets appelée PackHeader, placée dans la zone d’en-têtes juste après la table des sections et généralement précédée d’une chaîne de version en clair. Sa disposition, pour les révisions rencontrées dans les fichiers Win32/Win64, est la suivante :

OffsetTailleChampSignification
04magic"UPX!"
41versionRévision du format d’en-tête (pas le numéro de version d’UPX)
51formatFormat cible (PE Win32, PE Win64…)
61methodIdentifiant de la méthode de compression
71levelNiveau de compression (1 à 10)
84u_adlerAdler-32 des données décompressées
124c_adlerAdler-32 des données compressées
164u_lenTaille décompressée
204c_lenTaille compressée
244u_file_sizeTaille du fichier d’origine
281filterIdentifiant du filtre (transformation des appels/sauts)
291filter_ctoParamètre du filtre (octet marqueur)
301(réservé)
311checksum d’en-têteSomme des octets 4 à 30, modulo 251

Quelques remarques importantes en pratique :

Et l_info / p_info ?

La documentation et le code source d’UPX mentionnent souvent les structures l_info et p_info. Elles appartiennent aux formats de type Unix (ELF et loaders apparentés), où elles décrivent le loader et les blocs du programme. Elles ne font pas partie du format de packing des PE Windows, qui repose sur le PackHeader et sur un bloc de restauration situé dans l’image compressée, décrit plus loin. Ce travail ciblant les PE Windows, l_info et p_info n’interviennent pas.


Étape 1 : détecter UPX en x86 et x64

Le trou à combler

Avant ce travail, les signatures UPX chargées dans le jeu de règles YARA de Malva.RE visaient des stubs de décompression ARM (variantes Windows CE). Elles ne matchaient jamais un exécutable Windows x86 ou x64 : en pratique, les PE packés UPX standard n’étaient pas du tout tagués comme tels.

La nouvelle règle de détection combine des indices indépendants ; un seul suffit, sur un PE valide comportant au moins deux sections :

Ce sont les motifs de point d’entrée qui rendent la détection résistante aux sections renommées et à la signature effacée : un attaquant peut réécrire librement les métadonnées, mais le stub doit conserver son code pour fonctionner.

Score structurel

En plus de la règle YARA, le dépackeur réalise sa propre analyse structurelle avant toute tentative. Il recherche :

  1. La disposition UPX : une section virtuelle vide (aucune donnée brute, taille virtuelle d’au moins une page) immédiatement suivie d’une section stockée contenant le point d’entrée.
  2. Les noms de sections UPX conventionnels.
  3. Un motif d’octets de type stub au point d’entrée.
  4. Une forte entropie dans la section stockée (comptée uniquement avec la disposition, l’entropie seule ne voulant rien dire).
  5. Un PackHeader, recherché dans des fenêtres bornées : début du fichier, fin du fichier et début de chaque section. Le fichier n’est jamais parcouru octet par octet sur des centaines de mégaoctets d’overlay.

Chaque indice contribue à un score, et les incohérences sont consignées comme anomalies : signature effacée, checksum d’en-tête invalide, tailles déclarées impossibles au regard de la taille du fichier, disposition UPX avec sections renommées, disposition UPX sans en-tête lisible. Ces anomalies apparaissent dans le rapport, car un en-tête UPX trafiqué est un signal en soi : un logiciel légitime n’a aucune raison de le faire. Lorsqu’une telle altération est constatée, l’échantillon reçoit également un tag dédié.


Étape 2 : décompresser l’image

Localiser le flux compressé

Le PackHeader indique les tailles compressée et décompressée, mais pas l’offset du flux compressé dans le fichier : dans un PE, l’en-tête se trouve dans la zone d’en-têtes alors que les données sont dans UPX1, et le stub sait simplement où chercher. Le dépackeur construit donc une liste courte et bornée d’offsets candidats :

La liste est dédoublonnée et plafonnée : un fichier hostile ne peut pas forcer le dépackeur à essayer un nombre illimité de positions.

Méthodes de compression

UPX code l’algorithme de compression dans l’octet method du PackHeader :

IDMéthodeLargeur du tampon de bitsStatut
2NRV2B32 bits little-endianPris en charge, validé contre upx -d
3NRV2B8 bitsPris en charge, validé par vecteurs de grammaire
4NRV2B16 bits little-endianPris en charge, validé par vecteurs de grammaire
5NRV2D32 bits little-endianPris en charge, validé contre upx -d
6NRV2D8 bitsPris en charge, validé par vecteurs de grammaire
7NRV2D16 bits little-endianPris en charge, validé par vecteurs de grammaire
8NRV2E32 bits little-endianPris en charge, validé contre upx -d
9NRV2E8 bitsPris en charge, validé par vecteurs de grammaire
10NRV2E16 bits little-endianPris en charge, validé par vecteurs de grammaire
14LZMAsans objetPris en charge, validé contre upx -d et un vecteur 7-Zip indépendant

Tout autre identifiant de méthode est refusé explicitement. En particulier, un octet de méthode inconnu n’est jamais interprété en silence comme un autre algorithme.

NRV2B, NRV2D, NRV2E

La famille NRV (issue de la bibliothèque UCL) regroupe des variantes simples de LZ77 avec un flux de contrôle orienté bits. Les bits de contrôle sont lus par mots de 8, 16 ou 32 bits selon la variante, bit de poids fort en premier, et entrelacés avec des octets littéraux :

Le flux étant auto-terminé, un décodeur peut vérifier qu’il a consommé exactement c_len octets et produit exactement u_len octets. Les deux contrôles sont appliqués.

LZMA

Pour LZMA, UPX n’utilise pas l’en-tête standard de 13 octets du format .lzma. Il le remplace par un en-tête de propriétés compact de 2 octets :

Le flux codé par intervalles suit immédiatement, sans marqueur de fin ; la taille attendue provient de u_len. Les propriétés sont validées (bornes sur lc, lp, pb et cohérence entre les deux octets) avant toute allocation de modèle. Le décodeur est une implémentation LZMA complète (codage des littéraux avec prédiction par l’octet du match, distances rep0 à rep3, short reps, codeurs de longueur, slots de distance et bits d’alignement).

Contrôles d’intégrité

Lorsque l’en-tête est intact, une décompression n’est acceptée que si :

  1. l’Adler-32 des octets compressés correspond à c_adler ;
  2. le décodeur consomme la taille compressée déclarée (méthodes NRV) ;
  3. la sortie a exactement la taille déclarée u_len ;
  4. l’Adler-32 des octets décompressés correspond à u_adler.

Subtilité importante : u_adler est calculé sur l’image encore filtrée, c’est-à-dire avant l’inversion de la transformation des appels et sauts. Le vérifier après avoir défait le filtre échoue sur tous les échantillons filtrés.


Étape 3 : inverser les filtres d’appels et de sauts

Pourquoi UPX filtre le code

En x86, les instructions call rel32 (E8) et jmp rel32 (E9) stockent un déplacement relatif. Deux appels à la même fonction depuis des endroits différents ont donc des opérandes différents, ce qui se compresse mal. Avant compression, UPX réécrit ces opérandes en cibles absolues : les appels répétés à une même fonction deviennent des séquences d’octets identiques. Le stub inverse la transformation après décompression.

La famille de filtres utilisée sur les PE Windows est souvent appelée « filtres CTO », d’après le paramètre filter_cto stocké dans l’en-tête.

FiltreInstructions transforméesCodage de l’opérande
0x24call (E8)cible absolue, big-endian, octet marqueur en tête
0x25jmp (E9)cible absolue, big-endian, octet marqueur en tête
0x26call + jmpcible absolue, big-endian, octet marqueur en tête
0x46call + jmpmême principe, variante à marqueur
0x49call + jmp + sauts conditionnels jcc (0F 8x)même principe, couvre aussi les sauts conditionnels proches

Fonctionnement de l’inversion

Pour chaque opcode concerné dans la plage de code, le premier octet de l’opérande est comparé au marqueur (filter_cto). S’il correspond, l’opérande a été transformé : les 24 bits restants sont lus comme une position absolue en big-endian, reconvertis en déplacement relatif à la fin de l’instruction, puis réécrits en little-endian.

Le filtre n’est appliqué qu’à la plage de code d’origine, déterminée par les champs BaseOfCode et SizeOfCode de l’en-tête optionnel d’origine, qu’UPX sauvegarde dans l’image compressée.

Un piège classique : le marqueur n’est pas toujours nul

Il est tentant de supposer que l’octet marqueur vaut 0. Ce n’est pas le cas. UPX choisit le marqueur fichier par fichier pour limiter les faux positifs sur des données qui ressemblent par hasard à E8/E9. Certains binaires (les exécutables Visual Basic 6 en sont un exemple courant) se retrouvent avec un marqueur non nul. Avec un mauvais marqueur, deux problèmes surviennent simultanément : les vrais appels restent transformés, et des octets de données sans rapport sont « défiltrés » en données corrompues. On obtient un PE d’apparence plausible mais au code cassé, ce qui dégrade silencieusement toutes les analyses en aval.

Sur notre corpus de référence, UPX a retenu le filtre 0x49 pour les DLL x64 contenant des branchements, quelle que soit l’option de filtre demandée : le dépackeur doit lire le filtre effectivement appliqué dans le fichier, jamais le déduire de la ligne de commande supposée.

Le filtre 0x11 et tout autre identifiant de filtre inconnu sont refusés explicitement.


Étape 4 : reconstruire le PE d’origine

Le tampon décompressé n’est pas encore un fichier PE. C’est l’image mémoire des sections d’origine, suivie des métadonnées dont UPX a besoin pour restaurer tout le reste. Reconstruire un fichier valide demande plusieurs opérations distinctes.

Le bloc de restauration

Deux faits de disposition sont essentiels :

Juste après cette table des sections sauvegardée, UPX écrit des enregistrements compacts servant à restaurer imports, relocations et ressources. Leur présence dépend du contenu du fichier d’origine. Le dépackeur lit exactement les enregistrements attendus et exige que le bloc se termine là où il le doit : des octets résiduels inexpliqués signalent une disposition inconnue, et le fichier est refusé plutôt que reconstruit approximativement.

En-têtes et disposition des sections

Le fichier de sortie est assemblé ainsi :

Imports

Le fichier packé n’importe que ce dont le stub a besoin. La table d’imports d’origine est remplacée, dans l’image compressée, par un flux compact d’enregistrements d’imports. Pour chaque DLL importée, il stocke :

Deux profils de production existent et sont tous deux gérés :

  1. Noms reconstruits : l’enregistrement indique une destination où écrire les noms de DLL. Le dépackeur y dispose les noms de DLL, puis les entrées hint/name (alignées sur deux octets, avec le hint nul utilisé par UPX), remplit chaque thunk de l’IAT avec la RVA d’un hint/name ou avec un ordinal dont le bit de poids fort est positionné, puis écrit les descripteurs d’imports.
  2. Adresses conservées : les versions plus récentes d’UPX conservent dans l’image les thunks et adresses hint/name d’origine. Le dépackeur restaure les noms de DLL et de fonctions à leurs emplacements d’origine, et vérifie que chaque ordinal conservé correspond à l’enregistrement compact.

Le nombre de descripteurs reconstruits doit tenir dans la taille du répertoire d’imports d’origine. Tous les compteurs (modules, fonctions, longueurs de chaînes) sont bornés.

Relocations

Les relocations sont elles aussi stockées sous forme compacte :

Le dépackeur analyse d’abord le flux complet, puis reconvertit chaque cible en adresse absolue little-endian fondée sur l’ImageBase d’origine. Enfin, il régénère une table .reloc standard : entrées regroupées par page de 4 Kio en blocs de relocations de base, de type IMAGE_REL_BASED_HIGHLOW en PE32 ou IMAGE_REL_BASED_DIR64 en PE32+, chaque bloc étant complété jusqu’à une frontière de 4 octets. La table reconstruite doit avoir exactement la taille déclarée par le répertoire de relocations d’origine. Les fichiers dont les relocations ont été supprimées (IMAGE_FILE_RELOCS_STRIPPED) ne portent aucun enregistrement de relocations et n’en ont pas besoin. Seule la variante d’enregistrement observée sur notre corpus (flags 0) est acceptée.

Exports et TLS

Pour les DLL, UPX peut déplacer le répertoire d’exports hors de la zone compressée afin que le chargeur puisse résoudre les exports avant l’exécution du stub. Dans ce cas, le répertoire d’exports, sa table de fonctions, sa table de noms et sa table d’ordinaux sont recopiés à leur emplacement d’origine et leurs RVA recalculées. Lorsque les exports sont restés en place, ils sont repris de l’image décompressée.

Les données et callbacks TLS sont validés, jamais invoqués : la plage du modèle, l’adresse de l’index et chaque adresse de callback doivent se trouver dans l’image reconstruite.

Ressources

Les ressources constituent la partie la plus délicate. UPX conserve un répertoire de ressources dans le fichier packé, car Windows a besoin de certaines ressources (icône, informations de version, manifeste) sans exécuter le programme. Ce répertoire peut décrire un mélange de charges utiles :

Le dépackeur parcourt l’arbre de ressources stocké (avec détection de cycles et limites de profondeur et de nombre de nœuds), recopie les nœuds de répertoire, les noms et les entrées de données vers l’emplacement des ressources d’origine, replace les charges non compressées à leurs adresses d’origine et corrige les entrées de données en conséquence.

Les icônes demandent une attention particulière. Selon le réglage --compress-icons, UPX peut n’exposer que la première image du premier groupe d’icônes, afin que l’Explorateur affiche toujours une icône, et réduire le nombre d’images déclaré dans le répertoire du groupe. Le nombre d’origine est stocké dans le bloc de restauration ; le dépackeur le rétablit dans le premier GRPICONDIR. Les quatre réglages d’icônes (0 à 3) sont couverts par nos tests. Un arbre de ressources lui-même partiellement compressé, qui nécessiterait une fusion, est refusé.

Validation finale

Avant d’être accepté, le fichier reconstruit est de nouveau analysé comme PE et validé : chaque répertoire de données non vide doit être mappé dans le fichier, les descripteurs d’imports doivent être terminés et leurs thunks valides, les blocs de relocations bien formés avec des cibles dans l’image, les compteurs et ordinaux d’exports cohérents, les adresses TLS dans l’image et l’arbre de ressources exempt de cycles. Si un contrôle échoue, le candidat est écarté.

Packing imbriqué

Certains échantillons sont packés plusieurs fois. Après une couche réussie, le résultat est réexaminé, et les couches UPX suivantes sont décompressées jusqu’à une profondeur de trois. Une imbrication plus profonde est signalée comme un échec plutôt que de s’arrêter silencieusement à mi-chemin.


Étape 5 : récupérer les échantillons altérés

C’est là qu’un dépackeur statique fait la différence sur de vrais malwares. Lorsque le PackHeader est endommagé ou absent, le processus devient une recherche bornée, qui se termine toujours par la reconstruction et la validation complètes décrites ci-dessus.

En-tête endommagé mais présent

Un en-tête dont la signature a été effacée reste reconnaissable : l’octet de révision, le format, la méthode, le niveau et les tailles doivent tous être plausibles, et la méthode doit faire partie de celles que nous prenons en charge. Ces en-têtes sont exploités avec les mêmes contrôles d’intégrité que les en-têtes intacts, lorsque ces contrôles tiennent encore.

Des tailles d’en-tête mensongères

Si le décodage avec les tailles déclarées échoue, le dépackeur cesse de s’y fier et essaie les méthodes prises en charge (celle de l’en-tête en premier) à chaque offset candidat, en laissant les flux NRV auto-terminés définir leur propre longueur. Une note consigne la méthode, l’offset et le numéro de candidat retenus, et un écart entre la taille décompressée déclarée et la taille réelle est signalé comme anomalie.

LZMA sans aucune taille

Les flux LZMA d’UPX n’ont pas de marqueur de fin : sans u_len, le décodeur ne sait pas où s’arrêter. En phase de récupération uniquement, il s’arrête à un point où le codeur par intervalles est entièrement vidé et où la sortie se termine par un bloc de restauration plausible, c’est-à-dire un en-tête NT sauvegardé valide référencé par les quatre derniers octets. Il ne s’agit que d’une taille candidate : la reconstruction et la validation complètes doivent encore réussir.

En-tête entièrement effacé

Sans en-tête, le type de filtre et son marqueur sont inconnus, et une erreur produit du code silencieusement cassé. Le dépackeur les lit donc dans le stub lui-même : la fin de chaque routine de décompression connue (NRV et LZMA, 32 et 64 bits) est suivie du code d’inversion du filtre, dont la forme identifie le filtre et dont la constante embarquée est le marqueur. Ces éléments sont reconnus comme des profils d’octets bornés, relevés sur des fichiers produits par les outils UPX officiels, pour d’anciennes et de récentes générations de stubs. Aucune instruction n’est décodée, émulée ni exécutée.

Si le profil du stub est inconnu et que la section de code contient des opcodes de branchement susceptibles d’avoir été filtrés, le dépackeur refuse de produire une sortie plutôt que de deviner. Une mauvaise supposition donnerait un PE plausible mais corrompu, ce qui est pire que pas de PE du tout.

Ce contre quoi nous testons

Chaque fichier packé de référence est également décompressé après quatre altérations appliquées en mémoire, à l’image de ce que l’on observe dans la nature :

  1. les quatre octets UPX! effacés ;
  2. tous les noms de sections remplacés ;
  3. la taille décompressée remplacée par une valeur absurde, ce qui invalide aussi le checksum d’en-tête ;
  4. les 32 octets du PackHeader effacés.

Une mutation supplémentaire vérifie qu’un profil de filtre inconnu est refusé, même lorsque les données se décompressent correctement.


Sécurité et limites de ressources

Le dépackeur traite des entrées hostiles au sein d’un worker d’analyse partagé ; il est donc conçu de manière défensive :


Validation et périmètre actuel

La reconstruction est validée contre l’outil officiel upx -d (suppression des relocations désactivée) sur un corpus de programmes de test bénins que nous avons compilés puis packés avec les outils UPX officiels 3.91 et 3.95 : EXE et DLL, x86 et x64, les quatre familles de méthodes, cinq options de filtre, ressources conservées ou mixtes, et les quatre réglages d’icônes. Le succès exige la même architecture, le même type de fichier, le même point d’entrée, le même SizeOfImage, le même nombre de sections et les mêmes attributs de sections, ainsi que des données brutes identiques octet pour octet pour chaque section, padding compris. Les écarts admis se limitent aux en-têtes : stub DOS conservé, checksum à zéro, répertoires de certificats, de débogage, d’imports liés et d’IAT effacés. Les décodeurs sont écrits à partir de descriptions de formats ; aucun code source UPX ou UCL n’est réutilisé.

Limites connues, énoncées clairement :


Dans le rapport Malva.RE

Lorsqu’un échantillon packé UPX est soumis, le rapport affiche désormais :

Cela comble un angle mort frustrant : un stealer ou un RAT courant emballé dans UPX, dont l’en-tête a été abîmé pour faire échouer upx -d, est désormais analysé comme le stealer ou le RAT qu’il est réellement, sans jamais être exécuté.


Essayez sur vos propres échantillons

Vous pouvez soumettre des échantillons packés UPX à Malva.RE et inspecter le payload reconstruit directement dans l’arbre du rapport. Démarrer un essai gratuit. Gardez à l’esprit que les rapports produits avec l’offre gratuite sont publics.

Tags : Malva.RE UPX Packers Dépackage Format PE Analyse Statique Malware
// Conçue en Savoie

Conçue et développée en Savoie par Yneos

Malva.RE est éditée par Yneos, société française de cybersécurité et de développement de logiciels sécurisés, basée à Savoie Technolac (Le Bourget-du-Lac, France).

Découvrir Yneos →

// Malva.RE

Essayez Malva.RE sur un vrai échantillon

Comprenez la structure et le comportement des malwares sans exécution

Essayer gratuitement

Offre gratuite : rapports publics

Demander une démo →