SaeLog #6: Hacki w firmware w obsłudze EEPROM

tech • 2889 słów • 14 minut czytania

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

Zabrałem się za zabawy z firmwarem, którego celem jest dodanie wsparcia pamięci EEPROM adresowanych wordem a nie bajtem. Odkąd mogę testować swoje hacki bezpośrednio na urządzeniu, całą uwagę mogę poświecić na tych zadaniu, skupiając się na analizach i eksperymentach.

Moje urządzenie, jak i oryginalna wersja posiada pamięć EEPROM, która FX2 używa do różnych celów, między innymi do bootowania. Jest to pamięć szeregowa z serii AT24Cxx z komunikacją po magistrali I2C. A istotnymi różnicami między oryginałem a klonem (lub płytka developerską) jest wersja użytej kostki. Wspominałem już o tym, że oryginał wykorzystuje małe pamięci, gdzie adresowanie poszczególnych komórek jest 1 bajtowe, a mój układ zawiera cięższa wersję o większej pojemności i adresowana jest 2-bajtowo.

I2C i szeregowy EEPROM w FX2

Magistrala I2C jest powszechnie używana przez większość mikrokontrolerów i urządzeń, czasami pod różnymi innymi nazwami. Jest prosta i niezawodna, może kiedyś będę miał okazję bliżej przedstawić wszystkie szczegóły i mechanizmy. W EZ-USB, podobnie jak w innych systemach mikroprocesorowych wspierających I2C sprzętowo, do obsługi i zarządzania pracą tej magistrali dedykowano kilka rejestrów. W układach Cypressa są to 3 rejestry: I2CS (0xE678), I2DAT (0xE679) i I2CTL (0xE67A), które kolejno to kontrola i status magistrali, przesyłane dane oraz konfiguracja magistrali.

Szczegóły odnośnie korzystania z I2C w FX2 ładnie opisano w dokumentacji układu, w podrozdziale 13.5.3 znajduje się opis wysyłania danych, a w następnym 13.5.4 odbieranie danych przez magistralę. Być może przy opisie kodu wspomnę trochę o tym jak to wygląda w praktyce.

Kostki pamięci EEPROM z szeregowym interfejsem I2C zdobyły wielka popularność dzięki właśnie popularyzacji standardu I2C. Wszystkie układy podłączone do magistrali musza posiadać unikalny adres fizyczny. Kostki EEPROM wyprowadzają tylko 3 bity adresowe (A2, A1 i A0), które umożliwiają rozróżnienie kilku układów pamięci w obrębie tej samej grupy. Najstarsze 4 bity adresu są stale i określają grupę do której należy dany układ, dla pamięci EEPOROM jest to 1010. A na koniec 7 bitowego adresu przypada bit operacji - 1 dla zapisu i 0 dla odczytu. Dzięki czemu bez problemu większość małych układów pamięci EEPROM, w tym popularna rodzina 24Cxx pakowana jest w obudowy 8 nóżkowe (DIP-8, SO-8).

W moim układzie znajduje się kostka AT24C128, w wersji której (podobnie jak w wersji 256K) na zewnątrz wyprowadzone są tylko piny A1 i A0, co umożliwia do 4 kostek podłączonych do tej samej magistrali. Schemat podłączenia pamięci w systemie przedstawia poniższy schemat.

saelog6-eeprom-schematic

Zworka J1 odpowiada za widoczność pamięci w systemie, operując na ostatnim bicie adresu (A0).

W istocie, wspominałem o tym w notce dotyczącej analizatora i płytki Lcsoft, a także można znaleźć to w dokumentacji, że sam układ EZ-USB wspiera oba rodzaje pamięci. A identyfikację oparto na podstawie adresu fizycznego, gdzie decyduje właśnie wartość bitu A0:

…decydującym pinem adresu jest A0, który dla 8-bitowych adresów powinien znajdować się w stanie niskim, a dla 16-bitowych w stanie wysokim. Co implikuje dostępność 8-bitowych pamięci pod adresem 0xA0, a16-bitowych pod 0xA2 szyny I2C, jeśli chcemy wykorzystać pamięć w procesie bootowania. Po wykryciu typu podłączonej pamięci manual twierdzi, że EZ-USB zaraportuje wynik na liniach ID1 i ID0

