lundi 16 février 2009

Oupsx...

Cette semaine j'ai mis le nez dans le code de UPX 3.03, un packer connu pour être très facile à unpacker (ce qui se comprend puisque ce n'est pas pour ça qu'il a été conçu). Mais comme pour FSG, mon but n'était pas de faire de l'unpacking mais plutôt de comprendre comment le loader (le "stub" dans la terminologie UPX) fonctionne et réalise son unpacking. Ces stubs sont programmés en ASM et les commentaires sont plutôt laconiques, donc ça vaut le coup de sortir son débuggeur préféré pour comprendre à la main :-)

Ca ma permit de découvrir une "feature" assez marrante @Microsoft : quand on compile avec Visual Studio C++ un programme qui fait des opérations flottantes, le programme va vérifier dans le header PE que les sections de la mémoire qui ne doivent pas être "écrivables" (typiquement .text et .rdata) le sont bien. Sinon il déclenche un joli message "R6002: floating point not loaded error" et votre programme plante lamentablement. Le "side-effect" c'est que quand un packer lambda (comme FSG) décompresse son exécutable dans une zone mémoire (donc avec les droits d'écritures) il laisse les droits et donc le programme plante.
UPX résoud le problème en changeant les droits juste avant de jumper sur l'OEP mais ça implique un appel à VirtualProtect() pour pouvoir modifier le PE header (+ 1 pour remettre les anciens droits) et d'écrire dans la mémoire, donc une perte de temps.
Voir le post de kpnc sur le sujet.

Ayant une expérience très restreinte je ne sais pas comment les autres packers s'en sortent. Do you have any idea ?

Sur ce, voici le bouzin.



6 commentaires:

Ivanlef0u a dit…

Yo,
Je n'ai pas très bien compris la partie sur l'un-filtering. Tu dis "Une fois la décompression terminée, UPX passe dans une phase de « un-filtering ». Le filtrage est une
méthode que le packer emploie pour améliorer son ratio de compression."
Avant de faire de l'un-filtering le binaire n'est pas encore décompressé totalement en mémoire, c'est pas juste une autre méthode de decompress ? (qui dépend de la première)

Ensuite tu prends comme exemple des calls sur la même fonction. UPX prend l'offset du call (dst-src-5) et lui ajoute la RVA par rapport à la section .text pour trouver une constante. C'est opération étant faisable pour tous les calls (même sur les jmp au final) comment UPX sait lesquels il doit modif ou non ? Et comment par la suit il sait lesquels il doit reverser.
Tu dis " UPX fait alors l’hypothèse que les offsets initiaux ont des valeurs positives inférieures à
0xFFFFFF, donc qu’ils sont de la forme 0x00XXXXXX. Cela laisse le premier octet pour mettre un
« marqueur » qui permettrait à l’unpacker de trouver les CALL/JMP filtrés"
Ya pas moyen de baisé ca juste pour rire ? Ca me parait un peu gay ...

Bref je suis chiant mais c'est un zoli paper, continue dude !
+
Ivan

JoE a dit…

Yo Ivan,

la partie sur l'un-filtering est effectivement pas très claire, je mélange le filtrage avec le dé-filtrage (un-filtering because i have my TOEFL ;). Je vais modifier ça pour être moins confus.

Plus précisément :

"Avant de faire de l'un-filtering le binaire n'est pas encore décompressé totalement en mémoire, c'est pas juste une autre méthode de decompress ? (qui dépend de la première)"

Le binaire initial est arrivée à sa taille finale avant l'un-filtering, donc ce n'est pas de la décompression au sens "premier" du terme.

D'un point de vue chronologique au moment du packing :
binaire initial -> filtrage -> compression -> binaire packé

La méthode de filtrage est indépendante de la méthode de compression. Son but est "juste" de créer des répétitions dans le code du binaire original pour que la compression qui s'applique ensuite soit plus efficace (parce que les algos de compressions aiment les répétitions, genre la famille des LZ* :). Pour être plus "rigoureux" on pourrait dire qu'on diminue l'entropie du programme initial ^^

Ensuite vient la phase d'un-packing :
binaire packé -> décompression -> un-filtering -> construction IAT -> binaire initial (+- code du packer)

Là encore il n'y a pas de relations entre la phase de décompression et d'un-filtering. Chacune est la "réciproque" de la phase correspondante au moment du packing.

Il faut donc juste voir le filtrage UPX comme un ensemble d'opérations réversibles visant à créer des répétitions dans le code du programme. On pourrait très bien enlever cette phase, mais on perdrait en efficacité de compression.

