SaeLog #4: Firmware i jego ekstrakcja

tech • 3647 słów • 18 minut czytania

Ta notatka jest częścią serii Saleae Logic Hack. Zapoznaj się z pozostałymi wpisami.

Chcąc zajrzeć do kodu firmware zaszytego w urządzeniu Saleae Logic, trzeba go najpierw jakoś zdobyć. Kod ten w typowej aplikacji USB-FX2 ładowany jest do urządzenia po uprzednim jego wykryciu przez oprogramowanie zainstalowane na komputerze, wprost poprzez port USB. Idąc tym tropem można być pewnym, że znajdzie się go gdzieś w paczce z aplikacją lub w samym kodzie programu. Przyszła więc pora na jego wydobycie, bez tego nie będzie możliwa dalsza zabawa…

Download firmware w EZ-USB FX2

Proces ładowania kodu firmware do urządzenia jest określany mianem download. Proces ten jest dokładnie opisany w dokumentacji układów Cypressa. Ale mimo to, chciałbym tutaj przedstawić jak to wygląda z punktu widzenia aplikacji. Może to okazać się pomocne w dalszej części przy analizie kodu aplikacji analizatora, albo w dowolnej innej sytuacji, przy styczności z podobnymi układami opartymi na tym rdzeniu.

Ładowanie kodu do czipa następuje za pomocą transmisji kontrolnej lub wiadomości kontrolnej (różne API - różne nazwy) o typie 0x40 (vendor request write-to-device) i numerze 0xA0 (firmware load/anchor download). Wszystko zgodnie ze standardem USB i dokumentacją CY7C68013. Pole value w tym requescie służy do określenia lokalizacji (adresu) pamięci, gdzie zostaną zapisane dane. Polecenie to może zapisywać tylko do wewnętrznej pamięci, a w czasie tej operacji procesor powinien być utrzymany w stanie reset. Kontrola reset-u możliwa jest za pomocą manipulacji bitem 8051RES (bit 0) rejestru CPUCS, mapowanego pod adres 0xE600. Wpisanie 1 do tego bitu wprowadza CPU w stan resetowania, a 0 wznawia działanie 8051. Zapis do tego bitu mozliwy jest tylko dla USB hosta za pomocą komendy A0 (tej właśnie opisywanej).

Wysyłanie requestów kontrolnych można dokonać za pomocą WinUsb_ControlTransfer (Windows), usb_control_msg (*nix) lub libusb_control_transfer (libusb). Do wyboru do koloru…

Przykładowe ładowanie kodu firmware do mikrokontrolera, w porcjach po maksymalnie 16 bajtów, mogłoby wyglądać tak:

const size_t MaxLen = 16;

const unsigned char firmware[] = { ... };
const size_t length = sizeof(firmware);


// put the CPU in reset
unsigned char reset = 1;
libusb_control_transfer(handle, 0x40, 0xA0, 0xE600, 0, &reset, 1, USB_TIMEOUT);


const size_t rest = length % MaxLen;
const size_t algn = length - rest;

size_t addr = 0;
unsigned char* data = firmware;

// write the firmware aligned to a MaxLen byte boundary
while (addr < algn) {
	libusb_control_transfer(handle, 0x40, 0xA0, addr, 0, data, MaxLen, USB_TIMEOUT);
	addr += MaxLen;
	data += MaxLen;
}

// write the rest of firmware
if (rest > 0)
	libusb_control_transfer(handle, 0x40, 0xA0, addr, 0, data, rest, USB_TIMEOUT);


// bring the CPU out of reset
reset = 0;
libusb_control_transfer(handle, 0x40, 0xA0, 0xE600, 0, &reset, 1, USB_TIMEOUT);

Tak mniej więcej prezentuje się cały proces związany z ładowaniem kodu programu. Taki sam scenariusz w większości wykonują aplikacje komunikujące się z układami opartymi na USB FX2.

Download firmware w Saleae Logic

Obsługa firmware w Saleae Logic jest bardzo podobna do typowego scenariusza, o którym było wyżej. Kod binarny dla 8051 trzymany jest w sekcji .rdata aplikacji w postaci czystego hex-a, w formacie IntelHex. Po wykryciu urządzenia konwertowany jest na kod binarny i do niego wysyłany transmisją kontrolną. Tak to wyglądą w skrócie, ale warto prześledzić po kolei, jak to się dokładnie odbywa w kodzie programu. Pozwoli to spróbować opracować jakieś proste metody ekstrakcji kodu firmware z kodu programu.

