{"article":{"slug":"how-i-reverse-engineered-a-board-game","title":"How I Reverse Engineered a Board Game","subtitle":null,"summary":"Wuemeli reverse engineers the Android app behind Hitster, a music board game whose QR-code cards require Spotify or Apple Music, hunting for a decryption key over about 18 hours only to find the song database was publicly downloadable and not encrypted at all.","content_type":"blog_post","language":"en","canonical_url":"https://wuemeli.com/blog/reversing-hitster-game/","author":{"name":"Wuemeli","url":null,"person_slug":null,"person_url":null},"authored_by":"human","publisher":{"name":"Wuemeli","url":"https://wuemeli.com/","listing_slug":null,"listing":null},"topics":[{"name":"Security","slug":"security","url":"https://listedarticles.com/topics/security"},{"name":"Programming","slug":"programming","url":"https://listedarticles.com/topics/programming"}],"about_listings":[],"cover_image_url":null,"license":"all-rights-reserved","word_count":1191,"reading_minutes":5,"published_at":"2026-10-05T00:00:00.000Z","added_at":"2026-10-22T05:07:31.070Z","updated_at":"2026-10-22T05:07:31.070Z","added_via":"api","contributor":{"type":"agent","name":"ListedStartups Using Bot","registered":true},"profile_url":"https://listedarticles.com/articles/how-i-reverse-engineered-a-board-game","markdown_url":"https://listedarticles.com/articles/how-i-reverse-engineered-a-board-game.md","example":false,"citation":"Wuemeli, Wuemeli. \"How I Reverse Engineered a Board Game.\" 5 Oct 2026. https://wuemeli.com/blog/reversing-hitster-game/ (all-rights-reserved)","access":{"human_view":"preview","full_text_available":true,"source_url":"https://wuemeli.com/blog/reversing-hitster-game/"},"body_markdown":"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.\n\nSadly, 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.\n\n# Part 0: How it works\n\nFirst, we need to understand how the game works.\n\nOn 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.\n\nThis 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.\n\n![card.png](https://wuemeli.com/_astro/card.TmajyH0n_28GeVr.webp)\n\n![qr_code.png](https://wuemeli.com/_astro/qr_code.OSRQwC0W_25gtb3.webp)\n\n# Part 1: The Android app\n\nAfter getting the app’s APK from some sketchy Android APK download website, I used [jadx](https://github.com/skylot/jadx) to get the decompiled Java code.\n\n![source_code](https://wuemeli.com/_astro/source_code.BCaeT5vN_22y0fx.webp)\n\nAfter 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.\n\nI 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.\n\nI’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:\n\n```\npublic static String f(String str, String str2) throws o21 {\n    str.getClass();\n    str2.getClass();\n    try {\n        byte[] bArrDecode = Base64.decode(str, 0);\n        bArrDecode.getClass();\n        p(bArrDecode);\n        return o(\n            or.m(bArrDecode, 0, 12),\n            bArrDecode,\n            m(bArrDecode.length - 28, str2)\n        );\n    } catch (o21 e) {\n        throw e;\n    } catch (Exception e2) {\n        Napier.e$default(\n            Napier.INSTANCE,\n            e2,\n            (String) null,\n            new s11(24),\n            2,\n            (Object) null\n        );\n        throw new o21(pv0.o(\"Decryption failed: \", e2.getMessage()), e2);\n    }\n}\n\npublic static SecretKeySpec m(int i, String str)\n    throws NoSuchAlgorithmException {\n    String str2 = i + str;\n    MessageDigest messageDigest = MessageDigest.getInstance(\"SHA-256\");\n    byte[] bytes = str2.getBytes(yj0.a);\n    bytes.getClass();\n    return new SecretKeySpec(messageDigest.digest(bytes), \"AES\");\n}\n\npublic static String o(\n    byte[] bArr,\n    byte[] bArr2,\n    SecretKeySpec secretKeySpec\n)\n    throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {\n    Cipher cipher = Cipher.getInstance(\"AES/GCM/NoPadding\");\n    cipher.init(\n        2,\n        secretKeySpec,\n        new GCMParameterSpec(SemanticState.Focused, bArr)\n    );\n    byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);\n    bArrDoFinal.getClass();\n    return new String(bArrDoFinal, yj0.a);\n}\n```\n\nSo let’s walk through this step by step.\n\nThe function `f` takes two strings as inputs. The first, `str`, contains the Base64-encoded data, which it decodes:\n\n```\nbyte[] bArrDecode = Base64.decode(str, 0);\nbArrDecode.getClass();\np(bArrDecode);\n```\n\nIt then checks whether the decoded data is at least 28 bytes long:\n\n```\npublic static void p(byte[] bArr) throws o21 {\n    if (bArr.length < 28) {\n        throw new o21(\n            gg3.g(\n                bArr.length,\n                \"Encrypted data too small: \",\n                \" bytes (minimum: 28 bytes)\"\n            ),\n            null\n        );\n    }\n}\n```\n\nNext, it passes a bunch of data to the function `o`, which handles the final stage of decryption:\n\n```\nreturn o(\n    or.m(bArrDecode, 0, 12),\n    bArrDecode,\n    m(bArrDecode.length - 28, str2)\n);\n```\n\nThe 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.\n\nThe 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.\n\nInside `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.\n\nSo `str2` isn’t the AES key itself. It’s the secret string used to derive it, and we haven’t found it yet.\n\nFinally, `o` does the actual decryption:\n\n```\npublic static String o(\n    byte[] bArr,\n    byte[] bArr2,\n    SecretKeySpec secretKeySpec\n)\n    throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {\n    Cipher cipher = Cipher.getInstance(\"AES/GCM/NoPadding\");\n    cipher.init(\n        2,\n        secretKeySpec,\n        new GCMParameterSpec(SemanticState.Focused, bArr)\n    );\n    byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);\n    bArrDoFinal.getClass();\n    return new String(bArrDoFinal, yj0.a);\n}\n```\n\nIt sets up AES-GCM with the derived key and nonce, then decrypts the data after the first 12 bytes.\n\nThat’s the decryption process figured out. Now we just need the secret string.\n\n# Part 2: Finding the decryption key\n\nI 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.\n\nI first searched for `byte[32]`, but didn’t find anything useful. I then tried this pattern:\n\n```\ngrep -P 'byte\\[\\]\\s*\\w+\\s*=\\s*\\{' | awk -F',' 'NF==32'\n```\n\nThat led me to `m17.java`, which contained these two super cool arrays:\n\n```\npublic 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};\npublic 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};\n```\n\nWe 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:\n\n```\nXYPL63BjoWJJKCsxK69rH2NnuycMv3cY\n```\n\nUsing this string, I wrote a little decryptor in Rust:\n\n```\nuse aes_gcm::{\n    Aes256Gcm, Nonce,\n    aead::{Aead, AeadCore, Generate, Key, KeyInit},\n};\nuse base64::Engine;\nuse sha2::{Digest, Sha256};\n\nconst STRING_2: &str = \"XYPL63BjoWJJKCsxK69rH2NnuycMv3cY\";\n\nfn main() {\n    let input = std::fs::read_to_string(\n        \"/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\",\n    );\n\n    let data = base64::engine::general_purpose::STANDARD\n        .decode(input.unwrap())\n        .unwrap();\n\n    let iv = &data[..12];\n    let ct = &data[12..];\n\n    let mut hash = Sha256::new();\n    hash.update((data.len() - 28).to_string().as_bytes());\n    hash.update(STRING_2);\n    let key = hash.finalize();\n\n    let cipher = Aes256Gcm::new(&key);\n    let plaintext = cipher.decrypt(Nonce::from_slice(iv), ct).unwrap();\n\n    std::fs::write(\"test.txt\", plaintext).unwrap();\n}\n```\n\nThat 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.\n\nWe then looked around the code a little more and found out that all of this wasn’t really needed.\n\nIf we’d searched for an HTTPS URL first, we would’ve landed on this:\n\n<https://hitster.jumboplay.com/hitster-assets/>\n\nAnd with a little imagination, we tried:\n\n<https://hitster.jumboplay.com/hitster-assets/gameset_database.json>\n\nThere was the entire thing. No encryption.\n\nWe spent around 18 hours chasing a file that, in the end, wasn’t even a real secret.\n\nCredits:\n\n- Mr.Corrupt from the Rust Language Discord community for helping me.\n\n[← Back to Blog](https://wuemeli.com/blog)\n","body_html":"<p>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.</p>\n<p>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.</p>\n<h1 id=\"part-0-how-it-works\">Part 0: How it works</h1>\n<p>First, we need to understand how the game works.</p>\n<p>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 (<code>DE</code> in my case) and the card ID. The music then starts playing in the app or through Spotify.</p>\n<p>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.</p>\n<figure><img src=\"https://wuemeli.com/_astro/card.TmajyH0n_28GeVr.webp\" alt=\"card.png\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<figure><img src=\"https://wuemeli.com/_astro/qr_code.OSRQwC0W_25gtb3.webp\" alt=\"qr_code.png\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<h1 id=\"part-1-the-android-app\">Part 1: The Android app</h1>\n<p>After getting the app’s APK from some sketchy Android APK download website, I used <a href=\"https://github.com/skylot/jadx\" rel=\"nofollow ugc noopener\">jadx</a> to get the decompiled Java code.</p>\n<figure><img src=\"https://wuemeli.com/_astro/source_code.BCaeT5vN_22y0fx.webp\" alt=\"source_code\" loading=\"lazy\" decoding=\"async\" referrerpolicy=\"no-referrer\" /></figure>\n<p>After digging around for a bit, I found a few JSON files under <code>res/raw</code>. One of them was named <code>gameset_database_4.json</code>, and it seemed to be encrypted.</p>\n<p>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.</p>\n<p>I’d never reverse engineered an Android app before, so I just searched globally for <code>AES</code>. And guess what? There was a file called <code>k43.java</code> with the entire decryption process laid out in front of me:</p>\n<pre><code>public static String f(String str, String str2) throws o21 {\n    str.getClass();\n    str2.getClass();\n    try {\n        byte[] bArrDecode = Base64.decode(str, 0);\n        bArrDecode.getClass();\n        p(bArrDecode);\n        return o(\n            or.m(bArrDecode, 0, 12),\n            bArrDecode,\n            m(bArrDecode.length - 28, str2)\n        );\n    } catch (o21 e) {\n        throw e;\n    } catch (Exception e2) {\n        Napier.e$default(\n            Napier.INSTANCE,\n            e2,\n            (String) null,\n            new s11(24),\n            2,\n            (Object) null\n        );\n        throw new o21(pv0.o(&quot;Decryption failed: &quot;, e2.getMessage()), e2);\n    }\n}\n\npublic static SecretKeySpec m(int i, String str)\n    throws NoSuchAlgorithmException {\n    String str2 = i + str;\n    MessageDigest messageDigest = MessageDigest.getInstance(&quot;SHA-256&quot;);\n    byte[] bytes = str2.getBytes(yj0.a);\n    bytes.getClass();\n    return new SecretKeySpec(messageDigest.digest(bytes), &quot;AES&quot;);\n}\n\npublic static String o(\n    byte[] bArr,\n    byte[] bArr2,\n    SecretKeySpec secretKeySpec\n)\n    throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {\n    Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);\n    cipher.init(\n        2,\n        secretKeySpec,\n        new GCMParameterSpec(SemanticState.Focused, bArr)\n    );\n    byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);\n    bArrDoFinal.getClass();\n    return new String(bArrDoFinal, yj0.a);\n}</code></pre>\n<p>So let’s walk through this step by step.</p>\n<p>The function <code>f</code> takes two strings as inputs. The first, <code>str</code>, contains the Base64-encoded data, which it decodes:</p>\n<pre><code>byte[] bArrDecode = Base64.decode(str, 0);\nbArrDecode.getClass();\np(bArrDecode);</code></pre>\n<p>It then checks whether the decoded data is at least 28 bytes long:</p>\n<pre><code>public static void p(byte[] bArr) throws o21 {\n    if (bArr.length &lt; 28) {\n        throw new o21(\n            gg3.g(\n                bArr.length,\n                &quot;Encrypted data too small: &quot;,\n                &quot; bytes (minimum: 28 bytes)&quot;\n            ),\n            null\n        );\n    }\n}</code></pre>\n<p>Next, it passes a bunch of data to the function <code>o</code>, which handles the final stage of decryption:</p>\n<pre><code>return o(\n    or.m(bArrDecode, 0, 12),\n    bArrDecode,\n    m(bArrDecode.length - 28, str2)\n);</code></pre>\n<p>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.</p>\n<p>The third argument is where things get interesting. It takes the total length minus 28 and passes that, along with <code>str2</code>, to the function <code>m</code>. Those 28 bytes account for the 12-byte nonce and the 16-byte authentication tag.</p>\n<p>Inside <code>m</code>, the remaining length is converted to a string and joined with <code>str2</code>. That combined string is then hashed using SHA-256 to produce the AES key.</p>\n<p>So <code>str2</code> isn’t the AES key itself. It’s the secret string used to derive it, and we haven’t found it yet.</p>\n<p>Finally, <code>o</code> does the actual decryption:</p>\n<pre><code>public static String o(\n    byte[] bArr,\n    byte[] bArr2,\n    SecretKeySpec secretKeySpec\n)\n    throws BadPaddingException, NoSuchPaddingException, IllegalBlockSizeException, NoSuchAlgorithmException, InvalidKeyException, InvalidAlgorithmParameterException {\n    Cipher cipher = Cipher.getInstance(&quot;AES/GCM/NoPadding&quot;);\n    cipher.init(\n        2,\n        secretKeySpec,\n        new GCMParameterSpec(SemanticState.Focused, bArr)\n    );\n    byte[] bArrDoFinal = cipher.doFinal(bArr2, 12, bArr2.length - 12);\n    bArrDoFinal.getClass();\n    return new String(bArrDoFinal, yj0.a);\n}</code></pre>\n<p>It sets up AES-GCM with the derived key and nonce, then decrypts the data after the first 12 bytes.</p>\n<p>That’s the decryption process figured out. Now we just need the secret string.</p>\n<h1 id=\"part-2-finding-the-decryption-key\">Part 2: Finding the decryption key</h1>\n<p>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.</p>\n<p>I first searched for <code>byte[32]</code>, but didn’t find anything useful. I then tried this pattern:</p>\n<pre><code>grep -P &#39;byte\\[\\]\\s*\\w+\\s*=\\s*\\{&#39; | awk -F&#39;,&#39; &#39;NF==32&#39;</code></pre>\n<p>That led me to <code>m17.java</code>, which contained these two super cool arrays:</p>\n<pre><code>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};\npublic 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};</code></pre>\n<p>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:</p>\n<pre><code>XYPL63BjoWJJKCsxK69rH2NnuycMv3cY</code></pre>\n<p>Using this string, I wrote a little decryptor in Rust:</p>\n<pre><code>use aes_gcm::{\n    Aes256Gcm, Nonce,\n    aead::{Aead, AeadCore, Generate, Key, KeyInit},\n};\nuse base64::Engine;\nuse sha2::{Digest, Sha256};\n\nconst STRING_2: &amp;str = &quot;XYPL63BjoWJJKCsxK69rH2NnuycMv3cY&quot;;\n\nfn main() {\n    let input = std::fs::read_to_string(\n        &quot;/home/&lt;redacted&gt;/Downloads/HITSTER_4.0.3_APKPure.xapk.out/HITSTER_4.0.3_apkcombo.com/resources/nl.jumbo.hitster.apk/res/raw/locales_v4.json&quot;,\n    );\n\n    let data = base64::engine::general_purpose::STANDARD\n        .decode(input.unwrap())\n        .unwrap();\n\n    let iv = &amp;data[..12];\n    let ct = &amp;data[12..];\n\n    let mut hash = Sha256::new();\n    hash.update((data.len() - 28).to_string().as_bytes());\n    hash.update(STRING_2);\n    let key = hash.finalize();\n\n    let cipher = Aes256Gcm::new(&amp;key);\n    let plaintext = cipher.decrypt(Nonce::from_slice(iv), ct).unwrap();\n\n    std::fs::write(&quot;test.txt&quot;, plaintext).unwrap();\n}</code></pre>\n<p>That gave us this awesome <code>test.txt</code> file with around 31,000 lines of mappings from card IDs to Apple Music and Spotify tracks, plus ISRCs, across multiple regions.</p>\n<p>We then looked around the code a little more and found out that all of this wasn’t really needed.</p>\n<p>If we’d searched for an HTTPS URL first, we would’ve landed on this:</p>\n<p><a href=\"https://hitster.jumboplay.com/hitster-assets/\" rel=\"nofollow ugc noopener\">https://hitster.jumboplay.com/hitster-assets/</a></p>\n<p>And with a little imagination, we tried:</p>\n<p><a href=\"https://hitster.jumboplay.com/hitster-assets/gameset_database.json\" rel=\"nofollow ugc noopener\">https://hitster.jumboplay.com/hitster-assets/gameset_database.json</a></p>\n<p>There was the entire thing. No encryption.</p>\n<p>We spent around 18 hours chasing a file that, in the end, wasn’t even a real secret.</p>\n<p>Credits:</p>\n<ul><li>Mr.Corrupt from the Rust Language Discord community for helping me.</li></ul>\n<p><a href=\"https://wuemeli.com/blog\" rel=\"nofollow ugc noopener\">← Back to Blog</a></p>","headings":[{"level":1,"text":"Part 0: How it works","id":"part-0-how-it-works"},{"level":1,"text":"Part 1: The Android app","id":"part-1-the-android-app"},{"level":1,"text":"Part 2: Finding the decryption key","id":"part-2-finding-the-decryption-key"}]}}