Shellcode: pierwszy kod - odpalanie Kalkulatora

tech • 1788 słów • 9 minut czytania

Ta notatka jest częścią serii Shellcode: moje eksperymenty. Zapoznaj się z pozostałymi wpisami.

W ostatnich moich zapiskach udało mi się wydobyć adres bazowy modułu kernel32.dll oraz opracować funkcję (kod) do iteracji i szukania niezbędnych adresów funkcji z załadowanego modułu w pamięci. Teraz, gdy mam te niezbędne elementy każdego typowego shellcodu pod Windą, wreszcie nadeszła pora na napisanie jakiegoś bardziej sensownego kawałek kodu. Dla przykładu wybrałem sobie odpalanie standardowego kalkulatora (calc.exe).

Na początek może kilka słów jak wygląda typowy shellcode. W sieci ciężko coś znaleźć sensownego na ten temat. Najczęściej shellcode można podzielić na kilka elementów - prolog (header) i payload. I to w sumie niezależnie od systemu.

Pod Windą można to nieco bardziej uszczegółowić. W prologu najczęściej następuje przygotowanie środowiska niezbędnego do prawidłowego wykonania realnego kodu zawartego w payload. Choć wymagane środowisko silnie zależy od kodu i zamierzonego efektu, to często składa się ono z kilku podobnych i powtarzalnych elementów.

Wyznaczenie delty to nic innego jak poznanie miejsca w pamięci, gdzie wylądował nasz kod - adres bezwględny. Może on być używany jako punkt odniesienia do wyznaczania adresów (offsetów) różnych zmiennych i danych, niekoniecznie tych zawartych w naszym kodzie. To akurat w dzisiejszych moich zabawach jest nieistotne, tak jak i w poprzednich częściach, wszelkie napisy (stringi) wrzucam bezpośrednio na stos. Teoretycznie, póki co, problem ten mnie nie dotyczy, ale pewnie temat wróci przy jakimś exploitowaniu ;)

Kolejnymi etapami w strukturze shellcodu jest lokalizacja w pamięci adresu bazowego modułu kernel32 oraz resolwowanie funkcji API. Te etapy mam już za sobą. Wyznaczenie adresów funkcji z modułów nie zawsze pojawia się w kodzie. Czasami, zależnie od implementacji i sposobu interakcji z systemem, może to następować tuż przed samym jej użyciem. Tak na przykład działa HashAPI (o tym kiedyś). Podobne metody czasami też wykorzystywane są w shellcode-ach.

Mając już ten etap za sobą, wykorzystując kod z poprzednich moich zabaw, pozostaje mi tylko zając się tworzeniem nowego procesu, w którym wystartuje kalkulator.

Tworzenie nowego procesu (uruchomienie aplikacji) w Windowsie można wykonać na wiele różnych sposobów, używając kilku dostępnych funkcji w systemowym API. Jedną z najczęściej używanych funkcji jest znane i używane CreateProcess (oraz pochodne CreateProcessAsUser, CreateProcessWithLogon, CreateProcessWithToken, …):

BOOL WINAPI CreateProcess(
  _In_opt_    LPCTSTR               lpApplicationName,
  _Inout_opt_ LPTSTR                lpCommandLine,
  _In_opt_    LPSECURITY_ATTRIBUTES lpProcessAttributes,
  _In_opt_    LPSECURITY_ATTRIBUTES lpThreadAttributes,
  _In_        BOOL                  bInheritHandles,
  _In_        DWORD                 dwCreationFlags,
  _In_opt_    LPVOID                lpEnvironment,
  _In_opt_    LPCTSTR               lpCurrentDirectory,
  _In_        LPSTARTUPINFO         lpStartupInfo,
  _Out_       LPPROCESS_INFORMATION lpProcessInformation
);

Funkcja ta jest jakoby domyślnym narzędziem służącym do tworzenia nowego procesu w systemie i to ją powinno się w miarę możliwości zawsze używać. Niestety, jak widać wyżej, wymaga znacznej ilości parametrów, ale za to ma spore możliwości i wpływ na tworzony proces. Dla mojego przypadku użycia wymaga to dużo pracy, przygotowań i kodu… a w shellcode-ach dużo lepsze byłoby coś znacznie prostszego i przede wszystkim krótszego ;)

