Ottenere le istruzioni: linear sweep e recursive traversal

3 febbraio 2025 – 30 min – 6373 parole

Costruire il proprio insieme di strumenti di analisi è un ottimo esercizio per chi ha già delle basi e consente successivamente di poter passare ad implementare analisi più mirate nell’ambito del reverse engineering. Anche solamente vedere come poter implementare i diversi algoritmi fornisce uno schema mentale che potrebbe aiutare durante il reverse engineering di file eseguibili più difficili da analizzare, ovvero quelli offuscati.

Escludendo il formato del file, un file eseguibile contiene due tipi di informazioni principali: le istruzioni (cosa la CPU esegue) e i dati (che informazioni la CPU utilizza per eseguire le istruzioni). Per poter ricostruire le informazioni di alto livello, ogni pipeline di analisi implementa diversi passi: possiamo trovare il parsing del file eseguibile, il disassemblaggio delle istruzioni, la data flow analysis, la ricostruzione del grafo del controllo di flusso, l’inferenza dei tipi. Inferire questo tipo di informazioni è molto complesso data la natura dell’analisi statica in cui si affrontano molti problemi indecibili: poter derivare informazioni corrette è una questione difficile e ogni passo della pipeline di analisi influisce sul risultato finale.

Tra i primi problemi spinosi da affrontare con l’implementazione di qualsiasi strumento di reverse engineering, troviamo la questione del disassemblaggio. Dal momento che le istruzioni costituiscono la base per derivare la semantica del binario da analizzare (cosa il programma fa nel concreto), il disassemblaggio risulta essere il primo passo di analisi da implementare e contro cui scontrarsi.

All’interno di questo post, andremo a vedere come iniziare a costruire un semplice disassemblatore per il reverse engineering di un file binario PE che ha come target l’architettura Intel x86/64, basato su Nyxstone. Esploreremo due diverse tecniche di disassemblaggio, chiamate linear sweep e recursive traversal, andremo ad evidenziare alcuni punti deboli di queste tecniche e individueremo alcuni punti solidi con cui poter incominciare il disassemblaggio.

Il formato Portable Executable

Un file eseguibile può essere riassunto in maniera molto semplificata come un file contenente una serie di informazioni per il loader (ovvero il componente che carica in memoria l’esegubile e lo avvia), le istruzioni e i dati. Il formato PE (Portable Executable) è uno tra questi: si tratta di un formato per file eseguibili che principalmente hanno come target piattaforme basate su Windows.

Il formato PE per il formato eseguibile include due tipi di header:

Dopo le intestazioni, troviamo altre strutture dati molto importanti: le data directories e le sezioni. Le data directories sono una serie di strutture che è possibile trovare solamente nel file PE di tipo eseguibile e rappresentano alcune sezioni speciali come l’import API, la sezione delle risorse, la sezione delle risorse di debug, la directory TLS. Queste sezioni sono molto speciali e impartiscono delle direttive precise al caricatore che prepara delle strutture dati per alcune funzionalità, come la chiamata ad API del sistema, per gestire delle eccezioni o per gestire le rilocazioni a tempo d’esecuzione.

Successivamente, abbiamo la tabella dei sezioni: ogni entry contiene l’intestazione della sezione. Ogni intestazione include il nome della sezione, la dimensione virtuale, l’indirizzo virtuale, la dimensione fisica della sezione, caratteristiche e permessi associati ad ogni sezione. Il parsing di ogni file PE si completa con la lettura dei byte per ogni sezione e a seconda del tipo di sezione potrebbe essere necessario un ulteriore parsing (ad esempio per la sezione con le istruzioni potrebbe essere interessante effettuare il disassemblaggio).

Questi paragrafi volevano essere un richiamo molto veloce alle strutture che compongono un file eseguibile, ma per avere una buona documentazione, potrebbe essere utile consultare A dive into the PE file format e la documentazione ufficiale che Microsoft fornisce.

Il disassemblaggio

La CPU riesce ad eseguire il file PE semplicemente saltando da una istruzione ad un’altra ed eseguendole. Le istruzioni sono presenti in formato binario e sono codificate in questo modo perché la CPU e i vari sottocircuiti riescano ad eseguirle semplicemente alzando o abbassando alcuni segnali in uno schema ben preciso.

L’Instruction Set Architecture, ovvero l’insieme delle istruzioni dell’architettura, definisce la relazione tra come una istruzione deve essere codificata in binario e il valore semantico dell’istruzione. La traduzione da codifica binaria ad istruzione leggibile via testo prende il nome di disassemblaggio e consiste nel ricavare delle istruzioni da un file eseguibile. Queste istruzioni sono la base per inferire cosa il programma potrebbe fare a tempo di esecuzione e per i successivi passi di analisi (dataflow analysis, inferenza dei tipi e costruzione dei costrutti del grafo del controllo di flusso).

Ad alto livello non troviamo particolari ostacoli: vogliamo leggere le strutture dati del file in input basandoci sul formato file PE, vogliamo poter estrarre le istruzioni e mostrarle in qualche modo all’analista. Come vedremo, già questo passo di analisi presenta delle sfide relative a: dove sono le istruzioni, come capire cosa è una istruzione, cosa è un dato e come trovarle senza utilizzare metodi probabilistici.

InsPEctor

