Post

Bandle: A Lesson in Bad Cryptography

A textbook example of how not to do cryptography

Originally posted on medium.

TL;DR: Bandle used weak “encryption” backed by hard-coded keys to communicate with its vulnerable server.

No LLMs were involved in the writing of this article.

Bandle is a freemium online game, played from their website or app (which has over 500K downloads from Google’s Play Store). In August of 2024, I decided to reverse-engineer the app for fun.

Bandle

The goal of the game is to guess the name of a given song, in as few guesses as possible, when provided with a segment from the song played with only some of the instruments. After a wrong guess (or skip), more instruments join in, making the song more recognizable.

imageScreenshots of the Android app (left) and the web version (right)

As you can see, you are also provided with other “metadata” about today’s song:

  • Year of release
  • Number of views on YouTube
  • Par (difficulty)
  • Genre(s)

Enough exposition. The goal of this project is to take a closer look at Bandle and see if there’s a way to cheat the system via various methods. My own personal goal in researching the game was to improve my security research skills while also documenting my findings in a way that is easy to follow. For documentation, I used Obsidian.md, my go-to tool for documentation.

As a starting point, I focused on the web version since it’d be easier to look into its HTTP requests. I used Firefox as it’s my personal preference, specifically when doing stuff like this — their DevTools are great when playing around with basic APIs.

The game knows what today’s song is, obviously. For starters, the frontend must request the “levels” of today’s challenge from the backend — the mp3 file has to get to my browser somehow.

First look into web requests

Well, that was easy. The game asks for each level’s mp3 file individually, and the file even has a descriptive name. I also made a mental note that the file is served from the subdomain sound.bandle.app and not just bandle.app. Now, let’s look at the actual request:

Full request URL

The interesting part is the route the frontend calls:

https://sound.bandle.app/song/202408/Moonlight/3.mp3

The request being made teaches us a few things:

  1. The API served on sound.bandle.app has a route called /song
  2. More specifically, the call is made to a sub-route called 202408. It’s August as I’m writing this (the 8th month). That made me think that the month’s songs are known in advance.
  3. We actually get (part of) the song’s title in the request, let’s try searching for songs with the word “Moonlight”:

Suggestions for “Moonlight”Correct!

Well, that’s a good initial POC. But we still have room for improvement. For starters, we didn’t know for sure what the song was. In fact, the only hint we got was due to the creator calling the song “moonlight” in the API/DB.

Going back to the full request, I made note of a few other headers:

  • Accept header is set to media files, mainly audio (mp3, ogg, wav) but also video
  • There’s a cookie being sent

And in the response, there’s a header specifying server: Amazon S3 which teaches us about the deployment of the app.

Looking around some more, I came across another interesting request:

image

This request was made, again, to sound.bandle.app. Only this time to a different route: https://sound.bandle.app/answers202408.txt?d=1723880641202

A route called answers sounds promising. From the endpoint, I could see that it accessed the file directly, adding a query parameter called d=1723880641202 which I’ve decided I need to decipher in the future.

For now, I wanted to keep exploring this route. I started by sending the same request again, only this time without the query parameter. It worked, meaning this was optional. Using the browser to just GET this route, a file called answers202408.txt was returned, weighing in at 48.9 kB. The size of the file could definitely hold 30 song names.

Gibberish answers

The file seems like gibberish at first, but could be hexadecimal since it contains both digits and letters, but only up to f. Let’s try decoding it.

Trying to decode the answers.txt assuming it’s hexadecimal

That didn’t go so well; the output of the decoding was still gibberish. Trying a different month (July) with this request: https://sound.bandle.app/answers202407.txt seems to work quite well and gives us another file. However, this time we only get 4 bytes: 383e which translates to 8> in text. Maybe a hint to point to month 8? Perhaps just a coincidence. Manually searching older months, such as June, resulted in an AccessDenied error.

Access Denied June 2024

But clearly the game knows how to read this file, and since it’s a web app, I should be able to read the source code.

