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)