nie daj się cyberzbójom! – zapisz się bezpłatne szkolenie o bezpieczeństwie dla wszystkich

Testy aplikacji na Androida: analiza i zmiana sposobu działania aplikacji przez użycie frameworka Frida

29 czerwca 2017, 18:19 | Teksty | komentarze 3

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:

Jeśli nie pamiętacie jak zdekompilować plik APK to postaci Javy to jeszcze raz odsyłam do poprzedniego tekstu.

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.

Podany powyżej kod metody checkServerTrusted jest tylko przykładem. Nie jest poprawnym sposobem sprawdzania poprawności certyfikatów SSL/TLS i nigdy nie należy podobnego kodu umieszczać w swojej aplikacji. O poprawnym podejściu do tematu sprawdzania certyfikatów można przeczytać w dokumentacji Androida.

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.

Rys 1. Uruchomienie polecenia "frida"

Rys 1. Uruchomienie polecenia „frida”

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.)

Rys 2. Wylistowanie klas w aplikacji

Rys 2. Wylistowanie klas w aplikacji

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.

Rys 3. Szukamy żywej instancji klasy

Rys 3. Szukamy żywej instancji klasy

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.

Rys 4. Wywołanie metody checkServerTrusted na klasie

Rys 4. Wywołanie metody checkServerTrusted na klasie

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ą.

Rys 5. Uruchomienie testowej aplikacji bez Fridy

Rys 5. Uruchomienie testowej aplikacji bez Fridy

Rys 6. Uruchomienie testowej aplikacji z Fridą

Rys 6. Uruchomienie testowej aplikacji 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.

Rys 7. Przechwycenie wywołań setText

Rys 7. Przechwycenie wywołań setText

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

Spodobał Ci się wpis? Podziel się nim ze znajomymi:



Komentarze

  1. john

    swietny tekst! chociaz Firide znam i uzywam, super narzedzie do mobilnych aplikacji.

    Odpowiedz
  2. Piotr

    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.

    Odpowiedz
    • Michał Bentkowski

      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ę.

      Odpowiedz

Odpowiedz