L’idea è quella di sviluppare uno strumento a riga di comando che dato un file PE possa stampare le istruzioni che probabilmente rappresentano le funzioni eseguite a tempo d’esecuzione. Questo progetto, chiamato per l’occasione InsPEctor, è scritto in Rust e si baserà sul disassemblatore Nyxstone, un wrapper che sfrutta il disassemblatore già implementato all’interno di LLVM. Usare un framework come Nyxstone eviterà di soffermare la discussione su come scrivere un programma che disassembla una singola istruzione di un’architettura CISC (e soprattutto di evitare di scrivere la decodifica delle istruzioni su un’architettura come x86_64). Il nostro obiettivo è quello di trovare in maniera corretta le istruzioni e stamparle a riga di comando; vorremo trovare istruzioni che siano fedeli alla semantica del programma (ovvero vorremo trovare le stesse istruzioni che potenzialmente verranno eseguite a tempo d’esecuzione).

Rust è un ottimo linguaggio di programmazione che consiglio; consente di scrivere software che sono molto veloci e facilmente mantenibili. Rust inoltre mette a disposizione per gli sviluppatori una serie di dettagli che sono di importanza primaria come un semplice sistema per gestire le dipendenze, strumenti di analisi statica che aiutano nella scrittura di codice pulito e un sistema per forzare lo sviluppatore a scrivere la documentazione. Oltre a Rust, andremo ad utilizzare Nyxstone, sviluppato dal team di Emproof ed è un wrapper posizionato sopra LLVM. Tra i vari framework di disassemblaggio, è sembrato quello più semplice da integrare e più solido perché sfrutta i disassemblatori già scritti per le varie architetture di LLVM. Inoltre, nyxstone presenta una interfaccia anche per assemblare del codice, elemento che potrebbe risultare interessante per ulteriori progetti. Presenta i bindings per Rust, C++ e Python e per questo risulta molto semplice da integrare anche su progetti già esistenti o molto grandi.

Dal momento che InsPEctor può essere molto interessante da manipolare, il codice è disponibile su Github e sarà utilizzato in altri articoli per estendere funzionalità o implementare nuove analisi. Ulteriori informazioni su come costruire il progetto e alcuni requisiti di sistema sono disponibili all’interno del README.

Per i nostri esempi, utilizzeremo calc.exe (shasum: 46c7f236b50fb10c1fb684b085c72c985c3c3a77), un file eseguibile che si occupa di eseguire una chiamata alla ShellExecuteW per aprire la vera calcolatrice di Windows, implementata all’interno dei componenti della shell. Questo file non pesa molto (circa 192kb) e risulta essere molto semplice dal punto di vista semantico. Per confrontare la bontà dei nostri metodi, possiamo utilizzare un qualsiasi tipo di disassemblatore commerciale come Binary Ninja o IDA oppure un disassemblatore open-source come Ghidra. Diciamo che il nostro strumento è quasi corretto se riesce ad identificare le stesse istruzioni che un qualsiasi altro disassemblatore riesce ad identificare allo stesso indirizzo.

Tutte le informazioni relative al disassemblaggio e alla costruzione di questi strumenti possono essere applicate, con qualche sottile differenza, anche ad altri tipi di formato file eseguibili, come ELF e Mach-O, e con target di altre architetture (come ARM o PowerPC). Ogni architettura avrà alcune particolarità che bisognerà gestire in fase di implementazione del disassemblaggio, ma grossomodo sarà sempre lo stesso algoritmo.

Lasciamo al lettore la possibilità di implementare in modo autonomo un parser binario; il progetto InsPEctor si avvale della libreria Goblin che si è rivelata solida nella lettura di formati file binari come ELF, MachO e PE.

Riscrivere un parser binario per un formato file come PE è un altro buon esercizio per gli analisti ma esula dallo scopo di questo post. Inoltre, ogni lettore di formati file binari richiede un corpus di testing particolarmente vasto per risolvere alcuni dettagli ambigui del formato file; per velocizzare lo sviluppo, abbiamo optato per l’utilizzo di questa libreria. Oltre alla velocità di lettura di un file eseguibile, Goblin consente di ignorare la questione spinosa della traduzione da indirizzo virtuale assoluto e indirizzo virtuale relativo (per ulteriori informazioni: “Inside PE File Format”).

Di seguito il codice che si è utilizzato per effettuare il parsing:

// Creazione della path da una stringa
let path = Path::new(&path_str);

// Lettura del file in input
let buffer = fs::read(path)?;

// Chiamata a Goblin per il parsing; ci aspettiamo che in input ci sia un file PE.
let pe = match Object::parse(&buffer)? {
    Object::PE(pe) => pe,
    _ => bail!("We do not support any files beside PE"),
};

La variabile pe si troverà valorizzata da un oggetto di tipo Object::PE(pe). A questo punto possiamo iniziare a gestire il file assumendo che per forza sia un file PE. Come esercizio per il lettore si potrebbero implementare controlli più accurati (il file in input esiste? il file in input è un file PE o un oggetto DOS?). Possiamo iniziare con l’analisi delle sezioni per trovare i byte grezzi delle istruzioni.

Trovare le istruzioni

Per trovare le istruzioni all’interno del file eseguibile, possiamo utilizzare diversi metodi e approcci. Procediamo per gradi: sfruttiamo le informazioni date dal formato file per individuare dove è possibile trovare i byte grezzi delle istruzioni. Le informazioni date dal formato file specificano quali zone di memoria devono essere allocate a tempo d’esecuzione e i permessi ad essi associati.

Al fine di identificare le istruzioni, dobbiamo utilizzare una euristica, un metodo che potrebbe non funzionare sempre ma risulta efficace nel capire dove potrebbero essere le istruzioni. L’obiettivo è utilizzare le sezioni ricavate dal formato file, iterare su di esse e trovare le prime sezioni il cui permesso associato è di esecuzione. Le istruzioni in memoria infatti devono risiedere in una sezione di memoria che è possibile eseguire, altrimenti quello che vedremo all’interno dell’esecuzione è un Segmentation fault. Un’ulteriore regola molto semplice che potremo utilizzare è quella di identificare sezioni con il nome .text, tuttavia questo approccio ha dei limiti, specialmente per altri file binari che potrebbero utilizzare .code come nome della sezione o .upx0 e .upx1 per il famoso packer UPX.

