Blood2: Analiza cracka

tech • 1876 słów • 9 minut czytania

Kolejna część dotycząca mojej starej ulubionej (ostatnio) gry, którą potraktowałem jako narzędzie analizy i zabawy w reverse enginering. Jak wspomniałem w poprzedniej (pierwszej) części, miałem problem ze znalezieniem odpowiedniego programu neutralizującego wymóg posiadania płyty CD. A wszystko na co trafiłem było jednosegmentowymi aplikacjami DOS-a. A jak wiadomo Windowsy 64-bitowe nie posiadają już subsystemu do odpalania 16-bitowych programów. Mimo, iż 64-bitowe procesory w trybie “long mode” jako tako dałyby radę z takim kodem, o ile nie wymagałyby wirtualnego trybu chronionego. Tak, czy inaczej, zostałem zmuszony do zabawy w crackera, co w ostatniej notce przedstawiłem. Obiecałem również, że przyjrzę się bliżej temu “comowemu” crackowi z bliska.

16-bitów z sieci

Po diasemblingu, kod programu okazał się bardzo prosty, typowe operacje na plikach, korzystając z funkcji DOS - poprzez przerwanie 21h. Nic odkrywczego i ciekawe tutaj nie znalazłem. Aczkolwiek pewne fragmenty mnie zaciekawiły, bo albo były błędne, albo ja coś do końca nie kontaktowałem za dobrze, kiedy to analizowałem.

W dalszej części, w przedstawianym kodzie pomijam wszelkie nieistotne dla nas informacje i fragmenty kodu, do których mogę zaliczyć informacje i komunikaty identyfikujące autora, grupę i inne zbyteczne. Kod wzbogaciłem o komentarze, opisujące niektóre instrukcje. Część etykiet i nazw zamieniłem na bardziej czytelne, odpowiednie dla danych fragmentów, co powinno ułatwić czytanie i zrozumienie tegoż, nazbyt prostego kodu.

Początek trywialny, otwieramy plik wykonywalny gry. Jeśli operacja się powiedzie to przechodzimy dalej. W przypadku błędu, wyświetlamy odpowiedni komunikat i zamykamy program z kodem błędu 1.

seg000:014A		mov ax, 3D02h				; otwarcie pliku dyskowego w trybie rw
seg000:014D		mov dx, 152h				; o nazwie spod DS:DX - "blood2.exe"
seg000:0150		jmp short 1015Dh
seg000:0152		db 'blood2.exe',0
seg000:015D		int 21h						; (1)
seg000:015D
seg000:015F		jnb short FileOpen			; jak sie powiedzie to skok
seg000:015F
seg000:0161		mov ah, 9					; wyswietlenie komunikatu na ekran
seg000:0163		mov dx, 168h				; string musi byc zakonczony "$"
seg000:0166		jmp short 1018Eh
seg000:0168		db 'blood2.exe not found or is read-only!$'
seg000:018E		int 21h
seg000:018E
seg000:0190		mov ax, 4C01h				; zakonczenie procesu z kodem wyjscia 1
seg000:0193		int 21h

Miksowanie kodu programu i danych w plikach COM jest rzeczą najzwyklejszą, choć czyta się to trochę dziwnie, bo pełno skoków pomijających stringi.

Gdy plik zostanie już poprawnie otwarty, wykonywane jest pobranie jego rozmiaru. Potrzebne jest to do walidacji. Rozmiar pobierany jest za pomocą typowej sztuczki. Polega ona na przesunięciu wskaźnika pozycji w pliku na jego koniec i odczytania bieżącej pozycji zwracanej przez funkcję po poprawnym wykonaniu operacji.

