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.
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 adresem0xA0
, a16-bitowych pod0xA2
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 liniachID1
iID0
…
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.
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.
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)