Zoom Icon

Lezione 9 Manual Unpacking

From UIC Archive

Manual Unpacking

Contents


Lezione 9 Manual Unpacking
Author: Ntoskrnl
Email: [email protected]
Website: http://ntcore.com
Date: 05/01/2009 (dd/mm/yyyy)
Level: Slightly hard
Language: Italian Flag Italian.gif
Comments:



Tools

Explorer Suite
OllyDbg
ImpRec


Introduzione

È davvero da tanto che non scrivo un articolo in italiano, per di più un articolo per novizi. Se state leggendo questa lezione, significa che avete assimilato quasi tutte le basi necessarie che vi servono per reversare. Il manual unpacking è sicuramente la cosa più complessa da imparare. Anche se un keygen può risultare a volte ben più difficile di un packer, è molto più facile spiegare la teoria dietro ad un keygen, rispetto che spiegare il funzionamento di un packer. Questo è dovuto principlamente a due motivi. Innanzitutto, per unpackare è necessario avere una conoscenza di un numero più vasto di argomenti: non basta come per un keygen capire il codice macchina e saperlo debuggare. Inoltre, il termine "packer" è oggigiorno molto generico. I packer implementano ogni sorta di tecniche anti-reversing e nemmeno un libro intero basterebbe per spiegarle tutte in dettaglio.

Cercherò di farmi capire anche da chi non ha la minima idea di cosa sia un packer. Tuttavia, è inutile negare che dovrete sbattervi, e non poco, per capire questa lezione. Non confondete, anche se vi saranno altre lezioni dopo questa, per esempio quella sul .NET o sul Java, questa lezione rappresenta il vero termine del vostro percorso da novizi. Se riuscite a comprendere quanto segue, tutto il resto lo imparerete senza difficoltà e per lo più si tratterà di aggiornamenti al vostro pacchetto di conoscenze base. Ecco, potremmo definire l'articolo sul manual unpacking il culmine delle conoscenze di base. Per scrivere questo tutorial, ho solo poco più di mezza giornata. È un periodo molto impegnato per me. Tuttavia, questa mancanza di tempo ben si concilia con il tipo di articolo che scrivo. Data la mole di conoscenze che l'unpacking richiede, io provvederò a fornire solo un'infarinatura generale e indirizzerò verso altri articoli per approfondire i singoli argomenti.

Come detto, il termine "packer" è molto generico, quindi darne una definizione precisa è poco serio. In parole povere, è una protezione per eseguibili. Gli eseguibili su Windows (esistono packer anche per altre piattaforme, ma non tanti) sono, ad esempio, i file exe, dll e sys. I file eseguibili su Windows, anche se hanno estensioni diverse, sono tutti dei Portable Executable. Il Portable Executable, o più brevemente PE, è un formato file e il 90% del reversing di packer verte sulla conoscenza di questo formato. Prima di parlare del PE, vi dico in due parole cosa si intende genericamente col termine "packer".

Col termine "packer" si intende un programma che:

  • Comprime (e se critta si chiamava "crypter") dati, come il codice, all'interno di un eseguibile.
  • Aggiunge una sua porzione di codice alla fine dell'eseguibile.
  • Cambia l'Entry Point dell'eseguibile in modo da far eseguire il proprio codice all'avvio di questo.

Il codice aggiunto dal packer si occupa, quando eseguito, di decomprimere i dati e poi saltare al codice originale dell'eseguibile, consentendone la normale esecuzione. Agli inizi, si tendeva a distinguere di più tra packer e crypter, ma non ha molto senso. Il funzionamento di entrambi è il medesimo e la distinzione serve solo se veramente state cercando un compressore per rendere più "piccoli" di dimensioni i vostri programmi. Una protezione, anche se usasse un algoritmo di compressione, in genere aggiunge così tanti dati propri da rendere l'eseguibile protetto molto più grande di dimensioni rispetto a quello originale.

Se siete stati attenti, avrete notato che ho introdotto il termine "Entry Point". Un formato eseguibile, come il PE, deve far sapere al loader del sistema operativo la locazione, all'interno del file eseguibile, da cui comincia il codice da eseguire. Questa locazione si chiama appunto entry point, in breve: EP. Al termine dell'esecuzione del codice del packer, questo salta all'entry point del codice originale, che, nella terminologia del reversing, si chiama Original Entry Point, ovvero: OEP. EP e OEP perché sono due termini fondamentali.

L'informazione riguardante la collocazione dell'entry point è situata in una struttura all'interno del formato PE ed è proprio da qui che si inizia.

Portable Executable

È evidente che per questo paragrafo ho perlopiù riutilizzato stralci dell'articolo sul PE che scrissi all'età di 16 anni. Consiglio, comunque, di leggere quell'articolo in quanto questo paragrafo spiega solo le basi del PE necessarie per semplici unpack.

Prima di partire con la pratica faccio una piccola introduzione casomai non sapeste proprio cosa è il PE. Il PE (Portable Executable) è un formato di file, più precisamente un formato standard per gli eseguibili Win32, tale formato è stato scritto dalla Microsoft in base al COFF (Common Object File Format) che è appunto il formato standard per gli object-file su os unix-like e VMS e dal quale derivano tutti i formati quali PE, Elf ecc. Ma a cosa serve il PE? Serve a fornire al loader le informazioni necessarie per creare il processo/modulo. Per chi non lo sapesse, non sono solo gli exe sono PE ma pure le dll, i sys, gli ocx (e anche altri tipi di file). Infatti è possibile packare anche dll e persino device drivers.

Innanzitutto vediamo la struttura di un PE, ovvero come un normale PE ci si presenta. Per guardare la struttura di un PE è possibile usare un PE editor quale il CFF Explorer.
Mup1.png
Tutto ciò che viene prima dei “Section Headers” sono semplici strutture, mentre le sezioni sono rappresentate da un array di strutture, una struttura per ogni sezione nel PE. E per ora fermiamoci qua. Vediamo a livello di codice la parte iniziare di un PE. Come forse saprete tutte le strutture e le dichiarazioni riguardanti il PE stanno in WinNT.h.

La prima struttura che incontriamo in un file PE è il DOS Header. Ecco la struttura: typedef struct _IMAGE_DOS_HEADER { // DOS .EXE header

   WORD e_magic;        // Magic number
   WORD e_cblp;         // Bytes on last page of file
   WORD e_cp;           // Pages in file
   WORD e_crlc;         // Relocations
   WORD e_cparhdr;      // Size of header in paragraphs
   WORD e_minalloc;     // Minimum extra paragraphs needed
   WORD e_maxalloc;     // Maximum extra paragraphs needed
   WORD e_ss;           // Initial (relative) SS value
   WORD e_sp;           // Initial SP value
   WORD e_csum;         // Checksum
   WORD e_ip;           // Initial IP value
   WORD e_cs;           // Initial (relative) CS value
   WORD e_lfarlc;       // File address of relocation table
   WORD e_ovno;         // Overlay number
   WORD e_res[4];       // Reserved words
   WORD e_oemid;        // OEM identifier (for e_oeminfo)
   WORD e_oeminfo;      // OEM information; e_oemid specific
   WORD e_res2[10];     // Reserved words
   LONG e_lfanew;       // File address of new exe header

} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER; Di questa struttura ci servono solamente 2 elementi (gli altri elementi possono anche essere 0): e_magic e e_lfanew. e_magic come vedete è una WORD e ci serve per un primo controllo, ovvero e_magic deve corrispondere alle lettere ascii MZ. Se non corrisponde, allora il nostro file non è da considerarsi come eseguibile, nel winnt è definito IMAGE_DOS_SIGNATURE (0x4D5A = 'MZ') che sarà il valore con cui confrontare e_magic. Invece e_lfanew è un file offset che ci dice dove stanno gli Nt Headers ovvero il PE Header. Solitamente il PE Header viene dopo il DOS Header e dopo il DOS Stub (che serve solo se un programma Win32 viene avviato in DOS: ovvero dice che non è possibile, una cosa del genere: This program cannot be run in DOS mode), comunque, un PE può benissimo fare a meno del DOS Stub, che è anzi da considerarsi obsoleto. e_lfanew fa in modo che il PE header possa essere praticamente ovunque nel file anche se in genere si trova dove ho detto. Quindi per ottenere l'inizio della struttura NtHeaders basterà sommare all'indirizzo base del file in memoria il file offset contenuto in e_lfanew.

