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)