O rekompilacji i autorstwie z pociągami w tle

gynvael.coldwind.pl 11 miesięcy temu

Wczoraj na konferencji Oh My H@ck widziałem zaprezentowany chyba najciekawszy case-study ze stosowanej inżynierii wstecznej w tym roku (jeśli nie w tej dekadzie). Mówię oczywiście o prelekcji „Heavyweight Hardware Hacking”, gdzie zaprezentowane zostaly wyniki badań prowadzonych przez Redforda, q3ka, oraz mrticka (PanaKleszcza). w uproszczeniu i uproszczeniu można o nich poczytać m.in. na Z3S lub u q3ka na mastodonie. A dzisiaj na te publikacje oficjalnie odpowiedziała spółka NEWAG.

W odpowiedzi pojawiło się bardzo interesujące stwierdzenie na temat technikaliów inżynierii wstecznej, a ponieważ jest to temat, którym zajmuje się od prawie 20 lat, to postanowiłem się do tego odnieść.

Na początek kilka disclaimerów:

  1. O ile wychodzę w poście od cytatu z oświadczenia NEWAGu, to skupiam się na ogólnych technikaliach, a nie na tym konkretnym przypadku i tej konkretnej sprawie.
  2. Full-disclosure: jestem członkiem i założycielem Dragon Sector, natomiast nie pracowałem nad tym konkretnym projektem, więc nie mam praktycznie żadnych dodatkowych informacji poza tym co można znaleźć w Internecie oraz co było wczoraj na prelekcji.

Zacznijmy od cytatu z oficjalnego oświadczenia spółki NEWAG (podkreśliłem jedno najważniejsze słowo, które jest trochę bez znaczenia dla tego postu, ale które ładnie nawiązuje do problematyki metadanych; ale to inny temat i nie na dzisiaj):

8. W konsekwencji w dowolnym czasie możliwe było wykonanie tzw. reverse engineering systemu sterującego (tj. zhakowanie) poprzez przeniesienie jego kodu dekompilacji, modyfikacji oraz ponowne załadowanie zmienionego systemu sterującego. [...] 10. [...] Otóż oczywistym jest, iż choćby najlepszy haker może co najwyżej próbować poznać treść określonego zapisu cyfrowego. Żaden haker nie jest natomiast w stanie, na podstawie samego zapis cyfrowego, wskazać kto konkretnie jest „autorem” określonego zapisu cyfrowego.

Oba zacytowane stwierdzenia są prawdziwe. Tj. prawdziwym jest, iż jeżeli nie stosuje się żadnych mechanizmów podpisów cyfrowych firmware'u (trochę ironiczne, iż konsole do gier mają lepsze zabezpieczenia pod tym względem niż pociągi), to można sobie firmware zrzucić, zmodyfikować, i wrzucić go ponownie. Prawdziwym też jest, że, patrząc na sam kod maszynowy, trudno jest ustalić jego autorstwo – to problem tzw. „attribution” (przypisanie autorstwa) znany przede wszystkim analitykom zajmującym się złośliwym oprogramowaniem.

Co za tym idzie, w tym przypadku NEWAG argumentuje, iż ktoś inny ten kod mógł zmodyfikować, i jednocześnie wskazują, iż trudno w takich przypadkach jest określić kto daną modyfikację zrobił na podstawie kodu maszynowego.

Oczywiście w rzeczywistym świecie próby przypisania autorstwa nie ograniczają się tylko i wyłącznie do kodu maszynowego, ale ja jednak skupie się tylko na tym. Otóż okazuje się, iż analizując kod maszynowy można wyciągnąć jedną bardzo ciekawą informację z bardzo wysokim prawdopodobieństwem (graniczącym z pewnością): czy modyfikacja została zrobiona na poziomie kodu maszynowego, czy kodu źródłowego.

Podstawy, czyli proces kompilacji

Zacznijmy od przypomnienia podstaw tego jak działa budowanie (kompilacja) oprogramowania, w szczególności systemu w językach stosunkowo niskopoziomowych, jak np. język C.

W pewnym uproszczeniu można powiedzieć, iż programy z postaci źródłowej kompilowane są do danych oraz do kodu maszynowego. W plikach pośrednich (a często również wynikowych) dane mają zwyczaj trafiać do sekcji o nazwach typu .data (modyfikowalne dane zainiciowane inną wartością niż 0), .rodata (dane tylko do odczytu), czy .bss (modyfikowalne dane zainiciowane zerami). Kod maszynowy natomiast trafia do sekcji o nazwie typu .text.

