Offuscamento a strati: una tassonomia delle tecniche di offuscamento del software per la sicurezza a strati

Questa sezione esamina le tecniche di offuscamento per elementi di codice specifici. Questo strato copre la maggior parte delle pubblicazioni nell’area di offuscamento del software. Come mostrato in Fig. 4, in base a quali elementi si rivolge una tecnica di offuscamento, dividiamo questa categoria in cinque sottocategorie: layout di offuscamento, controlli di offuscamento, funzioni di offuscamento dei dati e classi di offuscamento.

Fig. 4
figura4

Le tecniche di offuscamento del livello code-element

Offuscamento layout

Offuscamento layout rimescola il layout di codici o istruzioni mantenendo intatta la sintassi originale. Questa sezione discute quattro strategie di offuscamento del layout: classificatori senza senso, rimozione di simboli ridondanti, separazione di codici correlati e codici spazzatura.

Identificatori senza senso

Questo approccio è anche noto come offuscamento lessicale che trasforma gli identificatori significativi in quelli senza senso. Per la maggior parte dei linguaggi di programmazione, l’adozione di regole di denominazione significative e uniformi (ad esempio, Notazione ungherese (Simonyi 1999)) è richiesta come buona pratica di programmazione. Sebbene tali nomi siano specificati nei codici sorgente, alcuni rimarrebbero nel software rilasciato per impostazione predefinita. Ad esempio, i nomi delle variabili globali e delle funzioni in C/C++ sono conservati in binari e tutti i nomi di Java sono riservati in bytecode. Poiché tali nomi significativi possono facilitare l’analisi del programma contraddittorio, dovremmo rimescolarli. Per rendere gli identificatori offuscati più confusi, Chan e Yang (2004) hanno proposto di impiegare deliberatamente gli stessi nomi per oggetti di tipi diversi o all’interno di domini diversi. Tali approcci sono stati adottati da ProGuard (2016) come schema di offuscamento predefinito per i programmi Java.

Rimozione dei simboli ridondanti

Questa strategia rimuove le informazioni simboliche ridondanti dal software rilasciato, come le informazioni di debug per la maggior parte dei propgram (Low 1998). Inoltre, ci sono altri simboli ridondanti per particolari formati di programmi. Ad esempio, i file ELF contengono tabelle di simboli che registrano le coppie di identificatori e indirizzi. Quando si adottano opzioni di compilazione predefinite per compilare programmi C / C++, ad esempio utilizzando LLVM (Lattner e Adve 2004), i binari generati contengono tali tabelle di simboli. Per rimuovere tali informazioni ridondanti, gli sviluppatori possono utilizzare lo strumento strip di Linux. Un altro esempio con informazioni ridondanti è codici smali Android. Per impostazione predefinita, i codici smali generati contengono informazioni iniziate con .linea e .fonte, che può essere rimosso per scopi di offuscamento (Dalla Preda e Maggi 2017).

Separare i codici correlati

Un programma è più facile da leggere se i suoi codici logicamente correlati sono anche fisicamente vicini (Collberg et al. 1997). Pertanto, la separazione di codici o istruzioni correlati può aumentare le difficoltà di lettura. È applicabile sia ai codici sorgente (ad esempio, riordino delle variabili (Basso 1998)) che ai codici assembly (ad esempio, riordino delle istruzioni (Wroblewski 2002)). In pratica, l’utilizzo di salti incondizionati per riscrivere un programma è un approccio popolare per raggiungere questo obiettivo. Ad esempio, gli sviluppatori possono mescolare i codici assembly e quindi impiegare goto per ricostruire il flusso di controllo originale (Tu e Yim 2010). Questo approccio è popolare per i codici assembly e i bytecode Java con la disponibilità di istruzioni goto (Dalla Preda e Maggi 2017).

Codici spazzatura

Questa strategia aggiunge istruzioni spazzatura che non sono funzionali. Per i binari, possiamo aggiungere istruzioni senza operazione (NOP o 0x00) (Dalla Preda e Maggi 2017; Marcelli et al. 2018). Inoltre, possiamo anche aggiungere metodi spazzatura, come l’aggiunta di metodi defunti nei codici smali Android (Dalla Preda e Maggi 2017). I codici indesiderati possono in genere modificare le firme dei codici e quindi sfuggire al riconoscimento di pattern statici.