W takich sytuacjach, szczególnie, gdy trzeba odpalić po prostu kolejny proces lub aplikację, idealnym rozwiązaniem wydaje się funkcja WinExec. I to pomimo tego, iż jest oznaczona od wieków jako deprecated i istnieje tylko dla zachowania kompatybilności, to jednak szybko nie zniknie z WinAPI. A jej prototyp jest bardzo krótki:

UINT WINAPI WinExec(
  _In_ LPCSTR lpCmdLine,
  _In_ UINT   uCmdShow
);

Idealnie! Wymaga tylko dwóch parametrów. Teraz uruchomienie kalkulatora sprowadzi się do banalnego:

WinExec("calc.exe", SW_SHOW);

Co poniekąd może być odpowiednikiem takiego wywołania z CreateProcess:

PROCESS_INFORMATION pi;
STARTUPINFO si;

ZeroMemory(&pi, sizeof(pi));
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);

BOOL ret = CreateProcess(
	"C:\\Windows\\system32\\calc.exe",
	NULL,
	NULL,
	NULL,
	FALSE,
	0,
	NULL,
	NULL,
	&si,
	&pi
);

W rzeczywistości WinExec jest prostym wrapperem na CreateProcess z małymi dodatkami. Kod zawarty w pliku kernel32.dll idealnie odpowiada temu powyżej, oczywiście poza obsługą i przekazywaniem flag wyświetlania okna, które jest wymagane w WinExec. Wystarczy zerknąć do kodu binarki, aby się osobiście o tym przekonać ;)

Muszę także wspomnieć, że za pomocą funkcji ShellExecute również da się odpalić naszą aplikację testową. Ale tutaj wygrywa jednak WinExec, więc czas zacząć składać cały kod do kupy.

….

Mój pierwszy shellcode będzie przewidziany do zwykłych procesów 32-bitowych. Dlatego poszukiwania adresu bazowego modułu kernel32 zmapowanego do przestrzeni adresowej procesu mogę oprzeć na kolejności list zawartych w danych loadera (PEB->Ldr). A kod szukanie adresu funkcji w tablicy eksportów tego modułu zaczerpnąć z poprzednich moich zabaw. Po małych modyfikacjach i dostosowaniu, oraz dodaniu call-a do zresolwowanej funkcji WinExec, całość wygląda następująco (składnia nasm-a):

bits 32

	; get kernel32 base
	mov esi, [fs:0x30]			; PEB
	mov esi, [esi + 0x0C]		; PEB->Ldr
	mov esi, [esi + 0x0C]		; 1st module - Ldr.InLoadOrderModuleList.Flink
	mov esi, [esi]				; 2nd module - Module.InLoadOrderLinks.Flink
	mov esi, [esi]				; 3rd module - Module.InLoadOrderLinks.Flink
	mov esi, [esi + 0x18]		; kernel32 base address - Module.DllBase

	; get WinExec address
	mov eax, [esi + 0x3C]		; offset to PE header
	mov ebx, [esi + eax + 0x78]	; IMAGE_EXPORT_DIRECTORY RVA
	add ebx, esi				; VA to export dir
	mov edx, [ebx + 0x20]		; AddressOfNames RVA
	add edx, esi				; VA to ENT
	xor ecx, ecx

next:
	mov eax, [edx]				; RVA to current name
	add eax, esi				; VA to curent name
	add edx, 4					; edx to next name
	inc ecx						; inc name idx
	cmp dword [eax + 0], 0x456e6957		; WinE
	jnz next
	cmp dword [eax + 4], 0x00636578		; xec\0
	jnz next

	dec ecx						; real name idx
	mov eax, [ebx + 0x24]		; AddressOfNameOrdinals RVA
	add eax, esi				; VA to EOT
	movzx ecx, word [eax + ecx * 2]		; func idx
	mov ebx, [ebx + 0x1C]		; AddressOfFunctions RVA
	add ebx, esi				; VA to EAT
	mov edx, [ebx + ecx * 4]	; function RVA
	add edx, esi				; VA to function

	; call WinExec "calc.exe"
	push 0						; null
	push 0x6578652E				; .exe
	push 0x636C6163				; calc
	mov eax, esp				; ptr to "calc.exe"
	push 5						; SW_SHOW
	push eax					; "calc.exe"
	call edx					; WinExec
	add esp, 12					; clean stack

