GUIDA Codice Metamorfo: modificare un file eseguibile (.exe)

DispatchCode

Moderatore
Staff Forum
Utente Èlite
2,223
1,854
CPU
Intel I9-10900KF 3.75GHz 10x 125W
Dissipatore
Gigabyte Aorus Waterforce X360 ARGB
Scheda Madre
Asus 1200 TUF Z590-Plus Gaming ATX DDR4
HDD
1TB NVMe PCI 3.0 x4, 1TB 7200rpm 64MB SATA3
RAM
DDR4 32GB 3600MHz CL18 ARGB
GPU
Nvidia RTX 3080 10GB DDR6
Audio
Integrata 7.1 HD audio
Monitor
LG 34GN850
PSU
Gigabyte P850PM
Case
Phanteks Enthoo Evolv X ARGB
Periferiche
MSI Vigor GK30, mouse Logitech
Net
FTTH Aruba, 1Gb (effettivi: ~950Mb / ~480Mb)
OS
Windows 10 64bit / OpenSUSE Tumbleweed

Codice Metamorfo: modificare un file eseguibile (binary rewriting) (.exe)





BubbleSort originaleBubbleSort modificato
bubble-sort-before.png
bubble-sort-after1.png


E' un progetto nato mesi fa, sotto la tenue luce di un lontano ricordo riguardante un paper letto alcuni anni prima. A questo paper seguirono alcune altre risorse - compresa Wekipedia - ma nulla di molto approfondito.
Il ricordo è riaffiorato grazie a una vecchia USB che conteneva materiale risalente a una decina di anni fa - che aveva bisogno di una bella pulizia.

Ho scelto di presentare il progetto sottoforma di articolo, ripensando un pò a quello che Marcus Aseth fece sul proprio Game Boy Emulator, così da aggiornarlo in futuro e mantenerlo come "paper" in lingua italiana.

Ho già "parlato" molto, ma non ho detto di che si tratta concretamente; ci arriverò tra qualche riga, in quanto ciò che mi preme fare come premessa è una precisazione circa il progetto stesso.
Ebbene, non ha la pretesa di essere una soluzione utile e funzionante e che vada a risolvere un problema specifico; potrebbe però rappresentare una soluzione interessante, forse anche solo curiosa, per confondere un pò le cose in un file eseguibile (poi sarò più chiaro, promesso) senza alterarne il funzionamento. E' anche un modo - e forse è lo scopo principale - per apprendere qualcosa di nuovo, e devo ammettere di aver trovato il tutto molto affascinante e interessante (nonchè anche complesso).

Le immagini di apertura verranno riprese e commentate in punti successivi dell'articolo.
Ringrazio anticipatamente chi mi leggerà. :)

Indice

  • 0. Breve preambolo: di che si tratta?
  • 1. Codifica delle istruzioni
  • 2. Modulo MCA (Machine Code Analyzer)
  • 3. Logica del disassembler
  • 3.1 NOP Squeeze
  • 4. PE rewrite: copia delle modifiche
  • 5. Test sul codice
  • 5. Problemi/errori

0. Preambolo




Il software in questione, che attualmente non è ancora completo, permette di apportare modifiche alla sezione TEXT/CODE (la sezione dedicata al codice) di un file eseguibile; al momento sto supportando il PE Header, formato utilizzato da Windows; tuttavia introducendo un nuovo parser si comporterebbe allo stesso modo anche sotto altri OS.

Le modifiche apportate riguardano le istruzioni in linguaggio macchina: in pratica, dopo il disassembly, queste vengono riscritte in una forma differente ma con le stesse funzionalità (metamorphic code).

Si consideri ad esempio l'azzeramento di una variabile:
C:
a = 0;

In assembly, ipotizzando che il contenuto sia rappresentato dal registro EAX, è probabile si vedrà:
Codice:
XOR   eax, eax

ciò che sarà possibile fare, sarà scambiare quello XOR con ad esempio:
Codice:
mov   eax, 0

Preciso che la funzionalità appena descritta non è ancora implementata, in quanto sto ancora sviluppando (e testando... debuggando) la logica utilizzata nel disasm (verrà spiegata nel punto 3), la cui logica è la stessa seguita in fase di esecuzione del programma.

