Hoe ik een bordspel reverse-engineerde
Onlangs kreeg ik een spel cadeau genaamd "Hitster". Het is een bordspel waarbij je probeert liedjes te identificeren en dingen te raden zoals wanneer ze zijn uitgebracht, wie de artiest of band is, enzovoort. Elke kaart heeft een QR-code die je scant met de Hitster-app, waarna de muziek wordt afgespeeld via Spotify of Apple Music.
Helaas vereist de app een abonnement op Apple Music of Spotify. In dit blogbericht duik ik in het konijnenhol van het reverse-engineeren van de Android-app om hun zeer, zeer slordige poging om de data te verbergen te omzeilen.
Deel 0: Hoe het werkt
Eerst moeten we begrijpen hoe het spel werkt.
Op de voorkant van elke kaart vind je de naam van de artiest of band, het jaar waarin het nummer uitkwam en de titel van het nummer. Op de achterkant staat een QR-code. Het scannen hiervan brengt je naar een URL met twee elementen in het pad: de regio van het spel (in mijn geval DE) en het kaart-ID. De muziek begint vervolgens te spelen in de app of via Spotify.
Dit betekent dat er een koppeling bestaat van Kaart-ID $\Rightarrow$ Spotify Track. Er zijn per regio ook meerdere kaartversies, dus het kaart-ID alleen is niet voldoende om een nummer te identificeren. Kaart-ID 1 kan in het VK "Wet Dream" zijn, maar in Duitsland "Tage wie diese".
Deel 1: De Android-app
Nadat ik de APK van de app had binnengehaald via een twijfelachtige Android APK-downloadsite, gebruikte ik jadx om de gedecompileerde Java-code te verkrijgen.
Na wat zoekwerk vond ik een paar JSON-bestanden onder res/raw. Een daarvan heette gamesetdatabase4.json en leek versleuteld te zijn.
Ik gebruikte een tool om het formaat te identificeren, en daaruit bleek dat het Base64 was. Aangezien dat een codering is en geen versleuteling, decodeerde ik dit eerst. Het resultaat was nog steeds niet leesbaar, waarna ik vermoedde dat het was versleuteld met AES of iets soortgelijks.
Ik had nog nooit eerder een Android-app ge-reverse-engineered, dus ik zocht simpelweg wereldwijd naar "AES". En raad eens? Er was een bestand genaamd k43.java waarin het volledige decryptieproces voor me lag:
public static String f(String str, String str2) throws o21 {
str.getClass();
str2.getClass();
try {
byte[] bArrDecode = Base64.decode(str, 0);
bArrDecode.getClass();
p(bArrDecode);
return o(
or.m(bArrDecode, 0, 12),
bArrDecode,
m(bArrDecode.length - 28, str2)
);
} catch (o21 e) {
throw e;
} catch (Exception e2) {
Napier.e$default(
Napier.INSTANCE,
e2,
(String) null,
new s11(24),
2,
(Object) null
);
throw new o21(pv0.o("Decryption failed: ", e2.getMessage()), e2);
}
}
public static SecretKeySpec m(int i, String str)
throws NoSuchAlgorithmException {
String str2 = i + str;
MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
byte[] bytes = str2.getBytes(yj0.a);
bytes.getClass();
return new SecretKeySpec(messageDigest.digest(bytes), "AES");
}
public static String o(
byte[] bArr,
byte[] bArr2,
SecretKeySpec secretKeySpec
)
throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
2,
secretKeySpec,
new GCMParameterSpec(SemanticState.Focused, bArr)
);
byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);
bArrDoFinal.getClass();
return new String(bArrDoFinal, yj0.a);
}
Laten we dit stap voor stap nalopen.
De functie f neemt twee strings als input. De eerste, str, bevat de Base64-gecodeerde data, die als volgt wordt gedecodeerd:
byte[] bArrDecode = Base64.decode(str, 0);
bArrDecode.getClass();
p(bArrDecode);
Vervolgens wordt gecontroleerd of de gedecodeerde data minstens 28 bytes lang is:
public static void p(byte[] bArr) throws o21 {
if (bArr.length < 28) {
throw new o21(
gg3.g(
bArr.length,
"Encrypted data too small: ",
" bytes (minimum: 28 bytes)"
),
null
);
}
}
Daarna wordt een reeks gegevens doorgegeven aan functie o, die de laatste fase van de decryptie afhandelt:
return o(
or.m(bArrDecode, 0, 12),
bArrDecode,
m(bArrDecode.length - 28, str2)
);
Het eerste argument zijn de eerste 12 bytes van de gedecodeerde data. Dat is onze IV (Initialization Vector), of nonce. Het tweede argument is de volledige gedecodeerde byte-array.
Het derde argument is waar het interessant wordt. De totale lengte minus 28 wordt genomen en samen met str2 doorgegeven aan functie m. Die 28 bytes staan voor de 12-byte nonce en de 16-byte authenticatietag.
Binnen m wordt de resterende lengte omgezet naar een string en samengevoegd met str2. Deze gecombineerde string wordt vervolgens gehasht met SHA-256 om de AES-sleutel te genereren.
str2 is dus niet de AES-sleutel zelf, maar de geheime string die wordt gebruikt om deze af te leiden. Deze hadden we nog niet gevonden.
Ten slotte voert o de eigenlijke decryptie uit:
public static String o(
byte[] bArr,
byte[] bArr2,
SecretKeySpec secretKeySpec
)
throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
2,
secretKeySpec,
new GCMParameterSpec(SemanticState.Focused, bArr)
);
byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);
bArrDoFinal.getClass();
return new String(bArrDoFinal, yj0.a);
}
Hier wordt AES-GCM opgezet met de afgeleide sleutel en nonce, waarna de data na de eerste 12 bytes wordt gedecrypteerd.
Het decryptieproces is nu bekend. Nu hebben we alleen nog de geheime string nodig.
Deel 2: De decryptiesleutel vinden
Ik deed dit in een Discord-call in de Rust Language Community-server, en iemand wees me erop dat ik zou moeten zoeken naar een byte-array met een lengte van 32 bytes.
Eerst zocht ik naar byte[32], maar vond ik niets nuttigs. Daarna probeerde ik dit patroon:
grep -P 'byte\[\]\s\w+\s=\s*\{' | awk -F',' 'NF==32'
Dit leidde me naar m17.java, dat deze twee interessante arrays bevatte:
public static final byte[] a = {117, -67, 13, -80, 43, 2, 93, 61, 9, 71, 61, -85, 12, -119, -123, 58, -13, 30, -119, -59, -30, 60, 3, 56, -34, -80, -28, -124, 88, -124, 1, 68};
public static final byte[] b = {45, -28, 93, -4, 29, 49, 31, 87, 102, 16, 119, -31, 71, -54, -10, 66, -72, 40, -80, -73, -86, 14, 77, 86, -85, -55, -121, -55, 46, -73, 98, 29};
We probeerden vervolgens uit te zoeken hoe we deze in iets bruikbaars konden omzetten. Na wat experimenteren suggereerde iemand om de twee arrays met elkaar te XORen. We interpreteerden de resulterende bytes als tekst en kregen dit:
XYPL63BjoWJJKCsxK69rH2NnuycMv3cY
Met deze string schreef ik een kleine decryptor in Rust:
use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, AeadCore, Generate, Key, KeyInit},
};
use base64::Engine;
use sha2::{Digest, Sha256};
const STRING_2: &str = "XYPL63BjoWJJKCsxK69rH2NnuycMv3cY";
fn main() {
let input = std::fs::read_to_string(
"/home/<redacted>/Downloads/HITSTER_4.0.3_APKPure.xapk.out/HITSTER_4.0.3_apkcombo.com/resources/nl.jumbo.hitster.apk/res/raw/locales_v4.json",
);
let data = base64::engine::general_purpose::STANDARD
.decode(input.unwrap())
.unwrap();
let iv = &data[..12];
let ct = &data[12..];
let mut hash = Sha256::new();
hash.update((data.len() - 28).to_string().as_bytes());
hash.update(STRING_2);
let key = hash.finalize();
let cipher = Aes256Gcm::new(&key);
let plaintext = cipher.decrypt(Nonce::from_slice(iv), ct).unwrap();
std::fs::write("test.txt", plaintext).unwrap();
}
Dit leverde ons een geweldig test.txt-bestand op met ongeveer 31.000 regels aan koppelingen van kaart-ID's naar Apple Music- en Spotify-tracks, inclusief ISRC's, over meerdere regio's.
We keken vervolgens nog wat verder in de code en ontdekten dat dit alles eigenlijk helemaal niet nodig was. Als we eerst hadden gezocht naar een HTTPS-URL, waren we hier op uitgekomen:
https://hitster.jumboplay.com/hitster-assets/
En met een beetje fantasie probeerden we:
https://hitster.jumboplay.com/hitster-assets/gameset_database.json
Daar stond het volledige bestand. Zonder enige versleuteling.
We hebben ongeveer 18 uur besteed aan het najagen van een bestand dat uiteindelijk helemaal geen geheim was.
Credits: Mr.Corrupt van de Rust Language Discord-community voor de hulp.
Groetjes,