Firefox DevTools will once again assist us greatly here.

To get started, I went into the Debugger tab and saw, to my surprise, readable JavaScript! I expected minified and obfuscated code, but this was quite fine, other than it being JS.

I downloaded all the files from the webpage and began reviewing them. Eventually, I stumbled onto a React functional component that got a parameter called answer to its constructor. I followed it back to the source, import after import, until I found this snippet:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const params = new URLSearchParams(window.location.search);
const dt = params.get("fd7491") ? new Date(params.get("fd7491")) : new Date();
const day = formatDate(dt);
const url = `https://sound.bandle.app/answers${formatDate2(dt)}.txt?d=${Date.now()}`;
fetch(url).then(async res => {
    const answers = JSON.parse(decrypt("isItReallyWorthIt", await res.text()));
    const theAnswer = answers.find(x => x.day === day);
    if (theAnswer.skin === "halloween") toggleColorScheme("dark");
    setAnswer(theAnswer);
    let status = JSON.parse(localStorage.getItem("status") || "{}");
    if (status.day !== theAnswer.day)
        status = {
            day: formatDate(new Date()),
            step: 1,
            win: false,
            loose: false
        };
    if (!status.guesses) status.guesses = [undefined, Array(theAnswer.instruments.length).fill("w")];
    setCurrentStatus(status);
    const stats = JSON.parse(localStorage.getItem("stats"));
    if (theAnswer.message && theAnswer.message.playMin <= (stats?.played || 0) &&
        (!theAnswer.message.platform || theAnswer.message.platform === "web"))
        setMessage(theAnswer.message);
    setDaySinceLastPlayed(theAnswer.id - (stats?.lastId || 0));
});

Which does what we were looking for. Let’s clean it up a bit to help us focus:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import {
    decrypt,
    formatDate,
    formatDate2
} from "../utils/helpers";
const params = new URLSearchParams(window.location.search);
const dt = params.get("fd7491") ? new Date(params.get("fd7491")) : new Date();
const day = formatDate(dt);
const url = `https://sound.bandle.app/answers${formatDate2(dt)}.txt?d=${Date.now()}`;
fetch(url).then(async res => {
    const answers = JSON.parse(decrypt("isItReallyWorthIt", await res.text()));
    const theAnswer = answers.find(x => x.day === day);
    console.log(theAnswer);
});

Upon immediate inspection, I noticed a few things:

  1. The app searches for a parameter called fd7491 , which also appears to be hexadecimal, but doesn’t mean anything as far as I know.
  2. The app does indeed use the /answers route as expected, and the missing piece of the puzzle, the ?d= parameter equals Date.now() which is a function that returns the number of milliseconds passed since the epoch, though I don’t know why it would be needed here.
  3. The information was “encrypted” using a known “salt” with the value “isItReallyWorthIt”. A wink from the developers towards people like me who’d waste their time reading their code? Perhaps.
  4. This snippet uses three imported methods from the file called helpers.js:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function formatDate(date) {
    let d = new Date(date);
    let month = (d.getMonth() + 1).toString();
    let day = d.getDate().toString();
    if (month.length < 2) month = "0" + month;
    if (day.length < 2) day = "0" + day;
    return [d.getFullYear(), month, day].join("-");
}
function formatDate2(date) {
    let d = new Date(date);
    let month = (d.getMonth() + 1).toString();
    if (month.length < 2) month = "0" + month;
    return [d.getFullYear(), month].join("");
}
function decrypt(salt, encoded) {
    if (!encoded) return null;
    const textToChars = (text) => text.split("").map(© => c.charCodeAt(0));
    const applySaltToChar = (code) => textToChars(salt).reduce((a, b) => a ^ b, code);
    return encoded.match(/.{1,2}/g).map((hex) => parseInt(hex, 16)).map(applySaltToChar)
        .map((charCode) => String.fromCharCode(charCode)).join("");
}