seg000:0195		push ax
seg000:0196		push cs
seg000:0197		pop  ds
seg000:0198		jmp short 101A2h
seg000:0198
seg000:019A FileSizeL dw 0B800h				; wymagany rozmiar pliku
seg000:019C FileSizeH dw 19h				; 1 685 504 bajtow
seg000:019E CurPosH   dw 0					; zmienne tymczasowe do operacji
seg000:01A0 CurPosL   dw 0					; na pozycji pliku
seg000:01A2
seg000:01A2		pop  bx						; eee?
seg000:01A3		push bx
seg000:01A3
seg000:01A4		mov ax, 4201h				; ustawienie wskaznika pliku wzgledem biezacej pozycji
seg000:01A7		mov cx, CurPosH				; CurPos = 0, czyli na poczatek pliku
seg000:01AB		mov dx, CurPosL
seg000:01AF		int 21h
seg000:01AF
seg000:01B1		mov CurPosH, dx				; zapamietanie biezacej pozycji
seg000:01B5		mov CurPosL, ax				; wzgledem poczatku pliku [DX:AX]
seg000:01B5
seg000:01B8		mov ax, 4202h				; ustawienie wskaznika pliku wzglededm konca
seg000:01BB		xor cx, cx					; zerowanie CX i DX,
seg000:01BD		xor dx, dx					; czyli ustawienie na koniec pliku
seg000:01BF		int 21h						; w istocie pobranie rozmiaru pliku [AX:DX]
seg000:01BF
seg000:01C1		cmp ax, FileSizeH			; porownanie rozmiaru pliku
seg000:01C5		jnz short FileSizeError		; jesli inny niz spodziewany
seg000:01C7		cmp dx, FileSizeL			; to skocz do obslugi bledu
seg000:01CB		jnz short FileSizeError
seg000:01CB
seg000:01CD		mov ax, 4200h				; ustawienie wskaznika w pliku wzgledem poczatku
seg000:01D0		mov cx, CurPosH				; na wartosci poczatkowe
seg000:01D4		mov dx, CurPosL
seg000:01D8		int 21h
seg000:01D8
seg000:01DA		jmp short FilePatch			; i skok do dalszych operacji

W miedzy czasie, przed operacją wyznaczenia rozmiaru pliku, zapamiętywana jest bieżąca pozycja pliku w zmiennej tymczasowej CurPos, aby na koniec ja przywrócić do stanu początkowego. Ten kod może być zastanawiający. Bo czy naprawdę jest to konieczne? Funkcja DOS 3D otwierająca plik, wedle dokumentacji, po poprawnym wykonaniu swojej roboty, ustawia wewnętrzny pointer w pliku na jego początek. Także wydaje mi się, że jest to zbędne.

Prócz tego powyższy kod według mnie nie jest poprawny, jego działanie jest niezdefiniowane. Uchwyt do otwartego pliku przez funkcję 3D przerwania 21h [1], zwracany jest w rejestrze AX. Pozostale funkcje operujące na plikach pod DOS-em spodziewają się uchwytu w rejestrze BX, a tutaj nie ma żadnej interakcji z tym związanej. Jest tylko dziwny kod ze stosem [2], ale nie bardzo rozumiem do czego miałby on tutaj służyć. W dalszych (niżej) fragmentach kodu, uchwyt jest poprawnie przekazywany przez rejestr BX przy operacjach plikowych. Jest to zastanawiające. Może IDA coś pomieszała, ale nie potwierdza tego kod binarny - w HEX-ach jest podobnie, opcody się zgadzają. Może ktoś bardziej doświadczony w 16-bitowcach mógłby rozjaśnić sprawę.

Wracając do kodu, gdy plik ma inny rozmiar niż spodziewany, na przykład w sytuacji próby zcrackowania nie tej wersji pliku (inny patch), wyświetlany zostaje odpowiedni komunikat i proces zostaje zakończony z kodem wyjścia 1, oznaczającym błąd:

seg000:01DC FileSizeError:
seg000:01DC		jmp short 101F4h
seg000:01DE		db 'Incorrect filesize!',0Dh,0Ah,'$'
seg000:01F4		mov ah, 9					; wyswietlenie na ekranie
seg000:01F6		mov dx, 1DEh				; wskazanego komunikatu
seg000:01F9		int 21h
seg000:01F9
seg000:01FB		mov ax, 4C01h				; zakonczenie procesu z kodem wyjscia 1
seg000:01FE		int 21h

A gdy walidacja rozmiaru pliku zakończyła się poprawnie, lecimy dalej…