Un differente approccio, molto simile alla crittanalisi, è quello di identificare le istruzioni tramite pattern matching (per notizia, gli approcci basati sul machine learning come Dyninst e ByteWeight utilizzano questo tipo di euristica). La maggior parte delle funzioni che utilizza lo stack per l’allocazione delle variabili locali inizia con le famose istruzioni push rbp; mov ebp, esp; (in byte 0x55 0x48 0x89 0xe5). Possiamo sfruttare questa informazione per individuare pattern molto probabili di funzioni che iniziano con quelle istruzioni. Questa euristica però non si rivela efficace dal momento che 0x55 0x48 codificano anche le due lettere ASCII UH. Dal momento che non è possibile individuare con precisione dove sono le istruzioni, questa euristica è di tipo probabilistica, perché in assenza di informazioni sicure non possiamo affermare con certezza quale istruzione è l’inizio di una funzione. Inoltre, risulta essere presente un altro problema: queste istruzioni sono valide per alcuni compilatori e per alcuni tipi di funzioni; per altri tipi di compilatori potrebbero essere validi altri tipi di istruzioni: sorgerebbe il problema di come poter aggiornare questa sorta di “firme”.

Quindi per riassumere qual è una euristica che potrebbe andare bene? La maggior parte dei software che iniziano il disassemblaggio partono da una sezione che sia eseguibile (generalmente la sezione eseguibile ha, a livello di permessi, sia il permesso in lettura che in esecuzione) e sfruttando il valore di un particolare campo è possibile trovare dove sono le istruzioni. Il particolare campo che citiamo è l’entrypoint ovvero l’indirizzo virtuale o offset fisico (a seconda del formato file) dove la CPU deve saltare per iniziare l’esecuzione. Riconosciamo così una affermazione solida che non può essere confutata: l’entrypoint deve puntare ad una istruzione eseguibile.

Identificando la sezione che contiene l’entrypoint, è possibile iniziare a capire quali dati potrebbero essere potenzialmente istruzioni e quali no. All’interno di un progetto come InsPEctor, si è potuto implementare diversi tipi di idee che abbiamo menzionato nei paragrafi precedenti.

La prima idea, la più semplice, è quella di identificare la sezione chiamata .text.

// Prima idea: sezione con il nome ".text"
let text_section = pe
        .sections
        .iter()
        .find(|section| section.name().unwrap() == ".text")
        .ok_or_else(|| anyhow!("No .text section found"))?;

Questa euristica fallisce già con il primo file eseguibile che utilizza una sezione eseguibile con un nome diverso da .text, come ad esempio .code. La seconda idea è quella di identificare allora tutte le sezioni eseguibili. Per identificare una sezione eseguibile, all’interno della struttura che descrive una sezione, è possibile trovare il campo Characteristics che identifica delle flag utilizzate per descrivere i permessi associati ad ogni sezione. Dal momento che siamo interessati a permessi eseguibili possiamo utilizzare IMAGE_SCN_MEM_EXECUTE (0x20000000) e IMAGE_SCN_MEM_READ (0x40000000). Il formato file PE però aggiunge una ulteriore flag che potrebbe venire comodo da controllare: IMAGE_SCN_CNT_CODE, una flag che indica “la sezione contiene codice eseguibile”; possiamo quindi controllare anche questo tipo di flag.

// Seconda idea: trovare la prima sezione eseguibile
let text_section = pe.sections.iter()
.find(|s| {
    s.characteristics & (IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE) as u32
        == (IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE) as u32
})
.ok_or_else(|| anyhow!("No executable section found"))?;

Per questo caso, utilizziamo find per arrestare la ricerca alla prima sezione eseguibile che possiamo trovare. Altrimenti se volessimo cercare tutte le sezioni, possiamo utilizzare .filter() e poi iterare sulle sezioni eseguibili. La terza idea ricalca l’idea della seconda aggiungendo un ulteriore tipo di clausola sulla ricerca. Vorremo la sezione eseguibile che contiene l’entrypoint del programma. L’entrypoint del programma è un valore esportato dalla struttura PE di Goblin quindi è molto semplice da trovare.

// Terza idea: trovare la sezione eseguibile che contiene l'istruzione puntata dall'entrypoint
let entrypoint = pe.entry;

let text_section = pe
            .sections
            .iter()
            .find(|s| {
            	// L'entrypoint deve essere un indirizzo che è presente tra l'inizio della sezione
            	// e la fine della sezione (fine == indirizzo di inizio + dimensione virtuale)
            	entrypoint > s.virtual_address as usize
                    && entrypoint < (s.virtual_address + s.virtual_size) as usize

            	// Controllo che la sezione sia eseguibile
                s.characteristics & (IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE) as u32
                    == (IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_EXECUTE) as u32
            })
            .ok_or_else(|| anyhow!("No executable section found"))?;

Salviamo quindi l’offset fisico di dove inizia e la dimensione della sezione eseguibile. Per iniziare, potremo definire un tipo di dato alias chiamato Address per facilitare la corrispondenza tra u64 e il tipo indirizzo. Tuttavia, non bisogna abusarne per evitare di confondere indirizzi fisici e indirizzi virtuali (all’interno del formato PE, l’indirizzo virtuale e l’indirizzo fisico sono due concetti molto distanti).

