I recently received a game called “Hitster” as a gift. It’s a board game where you try to identify songs and guess things like when they came out, who the artist or band is, etc. Each card has a QR code that you scan using the Hitster app, which then plays the music through Spotify or Apple Music.
Sadly, the app requires an Apple Music or Spotify subscription. In this blog post, I’ll go down the rabbit hole of reverse engineering the Android app and bypassing their very, very sloppy attempt at hiding the data.
Part 0: How it works
First, we need to understand how the game works.
On the front of each card, you’ll find the artist or band name, the year the song came out, and the song title. On the back, there’s a QR code. Scanning it takes you to a URL with two things in its path: the game’s region (DE in my case) and the card ID. The music then starts playing in the app or through Spotify.
This means there’s a mapping from Card ID ⇒ Spotify Track. There are also multiple card versions per region, so the card ID alone isn’t enough to identify a song. Card ID 1 might be “Wet Dream” in the UK, but “Tage wie diese” in Germany.


Part 1: The Android app
After getting the app’s APK from some sketchy Android APK download website, I used jadx to get the decompiled Java code.

After digging around for a bit, I found a few JSON files under res/raw. One of them was named gameset_database_4.json, and it seemed to be encrypted.
I ran a tool to identify the format, and it came back as Base64. That’s an encoding, so I decoded it first. The result still wasn’t readable, and I guessed it was encrypted with AES or something similar.
I’d never reverse engineered an Android app before, so I just searched globally for AES. And guess what? There was a file called k43.java with the entire decryption process laid out in front of me:
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);
}
So let’s walk through this step by step.
The function f takes two strings as inputs. The first, str, contains the Base64-encoded data, which it decodes:
byte[] bArrDecode = Base64.decode(str, 0);
bArrDecode.getClass();
p(bArrDecode);
It then checks whether the decoded data is at least 28 bytes long:
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
);
}
}
Next, it passes a bunch of data to the function o, which handles the final stage of decryption:
return o(
or.m(bArrDecode, 0, 12),
bArrDecode,
m(bArrDecode.length - 28, str2)
);
The first argument is the first 12 bytes of the decoded data. That’s our IV, or nonce. The second argument is the entire decoded byte array.
The third argument is where things get interesting. It takes the total length minus 28 and passes that, along with str2, to the function m. Those 28 bytes account for the 12-byte nonce and the 16-byte authentication tag.
Inside m, the remaining length is converted to a string and joined with str2. That combined string is then hashed using SHA-256 to produce the AES key.
So str2 isn’t the AES key itself. It’s the secret string used to derive it, and we haven’t found it yet.
Finally, o does the actual decryption:
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);
}
It sets up AES-GCM with the derived key and nonce, then decrypts the data after the first 12 bytes.
That’s the decryption process figured out. Now we just need the secret string.
Part 2: Finding the decryption key
I was doing this in a Discord call in the Rust Language Community server, and someone pointed out that I should look for a byte array with a length of 32 bytes.
I first searched for byte[32], but didn’t find anything useful. I then tried this pattern:
grep -P 'byte\[\]\s*\w+\s*=\s*\{' | awk -F',' 'NF==32'
That led me to m17.java, which contained these two super cool arrays:
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 then tried to figure out how to turn these into something useful. After a bit of experimenting, someone suggested XORing the two arrays together. We interpreted the resulting bytes as text and got this:
XYPL63BjoWJJKCsxK69rH2NnuycMv3cY
Using this string, I wrote a little 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();
}
That gave us this awesome test.txt file with around 31,000 lines of mappings from card IDs to Apple Music and Spotify tracks, plus ISRCs, across multiple regions.
We then looked around the code a little more and found out that all of this wasn’t really needed.
If we’d searched for an HTTPS URL first, we would’ve landed on this:
https://hitster.jumboplay.com/hitster-assets/
And with a little imagination, we tried:
https://hitster.jumboplay.com/hitster-assets/gameset_database.json
There was the entire thing. No encryption.
We spent around 18 hours chasing a file that, in the end, wasn’t even a real secret.
Credits:
- Mr.Corrupt from the Rust Language Discord community for helping me.