Domyslnie asemblacja nasm-em poleci do prostego binarnego pliku (w kodzie nie zawarto żadnych dodatkowych dyrektyw), ale można jawnie specyfikować format i plik wynikowy:

>nasm -f bin -o shellcode.bin shellcode.asm

Otrzymany po asemblacji kod wykonywalny zajmuje 103 bajty.

Do dalszych eksperymentów, kod ten zapisałem sobie w nieco bardziej dogodnej formie - najlepiej w postaci gołej tablicy bajtów:

unsigned char shellcode[] = {
	0x64, 0x8B, 0x35, 0x30, 0x00, 0x00, 0x00, 0x8B, 0x76, 0x0C,
	0x8B, 0x76, 0x0C, 0x8B, 0x36, 0x8B, 0x36, 0x8B, 0x76, 0x18,
	0x8B, 0x46, 0x3C, 0x8B, 0x5C, 0x06, 0x78, 0x03, 0xDE, 0x8B,
	0x53, 0x20, 0x03, 0xD6, 0x33, 0xC9, 0x8B, 0x02, 0x03, 0xC6,
	0x83, 0xC2, 0x04, 0x41, 0x81, 0x38, 0x57, 0x69, 0x6E, 0x45,
	0x75, 0xF0, 0x81, 0x78, 0x04, 0x78, 0x65, 0x63, 0x00, 0x75,
	0xE7, 0x49, 0x8B, 0x43, 0x24, 0x03, 0xC6, 0x0F, 0xB7, 0x0C,
	0x48, 0x8B, 0x5B, 0x1C, 0x03, 0xDE, 0x8B, 0x14, 0x8B, 0x03,
	0xD6, 0x6A, 0x00, 0x68, 0x2E, 0x65, 0x78, 0x65, 0x68, 0x63,
	0x61, 0x6C, 0x63, 0x8B, 0xC4, 0x6A, 0x05, 0x50, 0xFF, 0xD2,
	0x83, 0xC4, 0x0C
};

Jakoś ten fragment kodu trzeba przetestować. Najprościej to byłoby cały kod źródłowy w postaci mnemoników wpakować do VS jako inline-asm w jakieś gołej (naked) funkcji, oczywiście po dostosowaniu składni to tej łykanej przez asembler z VS-a. Po uruchomieniu takiej aplikacji powinien wystartować nowy proces z windowsowym kalkulatorem. W istocie tak powstawał ten kod, bo najprościej mi się eksperymentowało wprost pod Visual-em.

Kwestia testowania i uruchamiania różnych shellcodow bez zbędnych zabaw z jakimiś testowymi aplikacjami etc. wprost z kodu binarnego jest otwarta. W moim narzędziu syringe jest polecenie exec służące do wykonywania podanego fragmentu kodu binarnego. Ale obecnie przyjmuje ona kod jedynie w postaci parametru w formie stringa \x..\x..\x... Jest to w sumie pseudo-oficjalny format zapisu takich kodów, w takiej postaci są najczęściej rozpowszechniane. Mimo to opcja podania pliku binarnego z kodem do wykonania wydaje się sensowna i nieco by pomogła właśnie w takich sytuacjach. Niby zacząłem coś w tym kierunku robić… ale jak to zawsze bywa…

A tymczasem trzeba sobie jakoś pomóc i pokusić się o szybką testową aplikację, gdzie można będzie wpakować do testów shellcode w postaci tablicy bajtów lub tablicy napisowej. Tak zapisany kod można zrzutować po chamsku na wskaźnik na funkcję i wykonać lub do niego skoczyć. W wielu miejscach w sieci można znaleźć różne odpowiedniki takiego prostego popularnego kawałka kodu, służącego do tego celu:

unsigned char shellcode[] = {
	0x...
};

int main(int argc, char* argv[]) {

	((void(*)(void))(void*)shellcode)();

}

Niby działa, ale należy pamiętać, że zawartość code wyląduje w sekcji z danymi/stałymi (readonly) bez atrybutu wykonywalności. I w najlepszym wypadku dostaniemy crasha, zamiast spodziewanych efektów. Więc na starcie można się spodziewać typowego AV-a, coś pokroju:

Exception thrown at 0xAABBCCDD in main.exe: 0xC0000005:
  Access violation executing location 0xAABBCCDD.