Using these and the previous snippet, I quickly hacked together a POC script, and it worked! Here’s what I got:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
    "id": 731,
    "day": "2024–08–17",
    "folder": "202408/MMMBop",
    "song": "Hanson - MMMBop",
    "songDisplay": null,
    "instruments": [        "[drum] + [effect]",
        "bass",
        "[guitar] + [organ]",
        "[electric] + [harmony]",
        "voice",
        "clue"
    ],
    "youtube": "NHozn0YXAeE",
    "youtubeStart": 137,
    "view": 153,
    "skin": null,
    "year": 1997,
    "par": 2,
    "bpm": 105,
    "genre": [        "Pop"
    ],
    "clue": {
        "en": "Hmm",
        "es": "Hmm",
        "fr": "Hmm",
        "de": "Hmm"
    },
    "wiki_en": "MMMBop",
    "wiki_fr": "MMMBop",
    "wiki_es": "MMMBop",
    "wiki_de": null,
    "wiki_pt": "MMMBop_(can%C3%A7%C3%A3o)",
    "spotifyId": "0lnxrQAd9ZxbhBBe7d8FO8",
    "appleMusicId": 1440790544,
    "frontperson": "Taylor Hanson",
    "hashtag": null,
    "message": null
}

Looks like we got it. That’s pretty much game over, I suppose.

However, we do have reason to suspect this month’s songs are known in advance. So I modified my script, adding a day_offset variable that’s added to today’s date, and out came a different song. Now our script is more generic, allowing us to look into the future as many days as we desire.

So we already got the answers for this month. But there’s so much more to this game — most features can only be found in the app. So I started digging into it.

I found a handy tool called HTTP Toolkit that should enable me to orchestrate a MiTM attack with my phone as the victim and my laptop as the attacker. That way, I could easily see all the networking the app was doing, similar to the “Network” tab in the browser’s Developer Tools.

While the setup for this tool was really convenient, the documentation mentioned that some apps might not support used-installed CA certificates (which are a must to get this to work correctly). It appeared Bandle did not allow this.

And so I tried another approach: I’d extract the APK of the installed app using a 3rd party tool and decompile it to see what requests it was making. If I get my hands on the source code, my life will be easy. Even if the source code was obfuscated (which isn’t likely, since the JS the web version was serving wasn’t minified at all), I could probably still find magic strings of all the routes in the API the app uses.

After trying multiple ways of decompiling the app and putting them all into the same directory, I ran a simple bash one-liner to search for the text sound.bandle.app in the decompiled code.

And I found one file with 4 matches:

  1. https://sound.bandle.app/bonus/2015-02-01/file-systems/
  2. https://sound.bandle.app/song/2015-02-01/file-systems/
  3. https://sound.bandle.app/pack2/packs2__PRIVATE_FirebaseAuthCredentialsProviderelation-many-to-zero-or-many_blank_speed_sumOfSquaresizeMethod_isFirebaseServerApp
  4. https://sound.bandle.app/answersOnboarding.txt

The first three links didn’t help, and I wasn’t 100% sure I was hitting the right place. However, the answersOnboarding.txt file returned an actual response. The game does have a tutorial of sorts; this must be where the answers are kept. I got another encrypted file, but I couldn’t decode it, even with the key I found earlier.

While stumbling around the mess of decompiled code, seeing bits and pieces of it, a file called assets/index.android.bundle caught my eye. I looked inside, but it was binary. No worries, I won’t be scared off that easily.

Hermes?

I googled and realized Hermes is a JavaScript engine for running React Native. That meant the app was built using React Native, which explained the bizarre patterns I saw in the decompiled artifacts, unlike those of other apps I had looked into in the past. Makes sense if the web version was built first and the developers decided to port to Android later.

I then went back a step and tried to decompile Bandle again, but this time not with the intention of reading its code. Instead, I aimed to change its source code to allow user-installed certificates and recompile it into a modified version I could investigate with ease.