Znajomość komunikacji z pamięcią EEPROM poprzez magistralę I2C jest niezbędna do dalszych moich prac. Na pewno przydatna będzie przy analizie kodu firmware, a także przy wprowadzaniu ewentualnych modyfikacji. Całość bazuje na I2C, więc mogę ograniczyć się jedynie do protokołu definiującego co i kiedy należy wysłać lub odczytać z magistrali, aby układy się dogadywały poprawnie. Szczegółowo opisano to w dowolnym datasheecie pamięci EEPROM.

Wszystkie operacje zapisu i odczytu, a jest ich kilka, są do siebie podobne, przy najmniej w tej części początkowej związanej miedzy innymi z adresowaniem fizycznym układu i adresu komórek pamięci. Dlatego poniżej prezentuję, bazujące na dokumentacji przebiegi sygnałów na linii SDA przy sekwencyjnym zapisie i odczycie.

saelog6-eeprom-i2c

Obie wersje adresowania komórek pamięci różnią się jedynie długością adresu, zatem całość jest ze sobą kompatybilna z wyjątkiem fragmentu przesyłania bajtów adresowych. Kolorem czerwonym zaznaczyłem te fragmenty przebiegu, które występują w moim układzie, czyli w kostkach adresowanych słowem. Komentarz wydaje się zbędny, widać wyraźnie, że jeśli adresowanie jest dłuższe niż 1 bajt, to trzeba przesłać wszystkie jego części, co jest oczywiście bardzo logiczne.

Analiza kodu

Interesujące fragmenty kodu odpowiedzialne za odczyt i zapis danych w pamięci EEPROM można zlokalizować w kodzie firmware na różne sposoby. Posłużyć się można informacjami jakie udało mi się do tej pory znaleźć, które zamieściłem w poprzednich wpisach serii #SaeLog. Oczywiście można szukać po adresach rejestrów związanych z I2C, w pobliżu parsowania przychodzących/wychodzących danych, gdyż format pakietów jest znany znany, albo po funkcji de-kodującej przesyłane dane.

Funkcja czytającą dane z pamięci zlokalizowana jest pod adresem 0x09FB, a zapisująca pod 0x0D74.

Na pierwszy rzut przyglądałem się funkcji odczytującej dane, która może wydawać się nieco skomplikowana, ale po głębszym spojrzeniu jest całkiem banalna. Ot, proste manipulowanie rejestrami I2C w postaci typowych operacji na magistrali, które łatwo przełożyć na język wyższego poziomu. Poniżej flow graph tejże funkcji.

saelog6-read-chart

Wykres wydaje mi się bardziej czytelny niż wklejanie dużych ilości kodu maszynowego, a na jego podstawie łatwiej omówić i przedstawić kluczowe fragmenty kodu, a także korelacje zachodzące miedzy nimi. W tym celu nieco pokolorowałem bloki funkcjonalne i podobne fragmenty kodu.

Zaczynając od początku, realne działanie funkcji zaczyna się od przygotowania i wysłania sekwencji START i całego pierwszego pakietu, zawierającego adres fizyczny układu. Tym właśnie zajmuje się pierwszy blok oznaczony kolorem niebieskim:

00000A0F code_A0F:
00000A0F	mov   DPTR, #0xE678		; I2CS
00000A12	movx  A, @DPTR
00000A13	mov   R7, A
00000A14	mov   A, R7
00000A15	orl   A, #0x80			; 8 bit = START
00000A17	mov   R7, A
00000A18	mov   DPTR, #0xE678
00000A1B	mov   A, R7
00000A1C	movx  @DPTR, A			; I2CS |= START
00000A1D	mov   DPTR, #0xE679		; I2DAT
00000A20	mov   A, #0xA0
00000A22	movx  @DPTR, A			; I2DAT = 0xA0

Zaraz po nim kod wpada w pętle while oczekując na fizyczne wysłanie pakietu (DONE) - kolor zielony i sprawdzenie otrzymania potwierdzenia ACK od odbiorcy - kolor jasno-zielonkawy:

00000A23 code_A23:
00000A23	mov   DPTR, #0xE678
00000A26	movx  A, @DPTR
00000A27	mov   R7, A
00000A28	mov   A, R7
00000A29	jnb   ACC0, code_A23	; while (!(I2CS & DONE)) ;

