Dépackage statique d’UPX : reconstruire le PE d’origine sans l’exécuter
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 chaînes du vrai programme (URL, mutex, clés de registre, user agents) sont compressées ;
- la table d’imports ne liste que les quelques fonctions dont le stub a besoin (typiquement
LoadLibraryA,GetProcAddress,VirtualProtect,ExitProcess) ; - les règles YARA écrites pour le payload ne matchent jamais, puisque les octets recherchés n’existent pas dans le fichier ;
- les extracteurs de configuration de RAT et de stealers n’ont rien à analyser ;
- la classification de famille se résume à « exécutable packé », ce qui n’apprend pas grand-chose.
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 :
- Taille : des droppers et payloads plus petits sont plus faciles à diffuser et à embarquer dans des documents, des archives ou d’autres loaders.
- Obfuscation à moindre coût : la compression fait disparaître les chaînes en clair et la vraie table d’imports d’une inspection statique naïve.
- Ambiguïté : un binaire packé UPX n’est pas malveillant en soi ; « packé avec UPX » est donc à lui seul un signal de détection faible.
- Compatibilité : UPX gère EXE et DLL, 32 et 64 bits, et fonctionne avec la sortie de la plupart des compilateurs.
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 :
- la signature
UPX!est effacée ou écrasée ; - les sections
UPX0/UPX1sont renommées en quelque chose d’anodin (.text,.data, noms aléatoires) ; - des champs de l’en-tête de packing (tailles, sommes de contrôle) sont altérés pour faire échouer les contrôles d’intégrité ;
- l’en-tête de packing est entièrement effacé.
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 :
UPX0a une taille brute nulle et une grande taille virtuelle. Elle réserve l’espace d’adressage dans lequel les sections d’origine seront décompressées.UPX1contient les données compressées suivies du stub de décompression. Le point d’entrée du fichier packé pointe dans cette section.- Une troisième section (nommée
UPX2, ou.rsrclorsque des ressources sont conservées) contient la table d’imports minimale du stub et les ressources qu’UPX a choisi de ne pas compresser : typiquement l’icône principale, les informations de version et le manifeste, pour que l’Explorateur et le chargeur Windows y aient toujours accès.
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 :
| Offset | Taille | Champ | Signification |
|---|---|---|---|
| 0 | 4 | magic | "UPX!" |
| 4 | 1 | version | Révision du format d’en-tête (pas le numéro de version d’UPX) |
| 5 | 1 | format | Format cible (PE Win32, PE Win64…) |
| 6 | 1 | method | Identifiant de la méthode de compression |
| 7 | 1 | level | Niveau de compression (1 à 10) |
| 8 | 4 | u_adler | Adler-32 des données décompressées |
| 12 | 4 | c_adler | Adler-32 des données compressées |
| 16 | 4 | u_len | Taille décompressée |
| 20 | 4 | c_len | Taille compressée |
| 24 | 4 | u_file_size | Taille du fichier d’origine |
| 28 | 1 | filter | Identifiant du filtre (transformation des appels/sauts) |
| 29 | 1 | filter_cto | Paramètre du filtre (octet marqueur) |
| 30 | 1 | (réservé) | |
| 31 | 1 | checksum d’en-tête | Somme des octets 4 à 30, modulo 251 |
Quelques remarques importantes en pratique :
- L’octet de révision n’est pas la version d’UPX. Plusieurs versions d’UPX partagent la même révision, et la chaîne de version placée devant l’en-tête se modifie trivialement. Le dépackeur consigne la révision comme indice structurel, mais n’affirme jamais un numéro de version d’UPX qu’il ne peut pas prouver.
- Notre lecteur prend en charge les révisions d’en-tête de la disposition à 32 octets (10 à 14). Les autres révisions ne sont pas interprétées en devinant la position des champs ; elles passent par le chemin de récupération décrit plus bas.
- Les deux sommes Adler-32 et le checksum d’en-tête permettent de distinguer un véritable en-tête d’octets aléatoires, et une décompression correcte d’une décompression corrompue.
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 :
- une section nommée
UPX0,UPX1,.UPX0ou.UPX1; - la signature
UPX!dans les 4 premiers Kio du fichier (la zone d’en-têtes) ; - un prologue de stub connu au point d’entrée, reconnu comme séquence d’octets :
- EXE x86 :
pushad ; mov esi, imm32 ; lea edi, [esi+imm32] ; push edi; - DLL x86 :
cmp byte [esp+8], 1 ; jnz rel32 ; pushad ; mov esi, imm32(le stub ne décompresse que surDLL_PROCESS_ATTACH) ; - EXE x64 :
push rbx ; push rsi ; push rdi ; push rbp ; lea rsi, [rip+rel32] ; lea rdi, [rsi+imm32]; - DLL x64 : un prologue de sauvegarde de registres trop banal à lui seul, accepté uniquement avec la signature structurelle d’UPX (première section de taille brute nulle et de taille virtuelle non nulle).
- EXE x86 :
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 :
- 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.
- Les noms de sections UPX conventionnels.
- Un motif d’octets de type stub au point d’entrée.
- Une forte entropie dans la section stockée (comptée uniquement avec la disposition, l’entropie seule ne voulant rien dire).
- 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 :
- l’octet qui suit chaque PackHeader trouvé ;
- les adresses présentes comme opérandes immédiats dans le stub : en x86, le
mov esi, imm32qui charge le pointeur source ; en x64, lelea rsi, [rip+rel32]relatif à RIP. Elles sont lues comme des constantes puis converties en offsets de fichier ; aucune instruction n’est décodée ni exécutée ; - le début de chaque section stockée, plus quelques petits décalages.
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 :
| ID | Méthode | Largeur du tampon de bits | Statut |
|---|---|---|---|
| 2 | NRV2B | 32 bits little-endian | Pris en charge, validé contre upx -d |
| 3 | NRV2B | 8 bits | Pris en charge, validé par vecteurs de grammaire |
| 4 | NRV2B | 16 bits little-endian | Pris en charge, validé par vecteurs de grammaire |
| 5 | NRV2D | 32 bits little-endian | Pris en charge, validé contre upx -d |
| 6 | NRV2D | 8 bits | Pris en charge, validé par vecteurs de grammaire |
| 7 | NRV2D | 16 bits little-endian | Pris en charge, validé par vecteurs de grammaire |
| 8 | NRV2E | 32 bits little-endian | Pris en charge, validé contre upx -d |
| 9 | NRV2E | 8 bits | Pris en charge, validé par vecteurs de grammaire |
| 10 | NRV2E | 16 bits little-endian | Pris en charge, validé par vecteurs de grammaire |
| 14 | LZMA | sans objet | Pris 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 :
- un bit de contrôle à
1signifie « copier l’octet suivant tel quel » ; - un
0introduit une correspondance (match). Son offset est codé par un préfixe de longueur variable (de type Elias-gamma) suivi d’un octet supplémentaire. Une valeur de préfixe particulière signifie « réutiliser l’offset précédent », ce qui est très efficace sur du code répétitif ; - la valeur d’offset décodée
0xFFFFFFFFest le marqueur de fin de flux ; - la longueur du match est codée par quelques bits directs, ou par un autre préfixe de longueur variable pour les matchs longs. NRV2D et NRV2E utilisent le bit de poids faible de l’offset pour porter une partie de la longueur, et NRV2E dispose d’un schéma légèrement différent pour les longueurs courtes ;
- les matchs très lointains reçoivent implicitement un octet de longueur supplémentaire (au-delà de
0xD00pour NRV2B,0x500pour NRV2D/E).
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 :
- premier octet :
pbdans les 3 bits de poids faible,lc + lpdans les bits de poids fort ; - second octet :
lcdans le quartet bas,lpdans le quartet haut.
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 :
- l’Adler-32 des octets compressés correspond à
c_adler; - le décodeur consomme la taille compressée déclarée (méthodes NRV) ;
- la sortie a exactement la taille déclarée
u_len; - 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.
| Filtre | Instructions transformées | Codage de l’opérande |
|---|---|---|
0x24 | call (E8) | cible absolue, big-endian, octet marqueur en tête |
0x25 | jmp (E9) | cible absolue, big-endian, octet marqueur en tête |
0x26 | call + jmp | cible absolue, big-endian, octet marqueur en tête |
0x46 | call + jmp | même principe, variante à marqueur |
0x49 | call + 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 :
- l’octet zéro du tampon décompressé correspond à la plus petite RVA de section d’origine, et non à la RVA 0. Les en-têtes d’origine ne font pas partie de l’image mappée ;
- les quatre derniers octets du tampon contiennent l’offset, dans ce même tampon, d’une copie des en-têtes NT et de la table des sections d’origine.
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 :
- l’en-tête DOS et le stub DOS du fichier packé sont conservés ;
- les en-têtes NT et la table des sections d’origine sauvegardés sont écrits à l’
e_lfanewdu fichier packé ; - chaque section d’origine est recopiée depuis l’image décompressée vers son
PointerToRawDatad’origine, après vérification de l’alignement par rapport auFileAlignmentd’origine et de la présence effective des octets dans le tampon décompressé ; SizeOfImageest recalculé à partir de la table des sections et duSectionAlignment;- le
CheckSumPE est remis à zéro, et les répertoires de données qui ne décrivent plus rien de valide sont effacés : table des certificats, répertoire de débogage, imports liés (bound imports) et répertoire IAT.
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 :
- l’emplacement du nom de la DLL (les noms sont conservés dans la zone d’imports du fichier packé, pour que le stub puisse les passer à
LoadLibraryA) ; - l’offset de l’IAT d’origine ;
- une liste d’entrées étiquetées : import par nom (chaîne terminée par un zéro), par ordinal 16 bits ou par ordinal 32 bits (qui doit tout de même tenir dans le champ ordinal 16 bits du format PE), terminée par une étiquette nulle.
Deux profils de production existent et sont tous deux gérés :
- 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.
- 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 :
- un flux de deltas de position : un octet pour les petits deltas, une forme étendue sur 20 bits introduite par les octets
0xF0et au-delà, et une forme 32 bits complète en dernier recours, le tout terminé par un zéro ; - à chaque position relogée, UPX stocke le pointeur sous forme relative, en big-endian (ce qui se compresse aussi mieux).
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 :
- des ressources compressées avec le reste de l’image, désormais revenues dans le tampon décompressé à leur RVA d’origine ;
- des ressources laissées non compressées dans le fichier packé, chacune précédée de quatre octets contenant sa RVA d’origine.
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 :
- les quatre octets
UPX!effacés ; - tous les noms de sections remplacés ;
- la taille décompressée remplacée par une valeur absurde, ce qui invalide aussi le checksum d’en-tête ;
- 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 :
- traitement purement statique : l’entrée est un tableau d’octets ; aucun processus n’est lancé, aucun PE ni assembly n’est chargé, aucun appel natif, aucune émulation, aucun accès disque ou réseau n’a lieu ;
- les tailles d’entrée et de sortie sont plafonnées, et les tampons de sortie grandissent progressivement au lieu de faire confiance à une taille déclarée par l’en-tête ;
- un délai global avec annulation coopérative dans chaque boucle ; à expiration, la tâche de décompression est annulée puis attendue, si bien qu’aucun calcul orphelin ne continue à consommer du CPU ;
- chaque lecture, distance de référence arrière, longueur et adresse est vérifiée, et chaque parcours de structure PE est borné en nombre et en profondeur ;
- le nombre de candidats de récupération et la profondeur d’imbrication sont plafonnés ;
- un échec de décompression ne fait jamais échouer le scan : le tag UPX reste, et la raison de l’échec est affichée dans le rapport.
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 :
- seules les sorties d’UPX 3.91 et 3.95 ont été comparées à une référence indépendante à ce jour. Les autres versions 3.x, ainsi que les 1.x, 2.x, 4.x et 5.x, peuvent fonctionner lorsque les formats sont communs, mais ce n’est pas encore démontré ;
- les variantes NRV 8 bits et 16 bits ne sont validées que par des vecteurs de test, aucun PE officiel les utilisant ne figurant dans le corpus ;
- l’overlay et une éventuelle signature Authenticode ne sont pas restaurés ;
- une table des sections d’origine qui ne commence pas par la plus petite RVA est refusée ;
- les filtres inconnus (dont
0x11), les flags d’enregistrement de relocations inconnus, les arbres de ressources partiellement compressés nécessitant une fusion et les profils de stub non reconnus en l’absence des paramètres de filtre sont refusés ; - les assemblies .NET ne sont pas revendiqués : notre entrée de test .NET a été refusée par les outils UPX eux-mêmes ;
- seuls les PE Windows (x86 et x64) sont concernés : UPX pour ELF, Mach-O ou d’autres formats n’est pas traité par ce composant.
Dans le rapport Malva.RE
Lorsqu’un échantillon packé UPX est soumis, le rapport affiche désormais :
- le tag UPX, désormais levé aussi pour les fichiers x86/x64 ;
- la méthode de compression et le filtre identifiés, ainsi qu’une valeur de confiance (un score d’indices, pas une probabilité calibrée) ;
- les éventuelles anomalies : signature effacée, sections renommées, tailles incohérentes, en-tête absent, détails de la récupération heuristique. Un tag dédié signale les en-têtes UPX trafiqués ;
- le PE reconstruit, comme nœud enfant dans l’arbre des fichiers, verrouillé en tant qu’exécutable ou DLL Windows, et analysé comme n’importe quel autre binaire : structure PE, règles YARA appliquées au vrai programme, détection de famille, extraction de configuration et IOC.
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.