Poiché l’offuscamento del layout non manomette la sintassi del codice originale, è meno soggetto a problemi di compatibilità o bug. Pertanto, tali tecniche sono le più preferite nella pratica. Inoltre, le tecniche di identificatori senza senso e di rimozione di simboli ridondanti possono ridurre le dimensioni dei programmi, il che li rende ulteriormente attraenti (ProGuard 2016). Tuttavia, la potenza dell’offuscamento del layout è limitata. Ha una promettente resilienza agli attacchi di deobfuscazione perché alcune trasformazioni sono a senso unico, che non possono essere invertite. Tuttavia, alcune informazioni di layout difficilmente possono essere modificate, come gli identificatori di metodo da Java SDK. Tali informazioni residue sono essenziali per gli avversari per recuperare le informazioni offuscate. Ad esempio, Bichsel et al. (2016) hanno provato a deobfuscare le app offuscate da ProGuard e hanno recuperato con successo circa l ‘ 80% dei nomi.

Controlli di offuscamento

Questo tipo di tecniche di offuscamento trasforma i controlli dei codici per aumentare la complessità del programma. Può essere ottenuto tramite flussi di controllo fasulli, flussi di controllo probabilistici, controlli basati su dispatcher e controlli impliciti.

Flussi di controllo fasulli

I flussi di controllo fasulli si riferiscono ai flussi di controllo che vengono deliberatamente aggiunti a un programma ma non verranno mai eseguiti. Può aumentare la complessità di un programma, ad esempio, in McCabe complexity (McCabe 1976) o Harrison metrics (Harrison and Magel 1981). Ad esempio, la complessità di McCabe (McCabe 1976) viene calcolata come il numero di bordi su un grafico del flusso di controllo meno il numero di nodi e quindi più due volte dei componenti collegati. Per aumentare la complessità di McCabe, possiamo introdurre nuovi bordi o aggiungere nuovi bordi e nodi a un componente connesso.

Per garantire l’irraggiungibilità di flussi di controllo fasulli, Collberg et al. (1997) ha suggerito di impiegare predicati opachi. Hanno definito la previsione opaca come il predicato il cui esito è noto durante il tempo di offuscamento, ma è difficile da dedurre dall’analisi statica del programma. In generale, un predicato opaco può essere costantemente vero (PT), costantemente falso (PF) o dipendente dal contesto (P?). Esistono tre metodi per creare predicati opachi: schemi numerici, schemi di programmazione e schemi contestuali.

Schemi numerici

Schemi numerici compongono predicati opachi con espressioni matematiche. Ad esempio, 7×2−1 y y2 è costantemente vero per tutti gli interi x e y. Possiamo impiegare direttamente tali predicati opachi per introdurre flussi di controllo fasulli. La figura 5a illustra un esempio, in cui il predicato opaco garantisce che il flusso di controllo fasullo (cioè il ramo else) non verrà eseguito. Tuttavia, gli aggressori avrebbero maggiori possibilità di rilevarli se impiegassimo frequentemente gli stessi predicati opachi in un programma offuscato. Arboit (2002), pertanto, ha proposto di generare automaticamente una famiglia di tali predicati opachi, in modo tale che un offuscatore possa scegliere ogni volta un predicato opaco univoco.

Fig. 5
figura5

Controllo-offuscamento del flusso con predicati opachi

Un altro approccio matematico con maggiore sicurezza è quello di impiegare funzioni crittografiche, come la funzione hash \(\mathcal {H}\) (Sharif et al. 2008), e la crittografia omomorfa (Zhu e Thomborson 2005). Ad esempio, possiamo sostituire un predicato x==c con \(\mathcal {H}(x)==c_{hash}\) per nascondere la soluzione di x per questa equazione. Si noti che tale approccio è generalmente impiegato dal malware per eludere l’analisi dinamica del programma. Possiamo anche impiegare funzioni crittografiche per crittografare equazioni che non possono essere soddisfatte. Tuttavia, tali predicati opachi comportano molto sovraccarico.

