Introduction
A while ago while I was partying at a silent disco bar, I noticed something interesting: They have a digital jukebox - basically a website that lets people choose which music should be played. People can add songs or vote for existing ones. The song with the most votes will be played next.
I tried to add my own song, but I didn't get any votes so I did the only logical thing: I reverse engineered the website and wrote a bot to vote for my song.
How does it work?
Let's take a step back. How does the Jukebox even work? It's quite straightforward. You go to the website and add the song you want or vote for other songs:
Reverse Engineering it
Since it's a web app, the first thing I looked at was the network requests. I quickly realized, they are using websockets, since I saw no network requests when voting. After finding the wss://s-usc1f-nss-2524.firebaseio.com/.ws?v=5&ns=festify-79b08
websocket request, I was able to view all the messages:
The JSON data looked strange, and only parts made sense so I searched for it on the internet. I then found out that they are using a Firebase Realtime Database to store the data.
Firebase Realtime Database Wire Protocol
Turns out, the websocket is part of the undocumented Firebase Wire Protocol. Luckily, some people have already reverse engineered it and documented some parts. Why? Writing a custom backend is useful when Google could kill Firebase tomorrow and add it to their graveyard.
Encoding
All the websocket messages are JSON encoded. They only use single letter names, which makes it hard to understand but reduces the total amount of space per message. Luckily, the iOS SDK documented them in a single file.
I'll use the following format in this article:
- The original name of the field is denoted in brackets (e.g.
t
for[t]ype
) - Values that are not fixed are denoted with
<...>
(e.g.<data_msg>
) - Fixed values will be shown as strings or numbers.
The JSON message is wrapped in a data message envelope, which is used to send arbitrary data messages to the server.
{
"[t]ype": "data",
"[d]ata": <data_msg>
}
The data message will always have the following format. If the data message is a response, the action field is excluded. The request number is a sequential number used to match requests and responses.
{
"[r]equest_num": <1337>,
"[a]ction": <action>,
"[b]ody": <body>
}
The total body for each message, looks like this:
{
"[t]ype": "data",
"[d]ata": {
"[r]equest_num": <1337>,
"[a]ction": <action>,
"[b]ody": <body>
}
}
For the sake of readability, only the body will be shown in the following examples.
Auth Request
Metadata
- Action:
auth
To be able to operate on the database, you need to authenticate. The permissions and paths can be configured for every Firebase project in a database.rules.bolt file.
The body contains the id_token generated via the Firebase REST API (see next section), which looks like this:
{
"cred": <id_token>
}
The response includes the user_id
, id_token
and other information. Only the user_id
is needed for further requests.
{
"status": "ok",
"data": {
"auth": {
"provider": "anonymous",
"provider_id": "anonymous",
"user_id": "482MnymvNFU63WRznog2yPEq4Vz2",
"token": <id_token>,
"uid": "482MnymvNFU63WRznog2yPEq4Vz2"
},
"expires": 1695466112
}
}
Sign in anonymously
Firebase uses OpenID Connect for authentication. The anonymous
provider is used to sign in anonymously. To generate an id_token
and refresh_token
, we can use the Firebase Auth REST API.
curl 'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=<censored>' \
-H 'Content-Type: application/json' \
--data-binary '{"returnSecureToken":true}' \
--referer https://festify.us/
We need to modify the referer header, otherwise our request will be blocked. This is a security feature, but can be easily bypassed. Fun fact: The referer header can't be set in a browser (which is probably a good thing).
The response will contain the id_token
which can be used to authenticate:
{
"idToken": <id_token>,
"email": "",
"refreshToken": <refresh_token>,
"expiresIn": "3600",
"localId": <local_id>
}
Exchange Refresh Token
The id_token
is valid for one hour. To get a new one, we can exchange the refresh token as specified in the documentation.
curl 'https://securetoken.googleapis.com/v1/token?key=<censored>' \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'grant_type=refresh_token&refresh_token=<refresh_token>' \
--referer https://festify.us/
Listen Request
Metadata
- Action:
q
The listen request is used to query the database. It will return the current value and all future updates. All constants related to queries can be found in the iOS SDK again.
The body contains the following fields:
- path: The path to the data you want to query
- query: An object with custom queries you want to execute (optional)
- tag: Unique number used to identify the query (optional)
- hash: Hash of the query. Always empty.
{
"[p]ath": <path>,
"[q]uery": <query>,
"[t]ag": <tag>,
"[h]ash": <hash>
}
The response will contain the current value and all future updates. The data
field will contain the JSON as it's stored in the database or null
if not found.
{
"[s]tatus": "ok",
"[d]ata": <data>
}
Example: Query party information
We can find all the information about a party with the following request:
{
"[p]ath": "/parties/-NeUUbtM3cqSazkSGvFb",
"[h]ash": ""
}
The response contains all the information about the party (playback status, settings, etc.):
{
"[p]ath": "parties/-NeUUbtM3cqSazkSGvFb",
"[d]ata": {
"country": "NL",
"created_at": 1694892987111,
"created_by": "YGQejUpKxXYUe07fOc1P2s8NQ622",
"name": "jukebox050's Party",
"playback": {
"last_change": 1695439571657,
"last_position_ms": 137393,
"playing": false
},
"settings": {
"allow_anonymous_voters": true,
"allow_explicit_tracks": true,
"allow_multi_track_add": true,
"tv_mode_text": "Add your songs on www.festify.us!"
},
"short_id": "885427"
}
}
Example: Query all parties
Querying a single party is cool, but can we dump all of them? Yes, we just have to change the path:
{
"[p]ath": "/parties",
"[h]ash": ""
}
This returns all the parties ever created:
{
"[p]ath": "parties",
"[d]ata": {
"-DE8BOdD2E0LDt6lBq6n": {
"country": "DE",
"created_at": 1513257439539,
"created_by": "aYYftbv3wRWxZr8eowLx1KCJmPs1",
"name": "Today's Party",
"playback": {
"last_change": 1513257439539,
"last_position_ms": 0,
"playing": false
},
"short_id": "526655"
},
...
}
}
We can use jq to analyze the data. 279.669 parties have been created since the launch of Festify in 2017. The top country is Germany, followed by the US and Brazil.
$ jq -r '.d.b.d[] | .country' dump.json | sort | uniq -c | sort -nr
89306 DE
38237 US
24120 BR
19176 GB
14711 NL
7140 DK
6251 CH
5846 AU
5376 CA
4848 MX
4709 BE
4384 AT
...
Most of them seem to be used for private parties, which can be seen by some of the names (Parents at my age: "gRoWiNg/eVoLvInG FaMiLy", Me: tell your cat I said pspsps
is my personal favorite). Some of them are even used for weddings, prayers and company parties. Out of the 119.719 unique names the majority (80.767) are only used once. Most people use the default name Today's Party
, followed by some power users:
$ jq -r '.d.b.d[] | .name' fmt.json | sort | uniq -c | sort -nr
32333 Today's Party
488 wn8cleuqcwcm1excfj139ho1q's Party
297 Spo's Party
289 Grant Bowering's Party
238 Tsurox's Party
235 firefighter174's Party
37.592 parties have been created in the last year. Barely any of them are playing music, which makes sense since most of them are private parties.
Example: Query tracks of a party
What if we want to know the tracks in the queue of a party? Easy, just change the path and that's it.
{
"[p]ath": "/tracks/-NeUUbtM3cqSazkSGvFb",
"[h]ash": ""
}
This returns a list of all tracks in the queue (including the fallback tracks).
{
"p": "tracks/-NeUUbtM3cqSazkSGvFb",
"d": {
"spotify-4cOdK2wGLETKBW3PvgPWqT": {
"added_at": 1697149626950,
"is_fallback": true,
"order": 2256639839,
"reference": {
"id": "4cOdK2wGLETKBW3PvgPWqT",
"provider": "spotify"
},
"vote_count": 0
},
...
}
}
Put Request
Metadata
- Action:
p
Reading data is cool and all, but what if we want to change something? We can use the put requests to change the data in the database. Depending on the path, the permissions can be different. When in doubt, check the database.rules.bolt.
The message contains the path again and the data that should be stored.
{
"[p]ath": <path>,
"[d]ata": <json_data>
}
The response will just contain the status field:
{
"[s]tatus": "ok",
"[d]ata": null
}
Example: Vote for song
The following request will add a new song to the database.
{
"[p]ath": "/votes/<party_id>/spotify-<song_id>/<user_id>",
"[d]ata": true
}
To withdraw your vote, you can send the same request with d
set to null
.
Putting it all together
We learned how to authenticate, query and modify data in the database. These are all the building blocks we need to automate the process of voting for songs. I decided to write a Flutter app, to do exactly that and it worked like a charm!
Mitigation
The only way to prevent automated voting is to disable anonymous voting. Festify even provides such a setting, but it has not yet been enabled.
The reason is quite simple: You don't want to add too many unnecessary steps to the process of voting. Drunk people will most likely not want to enter their username and password just to add one song. The harder you make it to vote, the less people will vote.
Conclusion
This was a fun project and I learned a lot about Firebase. I hope you enjoyed reading this article and learned something new.