After a few minutes, I finished and installed the new app on my phone, only for it to crash on startup. The same error occurred when installing the APK on an emulator. I tried searching through logs via adb but I couldn’t find any indicative log stating what actually caused the crash. A shame, seeing the traffic would be great.

However, there was still a way to force Bandle into trusting HTTP Toolkit’s certificate — we need a rooted device for that. I didn’t feel like rooting my own phone. Lucky for me, rooting an emulated device is relatively easy these days, with open-source scripts doing the heavy lifting. A quick check with adb shell:

I am root

And with a click of a button, HTTP toolkit installs itself via ADB on the emulated device:

Installed HTTP Toolkit’s System-level certificates

Success! I was now able to see all the HTTP requests the app was making.

Now that I can see every HTTP request being made by the app on a rooted emulated Android device, let’s take a deeper look.

Once the sound portion is over, a new type of request has revealed itself — The bonus rounds. This feature (only available on the app) quizzes the player on trivia relating to today’s song. A request to https://sound.bandle.app/bonus/202409/Love.json was made, and here was the output:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
[    {
        "type": "lyrics",
        "data": {
            "start": "There's nothin' you can do",
            "correct": "that can't be done",
            "wrong": [                "that can't be fun",
                "to reach the sun",
                "to have some fun"
            ]
        }
    },
    {
        "type": "photo",
        "data": {
            "correct": "https://i.scdn.co/image/ab67616100005174e9348cc01ff5d55971b22433",
            "wrong": [                "https://i.scdn.co/image/f2d5b908b2be5e52ac5b5616925cbad5a3591ad3",
                "https://i.scdn.co/image/ab67616100005174d2e2b04b7ba5d60b72f54506",
                "https://i.scdn.co/image/ab67616100005174faeabbf20cf22ba59c117785"
            ]
        }
    },
    {
        "type": "frontperson",
        "data": {
            "correct": "John Lennon",
            "wrong": [                "Robert Plant",
                "Sonny Bono",
                "Phil Harris"
            ]
        }
    },
    {
        "type": "nationality",
        "data": {
            "correct": "England",
            "wrong": [                "Italy",
                "USA",
                "France"
            ]
        }
    },
    {
        "type": "trivia1",
        "data": {
            "question": {
                "en": "Why did The Beatles aim for the lyrics of 'All You Need is Love' to be simple and universally understandable?",
                "fr": "Pourquoi les Beatles voulaient-ils que les paroles de 'All You Need is Love' soient simples et compréhensibles par tous ?",
                "de": "Warum wollten die Beatles, dass die Texte von 'All You Need is Love' einfach und für alle verständlich sind?",
                "es": "¿Por qué los Beatles querían que las letras de 'All You Need is Love' fueran simples y universalmente comprensibles?",
                "pt": "Por que os Beatles queriam que as letras de 'All You Need is Love' fossem simples e compreensíveis por todos?"
            },
            "correct": {
                "en": "It was the British entry for the first ever live multinational, multi-satellite television program.",
                "fr": "C'était la participation britannique au premier programme télévisé multinational et en direct via satellite.",
                "de": "Es war der britische Beitrag zum ersten multinationalen, live übertragenen Fernsehprogramm per Satellit.",
                "es": "Era la entrada británica para el primer programa de televisión multinacional y en vivo por satélite.",
                "pt": "Foi a entrada britânica no primeiro programa de televisão multinacional ao vivo transmitido via satélite."
            },
            "wrong": {
                "en": [                    "The Beatles wanted to use simple lyrics to reach younger audiences.",
                    "They were inspired by popular slogans and advertising techniques.",
                    "The song was intended to promote the band's new album across the world."
                ],
                "fr": [                    "Les Beatles voulaient utiliser des paroles simples pour atteindre un jeune public.",
                    "Ils se sont inspirés de slogans populaires et de techniques publicitaires.",
                    "La chanson devait promouvoir le nouvel album du groupe à travers le monde."
                ],
                "de": [                    "Die Beatles wollten mit einfachen Texten jüngere Zuhörer ansprechen.",
                    "Sie ließen sich von beliebten Slogans und Werbetechniken inspirieren.",
                    "Das Lied sollte das neue Album der Band weltweit bewerben."
                ],
                "es": [                    "Los Beatles querían usar letras simples para llegar a una audiencia más joven.",
                    "Se inspiraron en eslóganes populares y técnicas publicitarias.",
                    "La canción estaba destinada a promover el nuevo álbum de la banda en todo el mundo."
                ],
                "pt": [                    "Os Beatles queriam usar letras simples para alcançar o público mais jovem.",
                    "Eles se inspiraram em slogans populares e técnicas de publicidade.",
                    "A música foi feita para promover o novo álbum da banda em todo o mundo."
                ]
            }
        }
    },
    {
        "type": "year1",
        "data": {
            "correct": "Louis Armstrong - What A Wonderful World",
            "wrong": [                "Kim Carnes - Bette Davis Eyes",
                "Bob Marley & The Wailers - Is This Love",
                "Ram Jam - Black Betty"
            ]
        }
    },
    {
        "type": "year2",
        "data": {
            "correct": "Aretha Franklin - Respect",
            "wrong": [                "Astrud Gilberto and Stan Getz - The Girl from Ipanema",
                "The Beatles - Don't Let Me Down",
                "The Beatles - Let It Be"
            ]
        }
    },
    {
        "type": "album",
        "data": {
            "correct": " Magical Mystery Tour",
            "wrong": [                "The Beatles",
                "All You Need Is Love",
                "Let It Be"
            ]
        }
    },
    {
        "type": "tempo",
        "data": {
            "correct": "David Bowie - Starman",
            "wrong": [                "Ritchie Valens - La Bamba",
                "Steppenwolf - Born To Be Wild",
                "ABBA - Mamma Mia"
            ]
        }
    }
]