Per comporre costanti opache resistenti all’analisi statica, Moser et al. (2007) ha suggerito di impiegare problemi 3-SAT, che sono NP-hard. Questo è possibile perché si possono avere algoritmi efficienti per comporre problemi così difficili (Selman et al. 1996). Ad esempio, Tiella e Ceccato (2017) hanno dimostrato come comporre tali predicati opachi con problemi di k-clique.

Per comporre costanti opache resistenti all’analisi dinamica, Wang et al. (2011) ha proposto di comporre predicati opachi con una forma di congetture irrisolte che si ripetono per molte volte. Poiché i loop sono impegnativi per l’analisi dinamica, l’approccio in natura dovrebbe essere resistente all’analisi dinamica. Esempi di tali congetture includono la congettura di Collatz, la congettura 5x+1, la congettura di Matthews. La figura 5b illustra come utilizzare la congettura di Collatz per introdurre flussi di controllo fasulli. Indipendentemente da come inizializziamo x, il programma termina con x=1 e originalCodes () può sempre essere eseguito.

Schemi di programmazione

Poiché l’analisi del programma contraddittorio è una grave minaccia per i predicati opachi, possiamo impiegare problemi di analisi del programma impegnativi per comporre predicati opachi. Collberg et al. suggerito due problemi classici, analisi del puntatore e programmi concorrenti.

In generale, l’analisi del puntatore si riferisce alla determinazione se due puntatori possono o possono puntare allo stesso indirizzo. Alcuni problemi di analisi del puntatore possono essere NP-difficili per l’analisi statica o addirittura indecidibili (Landi e Ryder 1991). Un altro vantaggio è che le operazioni del puntatore sono molto efficienti durante l’esecuzione. Pertanto, gli sviluppatori possono comporre previsioni opache resilienti ed efficienti con problemi di analisi del puntatore ben progettati, come il mantenimento di puntatori ad alcuni oggetti con strutture dati dinamiche (Collberg et al. 1998 bis).

Programmi simultanei o programmi paralleli è un altro problema impegnativo. In generale, una regione parallela di n istruzioni ha n! diversi modi di esecuzione. L’esecuzione non è determinata solo dal programma, ma anche dallo stato di runtime di un computer host. Collberg et al. (1998a) ha proposto di impiegare programmi simultanei per migliorare l’approccio basato sul puntatore aggiornando contemporaneamente i puntatori. Majumdar e Thomborson (2006) hanno proposto di impiegare programmi paralleli distribuiti per comporre predicati opachi.

Inoltre, alcuni approcci compongono predicati opachi con trucchi di programmazione, come sfruttare i meccanismi di gestione delle eccezioni. Ad esempio, Dolz e Parra (2008) hanno proposto di utilizzare il meccanismo try-catch per comporre predicati opachi per.Net e Java. Gli eventi di eccezione includono la divisione per zero, il puntatore nullo, l’indice fuori intervallo o anche particolari eccezioni hardware (Chen et al. 2009). La semantica del programma originale può essere ottenuta tramite schemi di gestione delle eccezioni personalizzati. Tuttavia, tali predicati opachi non hanno alcuna base di sicurezza e sono vulnerabili agli attacchi avanzati fatti a mano.

Schemi contestuali

Gli schemi contestuali possono essere impiegati per comporre predicati opachi varianti(cioè, {P?}). I predicati dovrebbero contenere alcune proprietà deterministiche tali da poter essere impiegati per offuscare i programmi. Ad esempio, Drape e et al. (2009) ha proposto di comporre tali predicati opachi che sono invarianti sotto un vincolo contestuale, ad esempio, il predicato opaco x mod3==1 è costantemente vero se x mod3:1?x++: x = x + 3. Palsberg et al. (2000) predicati opachi dinamici proposti, che includono una sequenza di predicati correlati. Il risultato della valutazione di ciascun predicato può variare in ogni esecuzione. Tuttavia, finché i predicati sono correlati, il comportamento del programma è deterministico. La figura 5c illustra un esempio di predicati opachi dinamici. Non importa come inizializziamo * p e * q, il programma è equivalente a y = x + 3, x = y + 3.