Ecco la struttura del Nt Headers: typedef struct _IMAGE_NT_HEADERS {

   DWORD Signature;
   IMAGE_FILE_HEADER FileHeader;
   IMAGE_OPTIONAL_HEADER32 OptionalHeader;

} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32; La Signature nella struttura sta ad indicare che il file è effettivamente un PE. Ovvero questo campo deve corrispondere ai caratteri 'PE'00. Offset 0 1 2 3 4 5 6 7 8 9 A B C D E F Ascii

000000E0 50 45 00 00 4C 01 06 00 PE..L.... 000000F0 7A AD 80 3F 00 00 00 00 00 00 00 00 E0 00 0E 01 z¡Ç?........Ó.... 00000100 0B 01 06 00 00 A0 02 00 00 D0 00 00 00 00 00 00 ......á..ð....... Dalle lettere PE inizia il nostro PE Header. La definizione di tale signature è IMAGE_NT_SIGNATURE. Adesso vediamo entrambe le strutture andando in ordine, partiamo dal File Header. typedef struct _IMAGE_FILE_HEADER {

   WORD Machine;
   WORD NumberOfSections;
   DWORD TimeDateStamp;
   DWORD PointerToSymbolTable;
   DWORD NumberOfSymbols;
   WORD SizeOfOptionalHeader;
   WORD Characteristics;

} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER; Ci sono diversi campi interessanti in questa struttura, ma in questo tutorial cerco di spiegare solo ciò che serve per unpackare. E per quel fine ciò che vi serve sapere in questa struttura è che NumberOfSections identifica il numero di sezione all'interno del PE e SizeOfOptionalHeader che specifica le dimensioni dell'Optional Header, ovvero la struttura che segue il File Header.

L'Optional Header è essenziale per unpackare, dato che contiene, fra le varie cose, l'Entry Point dell'eseguibile. Vediamo la struttura: typedef struct _IMAGE_OPTIONAL_HEADER {

   WORD Magic;
   BYTE MajorLinkerVersion;
   BYTE MinorLinkerVersion;
   DWORD SizeOfCode;
   DWORD SizeOfInitializedData;
   DWORD SizeOfUninitializedData;
   DWORD AddressOfEntryPoint;
   DWORD BaseOfCode;
   DWORD BaseOfData;
   DWORD ImageBase;
   DWORD SectionAlignment;
   DWORD FileAlignment;
   WORD MajorOperatingSystemVersion;
   WORD MinorOperatingSystemVersion;
   WORD MajorImageVersion;
   WORD MinorImageVersion;
   WORD MajorSubsystemVersion;
   WORD MinorSubsystemVersion;
   DWORD Win32VersionValue;
   DWORD SizeOfImage;
   DWORD SizeOfHeaders;
   DWORD CheckSum;
   WORD Subsystem;
   WORD DllCharacteristics;
   DWORD SizeOfStackReserve;
   DWORD SizeOfStackCommit;
   DWORD SizeOfHeapReserve;
   DWORD SizeOfHeapCommit;
   DWORD LoaderFlags;
   DWORD NumberOfRvaAndSizes;
   IMAGE_DATA_DIRECTORY DataDirectory[IMAGE_NUMBEROF_DIRECTORY_ENTRIES];

} IMAGE_OPTIONAL_HEADER32, *PIMAGE_OPTIONAL_HEADER32; AddressOfEntryPoint è un RVA che corrisponde all'Entry Point del PE, specifica da dove deve partire il codice all'interno del modulo in memoria. Servono adesso dei chiarimenti, ho usato il termine RVA: cosa vuol dire? Perché ho detto modulo e non file? Procediamo con ordine, dato che la prima risposta può rendere intuitiva quella alla seconda domanda. Innanzitutto, dovete sapere che quando il loader carica un PE in memoria non lo carica come è sul disco ma per quanto riguarda le sezioni le carica dove la Section Table gli dice di caricarle. Inoltre vi è un altro campo nell'Optional Header che specifica l'indirizzo al quale deve essere mappato il PE (il campo si chiama Image Base), in genere per gli exe l'Image Base è 00400000h e questo è un Virtual Address. Se la Section Table ci dice che la prima sezione (mettiamo sia quella che contiene il codice e in genere è così) che sta al File Offset (ovvero l'offset della sezione su disco) 200h deve in memoria essere mappata all'RVA (Relative Virtual Address) 1000h, avremo la sezione in memoria al Virtual Address 00401000h. Da questo possiamo enunciare che: RVA = VA - ImageBase e VA = RVA + ImageBase. Come arrivare da un RVA al File Offset lo vedremo dopo. In ogni caso passando alla seconda domanda, una volta mappato il file PE come la Section Table dice al loader, il campo AddressOfEntryPoint punta direttamente all'indirizzo di memoria. Quando leggiamo un file direttamente da disco è necesssario convertire RVA in File Offsets. Invece, quando operiamo su un modulo in memoria basta sommare un RVA all'Image Base del modulo in questione per ottenere la locazione a cui desideriamo accedere.

ImageBase specifica l'indirizzo dal quale deve essere mappato in memoria il file (quello che abbiamo citato parlando dell'AddressOfEntryPoint).

SectionAlignment specifica l'allineamento del file in memoria, ovvero delle varie sezioni (zona headers compresa). Dunque ci sono due tipi di allineamento: quello in memoria e quello su disco. Diciamo che su disco il PE è allineato a 200h, questo vuol dire che a partire dalla zona header tutto è allineato a 200h: ZONA HEADER - SIZE: Multiplo di 200h SECTION 1 - IDEM SECTION 2 - IDEM Se una sezione è 522h byte, su disco la sua grandezza sarà 600h. In memoria invece sarà grande 1000h. Gli allineamenti possibili su disco devono essere 200h o suoi multipli (tranne per i loaders su NT che consentono anche allineamenti di 1 byte).

FileAlignment specifica l'allineamento del file (come detto).

SizeOfImage specifica le dimensioni dell'immagine del PE una volta mappato in memoria, su 9x questo parametro non viene considerato ma se è sballato su NT, l'exe non viene eseguito. Ho dato una spiegazione un po' vaga, vediamo di capire meglio di che cosa si tratta, abbiamo detto che esiste un Section Alignment... Ecco se noi sommiamo la grandezza della zona degli header (allineata) e la grandezza di tutte le sezioni (ognuna di queste allineata) allora abbiamo calcolato il SizeOfImage. La grandezza della zona degli header è facile ricavarla, basta prendere l'RVA dal quale inizia la prima sezione e sommare poi le grandezze di tutte le sezioni.

SizeOfHeaders specifica le dimensioni degli headers (grandezza allineata al File Alignment), per sapere la grandezza basta prendere l'indirizzo fisico della prima sezione del PE (questo se non volete ricalcolare la grandezza per ottimizzazione, in quel caso dovreste sommare i vari header fino alla Section Table e allineare).

NumberOfRvaAndSizes numero di entry nell'array di DataDirectory.

DataDirectory questo array serve al loader per trovare in modo rapido diverse sezioni (da non confondere con le sezioni della Section Table) chiamate directory. typedef struct _IMAGE_DATA_DIRECTORY {

   DWORD VirtualAddress; // non è veramente un VA ma un RVA
   DWORD Size;

} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY; le directory sono: Export Table, Import Table, Resource Direcotry, Exception, Security, Base Relocation, Debug, Copyright, Global Ptr, Thread Local Storage, Load Configuration, Bound Import Table, Import Address Table, Delay Import, COM Descriptor. Queste in fondo non sono altro che (semplificando) sottosezioni delle sezioni che specifica la Section Table. In pratica la DataDirectory non è altro che un'ulteriore suddivisione. In ogni caso per adesso non ci interessa la DataDirecotry, dobbiamo ancora trattare prima la Section Table.

