Sicherung von API-Keys in Hybriden Anwendungen — FetchFood

Jannes Beyer
C³AI
Published in
12 min readFeb 10, 2022
FetchFood — eat smart, think local

Einleitung

In diesem Artikel wird ein Teil der App FeFood auf ein gewisses Sicherheitsmerkmal analysiert. Entstanden ist die App im Rahmen der Veranstaltung „ITS Venture Design“ an der Hochschule Flensburg im WS21/22. Diese App ist als Prototyp entwickelt wurden, und weist dementsprechend noch gewisse Sicherheitsmängel auf. Dabei soll genauer die Nutzung von API-Keys, die in der App verwendet wurden, betrachtet und auf entsprechende Sicherheitsstandards für diese überprüft werden.

TechStack — Aufbau der App

Um die Möglichkeit zu schaffen, die App sowohl für Android und iOS, als auch als Webanwendung nutzen zu können, wurde die Anwendung in der Programmiersprache Dart geschrieben. Diese wurde im Zusammenhang mit dem Cross-Plattform-Framework Flutter genutzt. Der Vorteil dabei ist, dass eine Flutter App vor der Veröffentlichung für die jeweilige Plattform kompiliert wird und als echte native App läuft. Somit benötigt man also kein Runtime-Modul und keinen Browser. Aus der gleichen Codebasis lassen sich auch Web-Apps für Browser sowie native Programme für Windows, Linux und macOS erstellen. Dies bringt schlichtweg den Vorteil, dass eine Anwendung die auf einer Code-Basis beruht, für mehrere Anwendungsumgebungen genutzt werden kann. Als Build-Management-Tool wird für die App Gradle verwendet.

Architektur der App

Für die Umsetzung der Architektur war zu Beginn der Entwicklung eine einfache Umsetzung des MVC-Patterns angedacht:

Im backend liegen zum einen die Models, die für die gesamte Anwendung genutzt wurden, zum anderen die Business Logik für die API Kommunikation mit dem externen Service (könnte somit als Controller angesehen werden). In screens befinden sich die jeweiligen Widgets zur Darstellung der UI-Komponenten und fungiert hier als View. Da es sich hierbei lediglich um einen Prototypen handelt und die Entwicklung recht schnell umgesetzt werden musste, kam es des Öfteren zu Verletzungen des Patterns und auch zu inperformantem Code. Wird die Entwicklung fortgesetzt, so sollte zunächst ein Refactoring und umdenken hinsichtlich der Architektur erfolgen.

Sicherheit der App

Wie bereits erwähnt, gab es durch eine schnelle und unsaubere Umsetzung des Projekts sehr oft inperformanten Code. Darüber hinaus wurden aber auch einige Sicherheitsfeatures verletzt. Dabei geht es hier um die Verwendung der API-Keys. Die Keys sind notwendig, um sich für die Verbindung zum externen Anbieter Back4App zu Authentifizieren. Bei der genaueren Analyse des Problems und der Lösungsfindung wird hier hauptsächlich auf die Android-Umgebung Bezug genommen.

Verwendung der Keys

Um zunächst einmal das Problem der Nutzung der API-Keys in der Anwendung aufzuzeigen, folgt zunächst einmal die Darstellung der aktuellen Umsetzung.

Zu Beginn muss für die Nutzung der API das entsprechende Package installiert sein:

flutter install parse_server_sdk_flutter

Um eine Verbindung zu Back4App zu erreichen wurde in der aktuellen Umsetzung die API-Keys als feste Konstanten in der main.dart innerhalb der main() methode im Code initialisiert:

void main() async {
...
const keyApplicationId = '1gUcztsODeW2fpU42Bk...';
const keyClientKey = 'vK8qLlObZQeEKij...';
const keyParseServerUrl = 'https://parseapi.back4app.com';
...
await Parse().initialize(keyApplicationId, keyParseServerUrl,
clientKey: keyClientKey, debug: true);
return runApp(MyApp());
}

Die Verbindung erfolgt dann über den Aufruf der Parse().initialize(…) Funktion.

Sicherheitsproblem der Umsetzung

Bei dieser Umsetzung gibt es Zwei große Sicherheitsprobleme:

  1. Die main.dart wird selbstverständlich nicht in die .gitignore hinterlegt und somit in das jeweilige Git-Repository gepusht . Dadurch sind die API-Keys leicht zugänglich für dritte.
  2. Selbst wenn ein Angreifer keinen direkten Zugang zu dem Git-Repository hat, erhält ein potenzieller Angreifer die Möglichkeit den Code ganz einfach zu decompilieren. Dadurch kann er Problemlos an die API-Keys kommen. Dies hätte zur Folge, dass die Daten verfälscht werden oder auch unendlich viele API-Calls durchgeführt werden können.

API keys are proxies to your identity and should be securely stored at all costs.

Lösungsansätze

Um den Problemen entgegenzutreten gibt es mehrere Lösungsansätze. Eine häufig verwendete Variante ist dabei oft die Nutzung der Android Keystore API oder auch die Nutzung der Shared Preferences. Im Falle der Shared Preferences muss jedoch stets darauf geachtet werden, die Daten zuvor zu verschlüsseln, da sonst bei der Durchführung eines Backups der Anwendung ein einfacher Zugriff auf die Daten möglich ist. Diese Varianten werden jedoch hauptsächlich für Keys und Tokens verwendet, die Dynamisch erzeugt werden. Da es sich bei der Anwendung um die Nutzung statischer API-Keys handelt, entfallen diese Varianten als Lösung.

Lösungsansatz 1: Key Zugriff über die BuildConfig

Die erste Lösung für statische API-Keys wäre die Nutzung der BuildConfig. Dafür muss zunächst eine application.properties Datei erzeugt werden, welche meist einfach im Root Verzeichnis hinterlegt wird. Wichtig ist hierbei, diese Datei in der .gitignore zu hinterlegen um das erste Vorrangegangene Problem zu vermeiden. Die Datei würde dann beispielsweise folgendes beinhalten:

KEY_APPLICATION_ID = '1gUcztsODeW2...'
KEY_CLIENT_ID = 'vK8qLlObZQeEKi...'

Nun wird in der build.gradle, welche im Verzeichnis android/app/build.gradle liegt auf die Properties zugegriffen. Um auf die Properties zugreifen zu können, muss zunächst folgendes definiert werden:

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('application.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}

Anschließend kann im Bereich defaultConfig{} auf die Properties anhand ihrer eindeutigen Namen, die in der application.properties eingetragen wurden, zugegriffen werden

android {
defaultConfig {
...
buildConfigField("String", "KEY_APPLICATION_ID", localProperties['KEY_APPLICATION_ID'])
buildConfigField("String", "KEY_CLIENT_ID", localProperties['KEY_CLIENT_ID'])
}
}

Verwendet werden können die API-Keys im Code durch das BuildConfig Objekt :

String keyApplicationId= BuildConfig.KEY_APPLICATION_ID;
String keyClientId= BuildConfig.KEY_CLIENT_ID;

Entsprechend können nun einfach die Keys verwendet und der Parse().initialize(…) Funktion übergeben werden.

Diese Umsetzung ist eine sehr häufig verwendete Variante. Doch ist sie auch sicher? Nicht ganz …

Durch das hinzufügen der Datei in die .gitignore wurde zwar das Problem mit GitHub behoben, jedoch könnte ein Angreifer durch den Reverse Engineering Prozess Zugriff auf alle Klassen und auch auf die BuildConfig erlangen und somit wiederum Zugriff auf die API-Keys.

Lösungsansatz 2: Key Zugriff über Ressourcen Dateien

Ein weiterer häufig verwendeter Ansatz ist die Nutzung von Ressourcen Dateien. Hier wird ähnlich wie im vorangegangenen Ansatz die API-Keys in einer zusätzlichen Datei application.xml hinterlegt. Auch diese Datei wird in die .gitignore eingetragen, um das erste Sicherheitsproblem zu beheben. Anschließend kann im Code über den Context auf die Keys in der Ressourcen Datei zugegriffen werden. Damit ergibt sich jedoch dasselbe Problem wie im ersten Lösungsansatz. Auch diese Methode wäre nicht sehr resistent gegen den Reverse Engineering Prozess.

Lösungsansatz 3: Native Bibliotheken mit NDK

Um die API-Keys auch gegen das Prinzip des Reverse Engineering sicher zu machen, beziehungsweise dem Angreifer diese Möglichkeit schwerer zu machen, ist die Nutzung von Nativen Bibliotheken, die NDK verwenden, hilfreich. Doch was ist NDK überhaupt?

NDK ist ein Natives Development Kit von Android, welches die Möglichkeit bietet, nativen Code in C oder C++ zu schreiben. Meistens wird NDK verwendet, um eine höhere Performance zu erlangen oder um einfach C oder C++ Frameworks zu verwenden, ohne diese in Java oder Kotlin neu schreiben zu müssen. Die Dateien werden von Gradle verpackt und deren Methoden und Funktionen können dann einfach über ein Interface aufgerufen werden.

Was ist der Vorteil? Solchen Code zu decompilieren ist für Angreifer eher schwierig, da diese in .so Dateien compiliert werden. Diese Dateien enthalten ausschließlich Rohe Daten und ein paar Strings. Würde der Angreifer den Reverse Engineering Prozess durchführen, wäre es für diesen sehr schwierig den Punkt auszumachen, an dem sich die Keys befinden, da sich dessen Positionen bei dem Prozess, abhängig vom jeweiligen Computer, verändert haben.

Da der dritte Lösungsansatz eine Behebung beider Probleme bietet, wird im nächsten Abschnitt die Implementierung dessen innerhalb der App FeFood durchgeführt.

Implementierung des Sicherheitsfeatures

Da die Lösung 1 und Lösung 2, wie im vorherigen Abschnitt erläutert, lediglich eine Sicherheit hinsichtlich GitHub gewährleisten und nicht gegen Reverse Engineering, wird entsprechend Lösung 3 für die Anwendung umgesetzt.

Wie bereits zuvor erwähnt, wird NDK hauptsächlich für die Entwicklung von Nativen Code verwendet. Es ist jedoch nicht notwendig eigenen Code für das verschlüsseln von Secrets zu schreiben. Es gibt bereits mehrere Bibliotheken, die das NDK verwenden und die Nutzung von Nativen Code ermöglichen, welcher für das Sichern von Secrets verwendet werden kann.

In diesem Beispiel soll dafür das Gradle Plugin Hidden-Secrets-Gradle-Plugin herhalten.

Schritt 1:

Da für die Anwendung die Installation des Plugins in DSL (Domain Specific Language) nicht funktioniert, muss die Legacy Variante installiert werden. Dafür muss in der build.gradle im Root Verzeichnis zunächst eine weiteres Repository hinzugefügt werden:

maven {
url "https://plugins.gradle.org/m2/"
}

Anschließend wird der Pfad in den dependencies hinterlegt

dependencies { 
...
classpath "com.klaxit.hiddensecrets:HiddenSecretsPlugin:0.1.3"
}

Zum Schluss muss dann in der build.gradle, die sich im App Verzeichnis befindet, das Plugin ergänzt werden

apply plugin: 'com.klaxit.hiddensecrets'

Nun können die Secrets in der Anwendung gespeichert werden. Dafür muss zunächst sichergestellt werden, dass der Gradle Deamon läuft, um entsprechende Befehle mit dem Gradle Wrapper durchführen zu können. Ist dies der Fall, können nun entsprechende Befehle für die beiden Secrets ausgeführt werden:

gradlew hideSecret -Pkey=1gUcztsODeW2fpU42Bk... -PkeyName=KeyApplicationId -Ppackage=com.example.fefoodgradlew hideSecret -Pkey=vK8qLlObZQeEKijE... -PkeyName=KeyClientId -Ppackage=com.example.fefood

Es sollte nun in der App unter app/src/main das Verzeichnis cpp erstellt worden sein. Die Struktur sieht in etwa wie folgt aus:

In der secrets.cpp befinden sich nun die Verschlüsselten API Keys. Für die Verschlüsselung werden die Funktionen der sha256.cpp verwendet. Darüber hinaus bietet secrets.cpp die Möglichkeit, eine eigene Verschlüsselung hinzuzufügen. Dadurch werden die API-Keys bevor sie in die App integriert werden zusätzlich ent- und verschlüsselt. Dafür muss lediglich in der Funktion

char *customDecode(char *str) {
/* Add your own logic here
return str;
}

der entsprechende Code nachgetragen werden. Dies kann auch optional geschehen.

Bevor es nun in die nächsten Schritte geht, wird noch eine Änderung für spätere Prozesse vorgenommen. Im ios Verzeichnis wird ein Ordner Classes erstellt. In diesen Ordner verschieben wir nun alle cpp und header Dateien. Um diese Dateien mit ios zu verlinken müssen noch folgende Schritte durchgeführt werden:

  1. Öffnen der Datei Runner.xcworspace in Xcode
  2. Hinzufügen der cpp und header Dateien in das Xcode Projekt

Warum die Dateien in den ios Ordner verschoben werden sollten, hat Flutter bereits in seiner Dokumentation erläutert:

You add the sources to the ios folder, because CocoaPods doesn’t allow including sources above the podspec file, but Gradle allows you to point to the ios folder. It’s not required to use the same sources for both iOS and Android; you may, of course, add Android-specific sources to the android folder and modify CMakeLists.txt appropriately.

Da nun die Secrets in die Anwendung übertragen wurden, muss nun in dem Android Build Prozess das compilieren von C++ Dateien aktiviert werden und dabei angegeben werden, welche Dateien zusätzlich compiliert werden sollen. In diesem Fall die vom Plugin erzeugte CMakeLists.txt. Definiert wird in der Datei

  1. welche Version für CMake verwendet werden soll
  2. der Name der Bibliothek die man verwenden möchte (in diesem Fall die secrets.cpp)
  3. ob die Bibliothek STATIC oder SHARED sein soll
  4. der relative Pfad zum Quellcode, also zu secrets.cpp selbst

Mit den Pfadänderungen der Dateien in das ios Verzeichnis, sollte die Datei nun wie folgt aussehen:

cmake_minimum_required(VERSION 3.4.1)
add_library( # Specifies the name of the library.
secrets
# Sets the library as a shared library.
SHARED
# Provides a relative path to your source file(s).
../../../../..ios/Classes/secrets.cpp )

Eingebunden wird diese in der build.gradle im App Verzeichnis. Dabei wird zunächst NDK aktiviert und anschließend für den CMake Befehl der Pfad zu der CMakeLists.txt angegeben:

android {
...
externalNativeBuild {
cmake {
path "src/main/cpp/CMakeLists.txt"
}
}
}

Damit sind alle Vorbereitungen für die Sicherung und Nutzung der API-Keys getroffen. Wird die Anwendung erneut gebuildet so wird eine Klasse Secrets erzeugt. Entweder in Java oder in Kotlin.

Ab hier gibt es nun mehrere Möglichkeiten, wie man mit dem Nativen Code umgehen kann:

  1. Die erzeugte Secrets Klasse kann einfach in einer Nativen Android App verwendet werden
  2. Es kann die MethodChannel Variante zum nutzen von Nativen Code innerhalb Flutters genutzt werden
  3. Es kann die FFI Bibliothek von Flutter verwendet werden

Im Folgenden wird daher auf alle Varianten eingegangen.

1. Variante

Wenn man nun eine Native App hat, kann die Secrets Klasse einfach importiert und deren Funktion für das erhalten der Keys verwendet werden. Die Klasse sieht dabei wie folgt aus:

class Secrets {

companion object {
init {
System.loadLibrary("secrets")
}
}
external fun getYourSecretKeyName(packageName: String): String external fun getKeyApplicationId(packageName: String): String external fun getKeyClientId(packageName: String): String
}

2. Variante

Jedoch handelt es sich hierbei nicht um eine Native App, sondern um eine Hybride App mit Flutter. Um Nativen Code in Flutter nutzen zu können, ist eine zusätzliche Implementierung durch den MethodChannel erforderlich. Dafür muss in der MainActivity.kt die configureFlutterEngine Methode überschrieben werden. Der CHANNEL kann ein beliebiger String sein, der sich beispielsweise nach dem Package Namen richtet. Die Funktion sollte dann in etwa wie folgt aussehen:

class MainActivity: FlutterActivity() {
private final CHANNEL = "com.example.fefood/keys";
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getKeyApplicationId") {
result.success(getKeyApplicationId())
} else if (call.method == "getKeyClientId") {
result.success(getKeyClientId())
}
}
}
}

Des Weiteren muss in dieser Klasse die Funktionen getKeyApplicationId und getKeyClientId implementiert werden, welche wiederum die Funktionen der Secrets.kt verwendet.

In Flutter gibt es ebenfalls den MethodChannel welcher an benötigter Stelle initialisiert wird. In diesem Fall einfach innerhalb der main() Methode:

void main() async {
...
const plattform = MethodChannel("com.example.fefood/keys");
}

Dabei ist es sehr wichtig, dass exakt der gleiche CHANNEL Name, wie zuvor definiert, verwendet wird. Dann bleibt nur noch

String  keyClientKey = await plattform.invokeMethod("getKeyApplicationId");

hinzuzufügen und die Keys können verschlüsselt innerhalb von Flutter genutzt werden. Um selbe Variante für iOS zu benutzen, müsste das ganze Prozedere noch einmal implementiert werden, da sich die Prozesse in Android und iOS unterscheiden. Für die Umsetzung von MethodChannels in iOS gibt es auch eine sehr gut dokumentierte Anleitung von Flutter.

3. Variante

Um den Nativen Code für Android und iOS zu nutzen können, gibt es noch eine dritte Variante, nämlich das Package FFI. Dafür wird zunächst eine Dynamische Bibliothek erzeugt:

final DynamicLibrary nativeKeyLib = Platform.isAndroid
? DynamicLibrary.open('libsecrets.so')
: DynamicLibrary.process();

Hier ist anzumerken, dass nach der Flutter Dokumentation dieser Schritt zwischen iOS und Android variieren kann. Es müsste in dem Fall eine weitere Abfrage hinzu, ob es sich um die Platform iOS handelt.

Nun müssen die Funktionen die aus dem Nativen Code verwendet werden sollen aufgelöst werden. Für die keyApplicationId würde das ganze dann wie folgt aussehen:

final UTF8 Function(String packageName) getKeyApplicationId =   nativeKeyLib.lookup<NativeFunction<UTF8 Function(UTF8)>>('getKeyApplicationId').asFunction();

Die Funktion kann dann zum Aufruf der keyApplicationId genutzt werden

String keyClientKey = getKeyApplicationId("com.fefood.example")

Ähnliche Funktion muss auch für die keyClientId implementiert werden. Für das Web Module bietet die FFI Bibliothek noch keine Möglichkeit, um Nativen Code zur Sicherung von API-Keys verwenden zu können.

Ist die Anwendung nun sicher?

Nicht ganz, denn eine hunderprozentige Sicherheit kann man einfach nicht gewährleisten.

Nothing on the client-side is unbreakable

Des Weiteren müsste bei allen drei Varianten noch eine Native Implementierung für die Desktop Variante (Web Modul) zur Verfügung gestellt werden. Um hier Nativen Code (JavaScript) zur Verschlüsselung nutzen zu können, eignet sich für Dart das JS interop Package. Dies ermöglicht es JavaScript Funktionen über Flutter auszuführen.

Somit wären alle Anwendungsumgebungen abgedeckt. Selbst wenn jedoch für alle Anwendungsumgebungen eine zusätzliche Native Verschlüsselung für die API-Keys hinterlegt wurde, ist noch keine hundertprozentige Sicherheit gegeben, ABER man erschwert einem Angreifer erheblich die Möglichkeit Reverse Engineering anzuwenden. Dabei gilt im Allgemeinen für den Schutz vor Reverse Engineering, so viele Sicherheitsfeature wie möglich im Code zu verwenden, um diesen Prozess so gut es geht zu erschweren. Gute Artikel und Lösungsansätze dazu sind bei OWASP zu finden:

Fazit

Für die FeFood App wurden zum testen alle drei Varianten des dritten Lösungsansatzes implementiert. Es ist jedoch anzumerken, dass Back4App als Backend nur gewählt wurde, weil es sich bei der App um einen Prototyp/MVP handelt. Läuft die App Produktiv, würde entsprechend ein eigenes Backend entwickelt werden. Demzufolge wäre das Session-Management auch ein komplett anderes und es bleibt fraglich, ob die hier vorgestellten Lösungsansätze für das sichern der Keys überhaupt Anwendung finden würde.

Quellen

https://docs.flutter.dev/development/platform-integration/c-interop

--

--