La resistenza dei flussi di controllo fasulli dipende principalmente dalla sicurezza dei predicati opachi. Una proprietà di sicurezza ideale per i predicati opachi è che richiedono il tempo esponenziale del caso peggiore per rompere ma solo il tempo polinomiale per costruire. Si noti che alcuni predicati opachi sono progettati con tali problemi di sicurezza, ma possono essere implementati con difetti. Ad esempio, i problemi 3-SAT proposti da Ogiso et al. (2003) si basano su impostazioni di problemi banali che possono essere facilmente semplificate. Se tali predicati opachi sono implementati correttamente, sarebbero promettenti per essere resilienti.

Flussi di controllo probabilistici

I flussi di controllo fasulli possono creare problemi all’analisi statica del programma. Tuttavia, sono vulnerabili all’analisi dinamica del programma perché i flussi di controllo fasulli sono inattivi. L’idea dei flussi di controllo probabilistici adotta una strategia diversa per affrontare la minaccia (Pawlowski et al. 2016). Introduce repliche di flussi di controllo con la stessa semantica ma sintassi diversa. Quando si riceve lo stesso input più volte, il programma può comportarsi in modo diverso per diversi tempi di esecuzione. La tecnica è utile anche per combattere gli attacchi a canale laterale (Crane et al. 2015).

Si noti che la strategia dei flussi di controllo probabilistici è simile ai flussi di controllo fasulli con predicati opachi contestuali. Ma sono di natura diversa in quanto i predicati opachi contestuali introducono percorsi morti, sebbene non introducano codici spazzatura.

Controlli basati su Dispatcher

Un controllo basato su dispatcher determina i blocchi successivi di codici da eseguire durante il runtime. Tali controlli sono essenziali per l’offuscamento del flusso di controllo perché possono nascondere i flussi di controllo originali contro l’analisi statica del programma.

Un importante approccio di offuscamento basato sul dispatcher è l’appiattimento del flusso di controllo, che trasforma i codici di profondità in quelli poco profondi con maggiore complessità. Wang et al. (2000) in primo luogo ha proposto l’approccio. Figura 6 mostra un esempio dalla loro carta che trasforma un ciclo while in un’altra forma con switch-case. Per realizzare tale trasformazione, il primo passo è trasformare il codice in una rappresentazione equivalente con istruzioni if-then-goto come mostrato in Fig. 6; quindi modificano le istruzioni goto con le istruzioni switch-case come mostrato in Fig. 6. In questo modo, la semantica originale del programma viene realizzata implicitamente controllando il flusso di dati della variabile switch. Poiché l’ordine di esecuzione dei blocchi di codice è determinato dinamicamente dalla variabile, non è possibile conoscere i flussi di controllo senza eseguire il programma. Cappaert e Preneel (2010) hanno formalizzato l’appiattimento del flusso di controllo come l’utilizzo di un nodo dispatcher (ad esempio, switch) che controlla il successivo blocco di codice da eseguire; dopo aver eseguito un blocco, il controllo viene trasferito al nodo dispatcher. Inoltre, ci sono diversi miglioramenti all’appiattimento del flusso di codice. Ad esempio, per migliorare la resistenza all’analisi del programma statico sulla variabile switch, Wang et al. (2001) ha proposto di introdurre problemi di analisi del puntatore. Per complicare ulteriormente il programma, Chow et al. (2001) ha proposto di aggiungere blocchi di codice fasulli.

Fig. 6
figura6

Approccio di appiattimento del flusso di controllo proposto da Wang et al. (2000)

László e Kiss (2009) hanno proposto un meccanismo di appiattimento del flusso di controllo per gestire una sintassi C++ specifica, come try-catch, while-do, continue. Il meccanismo si basa su albero di sintassi astratta e impiega un modello fisso di layout. Per ogni blocco di codice da offuscare, costruisce un’istruzione while nel ciclo esterno e un composto switch-case all’interno del ciclo. Il composto switch-case implementa la semantica del programma originale e la variabile switch viene utilizzata anche per terminare il ciclo esterno. Cappaert e Preneel (2010) hanno scoperto che i meccanismi potrebbero essere vulnerabili all’analisi locale, cioè la variabile switch viene immediatamente assegnata in modo tale che gli avversari possano dedurre il blocco successivo da eseguire solo esaminando un blocco corrente. Hanno proposto un approccio rafforzato con diversi trucchi, come l’impiego dell’assegnazione di riferimento (ad esempio, swVar=swVar+1) invece dell’assegnazione diretta (ad esempio, swVar=3), sostituendo l’assegnazione tramite if-else con un’espressione di assegnazione uniforme e impiegando funzioni unidirezionali nel calcolo del successore di un blocco di base.