let begin_text_section_addr = text_section_offset.pointer_to_raw_data as Address;
let size_text_section = text_section_offset.size_of_raw_data as usize;

La tecnica del linear sweep

La prima tecnica di disassemblaggio che possiamo vedere, oltre ad essere quella più intuitiva, si chiama linear sweep e consiste nel chiamare il disassemblatore per ogni istruzione che troviamo. L’idea è quella di partire da un indirizzo, leggere una serie di byte e chiamare il disassemblatore fornito da Nyxstone per disassemblare una singola istruzione. Per ricavare la successiva istruzione, è necessario saltare i byte della istruzione già disassemblata e leggere da tale indirizzo.

In maniera molto semplice si può riassumere l’algoritmo attraverso questo pseudocodice:

while(curr_address < end_text_section_address):
	instruction_buffer = read(file_buffer, curr_address, curr_address + instr_generic_size)
	instr_disassembled = disassemble(instruction_buffer, current_address)

	print("instruction: ", instr_disassembled)

	current_address += instr_disassembled.size()

Parto inizializzando curr_address all’inizio dei byte grezzi che rappresentano le mie istruzioni e poi fino a quando non raggiungo la fine della sezione, continuo ad effettuare il disassemblaggio dell’istruzione. Per ogni potenziale istruzione, leggiamo una serie di byte dall’indirizzo corrente. instr_generic_size è la dimensione di una istruzione generica all’interno dell’instruction set architecture Intel X86_64. Dal momento che tale ISA definisce una dimensione variabile per istruzione, dobbiamo prendere il numero massimo di byte che una istruzione può occupare (per l’ISA x86_64 è 15 bytes). Il disassemblaggio dell’istruzione avviene tramite una certa funzione disassemble (Nyxstone fornisce due funzioni per ottenere l’istruzione: un metodo che ritorna solamente la stringa e un altro metodo che ritorna la stringa, i byte dell’istruzione e l’indirizzo al quale si è trovata l’istruzione) e si continua avanzando di indirizzo in indirizzo aggiungendo la dimensione dell’istruzione appena assemblata.

In rust, questo si traduce:

// Ultimo indirizzo della sezione esegubile
let end_address = file_read.text_section_addr as u64 + file_read.size_text_section as u64;

// Cursore che rappresenta il buffer o il contenuto del file su cui scorrere
let mut cursor = Cursor::new(
    &file_read.bytes[..file_read.text_section_addr as usize + file_read.size_text_section],
);

// L'indirizzo corrente
let mut curr_address = file_read.text_section_addr;

// Il buffer contenente una possibile istruzione
let mut buffer_instruction = [0; MAXIMUM_SIZE_X86_INSTR as usize];

Ora bisogna implementare il ciclo while; dobbiamo andare dal primo indirizzo fino all’ultimo che potrà essere l’indirizzo finale sottratto a 15 perché dobbiamo riempire l’intero buffer. Non è il metodo ottimo per implementare un disassemblaggio del genere perché è possibile che le ultime istruzioni non vengano considerate. All’interno del ciclo while, ci spostiamo con cursor.seek e leggiamo esattamente 15 bytes. Chiamiamo nyxstone che consente di disassemblare le istruzioni e di specificare quante istruzioni vogliamo disassemblare (il codice di questo metodo non è presente, ma semplicemente chiamiamo disassemble_to_instructions(bytes, address, 1) all’interno di nyxstone).

Una volta fatto questo, stampiamo indirizzo e istruzione. All’interno di nyxstone, l’istruzione viene rappresentata con alcuni campi come l’indirizzo dell’istruzione e il suo significato in testo (è una stringa). Affronteremo in seguito come sbloccare la funzionalità di questa informazione effettuando il parsing del testo. Andiamo avanti aggiungengo all’indirizzo corrente la dimensione dell’istruzione, fino a quando non abbiamo finito.

while curr_address < end_address - MAXIMUM_SIZE_X86_INSTR {
	let mut instruction_size = 1;

	if cursor.seek(SeekFrom::Start(curr_address)).is_ok() {
		cursor.read_exact(&mut buffer_instruction)?;

		let i = nyx_stone_helper
			.disassemble_to_instructions(buffer_instruction.as_slice(), curr_address);
        
        match i {
            Ok(i) => {
                if i.len() != 1 {
                    return Err(anyhow!("We expect only one instruction"));
                }
                println!("Addr: {:x}\tInstr: {}", i[0].address, i[0].assembly);
                instruction_size = i[0].bytes.len() as u64;
            }
            Err(_) => println!(
                        "Problema, non è possibile disassemblare la funzione all'indirizzo 0x{:x}",
                        curr_address
            ),
        }
    }

	curr_address += instruction_size;
}

Utilizzando il file d’esempio e provando ad eseguire inspector con questo file, notiamo subito qualcosa di strano:

Addr: 0x1000	Instr: int3
Addr: 0x1001	Instr: int3
Addr: 0x1002	Instr: int3
Addr: 0x1003	Instr: int3
Addr: 0x1004	Instr: int3
Addr: 0x1005	Instr: int3
Addr: 0x1006	Instr: int3
Addr: 0x1007	Instr: int3
Addr: 0x1008	Instr: jno 19
Addr: 0x100a	Instr: fstp qword ptr [rsi - 114]
Problema, non è possibile disassemblare la funzione all'indirizzo 0x100d
Addr: 0x100e	Instr: cmp eax, 1213415589
Addr: 0x1013	Instr: sub esp, 64

Da questo esempio, possiamo identificare alcuni problemi, come:

Ci accorgiamo che il nostro algoritmo potrebbe non essere il migliore, dal momento che di tutte le istruzioni, quelle reali individuate di altri disassemblatori sono quelle dall’indirizzo 0x1013 in poi. Abbiamo commesso un possibile errore: abbiamo supposto involontariamente che la sezione contenente le istruzioni iniziasse con una funzione, ma se invece iniziasse con un dato?

L’algoritmo di linear sweep purtroppo non sa quali informazioni rappresentino una istruzione o quale un dato. Ipotizziamo per esempio di avere un salto diretto ad una istruzione come jmp 0xa e dopo l’istruzione dei dati. Tramite il linear sweep, l’algoritmo tenterebbe di disassemblare i dati come istruzioni. Se non si riesce a disassemblare un certo buffer, potremo anche tentare di continuare con l’algoritmo, tuttavia potremo perdere una serie di informazioni molto importanti (da dove si ricomincia a disassemblare?).

Per migliorare l’algoritmo, potremo partire da una informazione sicura come l’entrypoint. Ricordiamo che l’istruzione puntata dall’entrypoint deve essere valida perché è la stessa informazione che il loader utilizza per saltare all’esecuzione del programma dopo aver preparato le aree di memoria. Tuttavia, anche dopo questo miglioramento, potremo avere problemi perché non sappiamo dove poter continuare successivamente.

Il problema di distinguere tra istruzioni e dati

All’interno delle euristiche che abbiamo dato per trovare una sezione eseguibile, abbiamo saltato un dettaglio fondamentale. Abbiamo infatti supposto che ogni sezione eseguibile contenesse solo istruzioni. L’assunzione si basa sulla seguente asserzione: presa una sequenza casuale di byte, tale sequenza codifica sempre una istruzione valida. Sfortunatamente, questo fatto non si rivela vero: all’interno delle sezioni eseguibili è possibile trovare anche dati.

Trovare dati all’interno di una sezione adibita alle istruzioni è tutt’altro che raro, specialmente con alcuni moderni compilatori. Un motivo per inserire dati in sola lettura tra le istruzioni è dato da alcuni vincoli architetturali che il compilatore deve adottare per rendere il codice più veloce.

Ad esempio, posizionare una funzione in un certo indirizzo potrebbe rendere più veloce il codice dal momento che il recupero delle istruzioni è implementato attraverso delle cache. Ipotizziamo per semplicità di avere una serie di istruzioni che sono state divise a metà tra due zone di memoria; un compilatore intelligente riconosce che il costo di recuperare 2 zone di memoria è più del costo di recuperare 1 singola zona di memoria. Quindi, si preferisce spostare le istruzioni in modo che si ottimizzi il numero di zone di memoria da recuperare. Il compilatore, spostando una funzione su un’altra “zona” di memoria crea un cosidetto “hole”, ovvero una sorta di cavità che di solito viene riempita con byte 0x00 o 0xcc. Un possibile utilizzo di questa zona inutilizzata potrebbe essere l’allocazione dei dati di sola lettura tra le istruzioni.

Un altro motivo che vedremo anche nella prossima sezione è quella data dalle jump table. Le jump table sono una serie di indirizzi che sono scritti in sequenza e sono utilizzate per poter implementare una serie di salti; principalmente per implementare il costrutto switch, possiamo vedere le jump table come delle tabelle che semplificano di molto il salto da un blocco elementare a potenzialmente più di uno. Tali tabelle rappresentano un’altra fonte di dato per lo strumento di reverse engineering.

Distinguere tra codice e dato è uno dei tanti problemi indecidibili, di cui non abbiamo algoritmi certi per poter identificare correttamente tra ciò che viene utilizzato come codice e ciò che viene utilizzato come dato. La maggior parte degli strumenti di analisi implementa alcune euristiche per poter differenziare tra istruzione e dato. Ad esempio, non riuscire a disassemblare un buffer di byte arbitrari potrebbe essere considerato come una serie di dati.

Inoltre, trovare all’interno di buffer dei byte che specificano indirizzi di memoria risulta essere un buon metodo per distinguere tra dati che rappresentano indirizzi e istruzioni con un certo grado di approssimazione. Non esiste tuttavia una soluzione globale: quello che è possibile derivare è frutto di una serie di euristiche alimentate da alcune informazioni presenti all’interno del formato file esegubile. Per esempio, trovare delle rilocazioni che puntano ad alcuni dati potrebbe indicare che tali dati siano in realtà indirizzi (l’informazione verrebbe poi propagata all’interno del file binario).

La tecnica del recursive traversal

Abbiamo compreso un possibile problema del linear sweep: l’ISA di Intel x86_64 presenta delle istruzioni che variano di dimensione, rendendo il disassemblatore sensibile al contesto. Sensibile al contesto vuol dire che a seconda di quale indirizzo si fa iniziare il processo di disassemblaggio, posso avere differenti istruzioni. Avere differenti istruzioni per una stessa sequenza di byte significa avere una semantica diversa a seconda di come effettuiamo il processo di disassemblaggio, introducendo ambiguità.

Per migliorare l’algoritmo, qualcuno potrebbe suggerire di utilizzare l’informazione “questo buffer non è possibile da disassemblare” come una sorta di discriminante tra istruzioni e dati. Questo tipo di euristica viene effettivamente utilizzata, ma presenta un sottile problema: i buffer che rappresentano istruzioni non equivale a dire che effettivamente tali istruzioni siano potenzialmente eseguibili. Nell’esempio di prima, l’istruzione all’indirizzo 0x100a (fstp qword ptr [rsi - 114]) è una istruzione che sembra essere valida dal momento che gestisce un floating point e un indirizzo di memoria. Ma non è presente all’interno delle istruzioni che i disassemblatori riescono ad estrarre dal binario. Il motivo è dato dal fatto che non esiste alcuna istruzione che riesca a raggiungere l’indirizzo 0x100a.