seg000:0200 FilePatch:
seg000:0200		pop  ax						; pobranie uchwytu pliku (3)
seg000:0201		mov  bx, ax
seg000:0203		push ax
seg000:0203
seg000:0204		mov ah, 42h					; ustawienie wskaznika w pliku
seg000:0206		mov al, 0					; wzgledem poczatku
seg000:0208		mov cx, 0					; na pozycje 1086
seg000:020B		mov dx, 43Eh
seg000:020E		int 21h
seg000:020E
seg000:0210		pop  ax						; pobranie uchwytu pliku
seg000:0211		mov  bx, ax
seg000:0213		push ax
seg000:0213
seg000:0214		mov ah, 40h					; zapisywanie do pliku
seg000:0216		mov cx, 2					; 2 bajtow
seg000:0219		mov dx, 21Eh				; z tego miejsca
seg000:021C		jmp short 10220h
seg000:021E		dw 9090h					; NOP NOP (4)
seg000:0220		int 21h

Tutaj bez problemów wyłaniają się fragmenty kodu związane z uchwytem pliku, przez kooperacje na rejestrach AX i BX [3]. Kod ten można byłoby uprościć wrzucając do jakieś zmiennej w pamięci uchwyt do pliku lub trzymać go w rejestrze BX od razu po jego otwarciu. To drugie tylko w sytuacji, gdy żadne inne operacje nie modyfikują tego rejestru. Nie pamiętam, czy jakieś funkcje IO na plikach zwracają coś w tym rejestrze.

Wskaźnik w pliku przesuwany jest na pozycję odpowiadającą 1086 bajtowi, pod która zapisywane są 2 bajty danych. Te zapisywane dane to dokładnie 2 opcody instrukcji NOP, a pozycja, pod która są zapisywane - 0x043E, to znany z pierwszej części offset do miejsca wystąpienia instrukcji jz short loc_401050.

Ciekawą uwagą wartą wspomnienia, jest fakt, że miejsce przechowywania danych do zapisania w pliku [4] IDA w pełni świadomie i inteligentnie potraktowała jako operacje NOP, uznając że następuje wyrównanie pamięci do 4-ki. Wstawiając w tym miejscu stosowną informacje:

seg000:021E		align 4

W rzeczywistości znajdują się tam dane, jak zaprezentowano w kodzie powyżej.

Na koniec zamykany jest plik, wyświetlany stosowny komunikat mówiący o pomyślnej modyfikacji pliku i następuje zakończenie procesu z kodem wyjscia równym 0, który świadczy o pozytywnej modyfikacji pliku gry.

seg000:0222		pop ax						; pobranie uchwytu pliku
seg000:0223		mov bx, ax
seg000:0225		mov ah, 3Eh					; zamkniecie pliku
seg000:0227		int 21h
seg000:0227
seg000:0229		mov ah, 9					; wyswietlenie komunikatu na ekranie
seg000:022B		mov dx, 230h
seg000:022E		jmp short 10241h
seg000:0230		db 'Patch applied.',0Ah, 0Dh,'$'
seg000:0241		int 21h
seg000:0241
seg000:0243		mov ax, 4C00h				; zakonczenie procesu z kodem wyjscia 0
seg000:0246		int 21h

Podsumowując powyższą analizę, kod bardzo prosty - szału nie ma.

Crack modyfikuje kod binarny gry, w taki sam sposób, jaki zrobiłem to ręcznie w pierwszej części. Bo jak wspomniałem w tamtej notatce, jest to najprostsze i najszybsze rozwiązanie, które działa i rozwiązuje problem. A przy tym nie wymaga zbytniej ingerencji i głębszej analizy innych fragmentów kodu.

Moje 32 bity

Jako uzupełnienie analizy, postanowiłem napisać własnego cracka, oczywiście w 32 bitach i w asemblerze. Czemu tak? Bo w końcu to tylko kilka wywołań funkcji, a fajnie czasem jest coś napisać na niskim poziomie. W tworzonym programie nie chciałem uzależniać się od jakiejkolwiek biblioteki (np. C run-time), więc skorzystałem jedynie z API systemowego. Dzięki temu kod wynikowy wynosi dokładnie 1 KB.

format PE GUI 4.0
include 'win32a.inc'

	push 0
	push FILE_ATTRIBUTE_NORMAL
	push OPEN_EXISTING
	push 0
	push FILE_SHARE_READ or FILE_SHARE_WRITE
	push GENERIC_READ or GENERIC_WRITE
	push FileName
	call [CreateFile]

	cmp eax, INVALID_HANDLE_VALUE
	jne FileOpen

	mov eax, FileNotFound
	jmp short ErrorExit

