BELEAAF (CSAW-2019-Quals) writeup con Unicorn
Unicorn is a lightweight multi-platform, multi-architecture CPU emulator framework e fa parte della "Reversing Trilogy" insieme a Capstone-engine e Keystone-engine. Nello specifico, Unicorn ci permette di emulare del codice for fun and profit.
Unicorn Framework
Unicorn framework è un CPU Emulator: ci permette di emulare codice nativo per varie architetture (Arm, Arm64, M68K, Mips, Sparc, X86, X86_64) permettendoci di studiare lo stato di emulazione (per esempio i registri) ad ogni istruzione.
Ci sono vari bindings per i più noti linguaggi (in particolar modo C/C++ e Python) ma nel seguito del post useremo Python per comodita' ed immediatezza.
Vi rimando al sito ufficiale per gli esempi di base e l'installazione.
Challenge BELEAAF - CSAW-2019-Quals
Per vedere in azione unicorn e la semplicita' con la quale ci permette di risolvere determinate challenge, andremo a risolvere la CTF beleaaf
della CSAW-2019-Quals (che potete scaricare da qui).
Iniziamo la CTF con la solita analisi preliminare del binario.
$ file beleaf
beleaf: ELF 64-bit LSB pie executable, x86-64,
version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 3.2.0,
BuildID[sha1]=6d305eed7c9bebbaa60b67403a6c6f2b36de3ca4,
stripped
$ ./beleaf
Enter the flag
>>> aaaa
Incorrect!
Come possiamo vedere, ci viene richiesta una password. Risolveremo la challenge usando unicorn
ma ci serve sapere almeno cosa emulare.
Apriamo, quindi, il binario con il nostro disassembler preferito (io userò Binary Ninja).
Nella figura precedente possiamo vedere l'assembly del primo blocco della funzione main
. Vengono eseguite le seguenti azioni:
- Lettura della password da
stdin
(evidenziato in giallo); - calcolo della lunghezza (evidenziato in grigio);
- confronto tra la lunghezza del nostro input e
32
(evidenziato in rosso).
Quindi se la lunghezza della password fosse inferiore o uguale a 32 caratteri, il programma uscira' (non mostrato in figura). In caso contrario, verrà effettuata una jump
all'interno del ciclo che ho riportato nella figura seguente.
La password, quindi, sarà lunga almeno 33 caratteri.
Nella figura precedente, possiamo notare facilmente i vari blocchi che compongono il ciclo. Inizialmente, viene assegnato 0
ad una variabile (rinominata counter
); dopo viene controllata la lunghezza e, infine, troviamo il corpo del ciclo. L'ultima istruzione incrementa la variabile counter
di 1.
Il grafo del ciclo ci evidenzia anche un'altra informazione molto importante: questo ciclo ha due punti in cui può interrompersi:
- quanto
counter
supera la lunghezza della nostra stringa (in testa al ciclo); - al termine del corpo del ciclo.
Vediamo piu' nel dettaglio questa seconda possibilità d'uscita.
Il binario verifica la quadword
salvata in var_a0_1
(o rbp-x098
) con il registro rax
: nel caso fossero differenti, il programma stamperebbe "Incorrect!" e richiamerebbe exit
.
Per risolvere la CTF, quindi, ci serve far combaciare rax
con il valore salvato. Ma cosa c'è nel registro e in quella variabile?
Ed ecco il corpo del ciclo (probabilmente il vostro sarà differente perché ho già rinominato le variabili e il nome della funzione). Questo codice è abbastanza facile da capire: si prende il byte del carattere inserito (per esempio: 'a' --> 0x61) e si passa come argomento a transform_input
.
Il valore restituito dalla funzione viene salvato in var_a0_1
. Viene poi preso un valore da un'area di memoria (che ho rinominato encrypt_data
) e salvato in rax
. Infine, viene effettuata la compare tra i due.
Completiamo l'analisi del ciclo vedendo i codici di testa e coda.
Ora che abbiamo visto tutto il ciclo, possiamo tradurlo nel seguente pseudo-codice:
int counter;
char a;
for(counter=0;counter<strlen(input_data);counter++){
a=input_data[counter];
if(encrypt_data[counter] != (long) transform_input(a)){
puts("Incorrect!");
exit(1);
}
}
In altre parole, il software non fa altro che scorrere la stringa passata in input, richiamare la funzione transform_input
e confrontare il valore restituito con dei encrypted data
.
Dobbiamo, quindi, analizzare la funzione transform_input
.
Traditional way
Ok! Iniziamo, allora alle prime righe c'e' il preambolo....
Different way
Sebbene la funzione non sia molto complessa vogliamo provare una tecnica differente. Inoltre, la CTF ha una vulnerabilità (non so se voluta o meno) che permette di risolverla tramite brute-force.
Come detto precedentemente, la password sarà lunga almeno 33 caratteri ma fare un attacco brute-force su 33 caratteri diventerebbe lungo.
Ma se potessimo, invece, testare un carattere alla volta invece che 33? L'attacco risulterebbe praticamente immediato in quanto si ridurrebbero drasticamente le chiavi da testare.
Risolveremo questa CTF in molto meno tempo usando unicorn
e gef
.
GEF
Perché gef
? Perché trovo che sia un ottimo wrapper di gdb
. Inoltre, é un unico file python così da evitare un po' di problemi di dipendenze.
A cosa ci serve il debugger? Per vedere i risultati della funzione transform_input
! Iniziamo inserendo un breakpoint a 0x00000950
(alla chiamata di transform_input
).
$ gdb beleaf
gef➤ pie breakpoint *0x00000950
gef➤ pie run
(Note: per chi non usasse molto GEF
, pie
permette di inserire breakpoint in binari, appunto, PIE. Questo permette di inserire gli indirizzi direttamente dai disassembler e gef
si occuperà della rilocazione usando gli indirizzi reali a binario avviato).
Ricordiamoci di inserire una stringa lunga 33 caratteri per poter entrare correttamente nel ciclo.
Enter the flag
>>> aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Breakpoint 1, 0x0000555555554950 in ?? ()
[+] base address 0x555555554000
────────────────────────registers ────
$rax : 0x61
$rbx : 0x00005555555549e0 → push r15
$rcx : 0x10
$rdx : 0x00007fffffffe0b0 → "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
$rsp : 0x00007fffffffe080 → 0x00007fffffffe238 → 0x00007fffffffe5ed → "/home/andrea/Documents/CTF/CTF/CSAW-CTF-2019-Quals[...]"
$rbp : 0x00007fffffffe140 → 0x0000000000000000
$rsi : 0xa
$rdi : 0x61
$rip : 0x0000555555554950 → call 0x5555555547fa
$r8 : 0xffffffffffffff80
$r9 : 0x40
$r10 : 0x22
$r11 : 0x246
$r12 : 0x00005555555546f0 → xor ebp, ebp
$r13 : 0x00007fffffffe230 → 0x0000000000000001
$r14 : 0x0
$r15 : 0x0
$eflags: [zero carry parity adjust sign trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0033 $ss: 0x002b $ds: 0x0000 $es: 0x0000 $fs: 0x0000 $gs: 0x0000
────────────────────────stack ────
0x00007fffffffe080│+0x0000: 0x00007fffffffe238 → 0x00007fffffffe5ed → "/home/andrea/Documents/CTF/CTF/CSAW-CTF-2019-Quals[...]" ← $rsp
0x00007fffffffe088│+0x0008: 0x0000000100000100
0x00007fffffffe090│+0x0010: 0x0000000000000000
0x00007fffffffe098│+0x0018: 0x0000000000000000
0x00007fffffffe0a0│+0x0020: 0x0000000000000022 ("""?)
0x00007fffffffe0a8│+0x0028: 0x0000000000000000
0x00007fffffffe0b0│+0x0030: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ← $rdx
0x00007fffffffe0b8│+0x0038: "aaaaaaaaaaaaaaaaaaaaaaaaaa"
────────────────────────code:x86:64 ────
0x555555554948 movzx eax, BYTE PTR [rax]
0x55555555494b movsx eax, al
0x55555555494e mov edi, eax
→ 0x555555554950 call 0x5555555547fa
↳ 0x5555555547fa push rbp
0x5555555547fb mov rbp, rsp
0x5555555547fe mov eax, edi
0x555555554800 mov BYTE PTR [rbp-0x14], al
0x555555554803 mov QWORD PTR [rbp-0x8], 0x0
0x55555555480b jmp 0x555555554890
────────────────────────arguments (guessed) ────
0x5555555547fa (
$rdi = 0x0000000000000061,
$rsi = 0x000000000000000a,
$rdx = 0x00007fffffffe0b0 → "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
)
────────────────────────threads ────
[#0] Id 1, Name: "beleaf", stopped 0x555555554950 in ?? (), reason: BREAKPOINT
────────────────────────trace ────
[#0] 0x555555554950 → call 0x5555555547fa
[#1] 0x7ffff7df6023 → __libc_start_main()
[#2] 0x55555555471a → hlt
────────────────────────────────
gef➤
Perfetto. Siamo entrati nel ciclo e stiamo per richiamare la funzione.
A questo punto molte possibilità si aprono davanti a noi:
- usare
gdb
per vedere il risultato della funzione così da vedere se è quello atteso; - richiamare la funzione più volte e cercare il modello matematico;
- usare DBI di varia natura (Frida per citarne uno) e instrumentare a runtime la funzione;
- fare patching del binario;
- emulare la funzione con vari valori fino ad ottenere quello cercato;
- ecc...
Useremo unicorn
per emulare la funzione transorm_input
ed ottenere così la flag.
Creare tutte le aree di memoria per unicorn
risulta molto macchinoso, ma gef
permette di creare tutto "l'ambiente" in maniera automatica tramite la funzione emulate
.
gef➤ emulate -n 1
emulated code................
.....
.........
================ Final registers ======
$cs = 0x000033 $ds = 0x000000
$eflags = 0x000246 $es = 0x000000
$fs = 0x000000 $gs = 0x000000
$r10 = 0x000022 $r11 = 0x000246
$r12 = 0x5555555546f0 $r13 = 0x7fffffffe230
$r14 = 0x000000 $r15 = 0x000000
$r8 = 0xffffffffffffff80 $r9 = 0x000040
$rax = 0x000011 $rbp = 0x7fffffffe140
$rbx = 0x5555555549e0 $rcx = 0x000044
$rdi = 0x000061 $rdx = 0x000061
$rip = 0x555555554955 $rsi = 0x00000a
$rsp = 0x7fffffffe080 $ss = 0x00002b
Abbiamo emulato la funzione e abbiamo il suo valore di ritorno ($rax
). Ora sappiamo che a
equivale ad 0x11
.
Ora possiamo creare uno script che effetti brute-force della flag in maniera automatica.
Noi possiamo emulare codice fin dove vogliamo, quindi perché non arrivare alla compare
? A quel punto di esecuzione, avremo in memoria il valore calcolato dalla funzione ed il valore atteso potendone verificare l'uguaglianza.
Ora useremo ancora una volta gef
per avere uno script Python modificabile.
gef➤ emulate -n 6 -s -o /tmp/csaw.py
[+] Unicorn script generated as '/tmp/csaw.py'
gef➤
Le opzioni passate sono:
-n 6
ci permette di eseguire 6 istruzioni (per raggiungere lacmp
);-s
non esegue lo script (tanto non serve eseguirlo dentro gdb);-o /tmp/csaw.py
scrive lo script nel file indicato come argomento.
Abbiamo, ora, uno script Python che ci permette di emulare quel pezzo di codice in autonomia (fuori gdb
).
Vediamo ora lo script (non riporto tutto il codice per una maggiore leggibilità).
uc = reset()
emulate(uc, 0x555555554950, 0x55555555497d)
Questo script richiama solo le funzioni reset
ed emulate
.
def emulate(emu, start_addr, end_addr):
print("========================= Initial registers =========================")
print_regs(emu, registers)
try:
print("========================= Starting emulation =========================")
emu.emu_start(start_addr, end_addr)
except Exception as e:
emu.emu_stop()
print("========================= Emulation failed =========================")
print("[!] Error: {}".format(e))
print("========================= Final registers =========================")
print_regs(emu, registers)
return
emulate
si occupa solo di far partire l'emulazione.
reset
e' molto piu' interessante, invece:
def reset():
emu = unicorn.Uc(unicorn.UC_ARCH_X86, unicorn.UC_MODE_64 + unicorn.UC_MODE_LITTLE_ENDIAN)
emu.mem_map(SEGMENT_FS_ADDR-0x1000, 0x3000)
set_fs(emu, SEGMENT_FS_ADDR)
set_gs(emu, SEGMENT_GS_ADDR)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RAX, 0x61)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RBX, 0x5555555549e0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RCX, 0x10)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RDX, 0x7fffffffe0b0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RSP, 0x7fffffffe080)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RBP, 0x7fffffffe140)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RSI, 0xa)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RDI, 0x61)
emu.reg_write(unicorn.x86_const.UC_X86_REG_RIP, 0x555555554950)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R8, 0xffffffffffffff80)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R9, 0x40)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R10, 0x22)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R11, 0x246)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R12, 0x5555555546f0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R13, 0x7fffffffe230)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R14, 0x0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_R15, 0x0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_EFLAGS, 0x202)
emu.reg_write(unicorn.x86_const.UC_X86_REG_CS, 0x33)
emu.reg_write(unicorn.x86_const.UC_X86_REG_SS, 0x2b)
emu.reg_write(unicorn.x86_const.UC_X86_REG_DS, 0x0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_ES, 0x0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_FS, 0x0)
emu.reg_write(unicorn.x86_const.UC_X86_REG_GS, 0x0)
#Mapping /home/andrea/Documents/CTF/CTF/CSAW-CTF-2019-Quals/beleaf: 0x555555554000-0x555555555000
emu.mem_map(0x555555554000, 0x1000, 0o5)emu.mem_write(0x555555554000, open('/tmp/gef-beleaf-0x555555554000.raw', 'rb').read())
#Mapping /home/andrea/Documents/CTF/CTF/CSAW-CTF-2019-Quals/beleaf: 0x555555754000-0x555555755000
emu.mem_map(0x555555754000, 0x1000, 0o1)emu.mem_write(0x555555754000, open('/tmp/gef-beleaf-0x555555754000.raw', 'rb').read())
#Mapping /home/andrea/Documents/CTF/CTF/CSAW-CTF-2019-Quals/beleaf: 0x555555755000-0x555555756000
emu.mem_map(0x555555755000, 0x1000, 0o3)emu.mem_write(0x555555755000, open('/tmp/gef-beleaf-0x555555755000.raw', 'rb').read())
#Mapping [heap]: 0x555555756000-0x555555777000
emu.mem_map(0x555555756000, 0x21000, 0o3)emu.mem_write(0x555555756000, open('/tmp/gef-beleaf-0x555555756000.raw', 'rb').read())
Come possiamo vedere, questa funzione inizializza i registri e le aree di memoria dell'emulatore.
Unicorn
Ora possiamo modificare lo script per automatizzare il brute-force della password.
Partiamo dalla funzione reset
. Possiamo modificarla facendo in modo che il valore inserito in rdi
(il parametro passato alla funzione transform_input
) sia variabile e non hardcoded.
def reset(char):
......
emu.reg_write(unicorn.x86_const.UC_X86_REG_RDI, char)
......
Con questa modifica abbiamo emulato il nostro input variabile.
Ora modifichiamo il "finto" main
:
char = 0x20
while char < 0x7f:
uc = reset(char)
# Ovviamente voi avrete altri indirizzi
if(emulate(uc, 0x555555554950, 0x55555555497d)):
print("Char "+chr(char)+" is correct!")
break
char += 1
Questo codice emulerà tutti i caratteri stampabili. Avremo, quindi, tutti i valori della funzione relativi ai vari caratteri.
Ma come capire, ora, qual è quello corretto? Potremmo leggere il registro eflag
oppure direttamente la memoria. Io ho scelto la seconda strada.
# Ho eliminato le varie print in quanto sono inutili.
def emulate(emu, start_addr, end_addr):
try:
emu.emu_start(start_addr, end_addr)
except Exception as e:
emu.emu_stop()
return False
address = emu.reg_read(registers['$rbp']) - 0x98;
to_find = emu.reg_read(registers["$rax"])
computed = struct.unpack('<q',emu.mem_read(address, 8))[0]
#print(hex(computed) + ' == '+hex(to_find)+' ?')
if computed == to_find:
return True
return False
Al termine dell'emulazione leggiamo il registro rax
dove ci sarà il nostro target. Mentre all'indirizzo rbp-0x98
ci sarà il valore calcolato: nel caso questi due valori siano uguali avremo trovato il nostro carattere.
Ora basterà ripetere questo processo per tutti i caratteri (in questo caso stiamo solo cercando il primo).
Come emulare questo "spostamento"? Dobbiamo essere noi a fare l'incremento del ciclo:
Bastera' quindi aggiungere un nuovo paramentro a reset
dove aumenteremo il valore counter
(è, effettivamente, solo un valore salvato in memoria).
def reset(char, counter):
.......
emu.reg_write(unicorn.x86_const.UC_X86_REG_RDI, char)
.......
# Il valore hardcoded e' stato copiato dal valore scritto in $RBP
emu.mem_write(0x7fffffffe110-0xa8, counter)
return emu
.......
pos = 0
char = 0x20
while char < 0x7f:
uc = reset(char, struct.pack('<I',pos))
# Ovviamente voi avrete altri indirizzi
if(emulate(uc, 0x555555554950, 0x55555555497d)):
print("Char "+chr(char)+" is correct!")
char = 0x20
pos += 1
char += 1
.......
Eseguite e la flag apparirà davanti ai vostri occhi! Piccolo suggerimento: la prima lettera sarà f
.
Conclusioni
In questo post abbiamo visto come usare unicorn
(con l'aiuto di gef
) per poter ridurre drasticamente la complessità di un attacco brute-force.
La vulnerabilità usata, però, porta a tantissimi scenari differenti. Per esempio, immaginate un binario che testi la seriale tramite un ciclo simile a questo. Oppure, spostandoci sull'elettronica, possiamo trovare gli attacchi di power analysis: in questo caso potremmo avere un aumento di "potenza consumata" ogni volta che il carattere sarà corretto. Oppure possiamo "misurare" le differenze "temporali" di esecuzione in quanto se il primo carattere risulterà sbagliato, il tempo di esecuzione del binario sarà inferiore.
Al seguente link potrete trovare il codice completo dell'exploit.
https://github.com/c3r34lk1ll3r/CTF/tree/master/CSAW-CTF-2019-Quals/beleafSpero vi sia stato utile e di avervi dato qualche spunto di riflessione! Alla prossima!