DownloadFx2Data

Na najniższym poziomie nie jest już tajemnicą, że aplikacja używa windowsowego API do komunikacji z portem USB. Można się spodziewać, że wiadomości kontrolne wysyłane przez aplikację będą za pomocą WinUsb_ControlTransfer. Tak też jest w rzeczywistości, cała transmisja kontrolna związana z firmware (i nie tylko, choć tylko do tego używana jest w aplikacji) opakowana jest metodą DownloadFx2Data z WindowsUsbDevice.

Jest ona bardzo prosta, przyjmuje 3 argumenty, określające wprost pod jaki adres oraz ile i jakie dane zostaną wysłane do urządzenia:

int __stdcall WindowsUsbDevice::DownloadFx2Data(short address, unsigned char* buffer, short length);

Pomijając obsługę błędów i walidację, jej kod jest bardzo prosty, ogranicza się do czystego opakowania systemowego API:

0051824B	mov  ax, [ebp+address]
0051824F	mov  esi, dword ptr [ebp+length]
00518252	mov  word ptr [esp+4Ch+var_packHi+2], ax	; WINUSB_SETUP_PACKET.Value
00518257	xor  ebx, ebx
00518259	push ebx									; Overlapped
0051825A	xor  edx, edx
0051825C	lea  eax, [esp+50h+var_3E+2]
00518260	push eax									; LengthTransferred
00518261	mov  word ptr [esp+54h+var_packLo], dx		; WINUSB_SETUP_PACKET.Index
00518266	mov  edx, [ebp+buffer]
00518269	push esi									; BufferLength
0051826A	push edx									; Buffer
0051826B	mov  word ptr [esp+5Ch+var_packLo+2], si	; WINUSB_SETUP_PACKET.Length
00518270	mov  eax, [esp+5Ch+var_packLo]
00518274	push eax									; SetupPacket Low Part
00518275	mov  eax, [ecx+0B8h]
0051827B	mov  byte ptr [esp+60h+var_packHi], 40h		; WINUSB_SETUP_PACKET.RequestType
00518280	mov  byte ptr [esp+60h+var_packHi+1], 0A0h	; WINUSB_SETUP_PACKET.Request
00518285	mov  edx, [esp+60h+var_packHi]
00518289	push edx									; SetupPacket High Part
0051828A	push eax									; InterfaceHandle
0051828B	mov  [esp+68h+var_3E+2], ebx
0051828F	call WinUsb_ControlTransfer

Mała ciekawostka, na która się natknąłem. Funkcja przyjmuje 6 parametrów, a na stosie odkładane jest 7, długo się męczyłem próbując znaleźć odpowiedź czemu tak jest, dlaczego połowa argumentów przesunięta jest o 1, i ogólnie “wtf!”. A to oczywiście przez drugi argument SetupPacket przekazywany przez wartość, będący strukturą WINUSB_SETUP_PACKET o rozmiarze 8 bajtów. Dlatego odkładana jest w dwóch kawałkach.

DownloadFirmware

Kolejną w hierarchii wywołań funkcją biorąca udział w procesie ładowania firmware-u jest metoda DownloadFirmware zaszyta w UsbDevice. Korzysta ona bezpośrednio z tej niskopoziomowej implementacji w DownloadFx2Data, o której wcześniej napisałem.

W istocie obiekt UsbDevice jest obiektem bazowym, a WindowsUsbDevice jest potomną klasą implementującą cześć wirtualnych metod. Tak przynajmniej klaruje mi się obraz po blizszej analizie tejże funkcji. W szczególności wnioskuję to po ułożeniu poszczególnych elementów w pamięci, przekazywanych wskaźnikach na this i call-ach. Oczywiście nie jest to aż tak bardzo istotne, aby uchwycić ogólne zrozumienie (a nawet dokładne) procesu zachodzącego w aplikacji.

Na potrzeby dalszej analizy, zawartość klasy UsbDevice można przedstawić następująco (w nawiasach offset względem początku):

struct UsbDevice {
	WindowsUsbDevice* dev;	//  0

	// hex file
	void* line;				// 36
	int count;				// 40
};

Tym razem wygodniej będzie zaprezentowanie pseudokodu (bardziej C++), zamiast fragmentów kodu binarnego czy asemblera, który odtworzyłem na podstawie krótkiej analizy kodu funkcji.

