Anti Disassembling Techniques
From UIC
Anti Disassembling Techniques
Contents |
| Infos | |
|---|---|
| Author: | quequero |
| Email: | |
| Website: | http://quequero.org |
| Date: | 25/04/2009 (dd/mm/yyyy) |
| Level: |
|
| Language: | Italian |
| Comments: | Un saluto a tutte le persone che il sisma del 6 Aprile ci ha portato via. |
Introduzione
Stavo curiosando tra i tutorial della UIC in cerca di spunti per sviluppare delle tecniche anti-disasm per ARM e mi sono accorto che... Non c'e' neanche un tutorial sulle tecniche anti-disasm per x86. Ok, dal momento che sto a letto malaticcio (e ho appena preso IDA 5.4 ;p) non mi posso esimere dal colmare questo vuoto, quindi oggi parleremo di tecniche che possono essere utilizzare per cercare di ingannare sia i disassembler che i debugger (o meglio... I disassembler dei debugger).
Tools
Essay
Il compito del disassembler e' quello di trasformare il codice macchina in codice mnemonico, che risulta piu' semplice da interpretare per gli umani. In questo ambito possiamo identificare modalita' di funzionamento differenti che ci tornano utili per raggruppare i disassembler in due grandi famiglie:
- Linear Sweep Disassemblers: disassemblano un'istruzione per volta
- Recursive Traversal Disassemblers: iniziano a disassemblare dall'entry-point e proseguono fino alla prima istruzione di branch (jmp, jnz, jb etc...) per poi riprendere a disassemblare direttamente da quel punto.
Tra i Linear Sweep Disassemblers troviamo il buon vecchio SoftICE, objdump e WinDbg. Tra i Recursive Traversals Disassemblers troviamo invece la vecchia cara IDA, OllyDbg e l'ottimo PEBrowse. Per riuscire a confondere un disassembler dobbiamo capire come funziona, e gia' questa suddivisione ci da' un'idea generale del loro modus operandi. Ma dalla nostra abbiamo un'arma molto potente: su x86 le istruzioni non sono a lunghezza fissa (come accade su ARM e su tutti i RISC ad esempio) ma possono essere lunghe da un byte in su, una situazione che senz'altro ci da' spazio di manovra per confondere il motore di disassembly, e come se non bastasse nulla ci vieta di mescolare dati e codice. A conti fatti sembra che sia piu' semplice fare del buon codice offuscato che un buon disassembler, ed in effetti e' cosi' (che sorpresa eh ;p), per una serie di ragioni che vedremo a breve.
Junk Bytes
Confondere un disassembler significa, essenzialmente, fare in modo che interpreti uno o piu' byte di dati come l'inizio di un'istruzione. Ancora una volta siamo fortunati perche' su x86 gli opcode che rappresentano l'inizio di un'istruzione sono ben 248 su 256, percio' non avremo sicuramente problemi a trovare quello che fara' per noi. Come primo test proviamo ad inserire nel codice dei byte di spazzatura ed a vedere come reagiscono i disassembler, quindi prendiamo questo piccolo programma e compiliamolo:
int a = 0;
goto label;
__asm _emit(0x83); // Junk byte
label:
a++;
return 0;
}
Per chi non la conoscesse, l'istruzione _emit serve ad inserire nel codice un byte che desideriamo e che in teoria dovrebbe rappresentare un opcode. Cominciamo ora il nostro studio: compiliamo il programma disabilitando le ottimizzazioni e la creazione delle info di debug (cosi' da non produrre il .pdb che tanto aiuterebbe la brava IDA ;p), disassembliamo poi il codice ottenuto con il Linear Sweep Disassembler di Visual Studio:
00401000 55 push ebp
00401001 8B EC mov ebp,esp
00401003 51 push ecx
00401004 C7 45 FC 00 00.. mov dword ptr [a],0
0040100B EB 03 jmp label (401010h)
0040100D EB 01 jmp label (401010h) ; Le ottimizzazioni sono disabilitate
0040100F 83 8B 45 FC 83.. or dword ptr [ebx-3F7C03BBh],1
00401016 89 45 FC mov dword ptr [a],eax
00401019 33 C0 xor eax,eax
0040101B 8B E5 mov esp,ebp
0040101D 5D pop ebp
0040101E C3 ret
Come vedete la strategia di disassemblare un'istruzione per volta non consente di riconoscere il nostro junk byte come tale che quindi diviene parte di un'istruzione di or, il risultato del disassembler e' quindi sbagliato. Vediamo ora come si comporta un normale Recursive Traversal Path disassembler come IDA v5.4:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 mov [ebp+a], 0
.text:0040100B jmp short label
.text:0040100D jmp short label
.text:0040100F db 83h ; Junk Byte
.text:00401010 label: mov eax, [ebp+a]
.text:00401013 add eax, 1
.text:00401016 mov [ebp+a], eax
.text:00401019 xor eax, eax
.text:0040101B mov esp, ebp
.text:0040101D pop ebp
.text:0040101E retn
La strategia adottata da disassembler piu' evoluti e' sicuramente piu' funzionale, il junk byte viene immediatamente identificato come tale, ed il risultato del disassembly e' corretto, ma in cosa differisce questo approccio dal primo? Nel caso di Visual Studio il disassembling e' un procedimento cieco che prende il primo byte e lo disassembla alla prima istruzione possibile, IDA invece e' in grado di identificare quelle porzioni di codice che non vengono mai toccate dal programma, e che quindi possono essere considerate come componenti estranei. Se vi state chiedendo come fa e' presto detto: viene calcolata la destinazione di tutte le istruzioni di branch, se un dato ramo di codice non viene raggiunto da nessun branch, allora viene marcato come estraneo. In effetto il nostro junk byte non viene raggiunto o richiamato da nessuno, quindi e' corretto isolarlo. Ma a questo punto dovrebbe esservi venuta un'ideuzza per confondere anche la bella IDA vero?
Opaque Predicates
Con questo termine Linn e Debray definirono un predicato che, in maniera non ovvia, torna sempre TRUE o FALSE. Come vedremo si tratta di un trucchetto efficace per confondere i Recursive Traversal Disassemblers, anche se torneremo a fine paragrafo sul discorso per una breve precisazione. Creare un predicato opaco e' un'operazione abbastanza semplice, guardate questo pezzetto di codice:
int a = 0;
if(!a)
goto label; // Arriveremo sempre qui
__asm _emit(0x83);
label:
a++;
return 0;
}
La variabile a viene inizializzata a 0 e mai piu' toccata, quindi il primo if() sara' vero e finiremo sempre sulla goto senza mai attraversare l'istruzione _emit. Questo e' un predicato opaco poiche' quello che viene tradotto in codice come un salto condizionale, e' a tutti effetti un jmp in piena regola. Disassembliamo nuovamente questo programma con IDA (il risultato che verrebbe con Visual Studio gia' lo conosciamo) ed esaminiamone il risultato:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 mov dword ptr [ebp-4], 0
.text:0040100B cmp dword ptr [ebp-4], 0
.text:0040100F jnz short loc_401015
.text:00401011 jmp short near ptr loc_401015+1
.text:00401013 db 0EBh
.text:00401014 db 1
.text:00401015 label:
.text:00401015 83 8B 45.. or dword ptr [ebx-3F7C03BBh], 1 ; Junk Byte
.text:0040101C mov [ebp-4], eax
.text:0040101F xor eax, eax
.text:00401021 mov esp, ebp
.text:00401023 pop ebp
.text:00401024 retn
Con questo semplicissimo trucco siamo riusciti a confondere anche IDA. Diamo invece uno sguardo al codice utilizzando l'ultima Beta disponibile di OllyDbg 2:
00401000 55 push ebp
00401001 8B EC mov ebp,esp
00401003 51 push ecx
00401004 C7 45 FC 00 00.. mov dword ptr ss:[ebp-4],0
0040100B 83 7D FC 00 cmp dword ptr ss:[ebp-4],0
0040100F 75 04 jne short 00401015
00401011 EB 03 jmp short label
00401013 EB 01 jmp short label
00401015 83 db 83 ; Junk Byte
00401016 label:
00401016 8B 45 FC mov eax,dword ptr ss:[ebp-4]
00401019 83 C0 01 add eax,1
0040101C 89 45 FC mov dword ptr ss:[ebp-4],eax
0040101F 33 C0 xor eax,eax
00401021 8B E5 mov esp,ebp
00401023 5D pop ebp
00401024 C3 retn
A quanto pare, nonostante la differenza in termini di denaro OllyDbg sembra essere piu' intelligente di IDA :>. In effetti prima abbiamo detto che e' necessario creare un predicato opaco che non sia ovvio per il disassembler... Bene, cambiamo un pochino le carte in tavola e vediamo chi riesce a starci dietro, compiliamo il seguente codice:
int a = 0;
if(!a)
goto label;
else
__asm _emit(0xB8); // Junk Instruction
__asm _emit(0x18); // Junk Instruction
a >>= 0; // Junk Instruction
label:
return 0;
}
In questo caso abbiamo aggiunto un ramo else al predicato, in modo da costringere il motore di disassembly ad eseguire un'analisi piu' approfondita. Passiamo l'eseguibile ad IDA e vediamone il responso:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
.text:00401004 mov [ebp+a], 0
.text:0040100B cmp [ebp+a], 0
.text:0040100F jnz short loc_401017
.text:00401011 jmp short label
.text:00401013 jmp short label
.text:00401015 db 0EBh, 1
.text:00401017 loc_401017:
.text:00401017 B8 18 8B.. mov eax, 0FC458B18h ; I nostri Junk Bytes
.text:0040101C mov [ebp+a], eax
.text:0040101F label:
.text:0040101F xor eax, eax
.text:00401021 mov esp, ebp
.text:00401023 pop ebp
.text:00401024 retn
IDA non riesce a disassemblare correttamente il codice, come vediamo viene messo in EAX un valore anomalo che poi viene spostato all'interno della variabile a, cosa che nel codice da noi scritto non succede. Tuttavia possiamo assistere alla risincronizzazione del motore, infatti vediamo che un'istruzione e' stata interpretata come dati, la successiva come codice (in maniera errata) e poi dalla terza in poi il disassembler e' stato capace di riprendere il normale flusso, infatti il codice successivo e' rappresentato correttamente. Vediamo ora OllyDbg cosa riesce a fare:
00401000 push ebp
00401001 mov ebp,esp
00401003 push ecx
00401004 mov dword ptr ss:[a],0
0040100B cmp dword ptr ss:[a],0
0040100F jne short 00401017
00401011 jmp short label
00401013 jmp short label
00401015 db EB
00401016 db 01
00401017 B8 18 8B 45 FC mov eax,FC458B18 ; Junk Bytes
0040101C mov dword ptr ss:[a],eax
0040101F label: xor eax,eax
00401021 mov esp,ebp
00401023 pop ebp
00401024 retn
A quanto pare anche OllyDbg e' caduto sotto i colpi della nostra ascia, questo ci fa ben sperare :) e se siete curiosi sappiate che anche il Linear Sweep Disassembler di Visual Studio presenta lo stesso identico risultato. In verita' dovremmo rimanere abbastanza sorpresi dal fatto che certi trick funzionino, abbiamo chiamato questi costrutti predicati opachi, ma e' evidente che non si tratta di predicati indecibili (se non a runtime), ma di predicati il cui risultato e' gia' deciso, ed in maniera piuttosto esplicita, gia' a compile time. Questo e' segno che il lavoro svolto dal disassembler e' estremamente gravoso e allo stato attuale nessun motore, tra quelli provati, e' in grado di seguire correttamente il flusso del programma. In sostanza possiamo dire che di opaco in questi predicati c'e' ben poco e l'occhio esperto del reverser sara' in grado di notare immediatamente il trucco. Nel caso appena visto potremmo fare anche di meglio, ad esempio inserendo dei junk byte che nel disassemblato non facciano apparire le altre istruzioni come data, questo e' solo un'esercizio ma l'idea di fondo resta la stessa: basta mettere il disassembler davanti ad un albero decisionale e potenzialmente saremo in grado di mandarlo in crisi, come se non bastasse i predicati appena visti possono essere complicati a piacimento :).
Fake Return Points
Un'altra strategia che confonde facilmente i disassembler e' quella che mi sono permesso di battezzare come Fake Return Points. I disassembler assumono infatti che l'istruzione eseguita al ritorno di una funzione sia quella immediatamente successiva alla CALL stessa, vediamo quindi cosa succede se quest'assunzione diviene errata.
int a = 0;
if(!a) {
func();
__asm _emit(0xB8);
a += 3;
}
return 0;
}
Al momento cosa fa func() non e' importante, l'importante e' che ci sia una chiamata a funzione, vediamo il risultato di IDA (non riporto quello di Olly visto che e' identico):
.text:00401040 push ebp
.text:00401041 mov ebp, esp
.text:00401043 push ecx
.text:00401044 mov [ebp+var_4], 0
.text:0040104B cmp [ebp+var_4], 0
.text:0040104F jnz short FakeBranch
.text:00401051 call func
.text:00401056 mov eax, 83FC458Bh
.text:0040105B rol byte ptr [ebx], 89h
.text:0040105E inc ebp
.text:0040105F cld
.text:00401060 FakeBranch:
.text:00401060 xor eax, eax
.text:00401062 mov esp, ebp
.text:00401064 pop ebp
.text:00401065 retn
Vediamo chiaramente che il disassembler si desincronizza all'indirizzo 00401056 e riprende a disassemblare correttamente soltanto all'indirizzo 00401060. La risincronizzazione e' dovuta solo al fatto che a quell'indirizzo c'e' la destinazione di un branch, altrimenti avrebbe continuato a disassemblare in maniera errata ancora per molti byte. Utilizzando questo approccio dobbiamo pero' stare molto attenti alla calling convention utilizzata, a questo scopo faccio una brevissima ma necessaria digressione.
Standard Calling Convention
Windows utilizza quella che e' stata ribattezzata Standard Calling Convention, sebbene non sia il vero standard (rappresentato dalla C Calling Convention, accettata da tutti fuorche' Microsoft) funziona in questo modo: i parametri della funzione vengono salvati sullo stack in right-to-left order e prima di tornare al chiamante i parametri utilizzati devono essere liberati. Questa convention conserva lo stato di tutti i registri ad eccezione di: EAX, ECX ed EDX (ed i relativi RAX, RCX ed RDX). Quindi dopo una chiamata _stdcall tutti i registri, ad eccezione di quelli appena nominati, saranno inalterati. Ovviamente dovendo liberare lo stack all'interno della chiamata, non e' possibile utilizzare questa convention per le funzioni che prendono un numero variabile di parametri (printf() su tutte). Guardiamo un esempio di chiamata a funzione con questa calling convention:
int __stdcall func(int a, int b) {
// ...
}
int main(int argc, WCHAR* argv[]) {
// ...
int x = func(a, 7);
// ...
}
Il disassemblato appare cosi':
.text:00401000 push ebp
.text:00401001 mov ebp, esp
...
.text:00401023 mov esp, ebp
.text:00401025 pop ebp
.text:00401026 retn 8 ; Qui viene pulito lo stack
; main()
.text:00401046 push 7 ; b
.text:00401048 mov ecx, [ebp+a] ; a
.text:0040104B push ecx
.text:0040104C call func
.text:00401051 mov [ebp+x], eax
Lo stack viene ripulito all'interno di func() quindi possiamo inserire quello che vogliamo dopo la chiamata a func(), senza doverci preoccupare di compromettere il funzionamento del programma.
C Calling Convention
A differenza della precedente la _cdecl richiede che sia il chiamante, e non la funzione stessa, a liberare i parametri dallo stack dopo il ritorno dalla funzione. Come la _stdcall anche la _cdecl garantisce la conservazione dello stato di tutti i registri ad eccezione di: EAX, ECX ed EDX. Questa e' la convention utilizzata per default dalla suite di GCC/G++ ed in generale per tutte le chiamate (anche su Windows) che utilizzano un numero variabile di parametri. Ecco un esempio di codice:
int __cdecl func(int a, int b) {
// ...
}
int main(int argc, WCHAR* argv[]) {
// ...
int x = func(a, 7);
// ...
}
Ed ecco come appare il disassemblato:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 push ecx
...
.text:00401023 mov esp, ebp
.text:00401025 pop ebp
.text:00401026 retn
; main()
.text:00401046 push 7 ; b
.text:00401048 mov ecx, [ebp+a] ; a
.text:0040104B push ecx
.text:0040104C call func
.text:00401051 add esp, 8 ; Qui viene pulito lo stack
.text:00401054 mov [ebp+x], eax
Nel main vediamo che dopo la chiamata a func() lo stack viene ripulito, in questo caso non possiamo utilizzare il trick del fake return point dal momento che non possiamo inserire codice immediatamente dopo la chiamata.
Fastcall Convention
Nelle fastcall i parametri delle funzioni vengono passati in ECX ed EDX, i restanti sullo stack nel solito right-to-left order. Come per la _stdcall anche nelle fastcall i parametri utilizzati sullo stack vengono liberati dal chiamante e non dalla funzione. Ricompiliamo un piccolo esempio:
// ...
}
int main(int argc, WCHAR* argv[]) {
// ...
int x = func(a, 7);
// ...
}
E guardiamone di nuovo il disassemblato:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
...
.text:0040102B mov esp, ebp
.text:0040102D pop ebp
.text:0040102E retn ; Lo stack verrebbe pulito qui
; main()
.text:00401046 mov edx, 7 ; b
.text:0040104B mov ecx, [ebp+a] ; a
.text:0040104E call func
.text:00401053 mov [ebp+x], eax
In questo caso i parametri sono soltanto due quindi vengono passati direttamente sui registri, se fossero stati tre uno sarebbe stato passato sullo stack che poi sarebbe stato ripulito direttamente all'interno di func().
C++ Calling Convention
Per il C++ abbiamo un'ulteriore convenzione dal momento che oltre ai soliti parametri, in maniera invisibile per lo sviluppatore viene sempre passato il puntatore al this. Non esistendo uno standard definito per questa convention, ogni compilatore utilizza la propria: Microsoft definisce la thiscall convention dove il this viene passato in ECX (per tutte le funzioni membro non statiche) ed il resto viene trattato ugualmente a una comune _stdcall. G++ invece passa il this sempre come primo parametro e tratta il resto come una comune _cdecl.
Torniamo ai Fake Return Points
E' quindi evidente che per utilizzare questo trick dobbiamo esplicitamente utilizzare una chiamata _stdcall, altrimenti il compilatore inserirebbe del codice dopo la funzione che non ci consentirebbe di utilizzare alcun trick anti-disassembly, poiche' non sarebbe possibile inserire dei junk bytes senza compromettere la funzionalita' del programma stesso. Nell'esempio che segue sposteremo il return point in avanti di un byte e modificheremo lo stato di una variabile globale. Anche qui fate attenzione perche' se compilate il programma con l'opzione per omettere il frame pointer non troverete il return point dentro EBP ma dentro ESP.
int __stdcall func() {
int k;
// Let's move the return point 1 byte ahead
__asm add dword ptr[ebp + 4], 1;
k = 1;
g_Flag = k;
return -k;
}
int main(int argc, WCHAR* argv[]) {
int a = 0;
if(!a) {
func();
__asm _emit(0xC7);
a += g_Flag;
} else {
__asm _emit(0x0F);
}
a += g_Flag - 4;
return 0;
}
Ecco IDA come disassembla il codice:
.text:00401020 push ebp
.text:00401021 mov ebp, esp
.text:00401023 push ecx
.text:00401024 mov dword ptr [ebp-4], 0
.text:0040102B cmp dword ptr [ebp-4], 0
.text:0040102F jnz short FakeBranch
.text:00401031 call func
.text:00401036 dw 8BC7h
.text:00401038 inc ebp
.text:00401039 cld
.text:0040103A add eax, dword_403374
.text:00401040 mov [ebp-4], eax
.text:00401043 jmp short near ptr loc_401045+1
.text:00401045 FakeBranch:
.text:00401045 jnp near ptr 40738458h
.text:0040104B add [ebx+448DFC55h], cl
.text:00401051 or bh, ah
.text:00401053 mov [ebp-4], eax
.text:00401056 xor eax, eax
.text:00401058 mov esp, ebp
.text:0040105A pop ebp
.text:0040105B retn
L'istruzione subito dopo la chiamata viene disassemblata in maniera errata ed anche il resto fino al punto in cui il disassembler riesce a risincronizzarsi. Anche stavolta vediamo OllyDbg come si comporta:
00401000 push ebp
00401020 push ebp
00401021 mov ebp,esp
00401023 push ecx
00401024 mov dword ptr ss:[ebp-4],0
0040102B cmp dword ptr ss:[ebp-4],0
0040102F jne short FakeBranch
00401031 call func
00401036 db C7 ; First Junk
00401037 mov eax,dword ptr ss:[ebp-4]
0040103A add eax,dword ptr ds:[403374]
00401040 mov dword ptr ss:[ebp-4],eax
00401043 jmp short 00401046
00401045 db 0F ; Second Junk
00401046 FakeBranch: mov ecx,dword ptr ds:[403374]
0040104C mov edx,dword ptr ss:[ebp-4]
0040104F lea eax,[ecx+edx-4]
00401053 mov dword ptr ss:[ebp-4],eax
00401056 xor eax,eax
00401058 mov esp,ebp
0040105A pop ebp
0040105B retn
Olly e' decisamente piu' attento in questo caso e non si lascia fregare facilmente, infatti e' in grado di identificare correttamente entrambi i junk byte. Suggerimento: provate a simulare l'epilogo di una funzione con la direttiva __asm all'interno di un predicato e vedete cosa accade dentro IDA.
Indirect Jumps
Se vogliamo vedere l'inferno in Terrra... Possiamo confondere ulteriormente le idee al disassembler utilizzando una vasta gamma di funzioni indirette insieme ai predicati opachi. Guardate questo esempio (ok potevo farlo in assembly, ma VS mi tornava piu' comodo ;p):
int a = 0, mark = 0;
if(!a) {
__asm mov eax, label;
__asm mov mark, eax;
} else {
__asm mov eax, main;
__asm mov mark, eax;
}
if(a)
__asm _emit (0xB8);
__asm mov eax, mark;
__asm jmp eax; // Saltiamo a label
label:
return 0;
}
Questa volta utilizziamo due variabili: a che viene utilizzata per il predicato opaco e mark che viene utilizzata per un jmp. Nel ramo del predicato che viene eseguito a runtime non facciamo altro che muovere dentro mark l'indirizzo di label. Nel ramo che invece non viene mai eseguito, copiamo dentro mark l'indirizzo del main. Poi aggiungiamo un secondo predicato opaco che semplicemente scrive il byte 0xB8, l'istruzione successiva carica in EAX il contenuto di mark e poi ci salta. Il disassembler non puo' sapere cosa conterra' EAX e quindi evitera' in toto di fare assunzioni a riguardo, infatti guardate cosa succede dentro IDA:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 8
.text:00401006 mov dword ptr [ebp-4], 0
.text:0040100D mov dword ptr [ebp-8], 0
.text:00401014 cmp dword ptr [ebp-4], 0
.text:00401018 jnz short FakeBranch
.text:0040101A mov eax, (offset loc_401037+1)
.text:0040101F mov [ebp-8], eax
.text:00401022 jmp short FakePredicate2
.text:00401024 FakeBranch:
.text:00401024 mov eax, offset _main
.text:00401029 mov [ebp-8], eax
.text:0040102C FakePredicate2:
.text:0040102C cmp dword ptr [ebp-4], 0
.text:00401030 jz short near ptr loc_401032+1
.text:00401032 loc_401032:
.text:00401032 mov eax, 0FFF8458Bh
.text:00401037 loc_401037:
.text:00401037 loopne loc_40106C
.text:00401039 ror byte ptr [ebx+3BC35DE5h], 0Dh
.text:00401040 add [eax+0], dh
.text:00401042 inc eax
.text:00401043 add [ebp+2], dh
.text:00401046 rep retn
Ahhh dolore e sofferenza, vediamo subito che il disassembler inizia a vacillare all'indirizzo 0040101A, infatti dal suo punto di vista viene caricato in EAX un'indirizzo che appartiene al secondo byte di un'istruzione. Ma e' poco dopo il secondo predicato opaco che inizia il delirio: il disassembler perde completamente il filo, tant'e' che non riesce neanche piu' ad identificare il termine della funzione. Vediamo ora lo stesso codice come appare all'interno di OllyDbg:
00401000 push ebp
00401001 mov ebp,esp
00401003 sub esp,8
00401006 mov dword ptr ss:[ebp-4],0
0040100D mov dword ptr ss:[ebp-8],0
00401014 cmp dword ptr ss:[ebp-4],0
00401018 jne short FakeBranch
0040101A mov eax,label
0040101F mov dword ptr ss:[ebp-8],eax
00401022 jmp short FakePredicate2
00401024 FakeBranch: mov eax,main
00401029 mov dword ptr ss:[ebp-8],eax
0040102C FakePredicate2: cmp dword ptr ss:[ebp-4],0
00401030 je short 00401033 ; Jump to real code
00401032 B8 8B 45 F8 FF mov eax,FFF8458B ; Junk bytes + real code
00401037 db E0 ; Real byte interpreted as junk
00401038 label: xor eax,eax
0040103A mov esp,ebp
0040103C pop ebp
0040103D retn
OllyDbg come sempre si comporta decisamente meglio, ma neanche lui e' riuscito ad identificare il jmp eax, pur riuscendo a recuperare la sincronizzazione molto prima di IDA che invece e' andata avanti per decine di byte (che non ho messo nel codice per brevita').
Jump Tables
Un'altra strategia utile sia contro il disassembling che contro il reversing in se e' quella di utilizzare le tecniche appena viste insieme a delle jump table. Una jump table (che poi e' ne' piu' ne' meno il risultato in asm di uno switch in C) consente di spezzare il flusso del programma e di confondere notevolmente il disassembler, vediamo subito un codice di esempio:
int a = 0;
int l1, l2, l3, lend, lloop;
WCHAR t = 'A';
__asm mov lloop, offset iloop;
__asm mov lend, offset end;
// Our tolower() version :)
while(1) {
switch(a) {
case 0: // Is it a number?
__asm mov l1, offset label1;
__asm jmp l1;
__asm _emit(0xC7);
case 1:
__asm _emit(0xE8);
case 2: // Is it already lower case?
__asm mov l2, offset label2;
__asm jmp l2;
__asm _emit(0xC7);
case 3:
__asm _emit(0xE8);
case 4: // Let's make it lowercase!
__asm mov l3, offset label3;
__asm jmp l3;
case 5:
__asm _emit(0xE8);
default:
__asm _emit(0xB9);
break;
}
iloop:
a += 2;
}
end:
// tolower() end
return 0;
label1:
if(t >= '0' && t <= '9') {
__asm jmp lend;
__asm _emit(0xB8);
} else
__asm jmp lloop;
label2:
if(t >= 'a' && t <= 'z')
__asm jmp lend;
else {
__asm jmp lloop;
__asm _emit(0xB8);
}
label3:
if(t < 'A' || t > 'Z') { // Invalid character
__asm jmp lend;
__asm _emit(0xB8);
}
t += 'a' - 'A';
__asm jmp lend;
__asm _emit(0x0F);
return 0;
}
Ok prendetevi un momento di tempo per capire il codice, quella che vedete e' una funzione simile alla tolower(), in pratica data una lettera in ingresso ne torna il relativo carattere in lowercase, altrimenti il carattere stesso nel caso si tratti di un simbolo/numero o di una lettera gia' minuscola. La funzione attraversa tre step:
- Controlla che la lettera passata non sia un numero, in caso termina il ciclo
- Controlla che la lettera non sia gia' minuscola, in caso termina il ciclo
- Controlla che la lettera non sia un simbolo (in caso termina il ciclo) altrimenti la converte in lowercase
A livello logico possiamo identificare tre step separati, e quindi possiamo fare una cosa inutile e dannosa: separare questi tre step il piu' possibile, ma come? L'idea che mi e' venuta e' stata quella di inserire la routine all'interno di uno switch() che dispatcha l'esecuzione alla subroutine corretta leggendo il valore di un counter a, che viene incrementato ad ogni loop di 2. Quindi il programma entra nel while(), entra nello switch, lo switch porta l'esecuzione su una label che a sua volta torna nel ciclo. Tanto per capirci si tratta di un ciclo esteso verso l'esterno. Il tutto condito con dei predicati opachi rappresentati da dei case che di fatto non vengono mai eseguiti (quelli con numero dispari), e con dei junk byte posti al di sotto di ogni case che comunque non vengono mai eseguiti perche' l'istruzione precedente e' sempre un jmp (e questo non dovrebbe ingannare il disassembler). E' da notare che il target di ogni jmp all'interno dei case viene riempito a runtime e quindi il disassembler non e' (ma dovrebbe esserlo) in grado di stabilire gli indirizzi di destinazione. Vediamo IDA cosa ci tira fuori... Preparatevi spiritualmente:
.text:00401000 push ebp
.text:00401001 mov ebp, esp
.text:00401003 sub esp, 20h
.text:00401006 mov [ebp+var_C], 0
.text:0040100D mov eax, 41h
.text:00401012 mov [ebp+var_18], ax
.text:00401016 mov [ebp+var_14], (offset loc_401066+1)
.text:0040101D mov [ebp+var_8], offset loc_401072
.text:00401024 loc_401024:
.text:00401024 mov ecx, 1
.text:00401029 test ecx, ecx
.text:0040102B jz short loc_401072
.text:0040102D mov edx, [ebp+var_C]
.text:00401030 mov [ebp+var_20], edx
.text:00401033 cmp [ebp+var_20], 5
.text:00401037 ja short loc_401066
.text:00401039 mov eax, [ebp+var_20]
.text:0040103C jmp ds:off_4010D8[eax*4]
.text:00401043
.text:00401043 loc_401043:
.text:00401043 mov [ebp+var_4], offset loc_401076
.text:0040104A jmp [ebp+var_4]
.text:0040104D db 0C7h, 0E8h, 0C7h
.text:00401050 inc ebp
.text:00401051 in al, 91h
.text:00401053 adc [eax+0], al
.text:00401056 jmp [ebp+var_1C]
.text:00401059 db 0C7h, 0E8h, 0C7h
.text:0040105C inc ebp
.text:0040105D lock lodsb
.text:0040105F adc [eax+0], al
.text:00401062 jmp [ebp+var_10]
.text:00401065 db 0E8h
.text:00401066 loc_401066:
.text:00401066 mov ecx, 83F44D8Bh
.text:0040106B rol dword ptr [edx], 89h
.text:0040106E dec ebp
.text:0040106F hlt
.text:00401070 jmp short loc_401024
.text:00401072 loc_401072:
.text:00401072 xor eax, eax
.text:00401074 jmp short loc_4010D3
.text:00401076 loc_401076:
.text:00401076 movzx edx, [ebp+var_18]
.text:0040107A cmp edx, 30h
.text:0040107D jl short loc_40108E
.text:0040107F movzx eax, [ebp+var_18]
.text:00401083 cmp eax, 39h
.text:00401086 jg short loc_40108E
.text:00401088 jmp [ebp+var_8]
.text:0040108B db 0B8h
.text:0040108C jmp short loc_401091
.text:0040108E loc_40108E:
.text:0040108E jmp [ebp+var_14]
.text:00401091 loc_401091:
.text:00401091 movzx ecx, [ebp+var_18]
.text:00401095 cmp ecx, 61h
.text:00401098 jl short loc_4010A8
.text:0040109A movzx edx, [ebp+var_18]
.text:0040109E cmp edx, 7Ah
.text:004010A1 jg short loc_4010A8
.text:004010A3 jmp [ebp+var_8]
.text:004010A6 jmp short loc_4010AC
.text:004010A8 loc_4010A8:
.text:004010A8 jmp [ebp+var_14]
.text:004010AB db 0B8h
.text:004010AC loc_4010AC:
.text:004010AC movzx eax, [ebp+var_18]
.text:004010B0 cmp eax, 41h
.text:004010B3 jl short loc_4010BE
.text:004010B5 movzx ecx, [ebp+var_18]
.text:004010B9 cmp ecx, 5Ah
.text:004010BC jle short loc_4010C2
.text:004010BE loc_4010BE:
.text:004010BE jmp [ebp+var_8]
.text:004010C1 db 0B8h
.text:004010C2 loc_4010C2:
.text:004010C2 movzx edx, [ebp+var_18]
.text:004010C6 add edx, 20h
.text:004010C9 mov [ebp+var_18], dx
.text:004010CD jmp [ebp+var_8]
.text:004010D0 db 0Fh, 33h, 0C0h
.text:004010D3 loc_4010D3:
.text:004010D3 mov esp, ebp
.text:004010D5 pop ebp
.text:004010D6 retn
Ero gia' confuso quando ho scritto il codice, ora sono confuso ancora di piu' e a dirla tutta... Anche IDA non sembra avere le idee chiare :). OllyDbg al contrario offre un disassemblato piu' coerente e meno istruzioni vengono disassemblate in maniera errata.
Conclusioni
Ora avete tutti gli strumenti necessari per sviluppare tante belle tecniche anti-disasm, sperimentate l'inlining delle funzioni, l'outlining, l'interleaving e fate in modo di mettere il disassembler sempre davanti a delle scelte che non possono essere calcolate correttamente se non a runtime. Allo stato attuale i disassembler non sono ancora molto intelligenti, percio' ognuno di noi puo' trarre vantaggio dal fatto che un semplice predicato opaco puo' metterli in crisi praticamente tutti.
Un predicato realmente opaco e' possibile?
Per rispondere a questa domanda servirebbe una dimostrazione formale, non so se ne esistano gia' ma ad occhio (e potrei sbagliarmi) penso che non sia possibile se assumiamo che il programma finale sia comunque deterministico, analizziamo alcuni casi:
- Il predicato viene deciso dal valore di un parametro scritto nel codice -> Il predicato non e' realmente opaco perche il valore e' deducibile anche staticamente.
- Il predicato viene deciso da un valore ottenuto indirettamente, magari dal sistema (es: GetTickCount()) -> Il predicato e' opaco ma il programma non e' piu' deterministico.
- Il predicato viene deciso da un parametro indiretto del sistema (es: GetTickCount() ^ GetTickCount() o un valore impostato da un thread esterno) -> Il predicato non e' opaco perche' possiamo calcolare staticamente il codominio della funzione che manipola il parametro.
Se riuscite a trovare una falla nel ragionamento e avete un'idea di come ottenere un predicato realmente opaco fatemi sapere.
Ringraziamenti & Bibliografia
- Un saluto a tutti i miei conterranei Abruzzesi in questo momento di grande dolore, quello del 6 Aprile 2009 sara' un giorno duro da dimenticare.
- Obfuscation of Executable Code to Improve Resistance to Static Disassembly, Linn/Debray.
- Reversing Secrets of Reverse Engineering Code, Eldad Eilam
- The IDA Pro Book
Disclaimer
I documenti qui pubblicati sono da considerarsi pubblici e liberamente distribuibili, a patto che se ne citi la fonte di provenienza. Tutti i documenti presenti su queste pagine sono stati scritti esclusivamente a scopo di ricerca, nessuna di queste analisi è stata fatta per fini commerciali, o dietro alcun tipo di compenso. I documenti pubblicati presentano delle analisi puramente teoriche della struttura di un programma, in nessun caso il software è stato realmente disassemblato o modificato; ogni corrispondenza presente tra i documenti pubblicati e le istruzioni del software oggetto dell'analisi, è da ritenersi puramente casuale. Tutti i documenti vengono inviati in forma anonima ed automaticamente pubblicati, i diritti di tali opere appartengono esclusivamente al firmatario del documento (se presente), in nessun caso il gestore di questo sito, o del server su cui risiede, può essere ritenuto responsabile dei contenuti qui presenti, oltretutto il gestore del sito non è in grado di risalire all'identità del mittente dei documenti. Tutti i documenti ed i file di questo sito non presentano alcun tipo di garanzia, pertanto ne è sconsigliata a tutti la lettura o l'esecuzione, lo staff non si assume alcuna responsabilità per quanto riguarda l'uso improprio di tali documenti e/o file, è doveroso aggiungere che ogni riferimento a fatti cose o persone è da considerarsi PURAMENTE casuale. Tutti coloro che potrebbero ritenersi moralmente offesi dai contenuti di queste pagine, sono tenuti ad uscire immediatamente da questo sito.
Vogliamo inoltre ricordare che il Reverse Engineering è uno strumento tecnologico di grande potenza ed importanza, senza di esso non sarebbe possibile creare antivirus, scoprire funzioni malevoli e non dichiarate all'interno di un programma di pubblico utilizzo. Non sarebbe possibile scoprire, in assenza di un sistema sicuro per il controllo dell'integrità, se il "tal" programma è realmente quello che l'utente ha scelto di installare ed eseguire, né sarebbe possibile continuare lo sviluppo di quei programmi (o l'utilizzo di quelle periferiche) ritenuti obsoleti e non più supportati dalle fonti ufficiali.