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)