void UsbDevice::DownloadFirmware(UsbDevice* this) {

	unsigned char data = 1;
	this->dev->DownloadFx2Data(0xE600, &data, 1);

	if (dev->count > 0) {

		int i = 0;
		int offset = 0;

		do {

			int len = 0;
			int addr = 0;
			unsigned char buf[256];

			if (HexFileHelper::GetDataFromHexFileString(&len, &addr, this->line + offset, buf) == 1)
				break;

			this->dev->DownloadFx2Data(addr, buf, len);

			offset += 28;
			++i;

		} while (i < this->count);
	}

	data = 0;
	this->dev->DownloadFx2Data(0xE680, &data, 1);
	this->dev->DownloadFx2Data(0xE600, &data, 1);
}

Kod ten podażą mechanizmem zgodnym z przyjętym standardem. Iteracja po danych hex file reprezentującego kod firmware, którego linie, jak się później okaże, to zwykłe obiekty std::string, z których pomocnicze funkcje wyciągają i konwertują dane na postać potrzebną do bezproblemowego załadowania do urządzenia.

Zauważyć można przełączenie mikrokontrolera w stan reset, na czas ładowania kodu programu. Ciekawostka, tuż przed wznowieniem pracy, następuje wyczyszczenie pierwszego bitu pod adresem 0xE680. Jest to bit SIGRSUME (Signal Remote Device Resume) rejestru USBCS (USB Control and Status), który odpowiada za wysłanie żądania USB Resume.

Działanie pomocniczych helperów operujących na reprezentacji hex-owej danych i konwersji pomijam. Zawierają znaczne ilości kodu, a ich działanie jest dosyć jasne, bazując na kontekście i przekazywanych do nim argumentach.

Hex file

Tajemnicze hex file przewijające się w tekście, które można znaleźć w obiekcie UsbDevice, jest niczym innym jak krotką składającą się z 2 elementów - wskaźnika na tablice stringów z poszczególnymi liniami pliku w formacie IntelHex oraz ilości tych linii - elementów tablicy.

Za odpowiednie przygotowanie tych danych odpowiada funkcja GetHexFileLines z DevicesManager. Wykonywana w ścieżce kodu reagującym na podłączenie/wykrycie urządzenia, wespół z tworzeniem odpowiedniego obiektu LogicDevice, Logic16Device itd… i masą innych nieistotnych dla mnie w tym momencie rzeczy…

Inicjuje ona wspomnianą strukturę w zależności od typu urządzania:

void DevicesManager::GetHexFileLines(int type /*edx*/, void* lines /*ecx*/, int* count) {

	switch (type) {
		case 0:
			*lines = &unk_E74D18;	// sub_A92690
			*count = 293;
			break;
		case 1:
		case 3:
			*lines = &unk_E79BF8;	// sub_A966E0
			*count = 439;
			break;
		case 2:
			*lines = &unk_E76D28;	// sub_A94190
			*count = 428;
			break;
		default:
			const char* msg = "Unknown type";
			// logging assert msg...
	}
}

Wyłania się obraz 4 przypadków, będących potencjalnymi typami urządzenia, dla których istnieją 3 różne wersje kodu firmware. Pod tymi wskaźnikami kryją się tablice stringów zawierające kolejne linie z pliku w formacie IntelHex.

Tablice kryjące się pod tymi wskaźnikami wypełniane są danymi w czasie inicjalizacji aplikacji. Dokonują tego odpowiednie funkcje, których nazwy (adresy) zanotowałem w komentarzach obok wskaźników. Mimo iż są to dane stałe i statyczne, trzymane w stałej (??) tablicy, ale w obiektach typu std::string, co pociąga za sobą wywołanie konstruktora i ich inicjalizację, a to następnie skutkuje ich kopiowaniem. Sprawa wyglądałaby inaczej, gdyby dane te były w inny sposób używane/trzymane w kodzie programu. W pewnych sytuacjach udałoby się uniknąć zbędnego kopiowania i zmusić kompilator do bezpośredniego używania tych danych. Ale to inna bajka, choć jest to ciekawy temat na inny wpis i eksperymenty…

Ciągle piszę, że poszczególne linie trzymane są w typowym std::string, pora to udowodnić. Dla mojej płytki testowej z identyfikatorami Saleae ładowany jest kod spod adresu 00E79BF8. Tablica pod tym adresem inicjalizowana jest przez funkcję sub_A966E0, więc ją wykorzystałem do analizy.

Mały fragment z tworzenia kilku początkowych elementów przedstawia się następująco:

