BELEAAF (CSAW-2019-Quals) writeup con Unicorn

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).

main function

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.


main: ciclo di controllo del nostro input

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.

main uscita dal ciclo

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?


main Corpo del ciclo

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.

main inizializzazione del ciclo
main incremento del counter

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....

transform_input function

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 la cmp);
  • -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/beleaf

Spero vi sia stato utile e di avervi dato qualche spunto di riflessione! Alla prossima!