Ho elencato i campi fondamentali dell'Optional Header per poter unpackare. Adesso passiamo alla Section Table. Si tratta di un array di strutture IMAGE_SECTION_HEADER. Alla Section Table si giunge così: bisogna sommare l'indirizzo dal quale iniziano gli Nt Headers alla grandezza dell'Optional Header (campo del File Header, ricordate?) a 18h (che sarebbe la grandezza del File Header + la dword-signature). Vediamo la definizione della struttura: typedef struct _IMAGE_SECTION_HEADER {

   BYTE Name[IMAGE_SIZEOF_SHORT_NAME]; // nome sezione max 8 caratteri
   union {
       DWORD PhysicalAddress;         // serve solo se il file è .obj
       DWORD VirtualSize;             // la grandezza della sezione
   } Misc;
   DWORD VirtualAddress;         // indica da quale RVA il loader deve mappare la sezione
   DWORD SizeOfRawData;          // grandezza della sezione arrotondata in base al file alignement
   DWORD PointerToRawData;       // file offset della sezione
   DWORD PointerToRelocations;   // negli exe questo campo è sempre 0 dato che non serve (solo per obj)
   DWORD PointerToLinenumbers;   // come PointerToRelocations
   WORD NumberOfRelocations;     // solo obj
   WORD NumberOfLinenumbers;     // mai visto utilizzato manco questo (nei comuni PE dico)
   DWORD Characteristics;        // caratteristiche della sezione

} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;

  1. define IMAGE_SIZEOF_SECTION_HEADER 40

Riassumendo i campi fondamentali sono: Nome della sezione, Virtual Address (è una RVA quindi per sapere a che VA sta una sezione di un modulo dovrete sommare l'image base a questo valore), VirtualSize (in genere il virtual size corrisponde alla grandezza della sezione vera e propria, senza Section Alignment, dato che ci pensa il loader ad allineare e quindi in genere l'unica sezione con Virtual Size maggiore di Raw Size è la .data), Raw Address e Raw Size (questi ultimi vengono anche chiamati Physical Address e Physical Size). L'ultimo campo importante è Characteristic; esso indica le caratteristiche della sezione. I flag più importanti sono (ricordatevi che sono tutti combinabili):
00000020h - la sezione contiene codice
00000040h - contiene dati inizializzati
00000080h - contiene dati non inizializzati
10000000h - sezione condivisibile
20000000h - sezione eseguibile
40000000h - sezione leggibile
80000000h - la sezione è scrivibile
Ma per essere esaurienti eccovi tutti i flags disponibili:

  1. define IMAGE_SCN_CNT_CODE 0x00000020 // Section contains code.
  2. define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // Section contains initialized data.
  3. define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // Section contains uninitialized data.
  1. define IMAGE_SCN_LNK_OTHER 0x00000100 // Reserved.
  1. define IMAGE_SCN_LNK_INFO 0x00000200 // Section contains comments or some other type of information.
// IMAGE_SCN_TYPE_OVER 0x00000400 // Reserved.
  1. define IMAGE_SCN_LNK_REMOVE 0x00000800 // Section contents will not become part of image.
  2. define IMAGE_SCN_LNK_COMDAT 0x00001000 // Section contents comdat.

// 0x00002000 // Reserved. // IMAGE_SCN_MEM_PROTECTED - Obsolete 0x00004000

  1. define IMAGE_SCN_NO_DEFER_SPEC_EXC 0x00004000 // Reset speculative exceptions handling bits in the TLB entries for this section.
  2. define IMAGE_SCN_GPREL 0x00008000 // Section content can be accessed relative to GP
  3. define IMAGE_SCN_MEM_FARDATA 0x00008000

// IMAGE_SCN_MEM_SYSHEAP - Obsolete 0x00010000

  1. define IMAGE_SCN_MEM_PURGEABLE 0x00020000
  2. define IMAGE_SCN_MEM_16BIT 0x00020000
  3. define IMAGE_SCN_MEM_LOCKED 0x00040000
  4. define IMAGE_SCN_MEM_PRELOAD 0x00080000
  1. define IMAGE_SCN_ALIGN_1BYTES 0x00100000 //
  2. define IMAGE_SCN_ALIGN_2BYTES 0x00200000 //
  3. define IMAGE_SCN_ALIGN_4BYTES 0x00300000 //
  4. define IMAGE_SCN_ALIGN_8BYTES 0x00400000 //
  5. define IMAGE_SCN_ALIGN_16BYTES 0x00500000 // Default alignment if no others are specified.
  6. define IMAGE_SCN_ALIGN_32BYTES 0x00600000 //
  7. define IMAGE_SCN_ALIGN_64BYTES 0x00700000 //
  8. define IMAGE_SCN_ALIGN_128BYTES 0x00800000 //
  9. define IMAGE_SCN_ALIGN_256BYTES 0x00900000 //
  10. define IMAGE_SCN_ALIGN_512BYTES 0x00A00000 //
  11. define IMAGE_SCN_ALIGN_1024BYTES 0x00B00000 //
  12. define IMAGE_SCN_ALIGN_2048BYTES 0x00C00000 //
  13. define IMAGE_SCN_ALIGN_4096BYTES 0x00D00000 //
  14. define IMAGE_SCN_ALIGN_8192BYTES 0x00E00000 //

// Unused 0x00F00000

  1. define IMAGE_SCN_LNK_NRELOC_OVFL 0x01000000 // Section contains extended relocations.
  2. define IMAGE_SCN_MEM_DISCARDABLE 0x02000000 // Section can be discarded.
  3. define IMAGE_SCN_MEM_NOT_CACHED 0x04000000 // Section is not cachable.
  4. define IMAGE_SCN_MEM_NOT_PAGED 0x08000000 // Section is not pageable.
  5. define IMAGE_SCN_MEM_SHARED 0x10000000 // Section is shareable.
  6. define IMAGE_SCN_MEM_EXECUTE 0x20000000 // Section is executable.
  7. define IMAGE_SCN_MEM_READ 0x40000000 // Section is readable.
  8. define IMAGE_SCN_MEM_WRITE 0x80000000 // Section is writeable.

Come detto le sezioni contengono tutto ciò che c'è in un file PE (headers esclusi). Vi sono diverse sezioni in genere in un PE (nulla vieta di mettere tutto in una sola eh), ma generalmente quelle che vengono prodotte dai compilatori sono .text (codice), .data (variabili inizializzate), .rdata (può contenere diverse cose: info di debug, description string, GUIDs e TLS Directory, imports), .idata (imports, obsoleta), .edata (exports, obsoleta), .tls (TLS Direcory), .rsrc (resource), .reloc (relocations) ecc.

Siamo arrivati alla DataDirectory che è un array di strutture: typedef struct _IMAGE_DATA_DIRECTORY {

   DWORD VirtualAddress;                         // RVA della sezione
   DWORD Size;                                   // grandezza

} IMAGE_DATA_DIRECTORY, *PIMAGE_DATA_DIRECTORY; contenute nell'Optional Header. L'array contiene al massimo 15 entry:

  1. define IMAGE_DIRECTORY_ENTRY_EXPORT 0 // Export Directory
  2. define IMAGE_DIRECTORY_ENTRY_IMPORT 1 // Import Directory
  3. define IMAGE_DIRECTORY_ENTRY_RESOURCE 2 // Resource Directory
  4. define IMAGE_DIRECTORY_ENTRY_EXCEPTION 3 // Exception Directory
  5. define IMAGE_DIRECTORY_ENTRY_SECURITY 4 // Security Directory
  6. define IMAGE_DIRECTORY_ENTRY_BASERELOC 5 // Base Relocation Table
  7. define IMAGE_DIRECTORY_ENTRY_DEBUG 6 // Debug Directory