Vogliamo evitare quindi tutte le situazioni in cui non siamo sicuri che un’istruzione sia effettivamente eseguita. Come vedremo, non potremo essere sicuri al 100% che questo non accada, ma possiamo affinare l’algoritmo di linear sweep, andando ad introdurre un’altra assunzione che tutti gli strumenti di reverse engineering fanno. Ogni istruzione disassemblata viene raggiunta da un’altra istruzione a partire dall’entrypoint.

L’algoritmo di recursive traversal consente di affinare l’algoritmo di disassemblaggio basandosi sull’informazione ricavabile dal controllo di flusso. Per ogni istruzione, valutiamo la sua potenza semantica e se l’istruzione che stiamo analizzando cambia il controllo di flusso, facciamo partire un altro disassemblaggio sul target dell’istruzione (ovvero il primo operando immediato dell’istruzione).

Supponiamo ad esempio di avere una serie di istruzioni come:

140001031	cmp     eax, 0x1
140001034 	jne     0x140001057	; faccio ripartire il disassemblaggio

140001036	test    r8b, r8b
140001039	je      0x140001043 ; faccio ripartire il disassemblaggio

14000103b 	movzx   eax, r8b
14000103f	inc     eax
140001041	jmp     0x140001048 ; faccio ripartire il disassemblaggio

Le informazioni che vengono puntate dalle istruzioni di salto condizionato e incondizionato sono potenzialmente delle istruzioni. L’idea è quella di avviare un ulteriore disassemblaggio agli indirizzi 0x140001057, 0x140001043 e 0x140001048.

Un possibile algoritmo è il seguente (scritto in Python):

instructions_found = []
def disassemble_at(address):
	current_address = address
	continue_disassemble = True
	while(continue_disassemble):
		instruction_buffer = read(file_buffer, curr_address, curr_address + instr_generic_size)
		instr_disassembled = disassemble(instruction_buffer, current_address)

		if instr_disassembled.changes_control_flow():
			# get the first immediate operand
			target = instr_disassembled.get_target() 
			# recursive call to disassemble_at
			disassemble_at(target + instr_disassembled.size())
		
		if instr_disassembled.is_ret() or instr_disassembled.is_jump():
			continue_disassemble = False

		if instr_disassembled is None:
			continue_disassemble = False

		instructions_found.append(instr_disassembled)

		current_address += instr_disassembled.size()

Facciamo partire il disassemblaggio da un certo indirizzo e analizziamo ogni tipo di istruzione che riusciamo a disassemblare con successo. Se l’istruzione che si è appena disassemblato cambia il controllo di flusso (ovvero il suo opcode fa parte dell’insieme jmp, ja, jb, je, jne, call), allora richiamo la funzione disassemble_at in maniera ricorsiva facendo riniziare il disassemblaggio dal target codificato da quella istruzione. Ad esempio, supponendo di avere una istruzione call 0x100019a, il disassemblaggio parte dall’istruzione 0x100019a.

L’algoritmo si arresta quando: non abbiamo ulteriori istruzioni da valutare all’interno di questo disassemblaggio (ovvero quando incontriamo l’istruzione di ritorno), oppure quando incontriamo una jump diretta non condizionale ad un altro ramo. Continuiamo infatti il disassemblaggio solo quando sappiamo che, dopo l’istruzione che cambia il controllo di flusso, è presente una istruzione valida. Questo è vero nei casi in cui l’esecuzione si “sdoppia” nel controllo di flusso con una jump condizionale. Non è vero per tutti quei casi in cui il disassemblaggio si ferma con tale istruzione, ad esempio con la ret, una jump diretta o potenzialmente una chiamata ad una funzione che non ritorna. Altri casi limite sono alcune istruzioni come breakpoint ed interrupt che andrebbero analizzate caso per caso.

L’ipotesi “una istruzione è valida solo se raggiunta” è molto forte da affermare dal momento che un possibile attaccante potrebbe sfruttare l’informazione “ogni operando immediato punta ad una istruzione valida” per inserire dei rami morti dove far continuare il disassemblaggio su altre sezioni. Inoltre, un attaccante potrebbe inserire nei rami morti altre istruzioni che cambiano il controllo di flusso i cui operandi immediati interessano porzioni del file binario già identificati come dati.

L’algoritmo di recursive traversal è molto potente perché disassembla solo le istruzioni che possono essere raggiunte da altre istruzioni. Qualcuno si potrebbe chiedere da dove si inizia a disassemblare: la risposta a questa domanda risulta essere molto semplice perché l’entrypoint è la prima istruzione in assoluto che dovremo sempre disassemblare (poiché sempre valida!).

In Rust, questo algoritmo si traduce nel seguente snippet di codice:

/// Function for calling the recursive disassembling
/// Parameters
/// - cursor: the pointer to a reader that implements seek + read
/// - start_address: the initial address where we want to start the disassemble
/// - global_list:
/// - instructions: an array of instructions
fn recursive_disassemble(
    cursor: &mut Cursor<&[u8]>,
    start_address: Address,
    global_list: &mut Vec<Address>,
    instructions: &mut Vec<Instruction>,
) -> Result<()> {

// Add the instruction address to the one already seen
global_list.push(start_address);

let mut continue_to_disassemble = true;

while continue_to_disassemble {
    if cursor.seek(SeekFrom::Start(current_address)).is_ok() {
        cursor.read_exact(&mut buffer_instruction)?;

        let instruction = nyxstone.disassemble_to_instructions(buffer_instruction.as_slice(),
         current_address);

        match instruction {
            Ok(instruction) if instruction.len() == 1 => {
                // parse the instruction
                let instr_parsed = InstructionParser::parse(
                    instruction[0].assembly.as_str(),
                    instruction[0].address,
                    instruction[0].bytes.len(),
                )?;

                if instr_parsed.change_cfg() {
                    if let Some(target) = instr_parsed.get_target()? {
                        let current_target =
                            target + instr_parsed.instruction_size as Address;
                        if !global_list.contains(&current_target) {
                            // Recursive calling
                            Self::recursive_disassemble(
                                cursor,
                                current_target,
                                global_list,
                                instructions,
                            )?;
                        }
                    }

                    if instr_parsed.is_conditional_jump() {
                        // Recursive calling
                        Self::recursive_disassemble(
                            cursor,
                            current_address + instr_parsed.instruction_size as Address,
                            global_list,
                            instructions,
                        )?;
                    }
                }

                if instr_parsed.is_ret() || instr_parsed.opcode == X86Opcode::Jmp {
                    continue_to_disassemble = false;
                }
                current_address += instr_parsed.instruction_size as Address;
                instructions.push(instr_parsed);
            }
            _ => {
                println!("We have no instructions for address: {:x}", current_address);
                current_address += 1;
                continue_to_disassemble = false;
            }
        }
    } else {
        continue_to_disassemble = false;
    }
}

È chiara la ragione per cui questo algoritmo è chiamato recursive traversal: troviamo la chiamata ricorsiva allo stesso metodo per disassemblare da un certo indirizzo in poi. La funzione infatti consta di 4 parametri importanti: un cursore utilizzato per effettuare l’operazione di lettura dei byte e del seek, l’indirizzo dal quale vogliamo far partire il disassemblaggio, una lista di indirizzi globali già selezionati per riavviare il recursive traversal e un vettore contenente le istruzioni.

[todo: descrivi potenziali problemi]

Tornando al programma d’esempio, possiamo evidenziare subito l’efficacia dell’algoritmo di recursive traversal rispetto all’algoritmo di linear sweep. Troviamo delle istruzioni che sembrano più corrette rispetto all’altro metodo:

1740, "sub rsp, 40"
1744, "call -1509"
1749, "add rsp, 40"
174d, "jmp -658"
14c0, "mov rax, rsp"
14c3, "mov qword ptr [rax + 8], rbx"

Per esempio, all’indirizzo 0x1740 troviamo una istruzione che cambia il registro rsp, lo stack pointer, per allocare 40 byte sullo stack. 2 istruzioni successive e abbiamo un’altra istruzione che cambia il registro rsp per deallocare i 40 byte riservati sullo stack. Questa potrebbe essere già una prima analisi di cosa è una funzione. Come ci aspettiamo infatti, dopo l’ultima istruzione jmp -658, abbiamo un altro indirizzo totalmente diverso, segno che il disassemblaggio salta ad un altro indirizzo.

Per quanto riguarda l’algoritmo in sé, un possibile dubbio risulta essere come prendere il target della istruzione che abbiamo appena disassemblato. Le istruzioni ritornate da Nyxstone sono istruzioni allo stato grezzo che presentano pochi dettagli: il testo dell’istruzione disassemblata, l’indirizzo e la dimensione in byte. La fase successiva è quella di scrivere una grammatica tramite Pest, in grado di effettuare il parsing della stringa ritornata dal framework Nyxstone. Per chi volesse il codice della grammatica è disponibile su Github; sono caldamente accettati miglioramenti.

instruction = { opcode ~ (operands ~ ","?)* ~ operands? }
opcode = { "xor" | "xchg" | ...}
operands = { memory | register | immediate }
register = { "rsp" | "rbp" | ...}

Pest richiede la scrittura del parsing da parte dello sviluppatore: questo passaggio potrebbe non essere banale perché gli operandi delle istruzioni hanno della ricorsione (un operando della memoria potrebbe essere un registro, un operando immediato o una combinazione tra questi). L’obiettivo è quello di ottenere delle strutture che riportano l’opcode e gli operandi dell’istruzione senza interrogare la stringa (mantenuta solo per effetto estetico, ma si può eliminare come campo).

/// Represents a concrete instruction for insPEctor.
#[derive(Clone, Default, Debug)]
pub struct Instruction {
    /// address for the instruction
    pub address: Address,
    /// opcode for the instruction
    pub opcode: X86Opcode,
    /// list of operands
    pub operands: Vec<X86Operand>,
    /// instruction size
    pub instruction_size: usize,
}

Un’alternativa è quella di effettuare un matching molto banale sul tipo di istruzione tramite la chiamata alla funzione instruction_str.contains(&opcode). Questo tipo di approccio è molto semplice ma a lungo andare potrebbe non essere la soluzione migliore, specialmente quando si deve fare matching di opcode come “mov” e non prendere istruzioni come “movzx” o “movabs”. Diventa poi molto complesso estrarre in maniera ordinata gli operandi come registri ed espressioni di memoria: ad esempio per estrarre il registro rax da mov rax, 0 bisogna controllare che non ci sia [rax], espressione di memoria. L’approccio consigliato è quello di sbattere la testa su una grammatica e costruire il parser aiutandovi da Pest. Un’alternativa molto valida alle grammatiche di Pest risulta essere anche PEG: entrambe le librerie consentono di scrivere una grammatica che possa andare a fare un matching preciso sul testo dell’istruzione che deriva da Nyxstone.

Un’ulteriore problema di questo algoritmo risiede nel fatto che ogni istruzione che cambia il controllo di flusso deve per forza avere il primo operando immediato. Questo ovviamente non è vero per ogni istruzione del controllo di flusso; con jump indiretti (ad esempio jne %rax) è possibile codificare la semantica rip = rax, ovvero salta all’indirizzo che è presente all’interno di RAX. Non è raro trovare questo tipo di codice, specialmente in funzioni che utilizzano le jump table dove c’è la necessità di utilizzare un registro per contenere il prossimo indirizzo al quale saltare.

Gli attaccanti potrebbero sfruttare questo problema per trasformare ogni salto di tipo diretto in salti di tipo indiretto, utilizzando una tecnica di offuscamento chiamata Control Flow Indirection. FairPlay e numerosi altri programmi offuscati hanno i salti di tipo indiretto per distruggere questo tipo di disassemblaggio e forzare il disassemblatore ad utilizzare un approccio lineare (diminuendo la precisione delle istruzioni analizzate).

Aggiungere informazione da altre strutture

Abbiamo constatato che per codice che non viene mai chiamato (ad esempio con chiamate indirette o con istruzioni che utilizzano operandi non immediati), il recursive traversal potrebbe non essere un buon algoritmo. Seppur questo dettaglio non ricopra un ruolo importante per il nostro disassemblatore, vogliamo capire se possiamo aumentare la precisione dell’algoritmo.

Possiamo includere all’interno dell’algoritmo alcune posizioni di memoria che sappiamo essere per certo delle istruzioni (nella fattispecie, l’inizio di alcune funzioni). Questo tipo di informazione viene ricavata da alcune strutture del formato file. Ad esempio, iterare sulle funzioni che sono esportate dal file binario, iterare sulla sezione che specifica dove possono esserci eccezioni sono alcune delle tecniche che vengono utilizzate per aggiungere informazioni al contesto.

La potenziale idea è quella di collezionare indirizzi ground-truth e poi avviare il disassemblaggio anche su quegli indirizzi: in questo modo si dovrebbe aumentare la coverage del disassemblatore sul file binario. Come ogni struttura, ciò potrebbe essere utilizzato da parte di alcuni attaccanti per disassemblare codice inutile per aggiungere rumore all’analisi.

def collect_addresses():
	addresses = []

	# parsing della sezione eccezioni
    addresses.extend(parse_exception_section())

	# parsing degli export
	addresses.extend(parse_export_section())

    # parsing delle rilocazioni
	addresses.extend(parse_relocation_section())

    for address in addresses:
		disassemble_at(address)

Per quanto riguarda il target delle istruzioni che non sono operandi immediati, è necessario implementare delle analisi più robuste e profonde per capire il contenuto dei registri. Alcuni algoritmi che sono stati proposti nel corso del tempo includono l’applicazione di analisi data-flow, la Value Set Analysis e l’esecuzione concreta delle istruzioni per calcolare il target di un possibile salto. Ad esempio, trasformare i salti di tipo diretto in salti di tipo indiretto consente di rompere la maggior parte dei disassemblatori. In altri casi, è davvero molto complesso poter inferire dove punta un registro (non abbiamo analisi che consentano di derivare il contenuto della memoria a priori) e se vengono utilizzate le equazioni MBA questo potrebbe risultare un ulteriore ostacolo per un disassemblaggio cristallino.

Una possibile estensione

Scrivere un blog post per sviluppare un disassemblatore utilizzando PE e architettura Intel x86_64 voleva dire scrivere un disassemblatore per una combinazione di formato eseguibile e architettura molto complessa: il formato file PE è molto verboso e contiene alcune strutture solo per retrocompatibilità, mentre le istruzioni Intel sono di dimensione variabile, potrebbero esserci delle istruzioni overlapped e l’architettura CISC non migliora l’analisi. Con questo post siamo riusciti solamente a stampare le singole istruzioni contenute all’interno del binario.

Una pubblicazione che consiglio riguardo questo argomento è Disassembly of Executable Code Revisited in cui gli autori cercano di identificare quale tra le due tecniche sia la migliore, proponendo alcuni benchmark. Tra i problemi che abbiamo potuto citare, specialmente quelli che restano ancora aperti come il Tail Calling e l’inferenza della jump table tramite l’analisi Value-Set, posso suggerire la lettura di un articolo del team di Vector35, sviluppatori di Binary Ninja, dal titolo “Architecture Agnostic Function Detection in Binaries”.

Come possibile estensione per chi vuole cimentarsi nella costruzione di un disassemblatore, si può provare ad applicare gli stessi concetti per altri tipi di file esegubili come ELF o Macho e per altri tipi di architetture. L’idea è sempre la stessa: parsing del file ed esplorazione delle istruzioni in qualsiasi tipo di formato. Il tipo di architettura potrebbe modificare la scelta tra linear sweep e recursive traversal: le istruzioni di alcune architetture hanno una dimensione fissa e si potrebbe essere portati ad implementare direttamente la linear sweep. Seppur nel caso di architetture come ARM potrebbe aver senso, dovremo ritornare sempre a discutere del problema relativa alla differenza tra dati e istruzioni: la scelta della recursive traversal risulta essere un buon metodo per disassemblare correttamente un binario.

Inoltre, come qualcuno potrà già notare, InsPEctor risulta essere molto scarno a livello di funzionalità. Seppur la CPU veda singolarmente ogni singola istruzione, quello che potrebbe interessare agli analisti è un tipo di informazione di alto livello (come le funzioni e il grafo del controllo di flusso). Il prossimo articolo vedrà quindi l’estensione di questo strumento con la costruzione dei blocchi elementari, modificando parte dell’algoritmo di recursive traversal.

Come sempre, se avete critiche, suggerimenti o qualsiasi altro commento, potete scrivere un’e-mail a seekbytes@protonmail.com o su Twitter nicolodev.