Par contre il est vrai que le code n'est pas fonctionnel avant la phase de un-filtering, donc on pourrait l'associer à la phase de décompression dans le sens où elle est obligatoire pour remettre le binaire en état.
On pourrait reconstruire l'IAT sans faire le un-filtering, on se retrouverait avec un code qui a la même taille que le code initial et qui semble exactement le même sauf que chaque CALL/JMP relatifs est suivie d'un offset qui n'a pas de sens, puisque filtré.

"C'est opération étant faisable pour tous les calls (même sur les jmp au final) comment UPX sait lesquels il doit modif ou non ?"
Exactement, tous les CALL et JMP relatifs de ton code sont possiblement "filtrables" (modulo le problème de l'offset, j'y reviens). Pour les call absolues ça n'a pas d'intérêt puisque c'est déjà la même suite de bytes :-)

J'ai pas bien fouillé dans le code du packer en lui même mais après "observations", il cherche les opcodes 0xE8/0xE9 suivie de 4 octets formant un offset qui tombe dans la même section (la doc confirme ce point sans trop s'y attarder) et qui est de la forme 0x00XXXXXX, donc avec son MSByte à 0.

Ce premier byte à 0x00 va recevoir un "marqueur" qui est donc une valeur "unique" dans le sens où seuls les CALL/JMP qui ont été filtrés sont suivis par cet opcode/marqueur. Cela va servir à l'un-packer pour reconnaitre les CALL/JMP à réverser. C'est clair que ça parait un peu magique et qu'on doit pouvoir faire un programme qui ne laisse aucune des 256 possibilitées, donc qui empêche le filtrage.

Y a sûrement moyen de trouver un cas particulier où UPX va filtrer les 4 octets suivants un 0xE8 ou 0xE9 qui ne sont pas un CALL/JMP. Il "suffit" de remplir les conditions précédentes ("fake offset" qui tombe dans la section mémoire et valeur du type 0x00FFFFFF (en big-endian)). Mais en fait on s'en fiche complètement dans le sens où la phase de un-filtering va aussi remettre d'aplomb ce bout de code (les opérations étant réversibles) car il correspond à son critère de recherche (présence du marqueur). Au pire on aura perdu du temps parce qu'il est probable que comme cette chaine ne correspond pas à un vrai CALL/JMP, elle ne soit pas répétée ailleurs, donc aucun gain en compression.

J'espère que c'est plus clair.
Continue à "être chiant", c'est comme ça qu'on progresse :-)

Merci Ivan,
Let me know if something is false/not clear.

Ivanlef0u a dit…

Yo,
Ok mofo merci, c'est beaucoup plus clair dorénavant.

Je me demandais plutôt de fabriquer dès le départ un call "-tag-offset-" qui ne serait pas filtré par UPX. Ensuite lors de l'un-filtering la routine modifierait le couple "-tag>-offset-" et défoncerait donc l'instruction pour faire planter le prog. Je ne sais pas quel est la valeur du marqueur (je pense à 0x7F ou 0x80 pour obliger le code à avoir un offset très grand en signé ce qui n'arrive jamais). Faudrait y penser un peu plus juste pour déconner.

En tout cas c'est cool ce que tu fais mek !
+
Ivan

JoE a dit…
Ce commentaire a été supprimé par l'auteur.
JoE a dit…

Ok je vois.
"Théoriquement" (i.e. d'après la doc :) quand tu cales un CALL AABBCCDD dans ton binaire initial, UPX ne choisira pas 0xAA comme marqueur puisqu'il s'agit d'un opcode qui suit CALL/0xE8.

Après j'ai pas du tout regardé en pratique comment il va trouver la valeur du marqueur, j'ai fait pas mal de tests et c'était toujours la même, je pense qu'il prend le plus petit opcode non utilisé. On doit donc pouvoir "deviner" cette valeur genre en forçant l'utilisation dans notre code des n premiers opcodes avec du code inutile, le marqueur sera (avec un peu de chance) le n+1 ou le n+2.

Et comment ils trouvent les opcodes utilisés ?
Ca doit être un parcours linéaire a la con et si c'est "mal fait", peut être un moyen de cacher un CALL (en fait un opcode 0xE8 suivie de la valeur qu'on "devine" du marqueur devrait suffire à niker le prog une fois décompressé, pour peu que ça soit bien du code exécutable initialement).

A réfléchir pour créer la protection anti-UPX-packing :-)

A+ ch3f ;)

Aspirin742 a dit…

J'ai rien compris ! Mais ça à l'air bien !