00A96702	push  2Bh
00A96704	push  offset a1015310090e600		; ":1015310090E600E0FFEF54E7FFEF4410FF90E6"...
00A96709	mov   ecx, offset unk_E79BF8
00A9670E	call  sub_401E10					; (1)
00A96713	xor   ebx, ebx
00A96715	mov   [esp+18h+var_4], ebx

00A96719	push  11h
00A9671B	mov   esi, 0Fh
00A96720	push  offset a03154100eff022		; ":03154100EFF022A6"
00A96725	mov   ecx, offset unk_E79C14
00A9672A	mov   dword_E79C2C, esi				; [unk_E79C14 + 24] = 15
00A96730	mov   dword_E79C28, ebx				; [unk_E79C14 + 20] = 0
00A96736	mov   byte_E79C18, bl				; [unk_E79C14 +  4] = 0
00A9673C	call  sub_401E10
00A96741	mov   byte ptr [esp+18h+var_4], 1

00A96746	push  1Fh
00A96748	push  offset a0a00360075b2_0		; ":0A00360075B2077580007580012285"
00A9674D	mov   ecx, offset unk_E79C30
00A96752	mov   dword_E79C48, esi				; [unk_E79C30 + 24] = 15
00A96758	mov   dword_E79C44, ebx				; [unk_E79C30 + 20] = 0
00A9675E	mov   byte_E79C34, bl				; [unk_E79C30 +  4] = 0
00A96764	call  sub_401E10
00A96769	mov   byte ptr [esp+18h+var_4], 2

Wyodrębnione fragmenty to potencjalnie rozwinięte w miejscu wywołania ciało konstruktora w postaci

basic_string(const CharT* s, size_type count);

Które najpierw ustawia domyślne wartości 0 w membersach, a następnie wskakuje do metody assign (sub_401E10), przyjmującej argumenty przekazane w konstruktorze. Zastanawiający jest przypadek pierwszego obiektu (1), gdzie brakuje fragmentu z inicjalizacją wartościami początkowymi elementów składowych. Błąd, czy specyficzne działanie kompilatora?

Nieco dalej następuje zmiana konstruktora na wersję bez podawania rozmiaru przekazywanego C-stringa. I o dziwo, nie jest już rozwijana w miejscu wywołania. Ciekawe czy jest to działanie programisty, czy kompilatora. Jest to funkcja sub_401CC0:

00401CC0 sub_401CC0 proc near
00401CC0 Src = dword ptr 4
00401CC0
00401CC0	mov   edx, [esp+Src]
00401CC4	push  esi
00401CC5	mov   esi, ecx
00401CC7	mov   eax, edx
00401CC9	push  edi
00401CCA	mov   dword ptr [esi+18h], 0Fh		; [esi + 24] = 15
00401CD1	mov   dword ptr [esi+14h], 0		; [esi + 20] = 0
00401CD8	mov   byte ptr [esi+4], 0			; [esi +  4] = 0
00401CDC
00401CDC	lea   edi, [eax+1]					; inline strlen
00401CDF	nop
00401CE0	loc_401CE0:
00401CE0	mov   cl, [eax]
00401CE2	inc   eax
00401CE3	test  cl, cl
00401CE5	jnz   short loc_401CE0
00401CE7	sub   eax, edi
00401CE9
00401CE9	push  eax							; length
00401CEA	push  edx							; string
00401CEB	mov   ecx, esi
00401CED	call  sub_401E10					; assign(Src, strlen(Src);
00401CF2	pop   edi
00401CF3	mov   eax, esi
00401CF5	pop   esi
00401CF6	retn  4
00401CF6	sub_401CC0 endp

Funkcja sama oblicza długość przekazywanego ciągu tekstowego. Implementacja strlen została rozwinięta w kodzie. Dalej podtrzymuję pytanie, czy funkcja ta powstała przez działanie programisty, czy może kompilatora, który przekroczywszy jakąś granice optymalizacji/rozwijania, wygenerował takowy kod, a nie jak ten przedstawiony nieco wyżej.

Objekt string na podstawie przedstawionych fragmentów i analizy kodu można zapisać w postaci prostej struktury:

struct {
	int unk1;			// 0
	union {				// 4
		char buf[16];
		char* ptr;
	} data;
	int length;			// 20
	int reserved;		// 24
};

Rozmiar tej struktury jest równy dokładnie 28 bajtów, co koreluje z offset-em o jaki przesuwany jest wskaźnik w czasie iteracji po tablicy stringow przy ładowaniu kodu do urządzenia w metodzie DownloadFirmware z UsbDevice.

A budowa odpowiada wewnętrznej implementacji windowsowego (MSVC) std::string-a. Można to poświadczyć zaglądając do źródeł biblioteki, do nagłówka xstring, gdzie znaleźć można templejtową bazową klasę _String_val, służącą do przechowywania danych w basic_string.

class _String_val : public _Container_base {
public:

	enum {						// length of internal buffer, [1, 16]
		_BUF_SIZE = 16 / sizeof (value_type) < 1 ?
					1 : 16 / sizeof (value_type)
	};

	union _Bxty {				// storage for small buffer or pointer to larger one
		value_type _Buf[_BUF_SIZE];
		pointer _Ptr;
		char _Alias[_BUF_SIZE];	// to permit aliasing
	} _Bx;

	size_type _Mysize;			// current length of string
	size_type _Myres;			// current storage reserved for string
};

Brakuje tutaj odpowiednika pierwszej 4 bajtowej składowej. Może to być obiekt alokatora, zależnie od ułożenia w pamięci i ścieżki dziedziczenia, albo cześć bazowego _Container_base, który zależnie od ustawień kompilacji może zawierać wskaźnik na _Container_proxy.

Sekcja .rdata

Wielokrotnie już pisałem, że fizycznie dane leżą w sekcji .rdata modułu wykonawczego (pliku PE) aplikacji. Sekcja ta reprezentuje stałe dane, dostępne tylko do odczytu. Oczywiście wszystko to zależy finalnie i tak od kompilatora/linkera, ale większość stosuje się do tej praktyki i ogólnie przyjętych zasad. Jak już wspomniałem, to temat na inny wpis.

Dane znajdujące się w sekcji są częściowo uporządkowane, zależy to od procesowania przez linker, ale tak się składa, że dla każdej wersji firmware, dane te trzymane są w odwrotnej kolejności niż zawartość pliku hex:

; end of HEX file - last line in hex files
.rdata:00CDAC48 a00000001ff     db ':00000001FF',0

; firmware type 0 - sub_A92690
.rdata:00CDAC54 a010a170022bc   db ':010A170022BC',0
.rdata:00CDAC62 align 4
.rdata:00CDAC64 a100a0700eb9ff5 db ':100A0700EB9FF5F0EA9E42F0E99D42F0E89C45F045',0
.rdata:00CDAC90 a020a0500f222db db ':020A0500F222DB',0
[...]
.rdata:00CDD5BC a1005d50001120e db ':1005D50001120E177E087F6B8E0A8F0B7512087538',0
.rdata:00CDD5E8 a1005c500e4f52c db ':1005C500E4F52CF52BF52AF529C203C200C202C2B7',0
.rdata:00CDD614 a0a0df500000102 db ':0A0DF50000010202030304040505D7',0

; firmware type 2 - sub_A94190
.rdata:00CDD634 a010995000061   db ':010995000061',0
.rdata:00CDD642 align 4
.rdata:00CDD644 a100ce500f0a3c8 db ':100CE500F0A3C8C582C8CAC583CADFE9DEE780BEEE',0
.rdata:00CDD670 a100cd500fae493 db ':100CD500FAE493A3F8E493A3C8C582C8CAC583CA36',0
[...]
.rdata:00CE1334 a10134000438002 db ':10134000438002EF25E0FF5380FD0EBE08E6758066',0
.rdata:00CE1360 a10133000e4f580 db ':10133000E4F580FEEF30E70543800480035380FB33',0
.rdata:00CE138C a0a00360075b207 db ':0A00360075B207E4F5807580012221',0

; firmware type 1/3 - sub_A966E0
.rdata:00CE13AC a010ef90000f8   db ':010EF90000F8',0
.rdata:00CE13BA align 4
.rdata:00CE13BC a1010e500f0a3c8 db ':1010E500F0A3C8C582C8CAC583CADFE9DEE780BEEA',0
.rdata:00CE13E8 a1010d500fae493 db ':1010D500FAE493A3F8E493A3C8C582C8CAC583CA32',0
[...]
.rdata:00CE5270 a0a00360075b2_0 db ':0A00360075B2077580007580012285',0
.rdata:00CE5290 a03154100eff022 db ':03154100EFF022A6',0
.rdata:00CE52A2 align 4
.rdata:00CE52A4 a1015310090e600 db ':1015310090E600E0FFEF54E7FFEF4410FF90E60074',0

Do mojego hardware-u ładowane jest firmware 1, którego hex-file inicjowany jest przez funkcję sub_A966E0, a ta z kolei wypełnia tablicę spod adresu 0xE79BF8 stringami tworzonymi z wyżej przedstawionych danych (c-string-ów). Poczynając od a1015310090e600 (0x00CE52A4) i kolejno przechodząc aż do a010ef90000f8 (0x00CE13AC), plus na koniec hex-owy eof - a00000001ff (0x00CDAC48).

Ta ostania linia jest wspólna, znajduje się we wszystkich plikach hex, a w sekcji trzymane są tylko dane read-only, więc występuje w jednej reprezentacji.

Ekstrakcja kodu firmware

Gdy już wiem, gdzie i w jakim stanie znajduje się kod firmware oraz mechanizmy związane z jego ładowaniem do urządzenia, nadchodzi odpowiednia pora, aby zając się wreszcie tytułowym problemem. Czyli próbą zdobycia kodu, a dokładnie jego wydobycia na zewnątrz z kodu programu. Istnieje kilka metod jakie kiełkują mi w głowie.

Hookowanie transmisji USB

Skoro kod ładowany jest transmisją kontrolną USB do urządzenia, to naturalną opcją wydaje się podłączenie pod interfejs USB i przekierowanie strumienia danych. Można wykorzystać dowolną technikę hook-owania lub inject-owania. Ja chętnie założyłbym trampolinę na WinUsb_ControlTransfer za pomocą jakiegoś gotowego toola lub liba, np. MinHook (wydaje się bardzo przyjazny). Zawsze można użyć coś swojego już napisanego lub szybko coś prostego dopiero napisać ;)