Sekcje to nic innego jak ciągłe fragmenty pamięci, tj. po prostu tablice bajtów. Podczas kompilacji jednym z bardzo ważnych zadań kompilatora (oraz w bardziej ograniczonym stopniu linkera) jest pamiętanie na jakim offsecie (adresie) od początku sekcji znajduje się która zmienna, tablica, tekst, obiekt, funkcja, itd. Jest to bardzo istotne, ponieważ gdy tylko któraś funkcja będzie chciała odwołać się do innej funkcji lub jakiejś globalnej zmiennej/tablicy/obiektu/etc, to w tym miejscu kompilator musi wstawić odpowiedni adres z odpowiedniej sekcji. Przykładowo (pseudokod C/C++):

int a = 5; int main() { return a; }

W tym prostym programie mamy zmienną globalną a, oraz funkcję main. Zmienna globalna najpewniej powędruje gdzieś do sekcji .data, a kod maszynowy funkcji main gdzieś do sekcji .text. Jak można zauważyć w kodzie, funkcja main zwraca wartość przechowywaną w zmiennej a, tj. musi znać oraz użyć adresu w pamięci, gdzie znajduje się ta właśnie zapisana wartość zmiennej a. Można to również zaprezentować w taki sposób:

Oczywiście ten program jest absolutnie trywialny, więc graf jest czytelny i mamy na nim tylko jedno odwołanie, tj. tylko jedną strzałkę. W prawdziwych programach zmiennych i funkcji są setki lub tysiące, a odwołań jest często jeszcze więcej.

Zazwyczja po skompilowaniu/skonsolidowaniu wszystkie informacje o tym „co jest gdzie” są gubione. Tj. nie są już potrzebne, bo wszystkie adresy zostały już wyliczone i zapisane w kodzie, więc nie ma sensu ich emitować i zapisywać pliku wykonywalnym. W szczególności jest to prawdziwe dla wszelkich „płaskich” binarek, takich jak monolitycznych obrazów firmware'u oraz plików wykonywalnych bez wsparcia ASLR. Wyjątkiem będą m.in. dynamiczne biblioteki, gdzie często zachowuje się częściowe informacje o pozycjach funkcji (tablice eksportów) czy o pozycjach odwołań (tablice relokacji).

A dlaczego to jest istotne? O tym za chwilę.

Modyfikowanie kodu maszynowego

Kod maszynowy można modyfikować na kilka różnych sposobów. Odpowiedni sposób wybiera się według potrzeb, ale główna zasada brzmi: czym bardziej skomplikowane modyfikacje, tym bardziej skomplikowana będzie technika.

Najprostsze są modyfikacje, gdzie zmienia się malutki fragmencik kodu (kilka-kilkanaście bajtów) i gdzie modyfikacja jest de facto mniejsza (bajtowo) niż oryginalna funkcjonalność. W takim wypadku po prostu nadpisuje się stary kod maszynowy wybranego fragmentu nowym i dopełnia resztę NOPami (tj. instrukcjami, które nic nie robią), lub przeskakuje (instrukcją skoku) stare niepotrzebne bajty – zwykle nazywamy to „patchowaniem”. Tego typu modyfikacje są do bólu oczywiste w kodzie maszynowym z uwagi na powstałe artefakty (NOPy, skok omijający jakiś nieużywany kod, a często też nietypowa konstrukcja kodu maszynowego).

Sprawa się trochę komplikuje, jeżeli modyfikacja jest trochę większa, tj. kodu maszynowego, który chcemy dodać, jest więcej niż oryginalnego kodu. Np. chcemy obsłużyć jeszcze jedną sytuację w funkcji, lub dodać zupełnie nową funkcję. Wtedy musimy albo znaleźć trochę pustego miejsca gdzieś w okolicy (tzw. code cave) i dodać tam nadmiarowy kod, albo rozszerzyć trochę odpowiednią sekcje, albo dodać wręcz nową sekcję o odpowiednich atrybutach. Potem musimy jeszcze „hooknąć” odpowiednie miejsca, żeby przekierować bieg wykonania programu do naszej „jaskini”. W każdej z tych sytuacji jest podobnie jak poprzednio, tj. bardzo łatwo zauważyć tego typu modyfikację w kodzie maszynowym – m.in. dlatego, iż kawałki funkcji są w jakichś dziwnych odseparowanych miejscach, funkcje są na końcu sekcji (mogą być powody, czemu funkcje są na końcu sekcji, ale zwykle rozkład funkcji ma jakiś wzorzec, a dodana funkcja by od niego odstawała), czy w jakichś dodanych sekcjach.

A gdyby tak rozsunąć po prostu funkcje, tj. zrobić trochę miejsca na powiększenie jednej funkcji (tak, żeby dodać trochę więcej kodu) lub dodanie innej?