00000A2C	mov   DPTR, #0xE678
00000A2F	movx  A, @DPTR
00000A30	mov   R7, A
00000A31	mov   A, R7
00000A32	jb    ACC1, code_A50	; jmp if (I2CS & ACK)

Taki blok kodu będzie towarzyszył każdemu wysyłaniu danych na magistralę I2C, a także czasami przy odczycie.

Jeśli z jakiś powodów układ nie otrzymał potwierdzenia, ustawia bit STOP i zaczyna cała zabawę z transmisja od początku:

00000A35	mov   DPTR, #0xE678		; I2CS
00000A38	movx  A, @DPTR
00000A39	mov   R7, A
00000A3A	mov   A, R7
00000A3B	orl   A, #0x40			; 7 bit = STOP
00000A3D	mov   R7, A
00000A3E	mov   DPTR, #0xE678
00000A41	mov   A, R7
00000A42	movx  @DPTR, A			; I2CS |= STOP

00000A43 code_A43:
00000A43	mov   DPTR, #0xE678
00000A46	movx  A, @DPTR
00000A47	mov   R7, A
00000A48	mov   A, R7
00000A49	jnb   ACC6, code_A0F	; jmp if !(I2CS & STOP)
00000A4C	sjmp  code_A43			; else do-while

Bloki zawierające fragment tego kodu oznaczono kolorem czerwonym. W rzeczywistości w kodzie występuje kilka takich funkcjonalnych bloków, które fizycznie robią to samo, czasem nawet zakodowane co do opcodu tak samo. Bloki te oznaczono różnymi odcieniami koloru czerwonego. Dziwne, że kompilator nie dokonał lepszej optymalizacji, bez problemu można byłoby użyć tylko jednego wystąpienia w każdym przypadku.

Zgodnie z protokołem komunikacyjnym pamięci EEPROM, po pierwszym pakiecie kontrolnym wysyłane jest adres komórki. W kodzie funkcji odpowiada za to fragment kodu oznaczony kolorem żółtym:

00000A50	mov   DPTR, #0xE679
00000A53	mov   A, RAM_42
00000A55	movx  @DPTR, A

Po poprawnym wysłaniu leci kolejny pakiet z bitem START-u i adresem układu (kolor niebieski), podobnie jak to miało miejsce na początku, ale z bitem R/W w stanie wysokim.

Później już tylko odczytywanie danych, sekwencyjnie w pętli. Całość tego kodu oznaczyłem szarym tłem. Po odczytaniu całości wysyłany jest pakiet STOP (kolor pomarańczowy) i wyjście z funkcji.

Układ pamięci będzie tak długo wysyłał dane, dopóki nie podziękujemy mu za pomocą bitu NO ACK po ostatnim odebranym bajcie danych. W I2C w EZ-USB służy do tego bit LASTRD w rejestrze I2CS, który należy ustawić w trakcie lub przed ostatnią operacja odczytu. Skutkiem czego będzie ustawienie stanu wysokiego w czasie transmisji sygnału ACK, co odpowiada NO ACK.

00000AE2	mov   A, RAM_46				; length
00000AE4	dec   A						; A = length - 1
00000AE5	mov   R7, A
00000AE6	mov   A, R7
00000AE7	cjne  A, RAM_4A, code_AF8	; jmp if A != readed
00000AEA
00000AEA	mov   DPTR, #0xE678		; I2CS
00000AED	movx  A, @DPTR
00000AEE	mov   R7, A
00000AEF	mov   A, R7
00000AF0	orl   A, #0x20			; 6 bit = LASTRD
00000AF2	mov   R7, A
00000AF3	mov   DPTR, #0xE678
00000AF6	mov   A, R7
00000AF7	movx  @DPTR, A			; I2CS |= LASTRD
00000AF8
00000AF8 code_AF8:
00000AF8	mov   DPTR, #0xE679		; I2DAT
00000AFB	movx  A, @DPTR
00000AFC	mov   R7, A

I to tyle odnośnie funkcji czytającej dane z pamięci EEPROM. Wersja służąca do zapisu danych w pamięci jest dużo prostsza niż ta przedstawiona wyżej. A jej działanie będzie adekwatne, korzystające z podobnych fragmentów kodu, wiec nie widzę sensu się nią zajmować. Czas przejść do zabawy.

