Syringe - moja mała strzykawka (kodu)

tech • 1267 słów • 6 minut czytania

Wydobywając kod firmware z kodu procesu Sealogic (SaeLog #4), w jednym z możliwości związanych z hookowaniem transmisji USB, wspomniałem o technice wstrzykiwania kodu do uruchomionego procesu, wykorzystującej popularną metodę ze zdalnym wątkiem (CreateRemoteThread). Sugerując przy tym wykorzystanie dostępnych w sieci injectorów lub napisanie czegoś własnego. Od tego czasu, w wolnych chwilach, próbowałem okiełznać i uporządkować mój kod, jaki używałem do tego typu zabaw. W istocie zrodził się projekt syringe, jako uniwersalnego narzędzia związanego z wstrzykiwaniem kodu i nie tylko… i tak zacząłem pisać stary/nowy kod…

Właśnie wrzuciłem kod na githuba, repozytorium już dawno założyłem, gdy tylko pomyślałem o tym małym narzędziu (czyli gdzieś w końcówce kwietnia, po opublikowaniu raportu z SeaLog #4) Od tego czasu w wolnych chwilach coś dłubię, bazując na starych kodach i jakiś wstawkach. Obecna wersja jest czysto deweloperska, miejscami może przypominać bardziej proof-of-concept, zawiera dużo dziwnego i brzydkiego kodu. Ale z chwilowego braku czasu i blokowania innych rzeczy postanowiłem go opublikować. Mam nadzieję, że sukcesywnie będę go poprawiał i dalej rozwijał, oczywiście w miarę możliwości techniczno-czasowych. Jest wiele potencjalnych nowych komend oraz opcji jakie bym widział w tym narzędziu, ale to wszystko wymaga czasu.

Mimo wersji deweloperskiej, kod jest jak najbardziej użyteczny. Dostępne są 3 proste i podstawowe polecenia, niejako bazowe w tego typu narzędziach:

Available commands:
  code    Inject code into process
  dll     Inject (load) dll into process
  exec    Execute code

Wszystkie bazują na wspomnianej technice z tworzeniem wątku będacego jak entry-point wstrzykiwanego kodu. Metoda jest najbardziej znaną techniką wstrzykiwania kodu lub modułów DLL do innych procesów. Wstrzykiwany kod, po zapisaniu w przestrzeni adresowej danego procesu, traktowany jest jako ciało funkcji nowo tworzonego wątku. Można tego dokonać na własnym procesie (nic ciekawego) lub na dowolnym innym (bardziej ciekawe), systemowe API Windowsa zawiera niezbędne funkcje umożliwiające pracę na zdalnych (remote) procesach i wątkach.

Metoda ta jest szeroko znana i opisana w Internecie, dlatego, aby się nie powtarzać polecam odnośniki do źródeł, gdzie prosto i konkretnie opisano cały mechanizm. Na Infosec Institute w zasobach polecam artykuł “Using CreateRemoteThread for DLL Injection on Windows”, a jako uzupełnienie na blogu Open Security Research wpis autora pt. “Windows DLL Injection Basics”. Są to podstawy, o których warto wiedzieć przed dalszym czytaniem tekstu (niekoniecznie) lub kodu.

W przypadku wstrzykiwania modułu DLL, najprostszym sposobem tej metody, którego również używałem w pierwszych wersjach kodu syringe, było użycie LoadLibrary jako wejścia dla wątku utworzonego przez CreateRemoteThread. Funkcja ta przyjmuje jeden parametr, który jest nazwa biblioteki (modułu) do załadowania, co się dobrze składa, bo funkcje uruchamiane w ramach wątku, mogą przyjąć tylko jeden parametr. Wykorzystanie bezpośrednio tandemu CreateRemoteThread + LoadLibraryA jest najczęściej spotykanym trickiem do wstrzykiwania modułu DLL:

FARPROC LoadLibraryAddress = ::GetProcAddress(::GetModuleHandleA("kernel32.dll"), "LoadLibraryA");

::WriteProcessMemory(proc.get(), mem.get(), dll.c_str(), dll.size() + 1, nullptr);

Handle thread(
	::CreateRemoteThread(proc.get(), nullptr, 0, reinterpret_cast<LPTHREAD_START_ROUTINE>(LoadLibraryAddress), mem.get(), 0, nullptr)
);

::WaitForSingleObject(thread.get(), INFINITE);

DWORD excode = 0;
::GetExitCodeThread(thread.get(), &excode);

std::cout << "Thread finish, exit with code: " << excode << std::endl;

Kodem wyjścia wątku jest wartość zwracana przez funkcję uruchamianą w jego przestrzeni, co w tym przypadku będzie uchwytem do załadowanego modułu lub w przypadku błędu wartość NULL. Kod ten można pobrać poprzez funkcję GetExitCodeThread po zakończeniu działania wątku.

W przypadku błędu nie mamy żadnej informacji dlaczego załadowanie biblioteki się nie powidło. Nie ma możliwości pobrania wartości LastError z wątku już po jego zakończeniu. Choć ciekawe, czy przed zwolnieniem struktur wątkowych nie da się pobrać tej wartości bezpośrednio z TEB-a. Dlatego w kolejnej poprawce zmodyfikowałem mechanizm na bardziej rozbudowany, ale dający niezbędne informacje w przypadku problemów z załadowaniem biblioteki DLL.

Zamiast bezpośrednio uruchamiać LoadLibrary, lepiej będzie wstrzyknąć kod funkcji, która przejmie kontrolę nad ładowaniem modułu i w razie potrzeby pobierze niezbędne dane z wątku/procesu. Kod ten nie będzie niezwykły, prosty call po adresie funkcji do LoadLibrary i GetLastError:

void RemoteLoadDllFunction(LoadDllThreadData* data) {

	data->ModuleHandle = data->LoadLibrary(data->DllName);
	data->LastError = data->GetLastError();

}

Dane przekazywane do funkcji, zaalokowane w przestrzeni adresowej procesu docelowego pośredniczą w komunikacji. Zwierają dane wejściowe określające adresy funkcji i nazwę ładowanego modułu, a zwracane dane wyjściowe uzupełnione w czasie wykonania funkcji - handler załadowanego modułu oraz ewentualny kod błędu:

typedef DWORD (WINAPI *GetLastErrorFunc)(VOID);
typedef HMODULE (WINAPI *LoadLibraryFunc)(LPCSTR lpLibFileName);

struct LoadDllThreadData {

	// input
	LoadLibraryFunc LoadLibrary;
	GetLastErrorFunc GetLastError;
	CHAR* DllName;

	// output
	HMODULE ModuleHandle;
	DWORD LastError;

};

Wszystkie dane powinny znaleźć się w przestrzeni adresowej procesu, więc DllName wskazuje w miejsce za strukturą gdzie znajduje się zawartość stringa. Adresy funkcji pobrano z procesu źródłowego - strzykawki. Obecnie mimo róznych technik ASLR i randomizowania adresów bazowych ładowanych modułów w Windowsie, adresy te są stałe między reboot-ami. Ale trzeba mieć na uwadze, że kiedyś może się to zmienić i zacznie działać w bardziej odpowiedni (bezpieczniejszy) sposób. Wtedy będę musiał pomyśleć o nieco innej implementacji.

Kolejnym ciekawym trickiem jest determinacja rozmiaru kodu funkcji RemoteLoadDllFunction. W wykorzystuje ona fakt, że dla elementów oznaczonych jako static, bez różnych specyficznych opcji kompilacji i linkowania (incremental linking, etc.) funkcje te zostaną umieszczone w takiej kolejnościw kodzie wynikowym, w jakiej znajdują się w kodzie źródłowym.

static void RemoteLoadDllFunction(LoadDllThreadData* data) {
	[...]
}

// This is just a dummy function used to determine size of RemoteLoadDllFunction code
static void RemoteLoadDllFunctionEnd() {
	return;
}

Zatem wystarczy odjąć poszczególne adresy funkcji, aby otrzymać rozmiar kodu wynikowego, jaki należy wstrzyknąć do procesu:

const size_t sizeCode = reinterpret_cast<size_t>(RemoteLoadDllFunctionEnd) - reinterpret_cast<size_t>(RemoteLoadDllFunction);

Zachowanie to może się zmienić, zależne jest od kompilatora i linkera, zatem docelowo chciałbym to kiedyś zamienić na typowy opcode/shellcode przechowywany w tablicy. Jakieś TODO odnosnie tego znajduje się w kodzie.

W przypadku wstrzykiwania shellcodu przez polecenie code, sytuacja wygląda podobnie. Zamiast kodu funkcji stub ładującej dany moduł DLL, w zaalokowane miejsce w przestrzeni procesu zapisywany jest kod podany przez użytkownika, a nastepnie odpalany w nowo utworzonym wątku.

Zastanawiam się, czy zamiast uruchamiania nowego wątku z entry-point na wstrzyknięty kod, dorzucić lepiej kolejnego stuba dla odpalania kodu w try-catch SEH-a:

void RemoteExecFunction(void* code) {

	__try {

		FARPROC func = reinterpret_cast<FARPROC>(code);
		func();

	} __except (EXCEPTION_EXECUTE_HANDLER) {
		//...
	}
}

Może to zapobiec wywaleniu się procesu, gdy wstrzykiwany kod coś za bardzo popsuje. Ale sam nie jestem pewny, czy ma to jakiś większy sens.

Syringe bazuje obecnie tylko na przedstawionej wyżej metodzie opartej na remote-thread, ale oczywiście istnieje jeszcze wiele innych metod wstrzykiwania kodu do procesów. Niektóre wykorzystują całkiem zmyślne mechanizmy. Na pewno będę chciał rozszerzyć funkcjonalność syringe o inne metody injectingu, jak chociażby poprzez manipulacje kontekstem wątków, czy wykorzystanie bezpośrednio funkcji z ntdll. Ale to temat na inny czas.

Pomijając kwestie wstrzykiwania różnych rzeczy, kod projektu jest bardzo elastyczny i w łatwy sposób rozszerzalny. Bez problemu można dodawać nowe polecenia. W ciekawy, dosyć nie oczywisty sposób udało mi się zmusić Boost.Program_options do odpowiedniej współpracy, ale to zasługa kilku podpowiedzi na jakie trafiłem w sieci. Oprócz Boosta nie ma żadnych innych powiązań i zależności z innymi bibliotekami. Kod zgodny z C++11, powinien bez problemu dać się skompilować na innych kompilatorach, developowany na VC2k13.

Oprócz samej funkcjonalności kręcącej się wokół injectowania kodu, mam kilka innych pomysłów na przydatne polecenia i funkcje. Manipulacja pamięci dowolnego procesu, czyli odczyt i zapis danych w przestrzeni procesu przydaje się w wielu sytuacjach, szczególnie bez dostępu do debuggera lub chęci jego uruchamiania. Inną funkcją mogą być typowe informacyjne polecenia, które oprócz listowania informacji o procesach, mogą wyświetlać listę modułów, wątków, uchwytów etc. API jest bogate, a nie zawsze chce się na szybko pisać kawałki kodu, łatwiej uzyć gotowego narzędzia dostępnego pod ręką. Pewnie prędzej czy później pojawi się opcja z patchowaniem, oczywiście zgodna z kodem generowanym przez IDA, ale nie tylko.

Mam nadzieję, że syringe przerodzi się w zbiór prostych, ale i nie tylko, przydatnych i użytecznych narzędzi i poleceń. W wolnych chwilach, których niestety mało, będę starał się rozwijać projekt. Głównie na własny użytek, ale jeśli komuś również się przyda, to będzie to miły akcent.

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/