This includes the right and wrong answers for the bonus questions asked after the song.

The “More Bandle” tab, from which packs can be played and bought, has sent out a request to https://sound.bandle.app/pack2/packs2_en.json, returning all of the available packs. Here are the first two:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[    {
        "identifier": "bd_free1",
        "songCount": 5,
        "key": "f4ed2d1c",
        "isDaily": true,
        "isFree": true,
        "lang": {
            "title": "Free pack 1",
            "short": "Free 1",
            "sub": "[num] free songs to give you a taste of Bandle"
        }
    },
    {
        "identifier": "bd_free2",
        "songCount": 5,
        "key": "8552a30b",
        "isDaily": true,
        "isFree": true,
        "lang": {
            "title": "Free pack 2",
            "short": "Free 2",
            "sub": "[num] free songs to give you a taste of Bandle"
        }
    }
]

After that, another request was made to https://sound.bandle.app/pack2/free1_150116164250.txt, which returned gibberish. This forced me to go back a step and decompile the app using hermes-dec to obtain a decompiled JS of the app and see how it decrypts the gibberish, just as I did with the web version.

By reading the code and searching for magic strings like “decrypt” or the previous encryption key, I found the bytecode JS version of the same decryption function from the web version. From there, it was easy to see who called that function. Of course, I found the same key being used:

Original key in decompiled JS

But I also found another key used:

Onboarding key in decompiled JS

And this key was validated via a modified version of my script.

After some time away from the problem, I went through the decompiled code again, looking for calls to the “decrypt” method. I saw a pattern emerging from the other two hard-coded strings. I thought it meant that the key was in the variable called r3. However, in this case, a string with value ! was appended to r3. An exclamation mark at the end of a string? Sounds like a secret password to me.

Looking a little closer, this code segment had 3 other distinct “tells” that were hinting at the fact that the key wasn’t hard-coded, just known at compile time:

  1. Usage of Math.PI in a section not related to graphics rendering
  2. Usage of 10000000000000.0 which seemed way too out of place to be the random compile artifact
  3. Math.round — could be related to Math.PI?

A very strange place for Math.PI