Przechwytywane dane są binarne, więc można bezpośrednio tworzyć plik binarny, albo odtwarzać poszczególne call-e jako linie w formacie IntelHex.

Przykładowa testowa implementacja takiej funkcji dla tworzenia pliku binarnego może wyglądać tak:

BOOL __stdcall My_WinUsb_ControlTransfer_Bin(
	WINUSB_INTERFACE_HANDLE InterfaceHandle,
	WINUSB_SETUP_PACKET SetupPacket,
	PUCHAR Buffer,
	ULONG BufferLength,
	PULONG LengthTransferred,
	LPOVERLAPPED Overlapped)
{
	if (SetupPacket.RequestType == 0x40 && SetupPacket.Request == 0xA0) {

		const unsigned short address = SetupPacket.Value;

		if (address == 0xE600) {

			if (Buffer[0] == 1)
				file = fopen(FileNameBin, "wb");
			else {
				fclose(file);
				file = NULL;
			}

		} else 	if (file && address < 0xE600) {

			fseek(file, address, SEEK_SET);
			fwrite(Buffer, 1, BufferLength, file);
		}
	}

	return Org_WinUsb_ControlTransfer(InterfaceHandle, SetupPacket, Buffer, BufferLength, LengthTransferred, Overlapped);
}

Dla IntelHex-a trochę więcej pracy trzeba wykonać:

BOOL __stdcall My_WinUsb_ControlTransfer_Hex(
	WINUSB_INTERFACE_HANDLE InterfaceHandle,
	WINUSB_SETUP_PACKET SetupPacket,
	PUCHAR Buffer,
	ULONG BufferLength,
	PULONG LengthTransferred,
	LPOVERLAPPED Overlapped)
{
	if (SetupPacket.RequestType == 0x40 && SetupPacket.Request == 0xA0) {

		const unsigned short address = SetupPacket.Value;

		if (address == 0xE600) {

			if (Buffer[0] == 1)
				file = fopen(FileNameHex, "wt");
			else {
				fwrite(":00000001FF\n", 1, 12, file);
				fclose(file);
				file = NULL;
			}

		} else 	if (file && address < 0xE600) {

			char data[] = {
				SetupPacket.Length,			// length
				SetupPacket.Value >> 8,		// address in big endian
				SetupPacket.Value,
				0x00						// type
			};

			char crc = 0;
			for (size_t i = 0; i < sizeof(data); i++)
				crc += data[i];
			for (size_t i = 0; i < BufferLength; i++)
				crc += Buffer[i];
			crc = 0x100 - crc;

			char buffer[44];
			char* buf = buffer;

			*buf++ = ':';
			buf = hex(buf, data, sizeof(data));
			buf = hex(buf, Buffer, BufferLength);
			buf = hex(buf, &crc, 1);
			*buf++ = '\n';

			fwrite(buffer, 1, buf - buffer, file);
		}
	}

	return Org_WinUsb_ControlTransfer(InterfaceHandle, SetupPacket, Buffer, BufferLength, LengthTransferred, Overlapped);
}

Przewijająca się w kodzie funkcja hex służy do konwersji na reprezentację heksadecymalną. Bufor wynikowy powinien pomieścić dane wynikowe, czyli powinien być 2 razy większy niż dane wejściowe. W wyniku zwracany jest wskaźnik na kolejny element bufora za ostatnim dopisanym elementem (czyli takowy iterator end). Jakby coś to można wykorzystać takową implementację:

char* hex(char* buf, void* data, size_t len) {

	static const char dec2hex[] = "0123456789ABCDEF";

	unsigned char* iter = (unsigned char*)data;
	unsigned char* end = iter + len;

	while (iter != end) {
		char v = *iter++;
		*buf++ = dec2hex[(v >> 4) & 0xF];
		*buf++ = dec2hex[v & 0xF];
	}

	return buf;
}

Całość łatwo wpakować w DLL-ke i w entry poincie (DllMain) dorzucić obsługę zakładania i zdejmowania hooka, w zależności od zdarzenia procesu (load/unload):

BOOL APIENTRY DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpvReserved) {

	switch (fdwReason) {

		case DLL_PROCESS_ATTACH:
			DisableThreadLibraryCalls(hinstDLL);
			MH_Initialize();
			MH_CreateHookApi(L"winusb", "WinUsb_ControlTransfer", My_WinUsb_ControlTransfer_Bin, (LPVOID*)&Org_WinUsb_ControlTransfer);
			MH_EnableHook(MH_ALL_HOOKS);
			break;

		case DLL_PROCESS_DETACH:
			MH_DisableHook(MH_ALL_HOOKS);
			MH_Uninitialize();
			break;
	}

	return TRUE;
}

Prezentowany kod to taki przykładowo-testowy twór, bez obsługi błedów i innego kombinowania. Ale w testach dawał radę.

Stworzoną bibliotekę trzeba jeszcze jakoś wstrzyknąć do głównego procesu aplikacji, co przeważnie najprościej zrobić wykorzystując mechanizm oparty na CreateRemoteThread i LoadLibrary lub wykorzystać inny dostępny w sieci DLL injector. Może znajdę chwilę i uda mi się uporządkować własny kod, to może swoją wersję gdzieś wrzucę.

Program Logic ładuje bibliotekę WinUsb przy pierwszym użyciu - Delay-Loaded DLL, więc mogą wystąpić problemy, jeśli wstrzykniemy nasz kod, nim ta DLL-ka zostanie załadowana. Zakładanie hooka przez funkcję MH_CreateHookApi zakończy się błędem. Można to obejść wstrzykując najpierw winusb.dll, wtedy będzie pewność, że znajduje się ona w przestrzeni adresowej procesu.

Czytanie pamięci procesu

Inną ciekawą i dużo bardziej prostszą możliwością jest bezpośrednie zrzucenie danych z pamięci procesu aplikacji Logic. Skoro wiemy, gdzie przechowywane są dane i ich rozmiar, bazując na analizie funkcji DevicesManager::GetHexFileLines, to nie widzę żadnych przeszkód, wystarczy wykorzystać systemowe ReadProcessMemory.

Napisałem nawet implementację tego mechanizmu, w postaci prostego programu. Przyjmuje on jako argumenty, kolejno pid procesu aplikacji, adres w pamięci pod którym zaczyna się tablica z stringami linii InetlHex oraz jej rozmiar (ilość elementów).

#include <iostream>
#include <string>
#include <vector>
#include <memory>
#include <stdexcept>

#include <windows.h>
#include <psapi.h>
#include <tlhelp32.h>

struct CloseHandleDeleter {
	typedef HANDLE pointer;
	void operator()(HANDLE handle) const { ::CloseHandle(handle); }
};

typedef std::unique_ptr<HANDLE, CloseHandleDeleter> Handle;


ULONG_PTR GetProcessBaseAddress(HANDLE proc) {

	Handle snap(
		::CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, ::GetProcessId(proc))
	);

	if (snap.get() == INVALID_HANDLE_VALUE)
		return 0;

	MODULEENTRY32 me32 = { sizeof(MODULEENTRY32) };
	if (::Module32First(snap.get(), &me32))
		return reinterpret_cast<ULONG_PTR>(me32.modBaseAddr);

	return 0;
}