Utilità? Come dicevo inizialmente, imparare qualcosa di nuovo, e di divertente. Possibili applicazioni potrebbero essere comunque:
  • modificare con lo scopo di rendere più complessa la comprensione di una parte di un programma (o l'intero programma);
  • ottenere l'indirizzo di tutte le procedure presenti nell'eseguibile (verrà implementato);
  • ottenere info sulle istruzioni;
  • eventuali scopi non proprio leciti (malware), ammesso che l'euristica dell'AV non ne venga immediatamente disturbata;
  • combinare le modifiche con il crypt/decrypt di una o più parti del codice, con lo scopo di proteggerle;
  • riscrivere una procedura e richiamare quella invece dell'originale;
  • spostare una procedura in una posizione differente;
  • spezzare una procedura e collegarla con un salto incondizionato.

E altro ancora...
Le architetture supportate sono IA32 e IA64.

1. Codifica delle Istruzioni



Un'istruzione a 32/64bit presenta alcune similitudini con quella della CPU 8086 dell'epoca.
Il dettaglio dei campi che la compongono è mostrato di seguito (immagine tratta dal manuale di Intel per gli sviluppatori, 2nd libro):


instruction-format.png

Fig.1 - Formato di un'istruzione

Per dare un pò di contesto a ciò che vedrete di seguito spendo qualche parola sui singoli campi.

Prefissi
sono 9, si tratta dei segment override prefix (per bypassare l'associazione di default dei registri della CPU), l'address size prefix, l'operand size prefix etc.

REX
si tratta di un prefisso utilizzato in IA64: gli opcode da 0x40 a 0x4F cambiano di significato, e invece di indicare istruzioni INC e DEC ha altri significati, in base alla configurazione dei bit (evito dettagli ora)

Opcode
l'operazione eseguita: MCA gestisce OP a 1-byte, 2-byte (primo byte 0Fh), 3-byte (sia 38h, sia 3Ah); ci sono alcune eccezioni dovute a istruzioni che presentano lo stesso OP e che si differenziano utilizzando il byte successivo, ModRm (e indicate come "GrpN" "gruppo N")

ModRm
composto da 3 parti:
  • mod (2bit, 7,6) - se impostato a 11b indirizza un registro; tutte le altre combinazioni indicano un indirizzamento che coinvolge la memoria. In coppia con *r/m* specifica 32 modalità di indirizzamento (24 coinvolgono la memoria).
  • reg (3bit, 5..3) - in quasi tutti i casi specifica un registro;
  • r/m (3bit, 2..0) - può specificare un registro o essere utilizzato per formare una modalità di indirizzamento;

SIB (Scale Index Base)
richiesto da alcune configurazioni del byte ModRm. Si tratta di un campo composto da 3 sezioni, come ModRm, e che assumono i seguenti significati:
  • scale (2bit, 7,6) - è il fattore di scala, può essere 1, 2, 4, 8;
  • index (3bit, 5..3) - registro indice;
  • base (3bit, 2..0) - registro base;

Un esempio di un'istruzione con SIB byte:
Codice:
MOV EAX, [ECX*4+ESI]

Displacement
se presente, è l'indirizzo di memoria dal quale leggere o scrivere e può essere di 1, 2, 4, 8-bytes.

Immediate
valore immediato, è una costante numerica, un numero "harcodato" nel sorgente, come mov eax, 1.

Tutte le sequenze di byte che compongono l'istruzione devono essere posizionate esattamente nell'ordine nelle quale le ho riportate; non vi è un ordine per i prefissi l'importante è che precedano l'OP.

E' il momento di entrare nel vivo dell'articolo...

2. Modulo MCA (Machine Code Analyzer)



MCA - ha cambiato nome più volte - permette di decodificare le istruzioni riconoscendo i campi che la compongono (punto 1) e la sua lunghezza.

Ecco un esempio:
Codice:
len: 3, VA addr: 0x00F53902 | New VA: 0x00F53905
RAW bytes (hex): 8B 04 8E

Print instruction fields:
        Located Prefixes 0:
        OP: 0x8B, name: MOV
        mod_reg_rm: 0x4
        SIB byte: 0x8E
        disp (0): 0x0
        ############# REGISTERS ##################
        OP1: 0 (EAX)
        OP2: 0 ()
        ##########################################

MCA internamente mantiene una tabella di lookup con i nomi delle istruzioni; al momento supporta parte degli OP a 1-byte (non è una feature fondamentale, ci sono cose ben più importanti in questa fase). Quel "MOV" compare grazie a questa tabella.

Le prime righe dell'output mostrano la lunghezza, come si evince, mentre i campi VA addr e New VA contengono due indirizzi di memoria. VA può trarre in inganno: inizialmente logica + MCA erano predisposti per lavorare in-memory, a runtime; in pratica veniva modificato il programma stesso durante l'esecuzione.
Il campo "VA addr" conteneva l'indirizzo dell'istruzione (ed essendo in memoria di fatto era un Virtual Address, VA); "New VA" era il nuovo indirizzo di quell'istruzione, in memoria. Al punto successivo, quando parlerò della logica utilizzata, chiarirò perchè questi cambi di indirizzi (che possono vedersi in Fig.0 ad inizio articolo).
Attualmente è tutto statico, non avvengono modifiche durante l'esecuzione; tuttavia, in futuro, terminato lo sviluppo della parte statica, conto di rimetterci mano (non dovrebbe servire molto lavoro, in teoria, ma ci saranno limitazioni...).

RAW bytes è un array che contiene i byte che compongono l'istruzione; serve a facilitare la copia verso un file binario, in uno step successivo, dell'istruzione (più avanti mostro in che modo ciò avviene).

Prefixes
I prefissi devono precedere l'opcode, quindi la prima verifica che viene fatta è proprio questa; tramite uno switch ne verifico la presenza, utilizzando una tabella di lookup.

Queste sono le informazioni raccolte da MCA quando decodifica un'istruzione con un prefisso:
Codice:
------------------------------------------------
len: 5, VA addr: 0x00F53740 | New VA: 0x00F5399E
RAW bytes (hex): 66 C7 07 30 00

Print instruction fields:
        Located Prefixes 1: 0x66
        OP: 0xC7, name: MOV
        mod_reg_rm: 0x7
        disp (0): 0x0
        Iimm: 0x30
------------------------------------------------

0x66 è l'operand size prefix: significa che la memoria è stata indirizzata usando una WORD.
In asm abbiamo infatti:

Codice:
mov   word ptr [EDI], 30h

Questo è un altro caso di esempio, con un istruzione composta da un OP a 2-byte:
Codice:
len: 4, VA addr: 0x00F538E0 | New VA: 0x00F538E0
RAW bytes (hex): 0F 10 04 08

Print instruction fields:
        Located Prefixes 1: 0xF
        OP: 0x10, name:
        mod_reg_rm: 0x4
        SIB byte: 0x8
        disp (0): 0x0

Sembra un'istruzione semplice forse, ma si tratta del set SSE:
Codice:
MOVUPS XMM0, DQWORD PTR [ECX+EAX]

ModRm
Proseguendo con MCA, superata la decodifica dei prefissi (se presenti), verifico se l'OP è a 2-byte o 3-byte, e se non lo è, allora inizia la decodifica dei campi successivi.
Il primo campo che si trova è ModRm. Da esperienze pregresse (Nate - Not a True Emulator, e anche un altro progetto) ho pensato di mappare anche questo byte come gli OP.
La decodifica avviene utilizzando questa tabella:

C:
static size_t mod_reg_rm_1b[256] = {
        //      00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
        /* 00 */ 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0,
        /* 10 */ 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0,
        /* 20 */ 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0,
        /* 30 */ 1, 1, 1, 1, 0, 0, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0,
        /* 40 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* 50 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* 60 */ 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0,
        /* 70 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* 80 */ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
        /* 90 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* A0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* B0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* C0 */ 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0,
        /* D0 */ 1, 1, 1, 1, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, // 2 = Coprocessor Escape
        /* E0 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* F0 */ 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 1, 1
};

il procedimento è analogo per gli OP con più byte.

Questa è la tabella utilizzata, tratta dal manuale Intel:

op1b-decode.png

Fig.2 - Decodifica OP-1b

I campi indicati con E e G, hanno il byte ModRm.
Il codice che controlla la presenza è effettivamente questo:
C:
if((val = mod_table[instr->op]))
{
    if(val == X87_ESC)
        instr->instr_flags |= X87_FPU_FLAG;

    instr->instr_flags |= X86_MODREG_FLAG;

    instr->modregrm.mod_reg_rm = (uint8_t) *(data + (index));
    ....

SIB byte
Proseguendo nella lettura dei campi, sappiamo che è presente il SIB byte. Non ho ancora iniziato a mappare i SIB byte per avere lo mnemonico, ma dal byte 0x8E si possono ricavare tutte le sue componenti.
I singoli campi che lo compongono possono assumere i valori riportati nell'immagine sottostante:


sib-layout.png

Fig.2 - Indirizzamento a 32bit con SIB byte

Quindi:
Codice:
0x8E <-> 10'001'110b 

scaled = 10b -> mappa il valore 4
index = 001b -> ECX
base  = 110b -> ESI

MOV   EAX, [ECX*4 + ESI]

MCA verifica l'esistenza del SIB byte in questo modo:

C:
static size_t check_sib(x86_instr *instr)
{
    if(instr->modregrm.field.mod < 3 && instr->modregrm.field.rm == 4)
        return 1;
    return 0;
}


Displacement
Come detto nella sezione 1, se il sottocampo mod è 11b, allora il displacement non è presente; quindi per verificarne la presenza ho impostato questo tipo di controllo:

C:
if(instr->modregrm.field.mod < 3 && instr->sib.field.base != 0x05)
{
    instr->instr_flags |= X86_DISP_FLAG;

    instr->disp_len = decode_disp(instr->modregrm.field.mod, instr->modregrm.field.rm);
    memcpy(&instr->disp, (data + index), instr->disp_len);

    instr->length += instr->disp_len;
    index += instr->disp_len;
}

Poco sopra viene verificato il SIB (tramite check_sib()), e se presente, decodificato.
La decodifica del displacement, se presente, risulta relativamente semplice:

C:
static size_t decode_disp(uint8_t mod, uint8_t rm)
{
    if((mod == 0x02) || (!mod && rm == 5))
        return 4;
    else if(mod == 0x01)
        return 1;

    return 0;
}

Immediate
Per il valore immediato esiste un'altra tabella (un array simile ai precedenti per ModRm), creata sempre utilizzando un array:

C:
static size_t imm_byte_1b[256] = {
        //      00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
        /* 00 */ 0, 0, 0, 0, b, z, 0, 0, 0, 0, 0, 0, b, z, 0, 0,
        /* 10 */ 0, 0, 0, 0, b, z, 0, 0, 0, 0, 0, 0, b, z, 0, 0,
        /* 20 */ 0, 0, 0, 0, b, z, 0, 0, 0, 0, 0, 0, b, z, 0, 0,
        /* 30 */ 0, 0, 0, 0, b, z, 0, 0, 0, 0, 0, 0, b, z, 0, 0,
        /* 40 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* 50 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* 60 */ 0, 0, 0, 0, 0, 0, 0, 0, z, z, b, b, 0, 0, 0, 0,
        /* 70 */ b, b, b, b, b, b, b, b, b, b, b, b, b, b, b, b,
        /* 80 */ b, z, b, b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* 90 */ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, p, 0, 0, 0, 0, 0,
        /* A0 */ wd, wd, wd, wd, 0, 0, 0, 0, b, z, 0, 0, 0, 0, 0, 0,
        /* B0 */ b, b, b, b, b, b, b, b, v, v, v, v, v, v, v, v,
        /* C0 */ b, b, w, 0, 0, 0, b, z, wb, 0, w, 0, 0, b, 0, 0,
        /* D0 */ 0, 0, 0, 0, b, b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
        /* E0 */ b, b, b, b, b, b, b, b, z, z, p, b, 0, 0, 0, 0,
        /* F0 */ 0, 0, 0, 0, 0, 0, gr3b, gr3z, 0, 0, 0, 0, 0, 0, 0, 0
};

Le lettere rispecchiano circa i valori originali di intel: b sta per byte, v dipende dal tipo di operando, così come z (e da un prefisso); poi ci sono i gruppi (non ancora completi per la verità). Durante lo sviluppo si è reso ogni tanto necessario toccare qualcuno di questi valori (in ultimo i gruppi, gr3b e gr3z); l'ostacolo maggiore, quando si verifica un errore nella decodifica, è capire perchè, dove, e cosa coinvolge... il fix è relativamente rapido.
Riporto l'output della decodifica di un'istruzione che coinvolge un registro e un valore immediato:

Codice:
len: 3, VA addr: 0x00F53865 | New VA: 0x00F53A69
RAW bytes (hex): 83 C0 02

Print instruction fields:
        Located Prefixes 0:
        OP: 0x83, name: ADD
        mod_reg_rm: 0xC0
        Iimm: 0x2
        ############# REGISTERS ##################
        OP1: 0 (EAX)
        OP2: 0 ()
        ##########################################

In assembly, si vedrebbe:

Codice:
add   eax, 2


REX prefix

Per invididuare la presenza del prefisso REX, ho pensato di verificare l'architettura target (IA6 = 64bit, IA3 = 32bit):

C:
if(arch == IA6 && (prefix & 0x40))
{
    decode_rex_prefix(instr, prefix);
}

e questo è il corpo della funzione chiamata:

C:
static inline void decode_rex_prefix(x86_instr *instr, uint8_t rex)
{
    instr->instr_flags |= X64_REX_PREFIX;
    instr->x64_rex_prefix.field.rex_w = rex_w(rex);
    instr->x64_rex_prefix.field.rex_r = rex_r(rex);
    instr->x64_rex_prefix.field.rex_x = rex_x(rex);
    instr->x64_rex_prefix.field.rex_b = rex_b(rex);
}

Le altre funzioni altro non fanno che shiftare e ottenere il valore del campo con l'AND bit a bit:

C:
static inline uint8_t rex_w(uint8_t rex)
{
    return (rex >> 3) & 0x01;
}

Jcc / JMP / CALL
In aggiunta, non può mancare il rilevamento dei salti. Qui si complica un pò, in quanto ci sono salti con displacement (che specificano direttamente l'indirizzo al quale saltare), ed altri che specificano invece solo un valore immediato, da sommare o sottrarre all'indirizzo dal quale il salto avviene.

Codice:
------------------------------------------------
len: 5, VA addr: 0x00F537C2 | New VA: 0x00F5395D
RAW bytes (hex): E8 B1 00 00 00

Print instruction fields:
        Located Prefixes 0:
        OP: 0xE8, name:
        Iimm: 0xB1
        label points to: 0x00F53878
------------------------------------------------

In questo caso sommando 0xB1 al VA corrente si trova infatti la destinazione del salto (vanno sommati altri 5byte, la lunghezza dell'istruzione corrente).
Questa istruzione è un caso particolare: si tratta di una di quelle chiamate alla "jump table" posta nel file eseguibile dal compilatore.
Quel salto (tecnicamente è una CALL, una chiamata a funzione) infatti punta al codice mostrato qui sotto:

Codice:
------------------------------------------------
len: 6, VA addr: 0x00F53878 | New VA: 0x00F53A7C
RAW bytes (hex): FF 25 00 20 40 00

Print instruction fields:
        Located Prefixes 0:
        OP: 0xFF, name:
        mod_reg_rm: 0x25
        disp (4): 0x402000
        label points to: 0x00402000
------------------------------------------------

Questa ultima istruzione, che usa un displacement, è una API Call; vista in assembly:

Codice:
JMP DWORD PTR DS:[<&kernel32.WriteFile>]

3. Logica del disassembler


Visto lo scopo dell'intero progetto ho pensato di complicare un pò le cose dall'inizio, disassemblando seguendo il flusso dell'esecuzione.
Considerate i due screnshot (visti da Olly):


BubbleSort originaleBubbleSort modificato
bubble-sort-before.png
bubble-sort-after1.png

Il codice proviene da un piccolo programma scritto in assembly (essendo in una fase iniziale, dare in pasto anche un "Hello World" in C sarebbe un suicidio) che prende 10 numeri in input, li ordina, e restituisce quanti di essi sono pari. Il BubbleSort l'ho in realtà scritto in C, e poi ho copiato il codice generato e l'ho integrato nel sorgente asm (lo so, sembrava infatti scritto troppo bene :P ).

L'output di questo "programmino" che comprende BubbleSort è mostrato di sequito:

Codice:
4
3
1
2
6
7
9
0
8
5
0 1 2 3 4 5 6 7 8 9
5
Premi un tasto per continuare...

I 10 valori sono quelli inseriti in input, che vengono poi ordinati. La conta dei pari è presente giusto per inserire altro codice semplice da testare.

Dal codice originale si distinguono bene due cicli annidati, e non risulta difficile se si conosce un minimo di asm comprendere le condizioni di questi cicli e di quell'if interno (JLE, Jump if Lower or Equals; se è >, effettua lo swap dei valori).
Considerate ora il codice modificato: la prima cosa che si vede è un insieme di istruzioni che nell'altro codice si trova ben al di sotto (0x00401197).

Prima di proseguire, aggiungo un altro screen sempre relativo al BubbleSort (dopo la modifica):


bubble-sort-after.png)

Fig.3.1 - BubbleSort originale/modificato

Date uno sgurdo ora al precedente screen, all'indirizzo 0x00401131 noterete:

Codice:
JMP 0x40110D

Mentre nel secondo:
Codice:
JMP SHORT 0x40110D

L'istruzione del secondo screen punta allo stesso indirizzo, ma è più lunga di 3byte (e l'opcode è differente).



Prima di parlare del motivo di questa differenza, un accenno sul funzionamento della logica:
MCA si comporta un pò da CPU, svolgendo una sorta di "fetch & decode"
Successivamente l'azione svolta dipende dal tipo di istruzione:

Codice:
IF instr_vect[i].type & JMP
  IF instr_vect[i].label == DISASSEMBLED
    insert_label(instr_vect[i].label, DEFAULT_VOID);
    addr = get_future(future_label);
  ELSE
    addr = instr_vect[i].label;
    make_nop(instr_vect[i]);
    make_nop(instr_vect[i]);
  ENDIF
ELSE IF (instr_vect[i].type & JCC) || (instr_vect[i].type & CALL) || (instr_vect[i].type & JCC_EXT)
  insert_future(instr_vect[i].label);
  instr_vect[i].label = labels[index_disassem].disasm;
ENDIF

è una versione semplificata di ciò che avviene, ma la logica è quella. Ci sono due code nello pseudo-codice, che sono quelle utilizzate internamente durante il disassembly:
- label, mappa l'indirizzo del salto originale con quello nuovo;
- future, è una coda che dà priorità maggiore ai Jcc, e che contiene quindi Jcc, Jmp e CALL. JCC_EXT sono i salti estesi, quelli a 2byte.

I JMP vengono seguiti subito, ma solo se la destinazione non è ancora stata decodificata; tornando alla Fig.3.0, si può infatti notare che il primo JMP viene "risolto" subito, e che la destinazione viene "portata in cima", appena sotto alla MOV che coinvolge LOCAL.1 (che è ciò che avviene in fase di esecuzione del JMP).
Se il salto è già stato decodificato, allora viene aggiornato solo l'indirizzo di destinazione e... inserito un JMP con destinazione l'istruzione disassemblata in precedenza (MA, non al VA precedente, bensì al VA nuovo). Poi si estrae dalla coda (future) un nuovo indirizzo.

JCC: non vengono mai seguiti subito, l'indirizzo di destinazione viene salvato - in future - solo se mai disassemblato.
Per le CALL stessa procedura.

Quando ci si trova ad una RET, si estrae un nuovo indirizzo dalla coda; ci si ferma quando è vuota.
Al termine, vengono aggiornati tutti i salti utilizzando le "labels" mappate in precedenza e aggiornate durante l'esecuzione.



Per rispondere all'interrogativo lasciato aperto sui JMP: sono diversi in quanto ogni volta che viene inserito un nuovo JMP ad un'istruzione già disassemblata, questo viene scelto random: o viene inserito un salto SHORT o uno FAR. L'unica eccezione è il salto per il quale non si può scegliere (se > di 127, non sta in 1byte, quindi si usa il FAR):

C:
if((int64_t)instr->imm > 127 || (int64_t)instr->imm < -127)
    make_far_jmp(instr, instr_prefixes);
else
{
    if(rand() & 1)
        make_far_jmp(instr, instr_prefixes);
    else
        make_short_jmp(instr, instr_prefixes);
}
(si, sarebbe sufficiente l'abs)

Una scelta analoga avviene quando si trova un INC: viene scelto randomicamente se lasciare l'INC, oppure se trasformarlo in un ADD Reg, 1.
Quando si verifica uno di questi casi, che sia il JMP o l'ADD, devo riaggiornare i salti e non usare la lunghezza dell'istruzione che ho decodificato precedentemente, perchè sarà più corta o più lunga, a seconda delle scelte fatte (se era una INC e ora ho una ADD, sto aggiungendo 2byte, se era un JMP SHORT e ora è un JMP FAR, ne ho 3 in più).

Le "make_jmp" sono simili, ne riporto solo una:

C:
static void make_far_jmp(x86_instr *instr, int instr_prefixes)
{
    instr->imm -= 5;
    instr->op     = JMP_FAR;
    instr->length = 5;
    memcpy(instr->raw_bytes+instr_prefixes+1,&instr->imm,instr->length-1-instr_prefixes);
}

3.1 - NOP Squeeze

La parte logica vista sopra viene invocata (attualmente dal main) in questo modo:

C:
int32_t ep = get_entry_point(&pe);
disassembled_code *disasm_code = permu_disasm(pe.data_buffer + rva_to_offset(&pe, ep), IA3, false, true,&pe);
disasm_update_data(disasm_code, pe.section_bounds[0].start,&pe);
pe_update_data(&pe);

Tornerò sulle funzioni che riguardano il PE nella sezione successiva; soffermandoci ora sui parametri passati a permu_disasm(), il primo bool che si vede è chiamato "nop squeeze". Impostando questo parametro a true, laddove viene decodificato un JMP, al posto di questo non viene inserito nulla; tecnicamente non viene incrementato nemmeno "l'EIP" (diciamo il contatore dell'istruzione corrente, il New VA, per capirci) e di conseguenza l'istruzione successiva (che sarà la destinazione del salto) verrà posizionata al suo posto.
Lo squeeze consiste proprio in questo: è una sorta di compressione dei JMP seguiti.

Quando il parametro è a false, questi non vengono compressi; al posto dei byte del JMP rimosso vengono inseriti dei NOP.

C:
make_nop(&instr_vect[*instr_counter], *ipcounter, arch);
*instr_counter = *instr_counter + 1;
*ipcounter = *ipcounter + 1;

make_nop(&instr_vect[*instr_counter], *ipcounter, arch);
*instr_counter = *instr_counter + 1;
*ipcounter = *ipcounter + 1;

In caso lo squeeze fosse a true, viene eseguito questo codice:

C:
char *addr = (char *) instr_vect[*instr_counter].label;

if(nop_squeeze)
{
    memset((instr_vect + *instr_counter), 0, sizeof(x86_instr) * 2);
    return addr;
}

Ciò che si ottiene disabilitando lo squeeze lo si può vedere nella figura sottostante:


bubble-sort-after-nop.png

Fig.3.2 - BubbleSort originale/modificato con NOP

Mantenere il parametro a false ha alcuni vantaggi, come la possibilità di inserire del codice "randomico" (junk code) al posto dei NOP. Probabilmente sarà una delle feature da implementare, oltre a quelle viste in precedenza.


4 - PE: Parse & Rewrite


Al momento dell'apertura del file viene calcolata la dimensione e allocato l'opportuno numero di byte (al momento viene caricato interamente in memoria).
Avvengono tutti i check del caso sulla validità dei "numeri magici" all'interno del PE e dei puntatori (come e_lfanew, per individuare l'inizio dell'NT Header); alcune informazioni vengono mantenute in una strutura dati che ho creato appositamente, e che fa uso di puntatori alle strutture definite in windows.h.

C:
typedef struct _pe_info {
    char *data_buffer;
    char *virtualImage;
    size_t original_size;
    uint64_t new_size;
    section_bounds *section_bounds;

    IMAGE_DOS_HEADER *pDosHeader;
    IMAGE_NT_HEADERS *pNtHeader;
    IMAGE_FILE_HEADER *pFileHeader;
    IMAGE_OPTIONAL_HEADER *pOptHeader;
    IMAGE_SECTION_HEADER *sections;
} pe_info;

Nulla di particolare; l'operazione più delicata è il caricamento dell'optional header in quanto in quel momento vengono anche lette le sezioni e vengono mantenute in memoria "separatamente" (viene utilizzato il campo sections, della struttura mostrata sopra).

In quella struttura ci sono due membri che rivestono una maggiore importanza: data_buffer e virtualImage. Il primo rispecchia il file binario letto direttamente da disco. L'altro membro invece rispecchia le dimensioni del VirtualImage presente nel PE Header. Questo puntatore è quello utilizzato poi nell'effettuare tutte le operazioni viste sopra, nelle sezioni precedenti.

La memoria per virtualImage viene effettivamente allocata solo dopo l'aver letto le sezioni e averle copiate in memoria:

C:
int i = -1;
while(++i < n_sections)
{
    pe->sections[i] = section[i];
    size_t endSection = section[i].VirtualAddress + section[i].Misc.VirtualSize;
    if(endSection > virtualImageSize)
        virtualImageSize = endSection;
}

pe->virtualImage = calloc(virtualImageSize, sizeof(char));
assert(pe->virtualImage != NULL);
memcpy(pe->virtualImage, pe->data_buffer ,virtualImageSize);

Questo è solo un frammento dell'intera funzione, che legge anche l'optional header e subito dopo popola l'array già citato delle sezioni.
A questo punto virtualImage è allocato ed è stato copiato l'header. La mossa successiva consiste in pratica nella copia del contenuto delle sezioni (CODE, DATA,...) all'interno di virtualImage.

Dopo tutte queste operazioni, inizia il disassembly: prima in realtà viene anche ricavato l'EP (Entry Point) per capire da dove iniziare il disassembly. Riprendendo il codice mostrato sopra:

C:
int32_t ep = get_entry_point(&pe);
disassembled_code *disasm_code = permu_disasm(pe.data_buffer + rva_to_offset(&pe, ep), IA3, false, true,&pe);

rva_to_offset() è una vecchia funzione utilizzata nell'esempio sul parsing del PE Header (un altro articolo reperibile sul forum). Qui è necessaria in quanto data_buffer è il binario così come presente sul disco, mentre l'EP è un VA (Virtual Address), quindi deve essere convertito in un offset all'interno del file (mi è costato un pò di debug quella svista...).

Al termine del disassembly è necessario effettuare l'update: nel disassembler viene utilizzato l'array raw_bytes per mantenere i byte di ogni istruzione, come detto sopra. Ebbene, qui è dove torna davvero utile in quanto questo array (presente ovviamente per ciascuna istruzione) viene copiato in virtualImage all'offset corretto; al momento l'offset corretto si concretizza nel posizionare la prima istruzione in cima al file.
Questo è il codice che copia in virtualImage:

C:
void disasm_update_data(disassembled_code *disasm_code, uint32_t file_offset, pe_info *pe)
{
    uint32_t file_rva = file_offset;

    size_t capacity = disasm_code->instr_count;
    for(int i=0; i<=capacity; i++)
    {
        x86_instr first = disasm_code->x86_ptr[i];
#ifdef _DEBUG_X86
        print_debug(&first);
#endif
        memcpy((pe->virtualImage + file_rva), first.raw_bytes, first.length);
        file_rva += first.length;
    }

#ifdef _DEBUG_X86
    printf("Virtual Image Updated\n");
#endif
}

Potete immaginare quanto mi ci è voluto per rendermi conto di aver passato un VA invece di un file offset ora...

Ok, adesso ci siamo quasi! Quasi perchè virtualImage e tutte le altre eventuali modifiche devono essere copiate indietro. Non sarà sfuggito infatti che porre sempre la prima istruzione in cima al file significa dover modificare il campo AddressOfEntryPoint all'interno dell'header dell'eseguibile (non avviene ancora in automatico, ma avverrà in questa fase... ora per i test edito il file con un hex editor manualmente); fatto ciò, dobbiamo "copiare indietro" tutto quanto.

Prima di mostrare il codice che effettua la copia, integro con altri due screen realizzati su un piccolo codice che fa uso delle SIMD per sommare due array 4byte alla volta. Da notare gli indirizzi e lo spazio tra le funzioni, oltre alla presenza delle chiamate alle API in cima; da notare che dall'EP originale (0x00401160) a quello modificato ci sono 352byte.


Somma vettori originaleSomma vettori modificata
simd-jmp-before.png
simd-jmp-after.png

Questo è invece il codice che aggiorna il data_buffer:

C:
void pe_update_data(pe_info *pe)
{
    size_t header_size = pe->pOptHeader->SizeOfHeaders;
    memcpy(pe->data_buffer, pe->virtualImage, header_size);

    int n_sections = pe->pFileHeader->NumberOfSections;
    for(int i=0; i<n_sections; i++)
    {
        uint64_t size = min(pe->sections[i].SizeOfRawData,pe->sections[i].Misc.VirtualSize);
        if(pe->sections[i].Characteristics & IMAGE_SCN_CNT_CODE)
        {
            if(size > pe->new_size)
                memset((char*)(pe->sections[i].PointerToRawData + pe->data_buffer + pe->new_size),0, size - pe->new_size);

            size = pe->new_size;
        }

        memcpy((char*)(pe->sections[i].PointerToRawData + pe->data_buffer),(char*)(pe->sections[i].VirtualAddress + pe->virtualImage), size);
    }
}

Considerando che vado ad alterare la dimensione del file (si, dovrei espandere la sezione del codice e anche aggiornare i campi dell'header che fanno riferimento alle dimensioni e agli offset), o per meglio dire vado ad aggiungere più istruzioni di prima - o di meno - devo anche fare in modo di copiare la dimensione corretta. Non è bellissimo come codice, ma è ancora in fase di test.

Quando questa funzione è terminata allora siamo pronti per scrivere su file. Al momento - ma sicuramente manterrò tutto ciò - rinomino il file originale (in .bck) e creo un nuovo file exe ricopiando tutto data_buffer (che è ora aggiornato).

5. Test sul codice



Per verificare errori nella decodifica delle istruzioni e del comportamento che riguarda la parte logica, come problemi relativi ai salti, sto utilizzando un metodo visto girovagando in rete, trovato ancora prima di NaTE.
Attualmente effettuo il dump di ciò che viene decodificato in un file binario, dando a ciascuna istruzione una lunghezza fissa (16bytes). In questo modo utilizzando MCA verifico se viene decodificato male qualcosa, oppure se l'errore non dipende da questa parte, ma dalla logica.

C:
static void dump_binary_file(disassembled_code *disasm_code)
{
    FILE *hfile = fopen("dump_file.bin", "wb");
    uint64_t n = disasm_code->instr_count;
    for(uint64_t i=0; i<=n; i++)
    {
        fwrite(disasm_code->x86_ptr[i].raw_bytes, sizeof(uint8_t), disasm_code->x86_ptr[i].length, hfile);
        if(disasm_code->x86_ptr[i].length < 16)
        {
            uint8_t cc = 0xCC;
            for(int j=disasm_code->x86_ptr[i].length; j<16; j++)
                fwrite(&cc, sizeof(uint8_t), 1, hfile);
        }
    }
    fclose(hfile);
}

Il test sul decode della singola istruzione è per certi aspetti più semplice ma difficile da garantire: non c'è un modo univoco per codificare un'istruzione e cosa peggiore, se stiamo testando l'opcode N con l'operando sorgente un registro e quello di destinazione la memoria, non è detto che funzioni il contrario. I test non tengono conto di queste differenze ad ora; mi hanno permesso tuttavia di rilevare alcuni bug, specialmente nelle prime fasi del progetto (direi di averne scoperti circa una decina in questo modo, e altri dopo l'implementazione della parte logica).

Considerando che per testare MCA è necessario essere sicuri di avere i campi che compongono l'istruzione decodificati in modo corretto e di conseguenza avere anche la lunghezza corretta, non è possibile scriverli in C o in un altro linguaggio... così ho utilizzato Assembly (NASM, che pare l'unico disassembler a produrre del codice in formato binario senza header).
Tutti i test sono di questo tipo:

Codice:
; ------------------------------------------
; VOL. 2D, Appendix A, pg. 7-8 (Intel Manual)
; ------------------------------------------

[BITS 64]

; Macros
pad     equ   16

; -------------------------------
; 1-byte opcodes tests
; -------------------------------

;
; 00h - 0Fh
;
op00:
add     al, bl
times   pad - ($ - $op00) db 0x55

op01:
add     rbx, rcx
times   pad - ($ - $op01) db 0x55

op02:
add     ah, [var]
times   pad - ($ - $op02) db 0x55
....

Ogni istruzione utilizzata genera come opcode esattamente quello utilizzato come nome della label; il times è utilizzato per riempire con N byte (N = pad - $ - $opxx) di valore 0x55, ciascuna istruzione di lunghezza inferiore ai 16byte; detto in altre parole: se l'istruzione è lunga 2byte vengono riempiti i restanti 14 con il valore 0x55.

Sto pensando anche ad altre soluzioni per evitare NASM, ma l'unica idea che ho è quella di utilizzare C con asm inline e creare una funzione senza prologo/epilogo (una naked function o crearmi un "trampolino" con asm inline, usando JMP invece di una CALL) la quale andrà passata a MCA che produrrà l'output per ogni singola istruzione; in sostanza il test sulle istruzioni avverrebbe durante l'esecuzione e non più come ora, dopo l'aver assemblato con NASM il file binario (penso quindi che opterò per questa strada).

6. Problemi/Errori



Oltre alle feature già citate sopra, ed alle cose mancanti, sto avendo un pò di problemi nel capire cosa esattamente in alcuni casi provoca la generazione di un exe buggato. Sono certo dipenda dalla variazione di JMP/INC/ADD, ma non mi è ancora del tutto chiaro in quali casi particolari si verifichi.

La mancanza più importante è, ovviamente, una vera e propria metamorfosi: ho deciso di non iniziare ad implementarla in quanto la logica esposta sopra è alla base... e se ho comportamenti anomali in questa parte e procedo, dubito di poter pubblicare una prima versione alpha. Le uniche metamorfosi presenti ora sono quindi le sostituzioni di INC con ADD e tutto ciò che concerne i JMP e in generale il flusso.

Altro problema? Il make_jmp() dà per assodato si tratti di un salto con 2 byte... ma può non essere così (per assurdo se eseguissi l'engine dando in input l'exe prodotto in precedenza nel caso di BubbleSort, non andrebbe sicuramente a causa dei JMP FAR). E' un altro caso che dovrò gestire.

Le rilocazioni. Al momento sto ignorando la sezione .reloc, ma dovrà essere correttamente parsata e manipolata.

Per il momento è tutto, ora sarei curioso di avere qualche feedback; ad ogni aggiornamento importante scriverò altro qui sotto.
Tenete presente che proseguirò a rilento in quanto è un progetto che porto avanti tornato da lavoro - quando me la sento - e nei giorni di festa, quando ho tempo.

Se mi avete letto sino a qui, grazie! :)
 

Entra

oppure Accedi utilizzando
Discord Ufficiale Entra ora!