7 Trasformazioni dei dati
In questa sezione discuteremo
le trasformazioni che oscurano le strutture dati usate
nell’applicazione sorgente. Come indicato nella Figura 1e, queste trasformazioni sono classificate
in storage, encoding, aggregation, od ordering dei
dati.
7.1 Trasformazioni Storage e Encoding
In molti casi c’è un naturale modo di immagazzinare un particolare
oggetto in un programma. Ad esempio, per iterare attraverso gli elementi di un array probabilmente sceglieremo di allocare una variabile
locale di tipo Integer della dimensione appropriata
come variabile d’iterazione. Sono possibili altri tipi di variabili, ma possono
essere meno naturali e probabilmente meno efficienti.
Inoltre, c’è anche una naturale interpretazione del pattern
di bit che una particolare variabile può possedere è basata sul tipo della
variabile. Per esempio, possiamo normalmente assumere che una variabile integer di 16 bit immagazzini il pattern di bit ‘0000000000001100’ che rappresenta il valore intero 12.
Naturalmente, questa è solo una convenzione e molte
altre interpretazioni sono possibili.
Le Trasformazioni che offuscano il modo di memorizzare i dati (storage) tentano di scegliere una classe
d’immagazzinamento non naturale sia per i dati dinamici che per quelli statici.
Analogamente, le trasformazioni della codifica (encoding)
tentano di scegliere codifiche non naturali per i
comuni tipi di dati. Queste due trasformazioni spesso vanno mano nella mano, ma spesso possono essere usate singolarmente.
Come semplice
esempio di trasformazione della codifica (encoding), rimpiazzeremo una variabile intera i con
, dove
e
sono costanti. Per l’efficienza potremmo scegliere
come potenza di 2.
Nell’esempio seguente, usiamo
=8 e
=3;
Ci sarà un compromesso tra resilienza e potenza da una parte e costo
dall’altra. Una semplice funzione di codifica come nell’esempio di prima,
aggiunge un pò d’overhead
in termini di tempo d’esecuzione ma può essere deoffuscato usando le comuni tecniche di analisi dei
compilatori[33,21].
7.1.2 Promozione di variabili
C’è un certo numero di semplici trasformazioni del tipo di memorizzazione dei dati che promuovono le variabili da
una speciale classe di memorizzazione ad una più generale.
La potenza e la resilienza sono generalmente basse, ma se usate in modo
congiunto con altre trasformazioni, esse possono essere davvero efficienti.
Per esempio, in Java, una variabile integer può essere promossa ad un oggetto integer. Ciò è vero anche per gli altri tipi scalari che
hanno una corrispondente classe “packaged”.
Siccome Java supporta il garbage collection, gli oggetti saranno automaticamente rimossi quando non sono più referenziati. Vediamo un
esempio:
E’ anche possibile cambiare la durata del ciclo di
vita di una variabile. Semplicemente, tale
trasformazione converte una variabile locale in una globale
che poi è condivisa tra le invocazioni di procedure indipendenti. Per esempio,
se entrambe le procedure P e Q referenziano una variabile integer
locale, e P e Q non possono essere entrambe attive allo stesso tempo allora la
variabile può essere resa globale e condivisa tra
loro:
Questa trasformazione incrementa la metrica della tabella1 poiché il numero di
strutture dati globali referenziate da P e Q aumentano.
7.1.3 Splitting delle Variabili
Le variabili di tipo Boolean ed altre variabili aventi raggio d’azione ridotto possono essere
spezzate in due o più variabili. Indicheremo che una variabile V è spezzata
(split) in k variabili, … ,
come V=[
, … ,
]. Tipicamente, la potenza di questa trasformazione cresce
con k. Sfortunatamente, a causa del costo della trasformazione, solitamente k è
ristretto a 2 o 3.
Per permettere ad una variabile V di tipo T di essere spezzata in due
variabili p e q di tipo U, è necessario fornire 3 informazioni:
(1)
una funzione f(p,
q) che mappi i valori di p e q nel corrispondente
valore di V,
(2)
una funzione g(V)
che mappi il valore di V nel corrispondente valore di
p e q, e
(3)
nuove operazioni
(corrispondenti alle operazioni primitive sui valori di tipo T) che possono
essere compiute su p e q. Nel resto di questa sezione assumeremo che V è boolean, e p e q siano piccole variabili di tipo integer.
La figura 18a mostra una possibile scelta della
rappresentazione per spezzare le variabili di tipo boolean.
La tabella indica che se V è stata spezzata in p e q, e se, in qualche
punto nel programma, p = q = 0 oppure p = q = 1, allora V è
False.
Analogamente, p = 0, q = 1 oppure p = 1, q = 0 corrisponde a True. Data questa nuova rappresentazione, dobbiamo
sostituire le operazioni (&, | , ˜, ^).
Il modo più semplice è di fornire a run-time
una tabella di ricerca per ogni operatore. La tabella per & e per | sono
mostrate in Figura 18c e 18d rispettivamente.
Dati due valori booleani =[p, q] e
=[r, s],
&
è computato come
AND[2p + q, 2r + s].
Nella figura 18e mostriamo il risultato
dello splitting di tre variabili booleane
A=[,
], B=[
,
], e C=[
,
]. Un interessante aspetto della rappresentazione da noi
scelta è che ci sono diversi possibili modi di computare la stessa espressione booleana. Le espressioni (3) e (4) nella Figura 18e, per esempio, sembrano differenti, sebbene
entrambe assegnano False ad una variabile.
Analogamente, anche se le espressioni (5) e (6) sono completamente
differenti, entrambe computano A & B.
La potenza, la resilienza, e il costo di questa trasformazione crescono
con il numero di variabili in cui la variabile
originale è spezzata. La resilienza può essere ulteriormente migliorata
selezionando la codifica a run-time. In altre parole,
le tabelle di ricerca a run-time di Figura 18(b-d) non sono costruite in fase di
compilazione (che potrebbe renderle suscettibili ad analisi statiche) ma da
algoritmi inclusi nell’applicazione offuscante. Questo, naturalmente, dovrebbe
impedirci di usare codice in-line per computare
operazioni primitive, com’è stato fatto nell’espressione (6) in Figura 18e.
7.1.4 Convertire dati statici in dati procedurali
I dati statici, particolarmente stringhe di caratteri, contengono molte
informazioni utili per il reverse engineer.
Un semplice modo di offuscare una stringa statica è convertirla in un programma
che produce le stringhe. Il programma, che può essere, ad esempio, un DFA (Deterministic Finite Automaton) [1], può anche
produrre altre stringhe.
Come esempio, consideriamo la funzione G in Figura 19.
Questa funzione è stata costruita per offuscare le
stringhe "AAA", "BAAAA", e "CCB".
I valori prodotti da G sono G(1)="AAA", G(2)=”BAAAA”,
G(3)=G(5)=”CCB”, e G(4)=“XCB“ (che non è attualmente
usata nel programma).
Per altri valori degli argomenti, G può terminare o no.
Aggregare la computazione di tutte le stringhe statiche in una sola
funzione è, naturalmente, molto indesiderabile. Più
potenza e resilienza sono raggiunte se la funzione G è
spezzata in componenti più piccole che sono incluse nel "normale"
flusso di controllo del programma sorgente.
E’ interessante notare che possiamo combinare questa tecnica con la
trasformazione dell’interpretazione delle tabelle della
sezione 6.2.5. L’intento dell’offuscamento è di convertire una sezione di bytecode in codice per un’altra macchina virtuale. Il nuovo
codice sarà tipicamente immagazzinato come stringa statica
nel programma offuscato.
Per ottenere alti livelli di potenza e resilienza, comunque,
le stringhe devono essere convertite in programmi che le producono, come
spiegato sopra.
7.2 Trasformazioni per Aggregazione
In contrasto ai linguaggi imperativi e funzionali, i linguaggi
object-oriented sono più orientati ai dati che al
controllo. In altre parole, in un programma object oriented, il controllo è organizzato attorno alle strutture dati. Ciò significa che un’importante parte
del reverse engineering di
un programma object oriented
prova a ricostruire le strutture dati del programma.
Invece, è importante per un offuscatore provare a nascondere queste
strutture dati.
In molti linguaggi object oriented,
ci sono solo due modi per aggregare i dati: negli array
o con gli oggetti. Nelle prossime tre sezioni esamineremo i modi con cui queste strutture dati possono essere offuscate.
7.2.1 Fusione di variabili scalari
Due o più variabili scalari , …,
può essere fuso in una variabile
, purché il range combinato di
, …,
si adatti alla precisione di
. Per esempio, due variabili integer
di 32 bit possono essere fuse in una di 64 bit. L’aritmetica sulle variabili
individuali deve essere trasformata in un’aritmetica su
. Come semplice esempio, consideriamo la fusione di due
variabili integer a 32 bit X e Y in una variabile Z a
64 bit. Usando la funzione di fusione Z(X,Y) =
*Y +X otteniamo le identità aritmetiche in Figura 20a. Qualche piccolo esempio è dato in Figura 20b.
La resilienza della fusione di variabili è veramente
bassa. Un deoffuscatore ha bisogno solo di esaminare
l’insieme d’operazioni aritmetiche che sono state applicate ad una particolare
variabile allo scopo di ipotizzare che essa attualmente
consiste di due variabili fuse.
Possiamo aumentare la resilienza introducendo finte operazioni che non
corrispondono ad alcuna operazione ragionevole su
variabili individuali. Nell’esempio in Figura 20b, possiamo inserire operazioni che sembrano fondere
le due metà di Z, per esempio attraverso rotazione:
if () Z = rotate(Z,5).
Una variante di questa trasformazione è fondere , …,
in un array
=
del tipo
appropriato. Se
, …,
sono variabili contenenti object reference, per esempio, allora il tipo dell’elemento di
può essere qualunque
classe che nella gerarchia di ereditarietà sia più in alto di qualunque dei
tipi di
, …,
.
7.2.2 Ristrutturare Array
Varie trasformazioni possono essere create per oscurare operazioni
eseguite su array: possiamo spezzare (splitting) un array in diversi
sottoarray, fondere (merging)
due o più array in uno solo, piegare (folding) un array
(incrementare il numero di dimensioni), o appiattire (flatting)
un array (decrementando il numero di dimensioni).
La Figura 21 mostra qualche esempio di
ristrutturazione di array.
Nelle espressioni (1-2) un array
A è spezzato in due sottoarray
A1 e A2. Al contiene gli elementi di A che hanno
indice pari, e A2 contiene gli elementi che hanno indice dispari.
Le espressioni
(3-4) di Figura 21 mostrano come
due array d’interi B e C possono essere interfogliati
in un array risultante BC. Gli elementi da B a C sono
uniformemente distribuiti lungo l’array risultante.
Le espressioni (6-7)
dimostrano come un array monodimensionale D può essere piegato in un array bidimensionale D1. Le espressioni (8-9), infine,
dimostrano la trasformazione
inversa: un array bidimensionale E è appiattito in un
array monodimensionale E1.
Lo splitting e il folding
di array aumentano la
metrica della tabella1. La fusione di array e l’appiattimento,
dall’altra parte, sembra diminuire questa misura. Anche se
ciò sembra indicare che queste trasformazioni hanno solo una potenza marginale
o addirittura negativa, ciò in realtà può essere ingannevole. Il
problema è che le metriche di complessità della tabella1 non sono in grado di catturare un aspetto importante di alcune trasformazioni di strutture dati: esse introducono
strutture che prima non esistevano, oppure esse rimuovono le strutture dal
programma originale.
Ciò può incrementare enormemente l’oscurità del programma. Per esempio,
un programmatore che dichiara un array bidimensionale lo fa per uno scopo: la struttura scelta
mappa in qualche modo i dati che sono stati manipolati. Se
quell’array è piegato in una struttura monodimensionale, un reverse engineer sarà privato di molte informazioni preziose.
7.2.3 Modificare le relazioni
d’ereditarietà
Nei correnti linguaggi object-oriented come Java, il massimo concetto di modularizzazione
e astrazione è la classe. Le classi sono essenzialmente tipi
di dato astratti che incapsulano i dati (istanze di variabili) e i controlli (metodi).
Indicheremo una classe con C = (V, M), dove V è l’insieme delle variabili d’istanza di C e M è l’insieme dei suoi metodi.
Al contrario della tradizionale
nozione di tipi di dato astratti, due classi e
possono essere composte per aggregazione
(
ha una variabile
d’istanza di tipo
)così come per ereditarietà (
estende
aggiungendo nuovi
metodi e variabili d’istanza).
Prendendo in prestito la notazione usata in [27], indicheremo l’ereditarietà come .
Si dice
che eredita da
(la sua superclasse o
classe antenata). L’operatore
è la funzione che combina la superclasse con le nuove
proprietà definite in
. L’esatta semantica di
dipende dal particolare linguaggio di programmazione.
Nei linguaggi come Java, è generalmente interpretato come unione quando è applicato a variabili
d’istanza e come un’overriding quando è applicato ai
metodi.
In accordo con la metrica della tabella1, la complessità di una classe
cresce con la sua
ampiezza (distanza dalla sua radice) nella gerarchia di ereditarietà,
e il numero dei suoi diretti discendenti.
Ci sono due modi di base con
cui possiamo aumentare questa complessità: possiamo spezzare (fattorizzare) una classe (Fig. 22a) o
inserire una nuova classe fasulla (Fig. 22b).
Un problema con la fattorizzazione
di classi è la sua bassa resilienza; non c’è nulla che possa impedire ad un deoffuscatore di fondere le classi fattorizzate.
Per prevenire ciò, la fattorizzazione e l’inserimento
sono normalmente combinate come mostrato in figura 22d.
Un altro modo di aumentare la resilienza di questo tipo di trasformazioni è di assicurare che nuovi oggetti siano creati da tutte le
classi introdotte.
La Figura
22c mostra una variante dell’inserimento di classe, chiamata falsa
rifattorizzazione. La rifattorizzazione
è una tecnica per ristrutturare di programmi object-oriented
la cui struttura è deteriorata [22]. La rifattorizzazione
è un processo composto di due passi.
Primo, si scopre se due,
classi apparentemente
indipendenti, in realtà implementano comportamenti simili.
Secondo, caratteristiche
comuni d’entrambe le classi sono spostate in una nuova classe
padre (possibilmente astratta).
La falsa rifattorizzazione
è un’operazione simile, la differenza è che è eseguita su due classi e
che non hanno alcun comportamento in comune.
Se entrambe le classi hanno
variabili d’istanza dello stesso tipo, queste possono
essere spostate in una nuova classe padre . I metodi di
possono essere
versioni errate di qualcuno dei metodi di
e
.
7.3 Trasformazioni dell’ordine
Nella sezione 6.4 abbiamo discusso
che (quando possibile) la randomizzazione dell’ordine
con cui le computazioni sono svolte è un utile offuscamento. Analogamente, è
utile randomizzare l’ordine delle dichiarazioni
nell’applicazione sorgente. In particolare, randomizziamo
l’ordine dei metodi e delle variabili d’istanza nelle
classi e i parametri formali nei metodi.
La potenza di queste trasformazioni è bassa e la resilienza è one-way.
In molti casi sarà anche possibile riordinare gli elementi all’interno
di un array. Semplicemente, forniremo una funzione
f(i) che mappa l’i-esimo elemento nell’array
originale nella sua nuova posizione nell’array
riordinato: