Preorder drugiego tomu książki sekuraka: Wprowadzenie do bezpieczeństwa IT. -15% z kodem: sekurak-book
Testy aplikacji na Androida: analiza i zmiana sposobu działania aplikacji przez użycie frameworka Frida
Wstęp
Dwa lata temu napisałem tekst na Sekuraku, w którym opisałem w jaki sposób można podejść do analizy aplikacji mobilnych na Androida – krótko poruszyłem tam temat dekompilacji plików .apk i pokazałem jak zmienić działanie funkcji, która w jakiś sposób „przeszkadza” w przeprowadzeniu testów takiej aplikacji (w realnych przykładach może to być aplikacja sprawdzająca, czy urządzenie jest zrootowane). Polegało to na disasemblacji pliku .apk do formatu plików .smali, a następnie do ich ponownej asemblacji do .apk. Następnie wymagane było ponowne zainstalowanie aplikacji na telefonie i cieszenie się z jej nowych funkcji ;)
Takie rozwiązanie niestety jest dość pracochłonne – wymaga ręcznej podmiany istniejących kodów .smali, jak również ponownej instalacji aplikacji. Życie było łatwiejsze, gdyby można było wykonać taką samą operację na żywej aplikacji, w żaden sposób nie zmieniając jej oryginalnego kodu. I tutaj z pomocą przychodzi: Frida.
Czym jest Frida
Frida, jak mówi strona internetowa tego projektu, to world-class dynamic instrumentation framework. Mówiąc po polsku: framework, który pozwoli nam wstrzyknąć nasz własny kod w działający proces (może być to proces na Androidzie, ale również wspierane są: iOS, Windows, Linux czy macOS), a następnie na kontrolę tego procesu z poziomu kodu javascriptowego. Jeśli nie jest to jasne, to przykład kilka akapitów niżej powinien rozwiać wszystkie wątpliwości
O ile kod, który będziemy wstrzykiwali w inny proces będzie napisany w JavaScripcie, o tyle sama obsługa Fridy może odbywać się w innych językach programowania, np. w Pythonie. Stąd najprostszym sposobem na instalację Fridy jest skorzystanie z polecenia:
pip install frida
Dzięki temu zainstalowane zostaną narzędzia frida, frida-kill, frida-ps, frida-discover, frida-ls-devices i frida-trace. Żeby Frida mogła jednak wstrzykiwać się w procesy na urządzeniu mobilnym, musimy na nim również uruchomić odpowiednią binarkę z serwerem. Binarki znajdziemy na githubie projektu, powinniśmy szukać plików, których nazwa zaczyna się od: frida-server. W przypadku mojego urządzenia, poprawnym plikiem będzie: frida-server-10.1.1-android-arm. Plik musimy rozpakować i wrzucić na telefon androidowy, a następnie uruchomić go na telefonie z uprawnieniami roota. Plik najlepiej skopiować narzędziem adb:
# rozpakowujemy plik z serwerem xz -d frida-server-10.1.1-android-arm.xz # kopiujemy na urządzenie do katalogu /data/local/tmp ./adb push frida-server-10.1.1-android-arm /data/local/tmp # wchodzimy do konsoli urządzenia mobilnego # i przyznajemy sobie w niej prawa roota ./adb shell su # ustawmy jeszcze uprawnienia do wykonywania dla # serwera Fridy chmod 755 /data/local/tmp/frida-server-10.1.1-android-arm # możemy wreszcie włączyć Fridę /data/local/tmp/frida-server-10.1.1-android-arm
Serwer Fridy nie wypisuje żadnych komunikatów po uruchomieniu; jeśli wszystko jest OK, to „po prostu działa”.
Przed startem
Dla celów prezentacji Fridy, użyję tej samej aplikacji androidowej co w swoim poprzednim tekście. Dla przypomnienia: aplikacja próbuje pobrać plik ze ścieżki https://raw.githubusercontent.com/securityMB/random-stuff/master/apk-file.txt i wyświetlić go. W domyślnej konfiguracji jednak na pewno się to nie uda ze względu na sposób implementacji sprawdzania certyfikatów. Dla większej przejrzystości tekstu, poniżej przypominam zdekompilowany kod obu klas pojawiających się w aplikacji pl.sekurak.ssltest:
Listing 1. Klasa pl.sekurak.ssltest.MainActivity:
package pl.sekurak.ssltest; import android.os.Bundle; import android.os.StrictMode; import android.os.StrictMode.ThreadPolicy.Builder; import android.support.v7.app.e; import android.util.Log; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.widget.TextView; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.URL; import java.security.KeyManagementException; import java.security.NoSuchAlgorithmException; import javax.net.ssl.HttpsURLConnection; import javax.net.ssl.SSLContext; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.X509TrustManager; public class MainActivity extends e { protected void onCreate(Bundle paramBundle) { super.onCreate(paramBundle); StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder().permitAll().build()); setContentView(2130968598); paramBundle = (TextView)findViewById(2131296319); paramBundle.setText("Trying to get some data from https://raw.githubusercontent.com/securityMB/random-stuff/master/apk-file.txt"); try { Object localObject = SSLContext.getInstance("TLS"); ((SSLContext)localObject).init(null, new X509TrustManager[] { new a() }, null); HttpsURLConnection localHttpsURLConnection = (HttpsURLConnection)new URL("https://raw.githubusercontent.com/securityMB/random-stuff/master/apk-file.txt").openConnection(); localHttpsURLConnection.setSSLSocketFactory(((SSLContext)localObject).getSocketFactory()); localObject = new BufferedReader(new InputStreamReader(localHttpsURLConnection.getInputStream())); paramBundle.setText(((BufferedReader)localObject).readLine()); Log.d("SSLTest", "Mission accomplished."); ((BufferedReader)localObject).close(); return; } catch (SSLHandshakeException localSSLHandshakeException) { paramBundle.setText("Failed to get data from: https://raw.githubusercontent.com/securityMB/random-stuff/master/apk-file.txt. The SSL certificate is invalid."); return; } catch (NoSuchAlgorithmException paramBundle) {}catch (IOException paramBundle) {}catch (KeyManagementException paramBundle) {} } }
Listing 2. Klasa pl.sekurak.ssltest.a
package pl.sekurak.ssltest; import java.security.Principal; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; import javax.net.ssl.X509TrustManager; public class a implements X509TrustManager { public void checkClientTrusted(X509Certificate[] paramArrayOfX509Certificate, String paramString) {} public void checkServerTrusted(X509Certificate[] paramArrayOfX509Certificate, String paramString) { if (paramArrayOfX509Certificate[0] == null) { throw new CertificateException(); } if (paramArrayOfX509Certificate[0].getIssuerDN().getName() != "CN=sekurakowy.pl,O=sekurak.pl,C=PL") { throw new CertificateException(); } } public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } }
Kod, który szczególnie będzie nas interesował zawiera się w liniach 13-21 listingu 2. W tym miejscu sprawdzana jest poprawność certyfikatu w metodzie checkServerTrusted. Specyfika działania tej metody jest taka, że jeżeli certyfikat jest poprawny to metoda nie zwraca nic; jeśli natomiast certyfikat jest niepoprawny, zwracany jest wyjątek.
Docelowo będziemy chcieli zmienić działanie metody tak, by nigdy nie rzucała wyjątku.
Testujemy Fridę
Wracamy więc do Fridy. Zakładamy, że mamy już uruchomioną Fridę na urządzeniu mobilnym. Pierwszą rzeczą, która nas interesuje to listing procesów. Wykorzystamy do tego polecenie frida-ps. Przekażemy do niej parametr -U oznaczający, że Frida spróbuje się komunikować z urządzeniem podłączonym przez USB
$ frida-ps -U PID Name ----- ----------------------------------------------------------- 2369 IPSecService 2388 adbd 3724 android.process.acore 3955 android.process.media ... 9717 pl.sekurak.ssltest ...
Dowiadujemy się dzięki temu, że PID procesu, w który będziemy się wstrzykiwać to 9717. Spróbujmy zatem wstrzyknąć się w proces poleceniem:
frida --enable-jit -U 9717
Dzięki parametrowi –enable-jit nasz kod javascriptowy będzie działał zdecydowanie szybciej (choć może być bardziej pamięciożerny).
Po poprawnym wykonaniu polecenia, powinniśmy zobaczyć obrazek podobny jak na Rys 1.
W konsoli możemy teraz wpisywać kod JS, który zostanie wykonany w kontekście aplikacji androidowej.
Co istotne, gdy działamy na Androidzie, powinniśmy zawsze nasz kod umieścić wewnątrz wywołania Java.perform – dzięki temu Frida upewnia nas, że kod zostaje wykonany w kontekście maszyny wirtualnej Javy.
Wylistowanie istniejących klas
Spróbujmy więc w pierwszej kolejności wypisać wszystkie klasy, które istnieją w działającej aplikacji. W Fridzie mamy do tego metodę Java.enumerateLoadedClasses, do której argumentem jest obiekt JS z dwoma polami:
- onMatch – callback wykonywany w momencie znalezienia jakiejś klasy,
- onComplete – callback wykonywany w momencie zakończenia operacji.
Wypiszmy sobie zatem wszystkie klasy istniejące w programie poniższym kodem:
// Dzięki Java.perform upewniamy się, że kod wykonuje się // w kontekście maszyny wirtualnej Javy. Java.perform(() => { // Enumerujemy załadowane klasy... Java.enumerateLoadedClasses({ onMatch: clsName => { // W przypadku odnalezienia jakiejś klasy, wypisujemy // w konsoli jej nazwę. console.log(clsName); }, onComplete: () => { // Po zakończeniu operacji wypisujemy stosowny komunikat. console.log(" [*] COMPLETED"); } }) });
Po uruchomieniu kodu, dostaniemy gigantyczną listę wszystkich klas (Rys 2.)
Wywoływanie metod na istniejących instancjach klas
Z poziomu Fridy możemy też wywoływać metody na istniejących klasach w aplikacji. Funkcja Java.choose() szuka żyjących instancji klasy, którą przekażemy jako argument. Podobnie jak enumerateLoadedClasses, jako drugi argument przyjmuje obiekt z definicją callbacków onMatch in onComplete.
Spróbujmy zatem poszukać żywej instancji klasy pl.sekurak.ssltest.a (to klasa z listingu 2) – jeśli takowa się znajdzie po prostu wypiszmy odpowiednią informację w konsoli. Użyjemy następującego kodu:
Java.perform(() => { // Szukamy żywej instancji klasy pl.sekurak.ssltest.a Java.choose("pl.sekurak.ssltest.a", { onMatch: inst => { // Argument `inst` przechowuje znalezioną instancję klasy. // Wypiszmy informację o tym obiekcie w konsoli. console.log(inst); }, onComplete: () => {} }) });
Na rys 3. możemy zobaczyć wynik wykonywania kodu.
Zapis „pl.sekurak.ssltest.a@236c1ee2” – pojawiający się na screenie – jest zapewne dobrze znany wszystkim programistom Javy. Jest to wynik standardowego wywołania metody toString() na obiekcie klasy. Wniosek: klasa pl.sekurak.ssltest.a rzeczywiście w pamięci istnieje!
Zatem teraz wywołajmy jakąś metodę na tej klasie. Na przykład po prostu checkServerTrusted przekazując jako argumenty dwa nulle:
Java.perform(() => { // Szukamy żywej instancji klasy pl.sekurak.ssltest.a Java.choose("pl.sekurak.ssltest.a", { onMatch: inst => { // Wywołujemy metodę na znalezionej instancji. inst.checkServerTrusted(null, null); }, onComplete: () => {} }) });
Wynik widzimy na Rys 4.
W wyniku wykonania kodu wystąpił wyjątek – NullPointerException. Jest on jak najbardziej spodziewany, przypomnijmy sobie kod metody checkServerTrusted:
public void checkServerTrusted(X509Certificate[] paramArrayOfX509Certificate, String paramString) { if (paramArrayOfX509Certificate[0] == null) { throw new CertificateException(); } if (paramArrayOfX509Certificate[0].getIssuerDN().getName() != "CN=sekurakowy.pl,O=sekurak.pl,C=PL") { throw new CertificateException(); } }
W trzeciej linii następuje odwołanie do zerowego indeksu tablicy paramArrayOfX509Certificate. Wcześniej nie ma jednak sprawdzenia czy ten obiekt nie jest nullem. Stąd naturalnie: NullPointerException, jako że w wywołaniu funkcji przekazaliśmy nulla.
Podmiana działania metody
Pokazaliśmy, że potrafimy wywołać metodę checkServerTrusted, ale teraz chcielibyśmy jeszcze podmienić jej działanie: powinna nie rzucać wyjątków, tylko zawsze natychmiast kończyć swoje działanie.
Żeby to wykonać, musimy najpierw odwołać się do klasy, w której jest zdefiniowana ta metoda – a następnie podmienić jej właściwość implementation na takie działanie jakie jest przez nas oczekiwane. Aby odwołać się do klasy samej w sobie używamy funkcji Java.use:
Java.perform(() => { // Pobieramy klasę pl.sekurak.ssltest.a const cls = Java.use('pl.sekurak.ssltest.a'); // Podmieniamy implementacje metody checkServerTrusted. // Metoda powinna po prostu nic nie robić ;-) cls.checkServerTrusted.implementation = function() { return; } });
Kod oczywiście zadziała, jednak w naszej aplikacji mamy problem – bowiem ona próbuje pobrać treść pliku przez HTTPS tuż po uruchomieniu, więc podmiana działania metody w trakcie działania procesu jest już niewystarczająca.
W związku z tym musimy sami uruchomić nowy proces na telefonie i tak szybko jak tylko to możliwe – podmienić działanie metody. W tym celu użyjemy kodu napisanego w Pythonie według poniższego szablonu:
# -*- coding: utf-8 -*- import frida, sys CODE = r''' Java.perform(() => { // Pobieramy klasę pl.sekurak.ssltest.a const cls = Java.use('pl.sekurak.ssltest.a'); // Podmieniamy implementacje metody checkServerTrusted. // Metoda powinna po prostu nic nie robić ;-) cls.checkServerTrusted.implementation = function() { return; } }); ''' if __name__ == '__main__': # Pobieramy urządzenie podłączone przez USB. device = frida.get_usb_device() # Uruchamiamy proces pl.sekurak.ssltest. pid = device.spawn(["pl.sekurak.ssltest"]) # Podpinamy się pod proces. session = device.attach(pid) # Włączamy JIT by przyspieszyć działanie kodu JS. session.enable_jit() # Podpinamy skrypt, podobnie jak wcześniej # robiliśmy to z poziomu narzędzia Frida. script = session.create_script(CODE) # Umożliwiamy kontynuację działania procesu # (inaczej jest zablokowany jak w debuggerze) device.resume(pid) # Ładujemy nasz skrypt JS. script.load() # Ponieważ Frida uruchamia się na osobnym wątku # po przejściu na koniec programu, ten natychmiast # zakończyłby swoje działanie. Dlatego musimy użyć # funkcji, która sprawi, że będziemy czekać na # wejście użytkownika. sys.stdin.read()
W zmiennej CODE definiujemy ten sam kod JS, który wcześniej wklejaliśmy w narzędziu Frida. Natomiast w dalszej części programu mamy odpowiednie przygotowanie środowiska po to, by podpiąć Fridę do procesu natychmiast po jego uruchomieniu.
Na rys 5. widzimy widok z wykonywania aplikacji bez Fridy, a na Rys 6 – z Fridą.
Próba podmiany metody zakończyła się sukcesem – w jej efekcie aplikacja nie sprawdza w żaden sposób poprawności certyfikatu SSL.
Użycie Fridy do logowania wywoływanych metod
Ostatnim ciekawym przypadkiem użycia Frida, który pokażę w tym artykule, jest logowanie wywoływanych metod – tj. z jakimi argumentami dana metoda została wywoływana. Jest to bardzo przydatne przy testach aplikacji mobilnych, gdy na podstawie analizy kodu trudno czasem powiedzieć, co dokładnie jest celem danej funkcji, zaś na podstawie analizy sposobu jej działania (argumenty i wartość zwracana) można to stwierdzić w okamgnieniu.
W klasie MainActivity do ustawiania tekstu widocznego w aplikacji mobilnej (jak np. „Wow, you made it!”) jest używana metoda setText:
public class MainActivity extends e { protected void onCreate(Bundle paramBundle) { ... paramBundle = (TextView)findViewById(2131296319); ... paramBundle.setText("Trying to get some data from https://raw.githubusercontent.com/securityMB/random-stuff/master/apk-file.txt"); ... } }
Spróbujmy zatem przechwycić wszystkie wywołania metody setText w klasie TextView. Szybkie googlowanie pozwoli nam stwierdzić, że pełna nazwa klasy to: android.widget.TextView, a metoda setText jest przeładowana i może przyjmować różne kombinacje argumentów. Nie zastanawiajmy się nad tym, które dokładnie chcemy przechwycić – przechwyćmy wszystkie ;-)
Jeśli metoda Javowa jest przeładowana, to możemy wszystkie sposoby jej wywołania uzyskać przez dostęp do obiektu class.method.overloads. Dlatego w poniższym kodzie odwołam się do wszystkich metod typu setText, wypiszę na konsoli jakie argumenty zostały przekazane i wywołam oryginalną metodę:
// Poniższy kod powinien być wklejony w zmiennej CODE // we wcześniejszym kodzie pythonowym. Java.perform(() => { // Odwołujemy się do klasy android.widget.TextView const TextView = Java.use('android.widget.TextView'); // Zmieniamy działanie *wszystkich* metod setText TextView.setText.overloads.forEach(f => { f.implementation = function(...args) { // Logujemy fakt, że metoda została wywołana // wraz z argumentami: console.log(`[${new Date().toString()}] setText called! Args: ${args.join(', ')}`); // Wywołujemy oryginalną metodę. f.call(this, ...args); } }) })
Efekt widoczny na rys. 7.
Podsumowanie
Frida to bardzo rozbudowany framework, który może znacząco pomóc w analizie zachowania aplikacji mobilnych, dzięki możliwości przeprowadzenia poniższych operacji:
- Analiza klas żyjących w pamięci,
- Wywoływanie dowolnych metod na klasach żyjących w pamięci,
- Przechwytywanie i podmiana działania metod,
- Logowanie argumentów przekazywanych do wywoływanych metod.
Frida oferuje o wiele szersze możliwości niż opisane zostały w tym tekście; zainteresowanym polecam zerknąć do dokumentacji.
Michał Bentkowski, pentester w Securitum
swietny tekst! chociaz Firide znam i uzywam, super narzedzie do mobilnych aplikacji.
Wypadałoby napisać, że weyfikacja certyfikatu w podanym kodzie to tylko nic nie znaczący warunek, bo jeszcze ktoś coś takiego zaimplementuje i będzie dym…
Czytajcie kod jaki wrzucacie, macie całkiem spory zasięg, więc i odpowiedzialność wieksza.
Chyba zakładałem w ciemno, że nikt nigdy nie spróbuje takiego kodu użyć w swojej aplikacji.
Ale uwaga słuszna, dodałem już stosowną informację.