Hackowanie/Modyfikacje

Na podstawie wyżej prezentowanych różnic dotyczących pamięci EEPROM, wyłaniają się dwie kwestie, z jakimi muszę się zmierzyć. Są to wymagane modyfikacje w kodzie firmware, niezbędne do zastąpienia (a raczej wzbogacenia) kodu oszukującego oryginalnie małe kostki pamięci na te nieco większe.

Mam jedno wymaganie co do wprowadzanych zmian i modyfikacji. Najlepiej zorganizować wszystko tak, aby zmiany wprowadzane nie zwiększały kodu firmware, pozwoli to później w łatwy sposób wstrzyknąć zmiany do kodu aplikacji na komputerze. Dlatego ewentualny nowy kod trzeba gdzieś umiejętnie upchać w aktualnym kodzie nadpisując jakiś stary lub nieużywanym fragment. Wolne miejsce można zorganizować za pomocą optymalizacji powtarzanego kodu fizycznego czy logicznego, jaki przewija się w modyfikowanych funkcjach. Takim idealnym kandydatem są bloki przerywające operację w przypadku wystąpienia błędu/nie otrzymania potwierdzenia od układu, oznaczone odcieniami czerwieni, o czym wspominałem przy analizie.

Pierwszą wymaganą modyfikacją jest zmiana adresu fizycznego układu pamięci, co wynika bezpośrednio z wymagań układu FX2 i zmiany stanu pinu adresowego 0xA1. Moja kostka dostępna jest pod adresem 0xA2, a kod spodziewa się jej pod 0xA0.

Tutaj wystarczy zmiana 1 bitu w wartości odpowiedzialnej za adres fizyczny, w miejscach jego wysyłania. Dla ReadEEPROM, dane te, zlokalizowane pod adresami 0x0A20 i 0x0A94 należy zmienić na wartości przedstawione niżej:

00000A20	mov   A, #0xA2			; orginal: 0xA0
[...]
00000A94	mov   A, #0xA3			; orginal: 0xA1

Podobnie należy postąpić w WriteEEPROM, adresy urządzenia znajdują się w miejscu 0x0D89, a poprawka ogranicza się do zmiany na:

00000D89	mov   A, #0xA2			; orginal: 0xA0

Druga modyfikacja, to dodanie wysyłania pełnego adresu komórki pamięci, do tej pory mikrokontroler FX2 urządzenia wysyła tylko 1 bajt, a układ scalony z pamięcią w moim urządzeniu oczekuje 2-bajtowego adresu. Można rozwiązać to na kilka sposobów, ale wymaga to zdecydowanie więcej pracy.

Najprostsze wydaje się powielenie bloku kodu z wysyłaniem bajtu adresowanego z oczekiwaniem na DONE i sprawdzeniem ACK. Przed lub za aktualnym kodem. A taki kod zajmuje 24 bajty. Co jest większe niż ewentualne wolne miejsce z czerwonego bloku, które zajmuje 19 bajtów (wersja używana w pobliżu). Można odzyskać 2 takie bloki, ale może nie tędy droga.

Dodanie prostej pętli, która wykona wymagany kod dwukrotnie zmieniając dane do wysłania jest optymalnym rozwiązaniem. Problemem może być potrzeba użycia dodatkowej zmiennej, a nie bardzo wiem, czy mogę dotykać jakieś rejestry. Na szczęście w ReadEEPROM, niżej w pętli odczytującej sekwencyjne przesyłane dane jest takowa zmienna - RAM_4A używana tylko w tamtym miejscu, więc bez żadnych oporów mogę z niej skorzystać. W WriteEEPROM tez powinno cos się znaleźć, mimo iż tam nie ma żadnej pętli, ani sekwencyjnego zapisywania, ale coś wymyślę.

W pseudokodzie można to przedstawić tak:

int8_t x = 0;

do {

	I2DAT = x;
	while (!(I2CS & DONE))
		;

	if (!(I2CS & ACK))
		goto stop_retry;

	if (x == addr)
		break;

	x = addr

} while (1);