Tutaj trzeba albo ręcznie zmienić odpowiednie atrybuty fragmentu pamięci do której zmapowano zawartość code poprzez VirtualProtect. Albo też za pomocą odpowiednich narzędzi (dyrektywy, specyfikatory, kompilatora i linkera) stworzyć nowy segment (sekcję) i wymusić aby w nim wylądowała zawartość tablicy code. Ten nowy segment danych powinien posiadać atrybut wykonywalności albo go ostatecznie zmergowac ze standardowym .text, gdzie domyślnie znajduje się kod wykonywalny programu.

int main(int argc, char* argv[]) {

	DWORD prev;
	if (!VirtualProtect(shellcode, sizeof(shellcode), PAGE_EXECUTE_READWRITE, &prev)) {
		printf("VirtualProtect error: %d\n", GetLastError());
		return 1;
	}

	((void(*)(void))(void*)shellcode)();

	return 0;
}

W syringe implantacja exec działa tak, że alokowany jest bufor na nowy kod z odpowiednimi atrybutami, a jego uchwyt (adres) podawany jest do nowego utworzonego wątku. Tak, że całość działa w osobnym wątku, niezależnie od głównego kodu programu. Po szczegóły zapraszam do kodu źródłowego.

Jeśli wstrzykiwany uruchamiany kod binarny nie posiada żadnego poprawnego wyjścia lub zakończenia procesu, pewne jest, że po jego wykonaniu procesor nadal będzie próbował przetwarzać kolejne napotykane instrukcje i finalnie wywali wielkiego crasha. Dlatego do testów dodałem sobie na koniec shellcode-u instrukcję return (opcode C3), która ładnie posprząta i zwróci przepływ kodu do miejsca wywołania (w przypadku chamskiego traktowania kodu jako funkcji) lub zakończenia wątku (gdy uruchamiamy kod jako funkcje w nowym wątku).

Ostatecznie można na koniec zapisać shellcode-a w postaci typowego stringa, jak to się przyjęło w zwyczaju takich wstawek binarnych:

char shellcode[] = \
	"\x64\x8B\x35\x30\x00\x00\x00\x8B\x76\x0C\x8B\x76\x0C\x8B\x36\x8B"
	"\x36\x8B\x76\x18\x8B\x46\x3C\x8B\x5C\x06\x78\x03\xDE\x8B\x53\x20"
	"\x03\xD6\x33\xC9\x8B\x02\x03\xC6\x83\xC2\x04\x41\x81\x38\x57\x69"
	"\x6E\x45\x75\xF0\x81\x78\x04\x78\x65\x63\x00\x75\xE7\x49\x8B\x43"
	"\x24\x03\xC6\x0F\xB7\x0C\x48\x8B\x5B\x1C\x03\xDE\x8B\x14\x8B\x03"
	"\xD6\x6A\x00\x68\x2E\x65\x78\x65\x68\x63\x61\x6C\x63\x8B\xC4\x6A"
	"\x05\x50\xFF\xD2\x83\xC4\x0C";

Taki format idealnie nadaje się do wstrzykiwania, szczególnie w postaci różnych argumentów, czy innych stringów przekazywanych do atakowanego programu lub procesu. Jeśli kod ten będzie wykorzystany w takim celu to pojawia się pewien problem. Mianowicie w kodzie znajdują się znaki zerowe (null), więc jako string kod ten zostanie obcięty potraktowany jako ciąg znaków do pierwszego napotkanego znaku '\0', zgodnie z przyjętymi normami języka C. W ostateczności nasz kod nie wykona się poprawnie… Aby temu przeciwdziałać, często tworzy się takie kody, aby nie zawierały w wynikowym kodzie binarnym opcodów z zerami. Są to tak zwane null-byte-free shellcode i być może tym tematem zajmę się przy okazji kolejnych eksperymentów.

A teraz mogę spróbowałem odpalić ten mój pierwszy kod przez syringe:

>syringe exec "\x...\xC3"

Kod wykonał się prawidłowo, w efekcie czego odpaliła się instancja kalkulatora.

No i udało się! Mój pierwszy shellcode działa dobrze, może dlatego, że żadnej magii tutaj nie było. Ale teraz mogę przejść do kolejnych eksperymentów, ale to już nastepnym razem…

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/