int main(int argc, char* argv[]) {

	if (argc != 4) {
		std::cout << "usage: app pid address count\n" << std::endl;
		return 1;
	}

	try {

		int pid = std::stoi(argv[1]);
		unsigned long addr = std::stoul(argv[2], nullptr, 16);
		int count = std::stoi(argv[3]);

		Handle proc(
			::OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid)
		);

		if (!proc)
			throw std::runtime_error("Error open process");

		ULONG_PTR base = ::GetProcessBaseAddress(proc.get());
		if (!base)
			throw std::runtime_error("Error get process base address");


		struct String {
			enum { MaxStaticSize = 16 };

			int unk1;			// 0
			union {				// 4
				char buf[MaxStaticSize];
				char* ptr;
			} data;
			int length;			// 20
			int reserved;		// 24

		};

		std::vector<String> hex(count);

		SIZE_T size = count * sizeof(String);
		LPVOID address = reinterpret_cast<LPVOID>(base + addr);

		BOOL ret = ::ReadProcessMemory(proc.get(), address, hex.data(), size, &size);
		if (!ret)
			throw std::runtime_error("Error read process memory");

		char buf[44];

		for (const String& s : hex) {

			const char* line;

			if (s.length > String::MaxStaticSize) {
				ret = ::ReadProcessMemory(proc.get(), s.data.ptr, buf, s.length, &size);
				if (!ret)
					throw std::runtime_error("Error read process memory");
				buf[size] = '\0';
				line = buf;

			} else {
				line = s.data.buf;
			}

			std::cout << line << "\n";
		}

	} catch (std::exception& e) {
		std::cerr << e.what() << "\n" << "Code: " << ::GetLastError() << std::endl;
		return 1;
	}

	return 0;
}

Wspomniany adres to w rzeczywistości jest offset względem początku procesu (base adress), gdzie znajduje się tablica danych. Program Logic ma włączoną opcje ASLR, czyli pole DLL Characteristic ma ustawiony bit 0x40 w nagłówku PE (linkowany z opcją /dynamibase), przez co przy każdym uruchomieniu aplikacji adres bazowy jest inny. Ale tutaj w parametrze należy podać znany offset, który, kolejno dla 3 typów firmware wynosi: 0x00A74D18, 0x00A79BF8, 0x00A76D28.

W wyniku działania wyplute zostaną kolejne linie w formacie IntelHex rezydujące pod podanym adresem w pamięci:

>aap 11348 0x00A79BF8 439
:0A00360075B207E4F5807580012221
:10133000E4F580FEEF30E70543800480035380FB33
:10134000438002EF25E0FF5380FD0EBE08E6758066
:01135000019B
:011351002279
:07141B007F2112133080F95C
:0A0046000001020203030404050593
:100ED200EC4EFEED4F2446F58274003EF583E4931A
:100EE200FF3395E0FEEF24A1FFEE34E68F82F58317
:100EF2002290E6BCE0547EFF7E00E0D394807C002A
:100F020022F0E53B2401F53BE4353AF53AE4353984
:100F1200F539E43538F53822AF3FAE3EAD3DAC3C55
:100F2200AB3BAA3AA939A838C3020C32E545253BA6
:090F3200F582E544353AF583220D
:10056800E4F543F542F541F540C203C200C202C2B8
:10057800011200287E007F568E0A8F0B75120075B7
...

Wydaje mi się to najbardziej optymalnym i preferowanym rozwiązaniem. Nie trzeba się za dużo męczyć, prócz tego, że wymagana jest jedynie wiedza na temat adresu i długości tablic.

Czytanie z skecji .rdata

Zastanawiałem się jeszcze nad możliwością nie inwazyjnego sposobu dobrania się do kodu. Taka metoda mogłaby być oparta na wydobyciu kodu wprost z pliku binarnego aplikacji, z sekcji .rdata. Wszystko odbyłoby się bez potrzeby dostępu do uruchomionego programu.

Niestety nie jest tak łatwo, bazując na przedstawionej wcześniej strukturze i umiejscowieniu tych danych w sekcji .rdata, nie da się łatwo i przyjemnie napisać do tego celu skryptu (w perlu!). Podobnie jak w poprzednim sposobie, tutaj również wymagana jest jakaś wiedza, na temat danych - ich umiejscowienia i długości.

Dlatego zrezygnowałem z jakichkolwiek prób napisania kodu korzystającego z tego sposobu. Być może udałoby się opracować jakaś metodę analizy sekcji ze stałymi danymi, która umożliwiłaby perfekcyjne wyekstrahowanie danych bez potrzeby znajomości w/w informacji.

Teraz już wiem, jak wyczarować kod firmware, więc można przejść do następnego etapu zabawy. Analizowanie i hackowanie kodu urządzenia…

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/