Translacja tego na kod maszynowy i asembler będzie trochę trudniejsza i nieco inna, nie będzie odzwierciedlać kodu jeden-do-jednego, ale będzie funkcjonalnie równoważna. Chce wykorzystać istniejące bloki kodu, aby główny kod jaki znajdzie się w pętli był nietknięty. Wymagać to będzie kilku zmian w przepływie kontroli kodu.

Poniżej modyfikacje dla funkcji czytającej, z małymi komentarzami ułatwiającymi zrozumienie.

00000A32	jb    ACC1, code_A72		; jmp to do-while start
00000A32								; instead of to code_A50
[...]
; ---------------------------------------------------------------------------
00000A50								; old code begin
00000A50 code_A50:
00000A50	mov   DPTR, #0xE679
00000A53	mov   A, RAM_42
00000A55	movx  @DPTR, A				; I2DAT = RAM_42
00000A56
00000A56 code_A56:
00000A56	mov   DPTR, #0xE678
00000A59	movx  A, @DPTR
00000A5A	mov   R7, A
00000A5B	mov   A, R7
00000A5C	jnb   ACC0, code_A56		; while (I2CS & DONE)
00000A5F	mov   DPTR, #0xE678
00000A62	movx  A, @DPTR
00000A63	mov   R7, A
00000A64	mov   A, R7
00000A64								; old code end
; ---------------------------------------------------------------------------
00000A65	jnb   ACC1, code_A35		; jmp if !(I2CS & ACK)
00000A65								; to STOP (red) code
00000A68	clr   A
00000A69	cjne  A, RAM_42, code_A86	; break if RAM_42 != 0
00000A69								; jmp to code after send addr
00000A6C	mov   A, RAM_4A
00000A6E	mov   RAM_42, A				; RAM_42 = RAM_4A
00000A70	sjmp  code_A50				; loop, another iterate
00000A72
00000A72 code_A72:						; do-while start
00000A72	mov   A, RAM_42
00000A74	mov   RAM_4A, A				; RAM_4A = RAM_42
00000A76	mov   RAM_42, #0			; RAM_42 = 0
00000A79	sjmp  code_A50				; jmp to start loop

Podobnie to wygląda przy zapisie, tutaj w roli dodatkowej zmiennej wykorzystałem rejestr R5, w którym przekazano parametry do funkcji. 

00000D9B	jb    ACC1, code_DD9
[...]
; ---------------------------------------------------------------------------
00000DB9								; old code begin
00000DB9 code_DB9:
00000DB9	mov   DPTR, #0xE679
00000DBC	mov   A, RAM_5A
00000DBE	movx  @DPTR, A
00000DBF
00000DBF code_DBF:
00000DBF	mov   DPTR, #0xE678
00000DC2	movx  A, @DPTR
00000DC3	mov   R7, A
00000DC4	mov   A, R7
00000DC5	jnb   ACC0, code_DBF
00000DC8	mov   DPTR, #0xE678
00000DCB	movx  A, @DPTR
00000DCC	mov   R7, A
00000DCD	mov   A, R7
00000DCD								; old code end
; ---------------------------------------------------------------------------
00000DCE	jnb   ACC1, code_D9E
00000DD1	mov   A, RAM_5A
00000DD3	jnz   code_DEC
00000DD5	mov   RAM_5A, R5
00000DD7	sjmp  code_DB9
00000DD9
00000DD9 code_DD9:
00000DD9	mov   R5, RAM_5A
00000DDB	mov   RAM_5A, #0
00000DDE	sjmp  code_DB9

Poszło łatwo, chociaż musiałem ręcznie dekodować mnemoniki i bawić się w kompilatora, ale takie doświadczenia po raz kolejny okazują się bardzo przydatne.

Dla zainteresowanych prezentuję patch w formacie IDA, gdzie dokładnie widać co i gdzie zmieniłem w kodzie firmware:

This difference file has been created by IDA

firmware.hex
00000A21: A0 A2
00000A34: 1B 3D
00000A65: 20 30
00000A67: 1B CD
00000A68: 90 E4
00000A69: E6 B5
00000A6A: 78 42
00000A6B: E0 1A
00000A6C: FF E5
00000A6D: EF 4A
00000A6E: 44 F5
00000A6F: 40 42
00000A70: FF 80
00000A71: 90 DE
00000A72: E6 E5
00000A73: 78 42
00000A74: EF F5
00000A75: F0 4A
00000A76: 90 75
00000A77: E6 42
00000A78: 78 00
00000A79: E0 80
00000A7A: FF D5
00000A95: A1 A3

