
Offuscatore bin2bin PE x64 che non aggiunge una sezione al binario
Vedi 4. Compilazione per le istruzioni di compilazione.
Questo offuscatore è bin2bin, il che significa che prende un eseguibile già compilato e lo riproduce con i passaggi di offuscamento applicati. Questo può essere usato per proteggere un'applicazione senza avere accesso al codice sorgente originale. Al momento sono supportati solo file x64 PE (portable executable), ma ci sono piani per aggiungere supporto ad altri formati binari (es. ELF) in futuro.
Attualmente, tutti i noti offuscatori bin2bin inseriscono una sezione alla fine del binario per collocare il codice o i dati offuscati al suo interno. Questo per preservare il layout originale del binario, senza dover modificare il contenuto delle sezioni preesistenti. Questo è molto più semplice da gestire perché mantiene validi la maggior parte degli RVA (indirizzi relativi).
Questo progetto adotta un approccio unico a bin2bin, in cui qualsiasi codice o dato offuscato viene inserito all'interno delle sezioni originali del binario. Ciò richiede di tracciare ogni singolo RVA nell'applicazione. I vantaggi di questo approccio sono:
Questo documento descriverà sia la riscrittura del binario eseguibile sia le tecniche di offuscamento implementate. Le seguenti tecniche di offuscamento sono state implementate:
Inoltre, questo progetto supporta anche le eccezioni (eccezioni C++ e SEH) ed è in grado di offuscare funzioni che dispongono di gestione delle eccezioni.
Per facilitare il disassemblaggio e l'individuazione del codice nel binario, i file di simboli (sia PDB che MAP) sono accettati opzionalmente. Fornire i file di simboli non è obbligatorio ma aiuta nel disassemblaggio di binari complessi. Alcune funzionalità come il supporto alle eccezioni e l'appiattimento del flusso di controllo richiedono che venga fornito un file di simboli.
Un riscrittore binario prende un eseguibile e modifica il codice o i dati al suo interno per produrre un binario di output con le modifiche applicate.
Poiché il codice offuscato viene inserito direttamente nelle sezioni originali del binario, gli indirizzi relativi del programma devono essere tracciati in modo che tutti i riferimenti ad essi possano essere adeguati. Questo serve affinché i riferimenti puntino ancora alla stessa posizione dopo l'inserimento di codice e dati. Altrimenti, dati o codice verrebbero acceduti nella posizione sbagliata, modificando così il comportamento del binario di output e causando inoltre una grave instabilità.
Ogni volta che viene trovato un riferimento a un indirizzo relativo (ad es. istruzioni contenenti operandi relativi a rip o directory di dati PE), questo viene aggiunto a una lista di tracciamento da aggiornare al termine della riscrittura. Vengono tracciati sia l'RVA in cui avviene il riferimento (per sapere dove aggiornare il riferimento) sia l'RVA a cui si fa riferimento (per sapere con quale RVA aggiornare il riferimento).
Ogni volta che il disassembler trova un'istruzione relativa a rip, la aggiunge a un elenco di riferimenti da aggiornare al termine dell'offuscamento. Ciò garantisce che tutte queste istruzioni puntino ancora alla posizione che avevano originariamente. Anche altri casi di istruzioni relative, come le tabelle di salto, vengono aggiunti come riferimenti da aggiornare.
Tutti gli RVA tracciati devono essere adeguati ogni volta che byte vengono inseriti o rimossi dal binario. Ad esempio, ecco il gestore di inserimento dei byte:```cpp
void binwrite::binary_t::insert(const rva_t rva, const std::span data, const bool inclusive)
{
buffer_.insert_range(buffer_.begin() + rva.value(), data);
update_rvas(rva, static_cast<rva_t::size_type>(data.size()), inclusive);
}
`update_rvas` è il punto in cui ogni RVA tracciata viene aggiornata per riflettere il cambiamento avvenuto nel binario. Ecco un diagramma di questo processo:

Figura 1. Tracciamento degli indirizzi relativi.
I dati inseriti (blu) spostano i dati correnti (grigio). La RVA a cui fa riferimento l'istruzione (arancione) viene aggiornata per puntare alla stessa memoria, tenendo conto dei dati inseriti (blu).
## 2.2. Disassemblaggio
Tutti i potenziali punti di ingresso del codice (esportazioni, entry point, rilocazioni che puntano alla sezione del codice, ecc.) vengono aggiunti a una coda di disassemblaggio. Se è presente un file di simboli, anche tutte le funzioni descritte dal file di simboli vengono aggiunte alla coda di disassemblaggio. Ciascuna voce nella coda viene trattata come un singolo blocco di base.
Un blocco di base è un gruppo di istruzioni senza rami; questo significa che viene terminato in corrispondenza di istruzioni di controllo del flusso (es. jump, ret, int). I blocchi di base non terminano in corrispondenza delle chiamate, poiché nella maggior parte dei casi ci si aspetta che queste ritornino. Alcune funzioni non ritornano (es. _CxxThrowException) e d'ora in poi saranno denominate chiamate 'noreturn'.
Quando un blocco di base dalla coda di disassemblaggio viene elaborato, ogni istruzione viene disassemblata partendo dall'inizio finché non si verifica una delle seguenti condizioni:
- Viene raggiunto un altro blocco di base già analizzato, causando una sovrapposizione. Vedi “Suddivisione dei blocchi di base”.
- Viene trovata un'istruzione di terminazione (jump, return, int).
- Il disassemblaggio dell'istruzione non è riuscito.
- È stato trovato padding del codice.
Di seguito è riportato un diagramma del disassemblaggio e dell'ingresso nella coda di disassemblaggio (il controllo del padding del codice è omesso nel diagramma). Questo processo viene ripetuto finché la coda di disassemblaggio non è vuota.

Figura 2. Elaborazione del disassemblaggio.
### 2.2.1 Suddivisione dei blocchi di base
Se due blocchi di base si sovrappongono, uno di essi deve essere suddiviso. Questo evita che due blocchi descrivano le stesse istruzioni. Per esempio:```asm
wcslen proc
or rax, 0FFFFFFFFFFFFFFFFh
loc_140001078:
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
retn
wcslen endp
Questa è un'implementazione di wcslen, che ottiene la lunghezza di una stringa wide. Quando la prima istruzione 'or rax, FFFFFFFFFFFFFFFF' viene disassemblata come inizio di un blocco di base, continuerà a disassemblare fino al 'retn'.
Il 'jnz loc_140001078' salta all'indietro per formare un ciclo. Questo verrà aggiunto come riferimento, e la destinazione del jnz sarà aggiunta alla coda di disassemblaggio così come il ramo fallthrough (l'istruzione successiva). L'istruzione salta nel mezzo del blocco già analizzato, quindi non può semplicemente formare un nuovo blocco e disassemblare di nuovo fino al 'retn' poiché avrebbe una rappresentazione duplicata.
Il 'jnz' (salto condizionale) seguirebbe anche il ramo fallthrough per creare un nuovo blocco di base. Ora ci sarebbero 4 blocchi di base che appaiono così:
Blocco A:```asm
or rax, 0FFFFFFFFFFFFFFFFh
loc_140001078:
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
Blocco B:```asm
loc_140001078:
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
Blocco C:```asm
retn
Blocco D:```asm
retn
Questa è una rappresentazione errata dei blocchi di base, poiché duplica le stesse istruzioni su 4 blocchi. Questo può essere risolto suddividendo qualsiasi blocco di base che si sovrappone al disassembly corrente. Non ci saranno più istruzioni duplicate perché invece di creare una rappresentazione duplicata delle istruzioni a ogni sovrapposizione, le istruzioni esistenti verrebbero trasferite al nuovo blocco di base. La rappresentazione corretta usando la suddivisione è la seguente:
Blocco A:```asm
or rax, 0FFFFFFFFFFFFFFFFh
Blocco B:```asm
inc rax
cmp word ptr [rcx+rax*2], 0
jnz short loc_140001078
Blocco C:```asm
retn
### 2.2.2. Flusso di controllo indiretto
#### 2.2.2.1. Tabelle di salto
Le tabelle di salto vengono utilizzate per memorizzare gli indirizzi dei gestori per le istruzioni switch. Invece di avere molte istruzioni if/salti condizionali per ogni case in un'istruzione switch, viene mantenuta una tabella degli indirizzi dei gestori dei case. Ecco un esempio (binario LLVM/CLANG):```cpp
std::int32_t sub_140004080(const std::int32_t a1)
{
std::int32_t result;
switch ( a1 )
{
case 0:
result = 9;
break;
case 1:
result = 4;
break;
case 2:
result = 3;
break;
case 3:
result = 1;
break;
default:
result = 0;
break;
}
return result;
}
Questa istruzione switch viene compilata nel seguente assembly:```asm
; ecx = a1
cmp ecx, 3 ; check if above bounds, must be default case
ja short def_140004097 ; goto default if a1 above 3
mov ecx, ecx
mov eax, ecx
lea rcx, jpt_140004097
movsxd rax, ds:(jpt_140004097 - 140004100h)[rcx+rax*4] ; select correct index of jump table for a1
add rax, rcx
jmp rax ; goto index specified - the case handler
jpt_140004097:
dd offset loc_140004099 - 140004100h ; address of handler for first case
dd offset loc_1400040D5 - 140004100h ; address of handler for second case
dd offset loc_1400040BD - 140004100h ; address of handler for third case
dd offset loc_1400040C9 - 140004100h ; address of handler for fourth case
Il valore 'a1' viene confrontato con il valore massimo dei case e, se è superiore, si passa direttamente al gestore di default. Se a1 rientra nell'intervallo dei case, si accede alla relativa voce nella jump table e si salta all'indirizzo del gestore calcolato.
Le voci della jump table e i riferimenti alla jump table vengono tracciati in modo da essere mantenuti intatti.
##### 2.2.2.1.1. Jump table con limiti
Se una jump table non descrive tutti gli intervalli di valori che un'istruzione switch può utilizzare, viene usata una tabella con limiti per controllare i confini. Questo serve a reindirizzare lo switch all'istruzione default se il valore è fuori dai limiti. Per determinare il numero di istruzioni case, viene controllata l'istruzione di confronto per trovare il numero di voci. Ad esempio, l'istruzione 'cmp ecx, 3' indica che il numero di voci nella jump table è 3.
##### 2.2.2.1.2. Jump table senza limiti
Se una jump table copre tutti i possibili valori che le istruzioni case possono assumere (ad es. dal valore minimo UINT8 al valore massimo UINT8 per il tipo UINT8), viene usata una tabella senza limiti e senza controlli. Questo perché il compilatore sa che la jump table copre tutti i valori possibili. Non c'è alcuna istruzione di confronto che suggerisca il numero di voci della jump table, quindi le voci devono essere individuate per tentativi. La base della tabella viene controllata in modo incrementale alla ricerca di RVA validi in una sezione di codice e ogni voce valida viene tracciata. Questo metodo non è altrettanto sicuro, poiché potrebbe interpretare altri dati/istruzioni come voci della jump table; per questo, dove possibile, viene usato il controllo delle jump table con limiti.
##### 2.2.2.1.3. Diversi tipi di jump table
Il binary rewriter supporta le jump table su binari compilati con MSVC (incluse le tabelle multilivello), LLVM/CLANG e GCC.
Le jump table MSVC hanno 2 forme: normale e multilivello. Le jump table normali per MSVC sono un array di RVA. Ogni RVA punta al gestore dell'istruzione case.
Le tabelle multilivello di MSVC vengono usate per istruzioni switch con un gran numero di case che condividono i gestori. La versione multilivello ha 2 tabelle: una per l'array degli RVA dei gestori e una per associare i valori dei case agli indici nella prima tabella. Questo evita la ripetizione degli RVA nella prima tabella, poiché ogni valore di case deve descrivere solo l'indice di 1 byte invece di un RVA di 4 byte. Ecco un esempio di jump table multilivello:```asm
lea rdx, cs:140000000h
movsxd rax, edi ; load value
movzx eax, ds:(byte_1400023D8 - 140000000h)[rdx+rax] ; get handler index by value
mov ecx, ds:(jpt_14000209D - 140000000h)[rdx+rax*4] ; get RVA of handler by handler index
add rcx, rdx
jmp rcx
jpt_14000209D dd offset loc_14000209F - 140000000h
dd offset loc_1400020AB - 140000000h
dd offset loc_1400020B7 - 140000000h
dd offset loc_1400020CF - 140000000h
dd offset loc_1400020E7 - 140000000h
dd offset loc_1400020F3 - 140000000h
dd offset loc_1400020FF - 140000000h
dd offset loc_14000210B - 140000000h
dd offset loc_140002117 - 140000000h
dd offset loc_140002123 - 140000000h
dd offset loc_1400020C3 - 140000000h
dd offset loc_14000213B - 140000000h
dd offset loc_140002147 - 140000000h
dd offset loc_140002153 - 140000000h
dd offset loc_14000216B - 140000000h
dd offset loc_140002177 - 140000000h
dd offset loc_140002183 - 140000000h
dd offset loc_14000219B - 140000000h
dd offset loc_1400021A7 - 140000000h
dd offset loc_1400021B3 - 140000000h
dd offset loc_1400021BF - 140000000h
dd offset loc_1400021CB - 140000000h
dd offset loc_1400021D7 - 140000000h
dd offset loc_1400021E3 - 140000000h
dd offset loc_1400021EF - 140000000h
dd offset loc_1400021FB - 140000000h
dd offset loc_140002207 - 140000000h
dd offset loc_140002213 - 140000000h
dd offset loc_14000221F - 140000000h
dd offset loc_14000222B - 140000000h
dd offset loc_140002237 - 140000000h
dd offset loc_140002243 - 140000000h
dd offset loc_14000224F - 140000000h
dd offset loc_14000225B - 140000000h
dd offset loc_140002267 - 140000000h
dd offset loc_140002273 - 140000000h
dd offset loc_14000227F - 140000000h
dd offset loc_14000228B - 140000000h
dd offset loc_140002294 - 140000000h
; ... more handler addresses
byte_1400023D8:
db 0, 2Bh, 1, 2, 2Bh, 3, 2Bh, 4, 2Bh, 5, 6, 7, 8, 9, 0Ah, 0Bh, 0Ch, 0Dh
db 2Bh, 0Eh, 0Fh, 10h, 2Bh, 11h, 12h, 13h, 14h, 15h, 2Bh, 16h, 2Bh, 17h
db 18h, 19h, 1Ah, 1Bh, 1Ch, 2Bh, 1Dh, 1Eh, 1Fh, 20h, 21h, 22h, 2Bh, 23h
db 24h, 25h, 26h, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh, 2Bh
; ... more indexes to handler table
LLVM utilizza una tabella contenente offset relativi alla base della tabella. Aggiungendo l'indirizzo della base della tabella all'offset descritto dalla voce, è possibile calcolare l'indirizzo del gestore.
La tabella di salto di GCC è un array di rilocazioni DIR64. Ogni rilocazione punta all'indirizzo di un'istruzione case. Le rilocazioni sono già tracciate, quindi quelle voci della tabella di salto sono già risolte. A runtime, quelle voci di rilocazione verranno traslate dall'indirizzo di base, così ogni voce può essere dereferenziata per ottenere l'indirizzo dell'istruzione case.
Esistono altre forme di flusso di controllo indiretto che devono essere supportate. Ad esempio, nei binari CLANG che utilizzano eccezioni C++ con FuncInfo3, l'indirizzo di continuazione viene caricato nel registro rax e poi restituito al chiamante. Il chiamante salterà all'indirizzo di continuazione.```asm
lea rax, [rip+X]
retn
Anche se l'indirizzo a cui punta `lea` si trova in una sezione di codice, non vi è alcuna garanzia che si tratti effettivamente di codice. Le tabelle di salto e le stringhe possono essere collocate nelle sezioni di codice di un binario per motivi di località della cache. I percorsi di codice corretti devono essere scoperti e disassemblati per poter tracciare i riferimenti RVA al loro interno (oltre a poterli offuscare), quindi è fondamentale che questi casi possano essere identificati. Questi sono riferimenti 'rischiosi'.
Per correggere l'aggiunta errata delle tabelle di salto alla coda di disassemblaggio, il disassembler verifica prima se si tratta di una tabella di salto, prima di aggiungere tali riferimenti rischiosi alla coda.
Per correggere l'aggiunta errata delle stringhe a cui viene applicata l'istruzione lea alla coda di disassemblaggio, il disassembler tenta di disassemblarle come blocco di base con controlli di sanità aggiuntivi (ad es. deve esserci un'istruzione di terminazione se si tratta di uno di questi riferimenti rischiosi). Se uno qualsiasi di questi controlli di sanità fallisce su un blocco con riferimento rischioso, l'intero blocco viene ignorato e considerato dati.
Se viene fornito un file di simboli, nessun simbolo di dati verrà aggiunto come riferimento rischioso.
### 2.2.3. Funzioni
Alcune passate di offuscamento richiedono di sapere quali blocchi di base appartengono a quali funzioni. Per questo motivo, tutti i blocchi di base vengono assegnati alle funzioni corrispondenti che li possiedono. I file di simboli vengono analizzati per trovare tutti gli indirizzi delle funzioni e inserirli in un elenco.
Per ciascuna funzione, vengono eseguiti i seguenti passaggi:
- Ottenere il blocco di base di ingresso della funzione (il blocco di base all'indirizzo di inizio della funzione).
- Assegnare questo blocco di ingresso alla funzione.
- Trovare tutte le uscite del blocco di base (rami fallthrough, rami di destinazione).
- Per ogni blocco di base di uscita, assegnarlo alla funzione se non è il blocco di ingresso di un'altra funzione (RVA del blocco di base != RVA di qualsiasi funzione). Questi ultimi 2 passaggi vengono ripetuti per ogni blocco di base scoperto.
- Eventuali tabelle di salto nei blocchi scoperti vengono analizzate e i relativi blocchi di base di destinazione vengono assegnati alla funzione.
### 2.2.4. Gestione delle chiamate 'noreturn'
Le funzioni noreturn non ritornano. Se si verifica una chiamata noreturn, il blocco di base continuerà a essere disassemblato poiché le chiamate non terminano i blocchi di base. Non è previsto che il binario esegua istruzioni oltre la chiamata, quindi il compilatore non ha inserito un codice adeguato che termini il blocco di base. I controlli di disassemblaggio sopra menzionati probabilmente intercetteranno questi casi e termineranno il blocco di base.```asm
sub_140006310 proc
sub rsp, 38h
mov rax, cs:__security_cookie
xor rax, rsp
mov [rsp+38h+var_8], rax
mov [rsp+38h+pExceptionObject], 2Ah
lea rdx, __TI1H
lea rcx, [rsp+38h+pExceptionObject]
call _CxxThrowException
db 0CCh
sub_140006310 endp
algn_14000633D:
align 20h
For example, in this noreturn _CxxThrowException call, the compiler has inserted padding in the form of INT3 instructions, which would be caught both as padding and as a terminating instruction. There are some other cases such as UD2 instructions being inserted after a noreturn call which are also handled.
This is not an infallible way of detecting it, as the CodeDefender team also discusses, but there are countermeasures in place to correct any disassembly that has gone too far. If any jump table entries are found inside a basic block, then the basic block is split so that the jump table is given priority. This would prevent jump table entries being disassembled as instructions after a noreturn call. If the next instructions are valid code, the basic block would end up being split up anyway when the next address's basic block is getting disassembled/processed.
L'obfuscator fa uso di allocazioni di stack nei suoi passaggi di offuscamento. Questo gli consente di salvare il valore dei registri che utilizza (ad es. push rax) e di ripristinarli al termine del passaggio di offuscamento, in modo che i registri non vengano sovrascritti. Un frame pointer è un registro che punta a una posizione specifica nello stack.
Tutte le allocazioni di stack dovrebbero essere descritte dai codici unwind per una funzione (se non è presente un frame pointer), il che consente al gestore delle eccezioni del sistema operativo di ripercorrere lo stack fino all'indirizzo di ritorno e cercare il gestore delle eccezioni dell'applicazione. Queste allocazioni di stack devono trovarsi nel prologo (l'inizio della funzione) a causa di un limite su quanto lontano possono essere dall'inizio della funzione. Se le allocazioni di stack vengono effettuate al di fuori del prologo, i codici unwind non possono descriverle e il sistema operativo non sarebbe in grado di eseguire l'unwind, interrompendo il supporto alle eccezioni.
Se viene utilizzato un frame pointer, il sistema operativo non deve essere in grado di ripercorrere lo stack dal registro rsp, ma può farlo dal frame pointer. Ciò significa che l'obfuscator può effettuare allocazioni di stack al di fuori del prologo senza dover descrivere tali allocazioni nei codici unwind.
Il rewriter inserirà un registro frame pointer in ogni funzione runtime che non ne possiede già uno. Questo consente al sistema operativo di eseguire l'unwind dello stack anche dopo che l'obfuscator ha effettuato allocazioni di stack al di fuori del prologo.
Di seguito è riportato un esempio delle modifiche apportate al prologo e all'epilogo della funzione:
Prologo originale della funzione:```asm
DriverEntry proc
sub rsp, 38h
Prologo di funzione modificato:```asm
DriverEntry proc
push rbp
push rbp
sub rsp, 38h
lea rbp, [rsp]
lea rbp, [rsp]
Epilogo della funzione originale:```asm
add rsp, 38h
ren
DriverEntry endp
Epilogo della funzione modificato:```asm
add rsp, 38h
pop rbp
pop rbp
retn
DriverEntry endp

Figura 3. Layout dello stack prima dell'inserimento del frame pointer.
Il registro non volatile rbp viene usato come frame pointer dal rewriter. I registri non volatili devono essere preservati, quindi il valore di rbp viene salvato nello stack nel prologo e descritto dai codici di unwind (così l'unwinder del sistema operativo può ripristinare il valore originale di rbp). Il registro rbp viene salvato nello stack 2 volte per riallineare lo stack a 16 byte; il secondo push è puramente a scopo di riallineamento.
Poiché il valore di rbp viene salvato nello stack due volte nel prologo, deve anche essere estratto (pop) alla fine della funzione per riportare lo stack pointer al suo valore originale. Questo serve affinché l'indirizzo di ritorno sia in [rsp] per l'istruzione di ritorno.
I rispettivi codici di unwind vengono inseriti per queste istruzioni di push, così il sistema operativo sa che ci sono ulteriori allocazioni di stack nel prologo (da cui eseguire l'unwind del frame pointer). Ecco perché ci sono 2 pop nell'epilogo.
Per trovare quei basic block di uscita/epiloghi, tutti i basic block di una funzione vengono esaminati alla ricerca di un 'ret' o di un salto che porti all'esterno della funzione corrente. Anche i salti indiretti (es. jmp rcx) contano come uscita dalla funzione corrente, ad eccezione delle jump table. Con tutti i basic block di uscita raggruppati, è possibile inserire al loro interno le 2 istruzioni di pop per garantire che gli effetti dei push del prologo vengano annullati quando si esce dalla funzione.
L'istruzione 'lea rbp, [rsp]' alla fine del prologo serve a indicare al sistema operativo una posizione concreta dello stack dalla quale può eseguire l'unwind invece di farlo da rsp. Le rispettive informazioni e i codici di unwind vengono inseriti per questa istruzione di impostazione del frame pointer.

Figura 4. Layout dello stack errato dopo l'inserimento del frame pointer.
L'altra considerazione riguarda gli argomenti sullo stack, che si trovano prima dello stack pointer. Se in una funzione si accede allo stack dopo l'allocazione locale (in corrispondenza o dopo lo slot dell'indirizzo di ritorno), quei riferimenti devono essere aggiornati. Questo perché i push spostano lo stack di 16 byte, quindi anche tutti i dati referenziati successivamente devono essere spostati di 16 byte. Il diagramma precedente mostra come i riferimenti allo stack diventino disallineati e debbano essere corretti.
Ad esempio, a 'mov rax, [rsp+0x90]' verrebbe aggiunto 0x10 (decimale: 16) in modo che continui ad accedere allo stesso slot dello stack una volta eseguiti i push. L'istruzione corretta sarebbe: 'mov rax, [rsp+0xA0]'.
Se un'istruzione accede oltre l'allocazione locale dello stack, verrà aggiustata di 16 in modo da saltare i push eseguiti sullo stack. A volte lo stack pointer viene spostato in registri diversi e si accede allo stack tramite un registro diverso; in tal caso quel registro viene monitorato e aggiustato nello stesso modo in cui si procede per lo stack pointer.
Un altro caso è rappresentato dai catch handler per le eccezioni: in rdx ricevono l'indirizzo dell'EstablisherFrame (pari al valore del registro del frame pointer al momento dell'eccezione). I catch handler accederanno allo stack della funzione dell'eccezione tramite rdx, quindi anche rdx deve essere tracciato e aggiustato.
Di seguito è riportato il diagramma del layout dello stack dopo la correzione con gli aggiustamenti dei riferimenti allo stack.

Figura 5. Layout dello stack corretto dopo l'inserimento del frame pointer.
Il supporto alle eccezioni richiede che un file dei simboli venga fornito all'obfuscator, poiché necessita di avere quante più informazioni possibili sui simboli del binario.
Il rewriter analizza le informazioni di unwind di un binario per trovare le informazioni sui gestori di eccezioni. Anche gli RVA trovati vengono tracciati. I tipi di informazioni sui gestori di eccezioni supportati sono:
Il formato è un array delle seguenti voci di tabella:```cpp
struct c_scope_table_entry_t
{
std::uint32_t begin_rva; // where exception-throwing range begins
std::uint32_t end_rva; // where exception-throwing range ends
std::uint32_t handler_rva; // the handler type/rva (normally 1)
std::uint32_t target_rva; // the catch handler rva
};
struct c_scope_table_t
{
std::uint32_t entry_count;
c_scope_table_entry_t table[1];
};
I begin/end RVA descrivono quale intervallo di codice può generare un'eccezione. L'RVA di destinazione descrive il gestore di cattura che processa l'eccezione quando si verifica.
#### 2.3.2.2. FuncInfo3 e FuncInfo4
Usati per le eccezioni C++, la differenza principale tra FH3 e FH4 è che FH4 usa un formato compresso per cercare di risparmiare memoria. Condividono i seguenti descrittori:
- Mappa di unwind - elenco di oggetti C++ che devono essere distrutti, oltre all'offset dell'oggetto rispetto al frame.
- Mappa dei blocchi try - elenco dei gestori di cattura e dei tipi che ciascuno può catturare (es. std::runtime_error).
- Mappa IP2State - descrive lo stato degli oggetti in base al valore corrente del puntatore alle istruzioni / all'offset nella funzione.
Specifiche FH3:
- L'indirizzo di continuazione nella mappa dei blocchi try è mantenuto nel codice e restituito dal gestore di cattura in rax (es. lea rax, indirizzo_di_continuazione).
Specifiche FH4:
- Memorizza le informazioni della mappa in un formato intero compresso per risparmiare spazio.
- La mappa dei blocchi try contiene l'indirizzo di continuazione codificato nella struttura delle informazioni FH4.
Per le eccezioni C++ sono supportati solo MSVC e CLANG/LLVM. GCC non è supportato per le eccezioni C++ poiché usa un formato diverso da FH3/FH4.
### 2.3.3. Parsing di RTTI e ThrowInfo
Le informazioni sul tipo a runtime (RTTI) e le informazioni di lancio (throw info) sono strutture usate per ispezionare i tipi C++ a runtime, inclusa la gestione delle eccezioni. Queste strutture contengono molti RVA e pertanto devono essere tracciate per motivi di stabilità.
#### 2.3.3.1. RTTI
La mappa dei 'blocchi try' nei descrittori delle eccezioni C++ contiene informazioni sul tipo per sapere se possono catturare il tipo lanciato. Queste informazioni sul tipo sono chiamate RTTI e descrivono anche altre proprietà del tipo, come:
- Tabelle delle funzioni virtuali.
- Nome del tipo.
- Classi ereditanti.
Per le classi senza funzioni virtuali, viene generato solo un descrittore del tipo:```cpp
struct type_descriptor_t
{
std::uint64_t vftable_address; // this is a DIR64 relocation
std::uint64_t unk;
char name[1];
};
Questo viene rilevato analizzando le sezioni dati alla ricerca della rilocazione DIR64 nel campo membro 'vftable_address', verificando se si tratta di una reale tabella di funzioni virtuali.
Per le classi con funzioni virtuali, vengono generati un localizzatore dell'oggetto completo e un descrittore della gerarchia di classi. Il descrittore della gerarchia di classi contiene un array di classi base.```cpp
struct complete_object_locator_t
{
std::uint32_t signature;
std::uint32_t offset;
std::uint32_t constructor_offset;
std::uint32_t type_rva;
std::uint32_t hierarchy_rva;
std::uint32_t self_rva;
};
struct hierarchy_descriptor_t
{
std::uint32_t signature;
std::uint32_t attributes;
std::uint32_t base_class_count;
std::uint32_t base_class_list_rva;
};
struct base_class_array_t
{
std::uint32_t class_rvas[1];
};
struct base_class_descriptor_t
{
std::uint32_t type_rva;
std::uint32_t element_count;
std::uint32_t member_displacement;
std::uint32_t unk;
std::uint32_t unk1;
std::uint32_t attributes;
std::uint32_t hierarchy_rva;
};
Per trovarli, le sezioni dati vengono scansionate per le rilocazioni DIR64 che puntano a un complete object locator. Vengono eseguiti controlli sulla destinazione della rilocazione DIR64 per garantire che punti a un complete object locator (ad es. se self_rva punta all'RVA della base della classe, se i type descriptor e gli hierarchy descriptor vengono analizzati correttamente).
#### 2.3.3.2. ThrowInfo
ThrowInfo viene utilizzato per descrivere come distruggere l'oggetto eccezione una volta elaborato, oltre al tipo generato. Le classi ereditate dal tipo di eccezione generato sono descritte nell'array dei tipi catchable (contiene riferimenti RTTI) per garantire che il gestore delle eccezioni possa abbinarlo alle clausole catch.
Il ThrowInfo viene scansionato nelle sezioni dati controllando il contenuto dell'array dei tipi catchable con le informazioni RTTI precedentemente scoperte. Tutti gli RVA dei tipi catchable e del ThrowInfo vengono aggiunti alla lista di tracciamento.```cpp
struct throw_info_t
{
std::uint32_t attributes;
std::uint32_t pmfn_unwind; // address of exception object destructor
std::uint32_t forward_compat;
std::uint32_t catchable_type_array;
};
struct catchable_type_array_t
{
std::uint32_t count;
std::uint32_t type_rvas[1];
};
struct catchable_type_t
{
std::uint32_t attributes;
std::uint32_t rva_type;
std::uint32_t mdisp;
std::uint32_t pdisp;
std::uint32_t vdisp;
std::uint32_t size_of_thrown_object;
std::uint32_t optional_copy_constructor_rva;
};
Questa tecnica prende le istruzioni dell'architettura x86-64 e le traduce in un'architettura di CPU virtuale. Questo è molto più difficile da analizzare, poiché un reverse engineer dovrebbe prima comprendere l'architettura della CPU virtuale prima di analizzare ciò che fanno le istruzioni originali.
Questa implementazione utilizza un approccio generico per generare gli handler della macchina virtuale, così da poter offuscare un'ampia gamma di istruzioni senza dover codificare manualmente handler per ogni istruzione x86-64.



Figura 6. Architettura della macchina virtuale.
La sequenza originale di istruzioni che vengono virtualizzate viene sostituita con una chiamata al blocco di ingresso della macchina virtuale.
Quando si entra nella macchina virtuale, tutti i registri general purpose (tranne rsp) vengono salvati sullo stack. Anche il registro rflags viene salvato sullo stack. Questi slot dello stack vengono utilizzati come registri virtuali, ciascuno corrispondente al proprio registro originale (quindi lo slot per rax verrebbe usato al posto di rax). L'ordinamento di questi registri virtuali nello stack è randomizzato, quindi il layout dei registri di ogni handler della macchina virtuale cambia.
Le istruzioni push per inserire i registri general purpose nel layout dello stack virtuale sono anch'esse randomizzate e possono essere un sub rsp, 8; mov [rsp] reg o un push reg. Questo viene fatto per rendere più difficile creare firme per l'ingresso della macchina virtuale.
I registri hardware sono i registri general purpose dell'architettura x86-64. Ora che i registri hardware sono stati salvati nel loro stato di CPU virtuale sullo stack, possono essere sovrascritti liberamente. Lo stato della macchina virtuale tiene traccia di un elenco dei registri hardware attualmente disponibili che possono essere usati dagli stub della macchina virtuale. Una volta che lo stub viene completato, quei registri hardware vengono aggiunti di nuovo all'elenco per poter essere riutilizzati.
Ora l'applicazione è entrata nella macchina virtuale ed è il momento di passare l'esecuzione agli handler delle istruzioni target. Le istruzioni target sono le istruzioni x86-64 che vengono virtualizzate in questa architettura di CPU.
Innanzitutto, gli operandi dell'istruzione target devono essere caricati sullo stack. I valori degli operandi vengono caricati in un registro hardware libero. I valori degli operandi vengono quindi offuscati e salvati sullo stack. Questo viene fatto dal blocco precedente rispetto all'handler dell'istruzione (handler precedente o blocco di ingresso della macchina virtuale se questo è il primo handler).
L'offuscamento applicato ai valori degli operandi è il seguente:
Per gli operandi immediati, questo offuscamento può essere eseguito al momento dell'offuscamento poiché il valore è noto, quindi il calcolo non viene effettuato a runtime ed è di conseguenza più difficile da invertire.
Gli operandi nascosti che richiedono registri specifici (ad es. rsi e rdi per rep movsb) vengono caricati in quei registri specifici invece che in un registro hardware casuale.
Nel blocco dell'handler dell'istruzione, gli operandi vengono estratti dallo stack e deoffuscati. Viene eseguita l'operazione inversa per ottenere i valori originali degli operandi.
Se l'istruzione originale legge dal registro rflags, il registro rflags viene caricato dal contesto dello stack prima di eseguire l'istruzione.
Se l'istruzione originale scrive nel registro rflags, il registro rflags viene caricato dal contesto dello stack prima di eseguire l'istruzione. Dopo che l'istruzione originale viene eseguita, il valore di rflags aggiornato viene riscritto nel contesto dello stack.
Questo garantisce che le istruzioni virtualizzate abbiano esattamente lo stesso comportamento dei flag delle istruzioni originali.
All'interno del blocco dell'handler dell'istruzione, l'istruzione x86-64 originale viene codificata per usare operandi deoffuscati. Una volta eseguita l'istruzione originale, gli operandi risultanti vengono offuscati e salvati sullo stack.
Se questo è l'ultimo handler dell'istruzione, il successivo blocco di base sarà il blocco di uscita della macchina virtuale. In caso contrario, il blocco successivo sarà quello dell'handler successivo.
Il successivo blocco di base estrae gli operandi risultanti offuscati dallo stack e applica lo stesso processo di deoffuscamento. I valori risultanti vengono scritti nella loro destinazione originale. Questa potrebbe essere un registro virtuale nel contesto dello stack o una posizione specifica in memoria descritta dall'istruzione originale.
Questo processo si ripete finché un'istruzione nel blocco di base non può essere virtualizzata (ad es. un'istruzione che usa il registro rsp).
Il contesto della macchina virtuale deve quindi essere scaricato per tornare al codice non virtualizzato. Tutti i registri virtuali vengono estratti dallo stack nei rispettivi registri general purpose. Anche il registro rflags modificato viene ripristinato estraendolo dallo stack. Ora, il blocco di uscita della macchina virtuale ritorna al chiamante.
Se un'istruzione virtualizzata lancia un'eccezione, il sistema operativo deve essere in grado di eseguire l'unwind fuori dal contesto della macchina virtuale per riuscire a trovare un gestore di eccezioni appropriato nei chiamanti.
Per consentire tutto ciò, è necessario aggiungere le informazioni di unwind al binario, così che il layout dello stack della funzione della macchina virtuale sia noto al sistema operativo.
Un frame pointer viene caricato in rbp perché gli handler della macchina virtuale utilizzano allocazioni di stack al di fuori del prologo. Questo significa che rbp non può essere usato come registro hardware 'disponibile' dagli handler della macchina virtuale.
Tutti i registri hardware della macchina virtuale utilizzati vengono salvati sul contesto dello stack, quindi per quei push vengono inseriti i codici di unwind corrispondenti.
La funzione runtime per la funzione della macchina virtuale viene quindi inserita nella directory delle eccezioni. La macchina virtuale ora è in grado di gestire l'unwind.
Questa tecnica crea rami verso falsi blocchi di base con un flusso di dati errato per confondere un reverse engineer.
I predicati opachi sono affermazioni che restituiscono solo vero o falso.
I blocchi di base vengono duplicati e racchiusi in un'istruzione if con predicato opaco. Uno dei blocchi avrà il suo flusso di dati alterato in modo che sia simile, ma errato.```cpp
if (opaque_statement_always_true)
{
… original basic block
}
else
{
… incorrect but similar basic block
}
Per fuorviare ulteriormente un reverse engineer, tutti gli operandi delle istruzioni vengono raccolti e randomizzati. Ogni istruzione verrà ricompilata con operandi casuali presi dall'elenco raccolto. Questo garantisce che il comportamento del blocco duplicato sia diverso da quello originale.
Anche la posizione dei blocchi viene mescolata casualmente, così che la loro posizione fisica in memoria non riveli quale sia il ramo corretto.
Anche la condizione richiesta per la selezione del ramo viene scelta casualmente. Ad esempio, in un'iterazione, il ramo di fallthrough del salto condizionale porterà al blocco corretto. In un'altra iterazione, il ramo di destinazione del salto condizionale porterà al blocco corretto. Questo rende più difficile trovare il ramo corretto.
L'espressione stessa del predicato opaco è anche molto importante, perché se fosse facile da valutare non sarebbe efficace. Per questo motivo, l'[Ultimo teorema di Fermat](https://en.wikipedia.org/wiki/Fermat's_Last_Theorem) è stato scelto come espressione opaca. L'Ultimo teorema di Fermat “afferma che nessuna terna di interi positivi a, b e c soddisfa l'equazione a^n + b^n = c^n per qualsiasi valore intero di n maggiore di 2” (dove ^ indica l'elevamento a potenza). È stato dimostrato che questa espressione è sempre falsa date le condizioni. I 3 numeri a, b, c vengono scelti ed elevati a una potenza scelta casualmente da 3 a 7. L'espressione viene eseguita in un blocco di base appena creato e quindi il salto condizionale porta al blocco corretto.
Se i parametri a, b, c e n fossero noti, un attaccante potrebbe risolvere l'espressione e trovare il ramo corretto. Per ostacolare ciò, i parametri vengono scelti da valori decisi a runtime: il puntatore allo stack (rsp) e il puntatore alle istruzioni (ASLR farà sì che il rip venga rilocato a runtime). Per esempio:```cpp
if ((rsp^n) + (rip^n) != (c^n))
{
… incorrect but similar basic block
}
else
{
… original basic block
}
Il flusso di controllo è il percorso di esecuzione che un programma segue (ad esempio le 'istruzioni if'). Un cambiamento nel flusso di controllo avviene quando un blocco di base salta a un altro. Questi cambiamenti nel flusso di controllo possono essere raggruppati in un singolo stub dispatcher e mescolati per rendere più difficile la comprensione. Lo stub dispatcher sarà responsabile del cambiamento del flusso di controllo verso il blocco di base successivo, invece di farlo direttamente.

Figura 7. Appiattimento del flusso di controllo.
Tutti i blocchi di base di una funzione (eccetto il prologo) vengono raggruppati in un elenco e tutte le loro diramazioni (condizionali e incondizionate) vengono raccolte. A ogni blocco di base viene assegnato un ID/identificatore univoco. Uno stub dispatcher prende tutte le potenziali diramazioni e costruisce un'istruzione switch con tutti gli ID come case. Le istruzioni case saltano al blocco di base di destinazione. Tutti i salti verso i blocchi di base originali / il flusso di controllo originale vengono sostituiti con un salto all'istruzione switch con l'ID corretto (l'ID del blocco di destinazione).
Il layout fisico dei blocchi di base viene mescolato in modo che la loro posizione in memoria non fornisca alcun indizio su quale fosse il flusso di controllo originale.
Questa tecnica prende qualsiasi numero codificato in un'istruzione (spostamenti degli operandi di memoria, operandi immediati) e ne nasconde il valore reale calcolandolo a runtime.
Al momento dell'offuscamento, viene generato un valore casuale della stessa ampiezza in bit del numero originale. Questo viene aggiunto al numero originale e caricato in un registro non utilizzato come operando immediato.
A runtime, il numero casuale viene sottratto dal registro, ottenendo così il numero originale.
Se R è il numero casuale e N è il numero originale, l'espressione è effettivamente ((R+N) - R).
Per ri-codificare gli operandi di memoria, l'inserimento del registro viene effettuato nell'operando di base. Se esiste già un operando di base, il suo valore viene aggiunto sopra.
Per gli operandi di memoria stack sottoposti a sostituzione, viene aggiunto uno spostamento (displacement) al valore per tenere conto dello spostamento dello stack causato dai push. I push vengono utilizzati per salvare il registro rflags e anche il registro non utilizzato.
Ecco un esempio della sostituzione dell'operando di memoria in 'mov [rsp+24h], 0':```asm
push r11
pushfq ; save flags for now
mov r11, rsp
add r11, 1D4D9F71h
sub r11, 1D4D9F3Dh
popfq ; restore flags, the original instruction will now execute and populate flags with the correct values
mov dword ptr [r11], 0 ; original instruction substituted (r11 == rsp+24h)
pop r11
Ecco un altro esempio della sostituzione dell'operando immediato in 'add rbx, 8':```asm
push r10
pushfq
mov r10, 0FFFFFFFFE3F78112h
sub r10, 0FFFFFFFFE3F7810Ah
popfq
add rbx, r10
pop r10
Questa tecnica prende espressioni aritmetiche regolari (es. x+y) e le trasforma in espressioni più complesse che producono lo stesso risultato. Lo fa sostituendo le espressioni con identità lineari equivalenti.
Ad esempio, (x+y) ha l'identità equivalente ((x & y) + (x | y)). Quando l'obfuscator elabora (x+y), la sostituisce con una di queste identità, più difficili da reverse engineering.
Esiste un elenco di identità per le seguenti istruzioni aritmetiche: 'add', 'sub', 'and', 'or', 'xor'. Ciò significa che per ciascuna di queste istruzioni viene scelta una sostituzione più complessa e casuale. La selezione casuale delle identità da un elenco garantisce che ogni output di offuscamento sia diverso.
Per rendere più difficile la deoffuscazione, questa tecnica viene applicata in modo ricorsivo. Il risultato è che ogni identità sostituita viene ri-sostituita più volte, facendo crescere esponenzialmente la complessità. Di seguito è mostrato un esempio della trasformazione di (x+y) dopo 2 passaggi di questa tecnica:

Figura 8. Aritmetica booleana mista.
Un aspetto da considerare è il risultato del calcolo dei flag dell'istruzione. L'istruzione originale avrebbe avuto uno specifico comportamento dei flag applicato al registro dei flag, che non sarà lo stesso a causa dell'espressione suddivisa in operazioni/istruzioni diverse. Per risolvere questo problema, l'obfuscator emula il comportamento dei flag inserendo uno stub che calcola e applica i flag corretti.
Per 'and', 'or', 'xor' il comportamento dei flag è lo stesso dell'istruzione 'test'; inserendo un'istruzione test con gli stessi operandi, i flag verranno emulati correttamente per quelle 3 istruzioni sostituite.
Per 'sub', l'istruzione 'cmp' ha lo stesso comportamento dei flag e può essere inserita con gli stessi operandi per il calcolo dei flag.
Per 'add', SF, ZF, PF possono essere calcolati con 'test', ma CF, AF e OF devono essere calcolati manualmente. Viene quindi inserito uno stub che calcola manualmente CF, AF e OF per l'istruzione add.
Questi stub di emulazione dei flag suggeriscono quale fosse l'istruzione originale, quindi vengono inseriti solo quando assolutamente necessario. L'intero blocco di base viene analizzato per individuare le istruzioni che leggono e scrivono i flag. Le condizioni per l'aggiunta dell'emulazione dei flag sono le seguenti:
Questo garantisce che ogni volta che si raggiunge la fine di un blocco di base o che i flag vengono letti, questi vengano mantenuti aggiornati dallo stub di emulazione dei flag. Ciò impedisce che i condizionali (es. istruzioni if) intraprendano rami sbagliati.
Di seguito sono riportati alcuni comandi di esempio per compilare il progetto usando CMake. Eseguire questi comandi a partire dalla directory root del progetto.```
cmake -B build
cmake --build build
Sui sistemi Windows con Visual Studio installato, l'ultimo comando può essere saltato poiché il progetto può essere compilato tramite i file di soluzione Visual Studio generati (.sln). I file di soluzione Visual Studio si troveranno nella cartella 'build/'.
# 5. Utilizzo
L'offuscatore ha un sistema di configurazione basato su riga di comando. È possibile configurare quanto segue tramite gli argomenti della riga di comando:
- Quali passaggi di offuscamento utilizzare.
- Percorso del file binario di input.
- Percorso del file dei simboli di input (opzionale).
- Percorso del file binario di output (opzionale).
Di seguito è riportata una panoramica degli argomenti della riga di comando:
Usage: binprotect binary-path symbol-path [--out-binary-path VAR] [--control-flow-flattening VAR] [--virtual-machine VAR] [--opaque-predicates VAR] [--linear-substitution VAR] [--mixed-boolean-arithmetic VAR]
Positional arguments:
binary-path file path of input binary [required]
symbol-path file path of input binary's symbols [optional]
--out, --out-path, --out-binary-path desired file path of output binary
--cff, --control-flow-flattening enable control flow flattening pass [default: 1]
--vm, --virtual-machine enable virtual machine pass [default: 1]
--opa, --opaque, --opaque-predicate, --opaque-predicates enable opaque predicate pass [default: 1]
--lin, --linear-substitution enable linear substitution pass [default: 1]
--mba, --mixed-boolean-arithmetic specify amount of mixed boolean arithmetic passes [default: 2]
# 6. Acronimi
- bin2bin - binary to binary.
- RVA - indirizzo relativo.
- MBA - aritmetica booleana mista.
- [SEH - gestione strutturata delle eccezioni](https://learn.microsoft.com/en-us/cpp/cpp/structured-exception-handling-c-cpp).
- FH3 - FuncInfo3.
- FH4 - FuncInfo4.
- [MSVC - Microsoft Visual C++](https://en.wikipedia.org/wiki/Microsoft_Visual_C%2B%2B).
- [LLVM - macchina virtuale di basso livello](https://en.wikipedia.org/wiki/LLVM).
- [CLANG - frontend del linguaggio C/C++ per LLVM](https://clang.llvm.org/).
- [GCC - raccolta di compilatori GNU](https://en.wikipedia.org/wiki/GNU_Compiler_Collection).
- SF - flag di segno.
- ZF - flag di zero.
- PF - flag di parità.
- CF - flag di riporto.
- AF - flag di riporto ausiliario.
- OF - flag di overflow.
# 7. Crediti
Le seguenti persone hanno fornito consigli inestimabili durante lo sviluppo del progetto:
- Aita.
- Papstuc.
- Eriktion.
- IDontCode.
- Abdulla.
- Brit.
- Phage.