Oltre all’appiattimento del flusso di controllo, ci sono molte altre indagini di offuscamento basate sul dispatcher (ad esempio, (Linn e Debray 2003; Ge et al. 2005; Zhang et al. 2010; Schrittwieser e Katzenbeisser 2011)). Linn e Debray (2003) hanno proposto di offuscare i binari con funzioni di ramo che guidano l’esecuzione in base alle informazioni dello stack. Allo stesso modo, Zhang et al. (2010) ha proposto di impiegare funzioni di ramo per offuscare i programmi orientati agli oggetti, che definiscono uno stile di invocazione del metodo unificato con un pool di oggetti. Per migliorare la sicurezza di tali meccanismi, Ge et al. (2005) ha proposto di nascondere le informazioni di controllo in un altro processo autonomo e di impiegare comunicazioni tra processi. Schrittwieser e Katzenbeisser (2011) hanno proposto di impiegare blocchi di codice diversificati che implementano la stessa semantica.

L’offuscamento basato su Dispatcher è resistente all’analisi statica perché nasconde il grafico del flusso di controllo di un programma software. Tuttavia, è vulnerabile all’analisi dinamica del programma o agli approcci ibridi. Ad esempio, Udupa et al. (2005) ha proposto un approccio ibrido per rivelare i flussi di controllo nascosti sia con l’analisi statica che con l’analisi dinamica.

Controlli impliciti

Questa strategia converte le istruzioni di controllo esplicite in quelle implicite. Può impedire ai reverse engineer di indirizzare i flussi di controllo corretti. Ad esempio, possiamo sostituire le istruzioni di controllo dei codici di assemblaggio (ad esempio, jmp e jne) con una combinazione di mov e altre istruzioni che implementano la stessa semantica di controllo (Balachandran e Emmanuel 2011).

Si noti che tutti gli approcci di offuscamento del flusso di controllo esistenti si concentrano sulla trasformazione a livello sintattico, mentre la protezione a livello semantico è stata raramente discussa. Sebbene possano dimostrare una certa resilienza agli attacchi, la loro efficacia di offuscamento in materia di protezione semantica rimane poco chiara.

Offuscamento dei dati

Le attuali tecniche di offuscamento dei dati si concentrano su tipi di dati comuni, come numeri interi, stringhe e array. Possiamo trasformare i dati tramite scissione, fusione, procedurizzazione, codifica, ecc.

Suddivisione/fusione dei dati

La suddivisione dei dati distribuisce le informazioni di una variabile in diverse nuove variabili. Ad esempio, una variabile booleana può essere divisa in due variabili booleane e l’esecuzione di operazioni logiche su di esse può ottenere il valore originale.

La fusione dei dati, d’altra parte, aggrega diverse variabili in un’unica variabile. Collberg et al. (1998b) ha dimostrato un esempio che unisce due interi a 32 bit in un intero a 64 bit. Venaul e Venkatesh (2005) hanno proposto un altro metodo che racchiude diverse variabili in uno spazio con logaritmi discreti.

Procedurizzazione dei dati

La procedurizzazione dei dati sostituisce i dati statici con le chiamate di procedura. Collberg et al. (1998b) ha proposto di sostituire le stringhe con una funzione che può produrre tutte le stringhe specificando i valori dei parametri paticolari. Drape e et al. (2004) ha proposto di codificare i dati numerici con due funzioni inverse f e g. Per assegnare un valore v a una variabile i, lo assegniamo a una variabile iniettata j come j=f (v). Per usare i, invochiamo invece g (j).

Codifica dei dati