I tutaj wracamy do tego o czym pisałem w poprzedniej sekcji. Takie „rozsunięcie funkcji” wiązałoby się się z koniecznością poprawy wszystkich „uniważnionych” przez zmiany w adresach odwołań. WSZYSTKICH. jeżeli któreś byśmy pominęli, to kod by się po prostu crashował. Co więcej, pisałem w poprzedniej sekcji, iż informacje o tym gdzie co jest są gubione. Tj. żeby w ogóle zacząć realizować ten pomysł, trzeba najpierw dokładnie ustalić gdzie co się znajduje. To niestety jest jednym z trudnych problemów w inżynierii wstecznej, i stosuje się tutaj sporo heurystyki. Co za tym idzie, łatwo jest coś przeoczyć lub pominąć, a tak powstały błąd nie koniecznie musi objawić się od razu poczas testowania.

Tego typu podejścia się po prostu nie stosuje – jest niepraktycznie skomplikowane i ryzykuje się wprowdzenie nietrywialnych do znalezienia błędów.

Rekompilacja, tj. dekompilacja i ponowna kompilacja

Czasami jednak chcemy, żeby modyfikacje robiło się trochę wygodniej. I chcemy ich robić dużo. W takim przypadku dochodzimy do tytułowej „rekompilacji”, tj. zdekompilowania kodu (patrz punkt 8 w oświadczeniu) do postaci kodu w C, modyfikacji wybranych części, a potem skompilowaniu całości na nowo.

I teraz krótka dygresja o różnicy pomiędzy dezasemblacją/deasemblacją/disasemblacją (możecie wybrać ulubione spolszczenie angielskiego „disassembling”) a dekompilacją:

  • Deasemblacja to przetłumaczenie kodu maszynowego na blisko odpowiadający mu zapis mnemoniczny w języku asembler. Na tym się polega podczas tworzenia modyfikacji na poziomie kodu maszynowego (patrz poprzednia sekcja).
  • Dekompilacja to próba „podniesienia” kodu maszynowego do poniekąd odpowiadającego mu kodu w języku wyższego poziomu, tj. zwykle C (ew. pseudokodu).

Deasemblacja jest generalnie dość prostym procesem, ale jest z nią trochę problemów, które muszą być rozwiązywane heurystycznie – np. gdzie zaczynają się funkcje (jest trochę złośliwych przypadków, gdzie to nie jest oczywiste). Deasemblacja nie jest też 1:1, tj. zdeasemblowanie kodu maszynowego i jego ponowna kompilacja (asemblacja) niekoniecznie są w stanie dać taki sam wynik (m.in. dlatego, iż instrukcje asemblera można przetłumaczyć na kod maszynowy na kilka różnych sposobów). Ale to temat na trochę inny post.

Dekompilacja jest natomiast bardzo skomplikowanym problemem – m.in. dlatego, iż w procesie komplikacji gubione są wszystkie metadane o typach zmiennych, a te często jest ciężko wywnioskować z kodu maszynowego (w szczególności przy bardziej złożonych typach). Co więcej, z uwagi na optymalizacje wprowadzane przez kompilator (złośliwi mówią, iż to „komplikator”), nie wszystkie wyrażenia da się jakoś sensownie odtworzyć w języku wyższego poziomu (zobaczcie np. co kompilator robi z prostym dzieleniem). Co za tym idzie, da się zdekompilować a następnie zrekompilować kod, ale:

  • Jest to bardzo czasochłonny proces (a co za tym idzie: drogi), żeby wygenerowany przez dekompilator kod w ogóle zaczął się kompilować.
  • Nawet jak kod się kompiluje, prawdopodobnie zawiera bardzo dużo błędów, które objawią się dopiero w wyjątkowych sytuacjach (tygodnie–miesiące testów).
  • I najważniejsze, kod maszynowy po rekompilacji będzie wyglądał zupełnie inaczej, niż kod oryginalny (to wynika m.in. z różnicy w kompilatorach, wersjach kompilatorów, parametrów kompilacji, wprowadzonych zmian „żeby się w ogóle kompilowało”, czy choćby kolejności funkcji, zmiennych, czy plików źródłowych).

Ten ostatni punkt jest o tyle ważny, iż dzięki niemu bardzo łatwo wziąć np. oryginalny firmware oraz firmware, który podejrzewamy, iż jest wynikiem rekompilacji, i je porównać. jeżeli kod został zrekompilowany, to będzie to od razu widoczne.

Ciekawa informacja

Co za tym idzie, jeżeli kod został zmodyfikowany na poziomie kodu maszynowego lub wręcz zrekompilowany, to w analizowanym kodzie maszynowym znajdą się artefakty, które to wykażą. W szczególności jest to proste do wykazania, jeżeli dysponuje się również oryginalnym kodem maszynowym.

A jeżeli żadnych artefaktów nie ma, to jest praktycznie pewne, iż modyfikacja została wykonana na oryginalnym kodzie źródłowym, który następnie został skompilowany jak zwykle. A kto zrobił modyfikacje? Tego oczywiście nie wiadomo. Ale być może w tym przypadku zasadnym by było zadać pomocnicze pytanie: a kto miał dostęp do kodu źródłowego?

Idź do oryginalnego materiału