00000D8A: A0 A2
00000D9D: 1B 3B
00000DCE: 20 30
00000DD0: 1B CD
00000DD1: 90 E5
00000DD2: E6 5A
00000DD3: 78 70
00000DD4: E0 17
00000DD5: FF 8D
00000DD6: EF 5A
00000DD7: 44 80
00000DD8: 40 E0
00000DD9: FF AD
00000DDA: 90 5A
00000DDB: E6 75
00000DDC: 78 5A
00000DDD: EF 00
00000DDE: F0 80
00000DDF: 90 D9

Zmian w formacie diff dla ewentualnego kodu zapisanego w IntelHEX nie ma. Prawdopodobnie takowy się pojawi, w następnym odcinku, jak będę próbował wstrzyknąć to do aplikacji i tym podobne rzeczy. Tym czasem warto kilka słowach napisać o testowaniu patcha.

Testowanie

Testowanie zmian jest istotnym elementem całego procesu i nie podlega to dyskusji. Próby kodu łatwo przeprowadzić dwoma metodami, które powinny jednoznacznie stwierdzić, czy wszystko gra i buczy. Na początek można zapisać coś bezpośrednio w pamięci EEPROM korzystając chociażby z Vend_ax, a następnie odczytać za pomocą zmodyfikowanego firmware. Podobnie w druga stronę. A gdy wyniki wyglądają na poprawne, warto spróbować zapisać i odczytać bezpośrednio za pomocą zmodyfikowanego kodu.

Komunikacja z EEPROMEM wprost z urządzenia odbywa się poprzez zerowy punkt końcowy EP0, identyfikator pipki dla OUT to 1 (0x01), a 129 (0x81) dla strumienia IN. Żądania lecą według określonego formatu, o którym już pisałem w SaeLog #2, dla przypomnienia “pakiety” wyglądają tak:

 read: 07 33 81 ADR LEN
write: 06 42 55 ADR LEN [data]

Oczywiście zgodnie z tym co zapisałem w tamtej notatce, transmisja jest szyfrowana i wszystkie bajty lecące między urządzeniem a komputerem będą trochę inaczej wyglądać. O tych drobnych, acz ważnych szczegółach, warto pamiętać testując zmodyfikowany kod firmware poza oficjalną aplikacją.

Przykładowy zbiór danych do testów, gdzie input to dane w postaci źródłowej, a output po przejściu przez kod szyfrujący:

; read 8 bytes starting at 0x08
 input: 07 33 81 08 08
output: DF C6 B3 51 60

; write 8 bytes starting at 0x08
 input: 06 42 55 08 08 00 01 02 03 04 05 06 07
output: DC 13 6F 79 B8 D3 D5 D5 DF 2F E1 C9 E3

; data
 input: 00 01 02 03 04 05 06 07
output: DA 32 F4 34 F6 36 F8 90

Idealnymi narzędziami do testów są toolsy z pakietu Cypressa, oprócz CyConsole przydaje się również CyControl do łatwego wysyłania i pobierania danych z urządzenia podpiętego pod port USB.

Ja właśnie z tych przytoczonych wyżej narzędzi korzystałem, a zmiany w kodzie pozytywnie przeszły testy i działa to tak jak powinno.

No i udało się zhackować kod firmware dodając obsługę dwu-bajtowo adresowanych pamięci EEPROM. Oczywiście wszystko odbyło się kosztem obsługi zwykłych, małych kostek pamięci, ale jeśli ktoś bardzo chciałby to może spróbować nieco zmodyfikować kod. By na żywo zależnie od typu podłączonej pamięci odpowiednio reagować i sterować transmisją danych – wysyłaniem dodatkowego bajtu adresu.

Ja w kolejnej notce, która prawdopodobnie pojawi się nieco po urlopie, spróbuję przekonwertować patcha na wersje hex i wstrzyknąć do aplikacji.

Komentarze (0)

Dodaj komentarz

/dozwolony markdown/

/nie zostanie opublikowany/