Codifica dei dati codifica i dati con funzioni matematiche o cifrari. Eraul e venkatesh (2005) hanno proposto di codificare stringhe con cifrari affini (ad esempio, cifrario Caser) e impiegare logaritmi discreti per imballare le parole. Fukushima et al. (2008) ha proposto di codificare i numeri chiari con operazioni or esclusive e quindi decifrare il risultato del calcolo prima dell’output. Kovacheva (2013) ha proposto di crittografare le stringhe con il codice RC4 e quindi decrittografarle durante il runtime.

Trasformazione array

Array è una struttura dati più comunemente utilizzata. Per offuscare gli array, Collberg et al. (1998b) ha discusso diverse trasformazioni, come dividere un array in diversi sottoarray, unire diversi array in un array, piegare un array per aumentare la sua dimensione o appiattire un array per ridurre la dimensione. Venaul e Venkatesh (2005) hanno suggerito di trasformare gli indici di array con funzioni composite. Zhu et al. (2006); Zhu (2007) ha proposto di impiegare la crittografia omomorfa per la trasformazione dell’array, inclusa la modifica dell’indice, la piegatura e l’lusinghiero. Ad esempio, possiamo mescolare gli elementi di un array con i mod m mod n, dove i è l’indice originale, n è la dimensione dell’array originale e m e n sono relativamente primi.

Metodi offuscanti

Metodo inline/outline

Un metodo è una procedura indipendente che può essere chiamata da altre istruzioni del programma. Metodo inline sostituisce la chiamata procedurale originale con il corpo della funzione stessa. Metodo outline opera in modo opposto che estrae una sequenza di istruzioni e astrae un metodo. Sono buone aziende che possono offuscare l’astrazione originale delle procedure (Collberg et al. 1997).

Clone del metodo

Se un metodo è fortemente invocato, possiamo creare repliche del metodo e chiamarne casualmente uno. Per confondere l’interpretazione contraddittoria, ogni versione della replica dovrebbe essere unica in qualche modo, ad esempio adottando diverse trasformazioni di offuscamento (Collberg et al. 1997) o firme diverse (Venaul e venkatesh 2004).

Aggregazione/scattering del metodo

L’idea è simile all’offuscamento dei dati. Possiamo aggregare metodi irrilevanti in un metodo o disperdere un metodo in diversi metodi (Collberg et al. 1997; Basso 1998).

Metodo proxy

Questo approccio crea metodi proxy per confondere il reverse engineering. Ad esempio, possiamo creare i proxy come metodi statici pubblici con identificatori randomizzati. Ci possono essere diversi proxy distinti per lo stesso metodo (Dalla Preda e Maggi 2017). L’approccio è estremamente utile quando le firme del metodo non possono essere modificate (Protsenko e Muller 2013).

Classi offuscanti

Classi offuscanti condivide alcune idee simili con metodi offuscanti, come la divisione e il clone (Collberg et al. 1998b). Tuttavia, poiché la classe esiste solo nei linguaggi di programmazione orientati agli oggetti, come JAVA e.NET, li discutiamo come una categoria unica. Di seguito presentiamo le principali strategie per offuscare le classi.

Modificatori di rilascio

I programmi orientati agli oggetti contengono modificatori (ad es., pubblico, privato) per limitare l’accesso alle classi e ai membri delle classi. Dropping modificatori rimuove tali restrizioni e rendere tutti i membri pubblici (Protsenko e Muller 2013). Questo approccio può facilitare l’implementazione di altri metodi di offuscamento della classe.

Classe di scissione/coalescenza

L’idea di coalescenza/scissione è di offuscare l’intento degli sviluppatori quando progettano le classi (Sosonkin et al. 2003). Quando si uniscono le classi, possiamo trasferire variabili locali o gruppi di istruzioni locali in un’altra classe (Fukushima et al. 2003).

Appiattimento della gerarchia di classe

L’interfaccia è un potente strumento per programmi orientati agli oggetti. Simile al metodo proxy, possiamo creare proxy per classi con interfacce (Sosonkin et al. 2003). Tuttavia, un modo più potente è quello di rompere la relazione di ereditarietà originale tra le classi con le interfacce. Lasciando che ogni nodo di un sottoalbero nella gerarchia di classi implementi la stessa interfaccia, possiamo appiattire la gerarchia (Foket et al. 2012).

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.