// IMAGE_DIRECTORY_ENTRY_COPYRIGHT 7 // (X86 usage)

  1. define IMAGE_DIRECTORY_ENTRY_ARCHITECTURE 7 // Architecture Specific Data
  2. define IMAGE_DIRECTORY_ENTRY_GLOBALPTR 8 // RVA of GP
  3. define IMAGE_DIRECTORY_ENTRY_TLS 9 // TLS Directory
  4. define IMAGE_DIRECTORY_ENTRY_LOAD_CONFIG 10 // Load Configuration Directory
  5. define IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT 11 // Bound Import Directory in headers
  6. define IMAGE_DIRECTORY_ENTRY_IAT 12 // Import Address Table
  7. define IMAGE_DIRECTORY_ENTRY_DELAY_IMPORT 13 // Delay Load Import Descriptors
  8. define IMAGE_DIRECTORY_ENTRY_COR20 14 // .NET Directory

Prima di discutere la Import Directory va detta una cosa. Vi ricorderete del discorso "VA / RVA / File Offset", no? Ovvero, la struttura di ogni entry nella Data Directory ci dice l'RVA del soggetto, quindi per trovare tale elemento dovremo convertire un RVA in un File Offset. Come fare ciò? Vediamo velocemente questa piccola funzione: DWORD RvaToOffset(IMAGE_NT_HEADERS *NT, DWORD Rva) {

  DWORD Offset = Rva, Limit;
  IMAGE_SECTION_HEADER *Img;
  Img = IMAGE_FIRST_SECTION(NT);
  if (Rva < Img->PointerToRawData)
     return Rva;
  for (int i = 0; i < NT->FileHeader.NumberOfSections; i++)
  {
     if (Img[i].SizeOfRawData)
        Limit = Img[i].SizeOfRawData;
     else
        Limit = Img[i].Misc.VirtualSize;
     if (Rva >= Img[i].VirtualAddress &&
      Rva < (Img[i].VirtualAddress + Limit))
     {
        if (Img[i].PointerToRawData != 0)
        {
           Offset -= Img[i].VirtualAddress;
           Offset += Img[i].PointerToRawData;
        }
           
        return Offset;
     }
  }
  return 0;

} Questa funzione (che ho copiato da un codice scritto quando avevo 15 anni) non fa altro che controllare gli spazi fisici tra le sezioni e calcolare l'RVA corrispondente a un certo offset fisico. La funzione che uso nel CFF Explorer è più sofisticata. Come vedete i paremetri della funzione sono solamente due: un pointer a ImageNtHeader e l'RVA da convertire. Se la funzione non trova un offset fisico corrispondente ritorna 0.

Passiamo alla Import Table (o Directory) che conclude la conoscenza minima indispensabile per poter effettuare un semplice unpack.

Per quanto riguarda l'IT ci serve solo sapere l'RVA di dove sta, il campo Size è superfluo. Quindi se partiamo dall'RVA e ce lo convertiamo in File Offset, partendo dall'indirizzo calcolato troveremo un array di strutture IMAGE_IMPORT_DESCRIPTOR (ognuna di queste corrisponde ad un modulo da cui vengono importate funzioni). L'array si conclude con una struttura IMAGE_IMPORT_DESCRIPTOR nulla. Vediamoci tale struttura: typedef struct _IMAGE_IMPORT_DESCRIPTOR {

   union {
       DWORD Characteristics;
       DWORD OriginalFirstThunk;
   };
   DWORD TimeDateStamp;
   DWORD ForwarderChain;
   DWORD Name;
   DWORD FirstThunk;

} IMAGE_IMPORT_DESCRIPTOR;

typedef IMAGE_IMPORT_DESCRIPTOR UNALIGNED *PIMAGE_IMPORT_DESCRIPTOR; Vediamo i vari elementi della struttura:

OriginalFirstThunk è un RVA che punta ad un array di dwords ricordandoci che stiamo parlando a 32bit, sarebbe quindi più corretto dire un array dire strutture IMAGE_THUNK_DATA (che altro non sono che dwords o qword se siamo a 64bit) che possono avere significa diversi (che vedremo dopo). L'array si conclude con un elemento IMAGE_THUNK_DATA nullo.

TimeDateStamp generalmente è 0 ma se la IT è stata bind-ata (da bind: che neologismo. Probabilmente non sapete cosa vuol dire ma è una cosa che vedremo dopo quando parleremo della IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT, quindi se adesso non capite non preoccupatevi) teoricamente dovrebbe contenere la data del modulo che viene importato da questo descriptor, in ogni caso dovrebbe essere diverso da 0.

ForwarderChain serve ancora se la IT è stata bindata, comunque anche in quel caso semplicemente basterà settarlo diverso da 0 poiché fa parte di un metodo obsoleto per bindare.

Name questo campo è un RVA che punta a una stringa terminata da uno 0 che altro non è che il nome del modulo da importare. A questo proposito una piccola curiosità: se il nome del modulo da importare manca di estensione il loader di Win2k cerca un modulo senza estensione come suggerito, mentre quello di XP se non trova estensione fa automaticamente un strcat(NomeModulo, ".DLL").

FirstThunk è esattamente come OriginalFirstThunk un RVA che punta ad un array di IMAGE_THUNK_DATA ma non è assolutamente lo stesso array di OriginalFirstThunk, cioè può benissimo esserlo ma a quel punto, come in seguito vedremo, si può fare direttamente a meno dell'OriginalFirstThunk.

Bene, adesso cerchiamo di capire cosa può contenere un IMAGE_THUNK_DATA. Siccome il discorso non è proprio semplicissimo da capire alla prima lettura, cercherò di farmi capire il più possibile. Dunque abbiamo detto che OriginalFirstThunk (dimentichiamoci un secondo di FirstThunk) è un array di dwords (o di qwords nel caso il PE è a 64bit) che servono per importare funzioni. Ogni dword corrisponde ad una funzione importata dal modulo importato dal descriptor e l'array è terminato da una dword nulla. Ok, ma cosa vuol dire “serve ad importare una funzione”? Ci sono due modi di importare una funzione: per nome e per ordinal Se la funzione è importata per nome, allora la dword è un RVA che punta ad una struttura IMAGE_IMPORT_BY_NAME, vediamone la dichiarazione: typedef struct _IMAGE_IMPORT_BY_NAME {

   WORD Hint;
   BYTE Name[1];

} IMAGE_IMPORT_BY_NAME, *PIMAGE_IMPORT_BY_NAME; La struttura consiste di due membri: una word che sarebbe l'ordinal della funzione da importare (che generalmente è sempre settato a 0) e il nome della funzione che segue la word, il nome è terminato da uno 0. Quindi nel PE troviamo una cosa come: WORD_ORDINAL + NOME_FUNZIONE + 0.

