Posted on :: Tags:

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!

Adding the song:
The result:

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.

Meme: I'm the DJ now