I wanted to give it a try. The most obvious thing seemed to multiply Pi by 10000000000000, then round, and append an exclamation mark, leading to 31415926535898!. This felt right, and it was. I had now decrypted the JSON containing the songs provided in the 3 free packs.

A few months had passed, and I wanted to get back to this project. I made sure to update the app and went through its HTTP traffic once more. I was both elated, confused, and disappointed to find a new request to the route https://sound.bandle.app/guesslist/songs.json, which returns all songs.

I was sure it was too easy. I’d just go through each song the route would return, and search for the mp3 files, right? Not exactly, the files are served from a specific folder, correlating to the year and month in which that song was the daily song (assuming all songs from that list had appeared at least once). Moreover, since the song’s full name isn’t used as the filename but instead only one word, I’d have to iterate over each word from the title of the song.

So I made a quick Python script to generate all possible URLs, then try each of them to see which ones return a valid response. To define each possible folder, I started with a simple itertools.product

1
2
3
4
5
URL_FORMAT = "https://sound.bandle.app/song/{folder}/{song_key}/1.mp3"
YEARS = list(range(2020, 2026))
MONTHS = [f'{month:02}' for month in list(range(1, 13))]
POSSIBLE_FOLDERS = [str(x) + str(y) for x, y in list(product(YEARS, MONTHS))]

Then my script loads all the songs from the file to memory, and for each song, it sanitizes the song’s title (from dashes and apostrophes), removes the word “the”, and generates a list of all URLs with words in all possible months in all possible years.

After removing any duplicates from that list, I was left with about 200,000 GET requests that had to be made.

First rule of optimizing software: do less

I was worried about 2 things:

  1. I would eventually get IP-banned after making a massive amount of requests
  2. Even if I didn’t get banned, it would take a long time

I had no solution for problem 1 that wouldn’t make problem 2 worse (for example, using TOR would be horrible). I decided I’d use an async library to make my requests, since those would be network I/O Bound. Now all I had to do was add a “chunking” mechanism to my script to ensure Bandle’s S3 account wouldn’t be overly used.

1
2
3
for i in range(0, len(unique_urls), chunk_size):
    chunk = unique_urls[i:i + chunk_size]
    responses = await asyncio.gather(*(get(url, session) for url in chunk))

And passing every request through aiohttp, of course:

1
2
3
async def get(url: str, session: aiohttp.ClientSession) -> tuple[str, bool]:
    async with session.get(url=url) as response:
        return url, response.status == 200

And for fun (and since I had no clue how long this was about to take), I added a progress bar with tqdm. Not necessary by any means, but at least my script earned some style points.

One dry run later, I was ready for the real deal. Connecting my ancient laptop directly to the router for optimal speeds, I was excited. I decided to test it, giving it an input song list with just one song — today’s, which I knew had to answer to requests.

I was getting hits! I wrote my results to a CSV file that mapped a URL to a boolean value — indicating whether the request returned a 200 OK status. With about 900 Mbps of download speed and just over 20.5 minutes of runtime, the script was done.

I promise tqdm added style while it was still running

And now to check how many results we actually got out of the 2325 songs checked:

Actual songs I could play unofficialy

Just 904? That’s disappointing. But that worked surprisingly well for a very crude and inefficient method. And more importantly, I could scrape enough to create my own homebrew version of the app, allowing me to play for a very long time.

On October 2025, the time had come and I needed to reach out to Bandle’s developer and disclose my findings. I’ve reached out on Reddit, as I knew the developer was actively moderating r/Bandle. I suggested I’d send over my notes detailing my findings. After everything would get patched, I’ll make this article public 1. After some back and forth over email and suggesting mitigations, discussing Android security in general and agreeing on parts I would leave out, the lead developer gave me his blessing to publish this article on January 2026.

  1. If you’re confused about the article appearing as published in October 2025, it’s because that’s when it was written but kept private. Only in January 2026 would I change it’s visibility settings to “public”. ↩︎

This post is licensed under CC BY 4.0 by the author.