L'altro metodo di importare una funzione è per ordinal. Non abbiamo il nome della funzione né alcuna struttura IMAGE_IMPORT_BY_NAME e quindi risparmiamo diverso spazio. Però, in generale, è sconsigliato importare per ordinal poiché spesso le dll non hanno sempre lo stesso ordinal per una funzione: su una versione di windows una funzione potrebbe avere un certo ordinal e in un'altra versione quell'ordinal potrebbe corrispondere ad una funzione diversa. Se una funzione è importata per ordinal, il bit più alto della dword è settato, quindi non resta che prendersi solo gli altri 31 e considerarli come ordinal. Spiego meglio, sappiamo che se il bit più alto di una dword è settato (e solo quello) allora la dword è 80000000h, se l'ordinal della funzione che voglio importare è 45 allora l'IMAGE_THUNK_DATA sarà 80000045h (se siamo a 64 bit sarà 8000000000000045h). Per sapere se una funzione viene importata per ordinal, basta fare un controllo come questo: if (ImgTDataValue & IMAGE_ORDINAL_FLAG) {

   // la funzione è importata per ordinal

} FirstThunk svolge inizialmente (se il PE non è bindato) lo stesso compito di OriginalFirstThunk. Anch'esso è un RVA che punta a un array di dword che a loro volta contengono gli stessi valori degli elementi dell'array a cui punta OriginalFirstThunk. Per esempio se l'array di OriginalFirstThunk contiene IMAGE_THUNK_DATA che puntano a strutture IMAGE_IMPORT_BY_NAME, FirstThunk punterà alle stesse strutture (cioè entrambi gli array contengono valori che devono importare la stessa funzione). Quando il loader carica un PE legge l'array di OriginalFirstThunk e sovrascrive quello di FirstThunk con i veri indirizzi delle funzioni in memoria ecco perché l'array puntato da FirstThunk costituisce la Import Address Table (o più semplicemente IAT).

La IAT è un presente nell'array di Data Directory se avete notato. Tuttavia, la presenza della IAT directory è totalmente opzionale. Non pensate di trovarla in un PE packato. Uno dei compiti del reverser è individuare indirizzo e dimensioni della IAT.

Facciamo qualche specificazione, non è strettamente obbligatoria la presenza dell'array OriginalFirstThunk, si può fare a meno di questo array settando OriginalFirstThunk a 0. In questo caso il loader considererà l'array puntato da FirstThunk per ricavare le funzioni da importare (quindi una volta caricato il PE in memoria troveremo solo un array di FirstThunk sovrascritto). Nel caso, però, che un eseguibile lo si voglia bindare è strettamente necessaria la presenza di un OriginalFirstThunk: questo mi sembra importante ricordarlo per evitare malintesi: è bene che i normali PE abbiano entrambi gli array. I packer, poi, possono anche eliminare gli OFT, tanto i PE prodotti dai packer non sono normali PE in ogni caso.

Eccovi un esempio di codice che elenca le imports di un PE: // sintassi: nome_file

  1. include <windows.h>
  2. include <stdio.h>

DWORD RvaToOffset(IMAGE_NT_HEADERS *NT, DWORD Rva);