FileOpen:
	mov ebx, eax

	push NULL
	push ebx
	call [GetFileSize]

	cmp eax, [FileSize]
	jz FilePatch

	mov eax, IncorrectSize
	jmp short ErrorExit

FilePatch:
	push FILE_BEGIN
	push 0
	push [PatchPos]
	push ebx
	call [SetFilePointer]

	push 0
	push tmp
	push [PatchSize]
	push PatchData
	push ebx
	call [WriteFile]

	push ebx
	call [CloseHandle]

	push MB_OK
	push MessageTitle
	push PatchAppiled
	push 0
	call [MessageBox]

	mov eax, 0
	ret

ErrorExit:
	push MB_OK
	push MessageTitle
	push eax
	push 0
	call [MessageBox]

	mov eax, 1
	ret

tmp				dd 0
FileSize		dd 19B800h
PatchPos		dd 43Eh
PatchSize		dd 2h
PatchData		db 90h,90h

FileName		db "blood2.exe", 0
MessageTitle	db "Blood2: Crack me!", 0
FileNotFound	db "The blood2.exe file not found!", 0
IncorrectSize	db "Incorrect filesize!", 0
PatchAppiled	db "Patch applied!", 0

data import

	library \
		kernel32, 'kernel32.dll', \
		user32, 'user32.dll'

	import user32, \
		MessageBox, 'MessageBoxA'

	import kernel32, \
		CreateFile, 'CreateFileA', \
		GetFileSize, 'GetFileSize', \
		SetFilePointer, 'SetFilePointer', \
		WriteFile, 'WriteFile', \
		CloseHandle, 'CloseHandle'

end data

Po składni, niektórzy mogą się domyślić, że użyłem FASM-a. Nigdy nie miałem z nim styczności, ale gdy trafiłem na niego w sieci, uznałem, że warto kiedyś spróbować - i tak się potoczyło. Ja głownie swoje zabawy wykonuję w NASM-mie lub MASM-ie, a najwięcej to w debugerze z VS, czy IDA.

Kod jest poprawny i działający, ale można byłoby go ulepszyć. Przede wszystkim podzielić na sekcje danych, kodu, itd. Do tego FASM udostępnia makra i ciekawą składnie, usprawniająca tworzenie programów w niskopoziomowym języku. Głównie mam tutaj na myśli operacje invoke, która pozwoliłaby na zamianę standardowych wywołań funkcji przez zrzucanie na stos argumentów i skoku do procedury poprzez call, do prostej linii:

invoke func, arg1, arg2, arg3, ...

Na pewno skróciłoby to znacznie kod źródłowy, ale tak się składa, że bardziej jestem przyzwyczajony przez różnego rodzaju zabawy z re do “normlanej” metody, więc trzymałem się własnych przyczajeń.

Słowem zakończenia…

Mimo prostego kodu, analiza była bardzo ciekawa, poznając funkcje DOS-owe przerwania 21h, trochę frajdy z tym było. Osobiście nigdy w życiu nie miałem okazji napisania w asemblerze 16-bitowego kodu pod DOS-a, więc wydało mi się to fajną zabawą i poniekąd nauką. Choć to już trochę leciwa technologia to zawsze się coś ciekawego człowiek dowie.

Napisanie wersji 32-bitowej w asemblerze, też było ciekawym doświadczeniem. Mimo prostoty (jak wyżej), stworzenie całego programu w asemblerze, a nie tylko krótkie wstawki, czasami jest dobrym pomysłem. Na pewno szare komórki się przewietrzą.

Jedna mała uwaga, w czasie pisania tego tekstu, udało mi się znaleźć jeszcze kilką programów łamiących zabezpieczenie gry. I niekoniecznie były to dosowe aplikacje, trafiła się jedna nawet z 32-bitowym GUI. No ale, w międzyczasie już skupiłem się na tym problemie i osobistym udziale, wieć należało się trzymać planu.

Pozostało jeszcze trochę ciekawych rzeczy do sprawdzenia i przeanalizowania w kodzie gry, dotyczących zabezpieczenia i mechanizmu wykrywania nośnika w napędzie. O tym wkrótce w kolejnej (prawdopodobnie) części.

Podobnie, jak w pierwszej części, wszystkie przedstawione materiały służą jedynie celom poznawczym i edukacyjnym.

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/