int main(int argc, char *argv[]) {

  HANDLE hFile;
  BYTE *BaseAddress;
  DWORD FileSize, BR, IT_Offset;
  UINT x = 0, y;
  IMAGE_DOS_HEADER *ImageDosHeader;
  IMAGE_NT_HEADERS *ImageNtHeaders;
  IMAGE_IMPORT_DESCRIPTOR *ImageImportDescr;
  IMAGE_THUNK_DATA *Thunks;
  char *Name;
  IMAGE_IMPORT_BY_NAME *ImgName;


  // controlla numero argomenti
  if (argc < 2)
  {
     printf("\nInsufficient arguments\n");
     return -1;
  }
  printf("\nOpening file...\n");
  hFile = CreateFile(argv[1], GENERIC_READ, FILE_SHARE_READ,
     0, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
  if (hFile == INVALID_HANDLE_VALUE)
  {
     printf("Cannot open the file\n");
     return -1;
  }
  FileSize = GetFileSize(hFile, NULL);
  BaseAddress = (BYTE *) malloc(FileSize);
  if (!ReadFile(hFile, BaseAddress, FileSize, &BR, NULL))
  {
     free(BaseAddress);
     CloseHandle(hFile);
     return -1;
  }
  ImageDosHeader = (IMAGE_DOS_HEADER *) BaseAddress;
  // controlliamo il Dos Header
  if (ImageDosHeader->e_magic != IMAGE_DOS_SIGNATURE)
  {
     printf("Invalid Dos Header\n");
     free(BaseAddress);
     CloseHandle(hFile);
     return -1;
  }
  ImageNtHeaders = (IMAGE_NT_HEADERS *)
     (ImageDosHeader->e_lfanew + (ULONG_PTR) ImageDosHeader);
  // controlliamo il PE Header
  if (ImageNtHeaders->Signature != IMAGE_NT_SIGNATURE)
  {
     printf("Invalid PE Header\n");
     free(BaseAddress);
     CloseHandle(hFile);
     return -1;
  }
  if (!ImageNtHeaders->OptionalHeader.DataDirectory
     [IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress)
  {
     printf("This PE doesn't contain an IT\n");
     free(BaseAddress);
     CloseHandle(hFile);
     return -1;
  }
  // converte in offset fisico l'RVA
  IT_Offset = RvaToOffset(ImageNtHeaders,
     ImageNtHeaders->OptionalHeader.DataDirectory
     [IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress);
  // è valido?
  if (IT_Offset == 0)
  {
     printf("This dE Doesn't contain an IT\n");
     free(BaseAddress);
     CloseHandle(hFile);
     return -1;
  }
  printf("Import Table:\n");
  // ricava la Import Dir
  // e quindi i descriptors
  ImageImportDescr = (IMAGE_IMPORT_DESCRIPTOR *) (IT_Offset +
     (ULONG_PTR) BaseAddress);
  // passa in rassegna tutti i descriptors
  while (ImageImportDescr[x].FirstThunk != 0)
  {
     Name = (char *) (RvaToOffset(ImageNtHeaders,
        ImageImportDescr[x].Name) + (ULONG_PTR) BaseAddress);
     printf("\nModule Name: %s\n\nFunctions:\n\n", Name);
     // guarda quale array considerare
     Thunks = (IMAGE_THUNK_DATA *) (RvaToOffset(ImageNtHeaders,
        ImageImportDescr[x].OriginalFirstThunk != 0 ?
        ImageImportDescr[x].OriginalFirstThunk :
      ImageImportDescr[x].FirstThunk) + (ULONG_PTR) BaseAddress);
     y = 0;
     // passa in rassegna le funzioni
     while (Thunks[y] != 0)
     {
        // è importata per ordinal?
        if (Thunks[y] & IMAGE_ORDINAL_FLAG)
        {
           printf("Ordinal: %08X\n", (DWORD) (Thunks[y] -
              IMAGE_ORDINAL_FLAG));
           y++;
           continue;
        }
        ImgName = (IMAGE_IMPORT_BY_NAME *) (RvaToOffset(
           ImageNtHeaders, (DWORD) Thunks[y]) + (ULONG_PTR) BaseAddress);
        printf("Name: %s\n", &ImgName->Name);
        y++;
     }
     x++;
  }
  free(BaseAddress);
  CloseHandle(hFile);
  return 0;

}

// sappiamo a cosa serve

DWORD RvaToOffset(IMAGE_NT_HEADERS *NT, DWORD Rva) {

  DWORD Offset = Rva, Limit;
  IMAGE_SECTION_HEADER *Img;
  WORD i;
  Img = IMAGE_FIRST_SECTION(NT);
  if (Rva < Img->PointerToRawData)
     return Rva;
  for (i = 0; i < NT->FileHeader.NumberOfSections; i++)
  {
     if (Img[i].SizeOfRawData)
        Limit = Img[i].SizeOfRawData;
     else
        Limit = Img[i].Misc.VirtualSize;
     if (Rva >= Img[i].VirtualAddress &&
      Rva < (Img[i].VirtualAddress + Limit))
     {
        if (Img[i].PointerToRawData != 0)
        {
           Offset -= Img[i].VirtualAddress;
           Offset += Img[i].PointerToRawData;
        }
           
        return Offset;
     }
  }
  return 0;

} E qui termina l'infarinatura generale sul PE. In verità, le cose che andrebbero dette sul PE sono ancora tante. In particolare, andrebbero letti i paragrafi sulla Reloc Directory, Export Directory e Resource Directory. Questi paragrafi ve li potete andare a leggere nell'articolo sul PE e vi consiglio di esplorare qualche PE col CFF Explorer per fare pratica. Il formato PE è un formato molto complesso e per unpackare non è necessario conoscerlo tutto. Per esempio, non è necessario parlare della .NET Directory, che sarebbe la directory che contiene le informazioni e i metadati degli assembly .NET. Se volete approfondire anche quell'argomento, dopo aver letto l'articolo sul PE, potete leggervi quest'altro articolo che ho scritto (e che sarebbe anche l'unico esistente).

Basic Manual Unpacking

Esistono dei packer che altro non fanno che comprimere le sezioni dell'eseguibile senza implementare alcun meccanismo di protezione. In questi casi, l'unpacking è facilissimo. Le uniche cose da fare sono: trovare l'Original Entry Point, dumpare il PE dalla memoria, ricostruire la Import Table.

Il target utilizzato per il nostro unpacking di base è UPX. UPX è un compressore multipiattaforma per eseguibili. Il suo funzionamento è molto semplice e se dovessimo incontrare un PE packato con UPX è sufficiente usare il comando “upx -d” per unpackarlo. Tuttavia, alcuni malware utilizzano versioni modificate di UPX che non possono essere unpackate da UPX stesso. In quei casi, bisogna procedere con l'unpacking manuale (a meno che non sia possibile usare un tool o debugger script automatico, ma mettiamo che ciò non sia possibile).

In genere la prima cosa che si fa con un PE packato è usare un signature scanner per individuare il packer usato. Potete usare il signature scanner del CFF Explorer (chiamato Identifier) che, peraltro, fornisce il risultato di uno scan basilare anche fra le informazioni di un file. Oppure, potere usare PEiD o RDG che sono tools specifici e hanno un database di signatures aggiornato. Tutti gli scanner citati rivelano UPX come packer. Ma ammettiamo che nessuno riesca ad identificarlo. In questo caso, l'analisi la fa il reverser, partendo dalle sezioni del PE:
Mup2.png
Guardare le sezioni dovrebbe darci un'idea generale di come è strutturato il packer, non fornirci il nome del packer come nella screenshot sopra.

La prima cosa da fare è trovare l'OEP (Original Entry Point). Ci sono tanti modi di ricavare l'OEP. Uno di questi consiste nel mettere un bpx su una delle API comunemente chiamate dallo stub di eseguibili prodotti da compilatori noti come VC++. Queste API sono in genere GetCommandLineA, GetVersion, GetVersionEx ecc. Un altro modo consiste nel beccare il primo codice eseguito nella sezione di codice (.text). Tuttavia, non è sempre possibili utilizzare questi metodi, a causa di tecniche anti-debug o stolen bytes (vedremo dopo cosa sono gli stolen bytes). L'ultimo metodo, il più faticoso, consiste nel steppare a mano tutto il codice con il debugger. UPX è un packer molto semplice, dopo aver unpackato le sezioni e sistemato la Import Table, il codice del packer salta all'OEP: 006A50AD . 58 POP EAX 006A50AE . 50 PUSH EAX 006A50AF . 54 PUSH ESP 006A50B0 . 50 PUSH EAX 006A50B1 . 53 PUSH EBX 006A50B2 . 57 PUSH EDI 006A50B3 . FFD5 CALL EBP 006A50B5 . 58 POP EAX 006A50B6 . 61 POPAD 006A50B7 . 8D4424 80 LEA EAX,DWORD PTR SS:[ESP-80] 006A50BB > 6A 00 PUSH 0 006A50BD . 39C4 CMP ESP,EAX 006A50BF .^75 FA JNZ SHORT cffupx.006A50BB 006A50C1 . 83EC 80 SUB ESP,-80 006A50C4 .-E9 0D29EAFF JMP cffupx.005479D6 ; salta all'OEP dell'exe packato Ovviamente, in un packer che protegge gli eseguibili non sarà così facile trovare l'OEP. Arrivati all'OEP lasciamo il debugger aperto e dumpiamo il PE dalla memoria. Per dumpare un PE è possibile utilizzare diversi tools fra cui lo stesso OllyDbg. Personalmente, uso il Task Explorer di cui sono sicuro di come funziona, dato che l'ho scritto io. Il Task Explorer è solo un piccolo tool che ho scritto in una giornata di codice. Tuttavia, ha un meccanismo di dump corretto che funziona anche dove altri dumper (come LordPE) falliscono. Il dump è molto semplice: selezionate il processo che state debuggando e premete “Dump PE”:
Mup3.png
Salvate su disco e passiamo alla ricostruzione della Import Table. Ricostruire la Import Table (o meglio, la IAT) è spesso una delle parti più difficili dell'unpacking. Ovviamente, UPX non applica meccanismi di protezione e quindi sarà facile, ma già dal prossimo paragrafo vedremo come si può complicare il procedimento. Per ricostruire una Import Table useremo ImpREC un tool adibito proprio a ciò. Piccola nota: ImpREC funziona solo per PE a 32-bit. Per ricostruire la Import Table di eseguibili a 64-bit potreste dare uno sguardo a CHImpRec, tool sviluppato da un membro di ARTeam. Tuttavia, CHImpRec l'ho provato ed è ancora un po' sperimentale, diciamo. Quindi, per questo unpack ci affideremo ancora al vecchio ImpREC.

Aprite ImpREC e selezionate il processo dell'eseguibile debuggato. Immetete nella text box “OEP” l'Entry Point trovato tramite la nostra sessione di debug. Poi, pigiate “AutoSearch”. ImpREC vi informerà se è riuscito a trovare la IAT. In caso affermativo, premete “Get Imports” e ImpREC si procurerà le informazioni per ricostruire la Import Table. Casomai, premete anche “Auto Trace” nel caso ad ImpREC fosse sfuggito qualche import.
Mup4.png
A questo punto, premete “Fix Dump” e selezionate l'eseguibile dumpato in precedenza. ImpREC ricostruirà l'Import Table di tale PE e setterà anche il campo AddressOfEntryPoint nell'Optional Header. Cosa che, quindi, non dovremo fare noi.

Ora provate ad avviare l'eseguibile e vedrete che si avvia senza difficoltà. Avete terminato il vostro primo manual unpack. Fin qua sembra tutto molto semplice, ma considerate che questo è il caso più semplice di tutti. Anche solo rimamendo nel contesto di UPX, se il PE packato è una dll, è necessario ripristinare almeno le relocation. In poche parole, le dll possono essere mappate ad un Image Base diverso da quello previsto nell'Optional Header e quando ciò succede le informazioni nella Reloc Directory vengono usate per aggiustare tutti gli indirizzi assoluti all'interno del codice. Quando un file è packato, è il packer a svolgere la funzione di fixare le relocation e, quindi, non resta traccia nel PE delle informazioni di rilocazione. Se siete fortunati, il packer avrà lasciato intatto le informazioni originali e a voi basterà inserire l'RVA della Reloc Directory nella Data Dir apposita. Altrimenti, se le informazioni non si trovano più nel loro stato originale, vi toccherà ricostruirle. Piccola nota: da Vista in poi anche gli eseguibili possono essere mappati a Image Base diversi e hanno quindi relocation. Tuttavia, il numero di eseguibili in giro con relocs è basso.

Basic IAT Redirection

Con la IAT Redirection le cose si complicano. Consideriamo una IAT a runtime con solo 3 API:

77xxxxxx → indirizzo CreateWindowA
77xxxxxx → indirizzo SetWindowTextA
77xxxxxx → indirizzo GetDlgItem

Se il packer usa redirection la IAT non avrà l'aspetto di quella sopra. Infatti, i vari thunk della IAT non conterranno l'indirizzo reale delle API importate, ma punteranno ad uno stub messo dal packer che poi in seguito salterà al codice dell'API importata.

Quindi una IAT di questo tipo avrà questo aspetto:

xxxxxxxx → indirizzo packer_stub
xxxxxxxx → indirizzo packer_stub
xxxxxxxx → indirizzo packer_stub

Quando la redirection è semplice, è ancora possibile ricorrere a tools automatici che risolveranno la IAT tracciando lo stub del packer che li porta all'API reale. Ciò nonostante, gran parte di questi tracer automatici possono essere fermati complicando ulteriormente la IAT redirection, come vedremo in seguito.

Basic Anti-dumping

Le tecniche basilari di anti-dumping sono quasi inutili. Si basano su dei trick, anche obsoleti, che comunque non ingannano tools come il Task Explorer. Li accenno brevemente giusto per completezza. Uno dei trick storici consiste nel cancellare gli headers del PE a runtime. Questo trucco, oltre ad essere poco efficace contro tools da LordPE in poi, è anche dannoso in quanto dal VC++ 2008 in poi può causare un crash dell'eseguibile. Un'altra tecnica consiste nel sballare a runtime il campo SizeOfImage, anche questo trick è totalmente inefficace contro il Task Explorer. In verità, le poche tecniche efficaci contro il dumping non sono quelle che impediscono l'azione vera e propria, ma quelle che rendono il risultato del dump inutile. Ma questo lo vedremo più tardi.

Basic Anti-debugging

Ci sono decine di modi per un packer di controllare se il proprio codice sta venendo debuggato o se, comunque, un debugger è attaccato al processo corrente. Si va dalle banali chiamate a IsDebugPresent (che possono essere facilmente superate con un plugin per OllyDBG), all'uso di timers, fino ad arrivare a tecniche particolari e rare. Ogni mese escono malware che implementano nuove teniche anti-debug. Ovviamente, non posso mettermi a elencare tutti i metodi possibili. In questo paragrafo di basic anti-debugging posso solo esporre i concetti fondamentali. Considerate anche che le tecniche si evolvono e cambiano. Ai tempi di Softice e Win9x era molto comune controllare i registri di debug e altre cose simili. Oggi, invece tra le tecniche odierne va di moda l'uso dell'opcode rdtsc (che peraltro può essere usato anche per rivelare la presenza di una virtual machine). Comunque, alcuni concetti fondamentali non sono cambiati. Tra le tecniche basilari c'è il controllo di breakpoints (0xCC) all'interno del codice e le funzioni riguardanti il tempo (anche rdtsc è una funzione tempo). Prendiamo una tecnica molto vecchia che si basa su GetTickCount (una API che ritorna i millisecondi passati dall'accensione del computer): a = GetTickCount(); // codice b = GetTickCount(); elapsed = b – a; if (elapsed > 20) { // il codice viene debuggato } Il codice verifica se il tempo trascorso per eseguire il codice tra a e b è maggiore di 20 millisecondi. In tal caso, il codice sta venendo tracciato. È chiaro che la presenza di GetTickCount come nell'esempio è un po' troppo appariscente. Ma le varianti (compresa quella che sfrutta rdtsc) di questo tipo di tecnica sono tante.

Vi sono altre tecniche che sfruttano la gestione delle eccezioni per rivelare la presenza di debugger e vi sono molte tecniche anti-debug semplici come IsDebugPresent, ma meno conosciute. Per esempio, chiamando NtQueryInformationProcess è possibile ricavare i debug flags del processo corrente. Vengono spesso usati anche sistemi rozzi come chiamare FindWindow per stabilire se la finestra di un dato debugger (o di un altro tool) è presente.

Quel che cerco di farvi capire è che non esiste un solo metodo anti-debug e si va da sistemucci come quelli appena elencati, fino a tecniche più complesse come quella usata da Armadillo (che vedremo dopo).

Advanced

Nei sotto-paragrafi che seguono non elencherò ogni tecnica avanzata usata dai packer. Anche perché ciò sarebbe impossibile. Piuttosto cercherò di elencare alcune delle tecniche più famose e dei problemi a cui andate incontro se volete fare manual unpacking su target seri.

Resources On-Demand

Generalmente, quando le risorse di un PE vengono crittate (e badate che vengono sempre crittate selettivamente, altrimenti non sarebbe più possibile visualizzare l'icona dell'eseguibile o informazioni sulla versione dopo aver packato il file) esse vengono decrittate interamente a runtime. Ciò nondimeno, nulla vieta di hookare le API che gestiscono le risorse e decrittare solo quando una risorsa viene richiamata. Le cose si complicano ulteriormente quando il formato della Resource Directory viene distrutto, sostituito da un formato interno del packer. In tal caso, è necessario ricostruire il formato della Resource Directory. Leggete l'articolo sul PE per approfondire il tipo di formato.

Bogus & Junk Code

Per bogus code si intendono i finti opcode messi all'interno del codice per sballare il disasm di alcuni tools. Mettiamo che abbiamo questo tipo di codice: xor eax eax test eax eax jz label1: db 0xE9 mov eax, 10

ecc

La presenza dell'opcode E9 sballa il disasm perché l'istruzione mov che lo segue viene assimilata da questo opcode e quindi il disassembler mostrerà: xor eax eax test eax eax jz label1: jmp ...

disasm sballato

Il disasm viene rettificato in caso di debug appena raggiungiamo la locazione label1. Un disassembler avanzato come IDA, ovviamente, nota jump diretti come il jz e automaticamente esclude il bogus code. Ma, se vengono evitati jump diretti e vengono utilizzate operazioni più complesse per eseguire il salto, allora un disassembler può fare ben poco. Se è necessario risolvere il bogus code solo ai fini di analisi, è possibile dire a IDA cosa è codice e cosa no. E siccome la mole di bogus code è generalmente molto alta, conviene scriversi un IDC script per risolvere ogni occorrenza in modo automatico.

Il junk code è appunto codice spazzatura. Non serve a niente se non a far perdere la pazienza al reverser. Anche in questo caso se riuscite a identificare uno o più pattern, potete ricorrere all'IDC scripting per rimuoverne ogni occorrenza. Sempre che il packer non abbia usato un polymorphic engine sul junk code. In tal caso, identificare un pattern diventa molto difficile.

Polymorphic Engines

Un polymorphic engine, se non lo sapete, è un codice che si occupa di far assumere ad un dato opcode altre forme in modo random. Ovvero, ammettiamo che il codice originale implementi uno:

xor eax, eax

Questo operazione potrebbe essere riscritta così:

mov eax, 0

sub eax, eax

push ecx
mov ecx, eax
xor ecx, eax
pop ecx

and eax, 0

etc



Come potete vedere, si può riscrivere una data operazione in un'infinità di modi. Un polymorphic engine serve appunto a questo. E, quindi, può assumere diverse funzionalità. Combinato ad a un generatore di junk code, ne rende difficile la rimozione attraverso l'individuazione di un pattern.

Inoltre, gli engine polimorfici vengono anche usati per generare PE con entry points sempre diversi, in modo che gli antivirus non possano basarsi su una signature per identificare il malware. Questo concetto si può usare anche se parliamo a livello di protezione. Non identificare con un signature scanner il packer, costringe il reverser ad analizzare il codice del packer e gli preclude, nel caso non riesca a identificare il packer nemmeno analizzandone il codice, l'uso di tools e papers ad hoc per il target.

Stolen Bytes

La tecnica degli stolen bytes è molto comune e si trova in diversi contesti. Uno dei più comuni è quando il packer si prende una parte delle istruzioni dell'OEP. Mettiamo che le istruzioni dell'OEP siano queste: 00547859 push 58h 0054785B push offset dword_5E33F8 00547860 call __SEH_prolog4 00547865 xor esi, esi 00547867 mov [ebp-4], esi 0054786A lea eax, [ebp-68h] 0054786D push eax  ; lpStartupInfo 0054786E call GetStartupInfoW 00547874 push 0FFFFFFFEh 00547876 pop edi 00547877 mov [ebp-4], edi 0054787A mov eax, 5A4Dh 0054787F cmp word ptr ds:__ImageBase.unused, ax 00547886 jnz short loc_5478C0 00547888 mov eax, ds:dword_40003C 0054788D cmp dword ptr ds:__ImageBase.unused[eax], 4550h 00547897 jnz short loc_5478C0 Il packer potrebbe prendere una parte di queste istruzioni, cancellarle dal OEP original e incorporarle nel proprio codice. Esempio:

codice packer

XXXXXXX push 58h XXXXXXX push offset dword_5E33F8 XXXXXXX call __SEH_prolog4 XXXXXXX xor esi, esi XXXXXXX mov [ebp-4], esi XXXXXXX lea eax, [ebp-68h] XXXXXXX push eax  ; lpStartupInfo XXXXXXX call GetStartupInfoW XXXXXXX push 0FFFFFFFEh XXXXXXX jmp short loc_547876 ; salta all'OEP+sizeof(istruzioni_rubate)

codice originale

00547876 pop edi 00547877 mov [ebp-4], edi 0054787A mov eax, 5A4Dh 0054787F cmp word ptr ds:__ImageBase.unused, ax 00547886 jnz short loc_5478C0 00547888 mov eax, ds:dword_40003C 0054788D cmp dword ptr ds:__ImageBase.unused[eax], 4550h 00547897 jnz short loc_5478C0 Quando si esegue un dump su un eseguibile che utilizza la tecnica degli stolen bytes, è necessario rimettere gli stolen bytes a loro posto, se possibile. Sempre che il packer non abbia aggiunto codice proprio in mezzo alle istruzioni o reso il codice polimorfico. In tal caso, è probabile che il codice non si possa più metterlo alla locazione originale ed è necessario lasciarlo dov'è. Ad ogni modo, gli stolen bytes sono sempre una complicazione.

Advanced IAT Redirection

È possibile combinare la classica IAT redirection con la tecnica degli stolen bytes, in modo che lo stub del packer contenga anche parte del codice dell'API. Questo rende estremamente difficile per tools automatici ricostruire la IAT, dato che rimangono sprovvisti di qualsiasi riferimento diretto all'entry point dell'API. Generalmente, quando la IAT redirection si fa complicata, non si usano tools generici come ImpREC, ma si procede individuando il codice del packer che crea gli stub e risolve la IAT. Non che ciò sia semplice, anche perché un packer serio non fa uso di GetProcAddress ed implementa la propria versione interna di tale API. Il discorso si può ulteriormente complicare quando le API vengono assimilate per intero dal packer come vedremo nel prossimo paragrafo.

API Emulation

Alcune API semplici come per esempio IsDebuggerPresent possono venire assimilate per intero dal packer e incluse nel suo codice. .text:7DD83510 ; BOOL __stdcall IsDebuggerPresent() .text:7DD83510 public IsDebuggerPresent .text:7DD83510 IsDebuggerPresent proc near  ; CODE XREF: CtrlRoutine+34#p .text:7DD83510 mov eax, large fs:18h .text:7DD83516 mov eax, [eax+30h] .text:7DD83519 movzx eax, byte ptr [eax+2] .text:7DD8351D retn .text:7DD8351D IsDebuggerPresent endp Come vedete questa API non contiene jump né altre istruzioni che ne rendono difficile la rilocazione. Questa API può facilmente venire spostata da un packer all'interno del proprio codice e questo quando il file protetto viene generato. Il risultato di questa operazione sarà che quando il PE protetto viene eseguito, non si avrà alcun riferimento all'API vera e propria. Restano due soluzioni. La prima consiste nel lasciare che la IAT linki l'API emulata e spezzettare il descriptor della dll importata ogni qualvolta si incontra una API assimilata. Oppure cercare di trovare l'API originale. La seconda opzione si può anche scartare se il codice dell'API assimilata è stato modificato.

Auto-Debugging

Questa tecnica è usata da Armadillo. In pratica, il packer crea un child process e si auto-debugga. La tecnica dell'auto-debugging impedisce a qualsiasi debugger ring3 di attaccarsi al processo debuggato. Quindi è un'ottima tecnica anti-debugging. Il tutto sta nel legare in modo inseparabile processo debugger e debuggee. Altrimenti, sarebbe facile rimuovere il processo debugger. È qui che entrano in gioco trick come le nanomites.

Nanomites

Le nanomites sono una tecnica presente in Armadillo. In pratica si tratta di jump camuffati da “int 3” (cioè breakpoints). In pratica, il processo che debugga viene notificato dell'eccezione generata dall'int 3 ed esegue il jump necessario. Per risolvere i nanomites si possono sostituire tutte le occorrenze di int 3 nel codice con jump oppure emulare il comportamento di armadillo per risolverli. AndreaGeddon ha optato per quest'ultima opzione nel suo articolo su Armadillo. L'articolo in questione è la fonte più completa se volete approfondire Armadillo e il tool ArmaGeddon scritto da alcuni membri di ARTeam si basa proprio su tale articolo.

Virtual Machines

Le virtual machines sono forse il mio argomento preferito all'interno del contesto di manual unpacking. Si basano sul sostituire codice nativo con pseudo-codice. Per eseguire questo pseudo-codice verrà utilizzato un interprete. Il codice originale è perduto. Per ricavare il codice nativo bisogna estrapolarlo dall'inteprete e questo può essere un lavoro molto lungo. Le implementazioni più celebri di questo tipo di tecnologia sono il Code Virtualizer di Oreans (usato anche dalla loro protezione “Themida”) e da SecuROM. Se volete approfondire il Code Virtualizer di Oreans, consiglio l'ottimo articolo di Scherzo sul codebreakers journal. Per SecuROM ci sono invece gli ottimi articoli di deroko su ARTeam. Consiglio anche gli ottimi articoli di Rolf Rolles su OpenRCE. Tuttavia, questo è un topic molto avanzato. In un tutorial per newbie come questo si può solo accennarlo.

Argomenti Correlati

Vi sono diversi argomenti correlati al manual unpacking. Innanzitutto, è un ottimo esercizio scriversi il proprio packer. Gli articoli di Kill3xx a riguardo sono un po' vecchi, ma di alta qualità e per di più in italiano. C'è da dire che, per scriversi una protezione commerciale, si possono usare approcci diversi ed evitare di scriversi tutto il packer in assembly. Ma questo lo dovete decidere voi. Sicuramente usare assembly è un buon esercizio per un reverser. Un altro argomento correlato è lo scriversi un unpacker automatico per un dato packer. Vi sono due tipi di unpacker automatici: on e offline. Con ciò si intende se il PE viene unpackato eseguendolo (e debuggandolo) o staticamente da file sul disco. Al tempo (a 17 anni), scrissi un articolo su come creare un unpacker automatico online. Alcune volte si può evitare il manual unpacking patchando il codice decrittato in memoria. In questi casi si crea un loader per l'exe vero e proprio che dopo averlo caricato aspetta che venga decrittato il codice originale e poi patcha. Vi sono diversi articoli sulla UIC che si basano su questo procedimento. L'ultima cosa che mi sento in dovere di accennare è che esistono packer per assembly .NET. Questo tipo di packer è molto banale e si possono superare con un semplice dump. Basta utilizzare una utility come il mio .NET Generic Unpacker.

Esercitazione

Come già detto, ho scritto questo articolo in una mezza giornata e non ho assolutamente il tempo di scrivere un unpackme (cioè un crackme da unpackare) per farvi esercitare. Come esercitazione unpackate un target su tuts4you o qualche altra pagina di crackmes.

Conclusioni

Il manual unpacking è una delle discipline fondamentali nell'ambito del reversing, anche perché come avete modo di vedere è onnicompresiva di tutte le tecniche anti-reversing. Dieci anni fa era possibile scrivere un articolo generale su come unpackare qualsiasi protezione, o quasi, ma oggi la complessità delle varie protezioni non lo rende più possibile. Questa lezione dovrebbe avervi fornito di un'infarinatura generale, ma per poter unpackare dovete approfondire i vari argomenti e, soprattutto, esercitarvi. Per esercitarvi effettuate qualche unpack guidato e poi provate a muovere i primi passi da soli.

Ntoskrnl