Erik HeimdalEH
BlogResuméProjects

Melodifestivalen

8 minute read

Melodifestivalen is Sweden's most-watched TV show and is used for the selection process for Sweden's Eurovision Song Contest entry. The show has an accompanying social media app that is mainly used to cast votes, but also comes with another interesting feature - virtual trading cards. Today there are 1,455 card types available across 18 different packs, each with a different rarity. I wanted to see if I could automate the card collection process, a rabbit hole that would lead to the discovery of a series of vulnerabilities further discussed in part 2 of this article (not written yet).

Tools Used

  • Python: The primary programming language
  • Fiddler: For intercepting and analyzing network traffic
  • IMAP: For automated email verification
  • Raspberry Pi: For hosting the script

Method

First Steps

The card collection system is quite straightforward - you can open each card pack once per hour, receiving around 8 cards each time. The packs reset exactly on the hour (1:00, 2:00, 3:00, etc.), regardless of when you opened your last pack. I started with automating the process of opening packs once they became available, as manually doing this would obviously require you to be awake 24/7. This was done by reverse engineering the API and re-implementing it in Python, where I would just iterate over all packs - opening them once every hour in a loop.

Screenshot from the card collection overview, showing a few of the different packs.

This was a decent start that generated ~2,000 cards per day, but as some cards were exceedingly rare it quickly became clear that this rate was not enough to collect them all in time.

Race Conditions

While working on the automation, I discovered something interesting. By sending multiple pack opening requests in parallel, I could trick the server into giving me more cards than intended. This appeared to be a race condition where the server would process multiple requests before marking the pack as claimed. The optimum seemed to be around 6 requests at a time - any more than 10 and the success rate dropped significantly.

Left: Distribution of cards received per hour, showing a mean of 419.9 cards. Right: Hourly card collection rates, revealing consistent patterns throughout the day. n=16,323.

The graph to the left above shows that this method drastically improved the card collection speed. The program now yields ~10 000 cards per day on average (5x improvement from before). The relatively high standard deviation of 74.5 cards reflects the variability in number of requests accepted by the server when sent in parallel. The hourly distribution graph to the right shows that the collection rate remained fairly consistent throughout the day, averaging just over 400 cards per hour with peaks occasionally reaching over 500. This suggests that the number of requests accepted by the server is largely unrelated to the server load, as we would otherwise expect a clear difference in card rate between early mornings and afternoons (more users active at 8pm than 5am).

Some other tests I did showed that increased single core CPU performance resulted in more requests getting through. It seems likely that having a higher request throughput results in more of them being accepted by the server. I did not investigate this much further as I had another plan in mind.

Automatic Account Generation

Email Verification Bypass

To scale this operation, I needed more accounts. There is a problem though - Melodifestivalen requires both email and phone verification for new accounts using 6-digit OTPs. The email verification was the first hurdle.

An interesting discovery came when analyzing the registration endpoint in Fiddler. Despite requiring a "challenge" parameter, the same value could be reused indefinitely:

POST https://auth.prod.uno.svt.se/authentication/v5/registration/email HTTP/1.1
Host: auth.prod.uno.svt.se
Content-Type: application/json
Connection: keep-alive
x-uno-client: melodifestivalen-ios
Accept: */*
Accept-Language: en-GB,en;q=0.9
Accept-Encoding: gzip, deflate, br
User-Agent: Melodifestivalen/47 CFNetwork/1325.0.1 Darwin/21.1.0

{
    "email": "{{email_address}}",
    "approvedTermsVersion": 2,
    "challenge": "431%2FuUbIKP9bztJiMLuDrMfz%2BOnJKx%2BEPU3oZCONNmM%3D",
    "challengeMethod": "S256"
}

This points to a deeper issue with an incorrect implementation of their challenge-response system - which affected several other sensitive endpoints as well. Luckily for me, this saved time having to reverse engineer the application bytecode.

Now I just had to find a way to automatically create new email addresses for each of the accounts I wanted to make. Automatically generating new gmail accounts would likely be impossible nowadays, so I needed a better solution. Luckily I remembered reading about email subaddressing (detailed in RFC5233) from my research on unusual email addresses. It's a standard that allows me to generate several emails that are considered unique by Melodifestivalen but are all received by the same Gmail account. This was done by appending "+{random_string}" to a base email. I then set up IMAP for reading the emails through Python, and extracted the OTP from each email using simple regex. Accounts were created by running this simple logic in a loop.

Success! After calling the email registration endpoint with the correct OTP the server responds with the JWT tokens needed for authenticating via SMS, and we are ready for the next step.

{
    "accessToken": "jwt_access_token",
    "refreshToken": "jwt_refresh_token"
}

Phone Verification Bypass

The phone verification presented a more interesting challenge. I had around 200 phone numbers from an old project that could receive authentication SMS, but manually going through all the SIM cards would be time consuming. Public SMS receivers online were another option I considered, but they're often unreliable and come with the risk of account hijacking (as anyone can read the SMS).

Given how many issues had already been found, I found it worth investigating if we could find some other exploit that would allow me to bypass the check. In Fiddler, I tried sending requests to endpoints normally reserved for authenticated users, but with the "incomplete" access token returned from the email verification step. To my surprise, this worked! The phone verification requirement was apparently only enforced in the app's UI, not at the API level.

Scaling & Management

I got to work on the account generation, being careful to only create accounts during peak hours to blend in with server logs. Each account needed careful management - the JWT tokens contained expiry information that I'd check, refreshing any token with less than 5 minutes remaining.

Despite being able to create an infinite number of accounts, I ended up making just under 3,000. There were a few reasons for this:

  • Account management complexity: Having more accounts introduces a lot more problems that have to be considered
  • Avoiding suspicious activity: Spreading out account generation over time decreases the risk of automated anomaly detection
  • Hardware limitations: If it takes more than an hour to collect all albums for all accounts, there's no point in having more accounts

The new version that iterates over all accounts enabled me to collect over 15 million cards per day (x7,500 improvement over first version!) which quickly accumulated to an extraordinary amount.

Left: Correlation between total cards available and cards owned (log scale). Right: Heatmap showing ownership percentage across different card rarities. n=11,513,593.

The results were beyond anything I initially expected. At the end of the project, out of 1,036,570,980 total cards in circulation, I owned 303,042,778 of them. This represents 30% of all cards ever created. The graphs show some interesting patterns - there are some clear clusters in the card distribution. I believe this comes from different packs being released at different times, with more recent packs (fewer cards unpacked - further to the left) having a higher ownership by me (further up). In general, the less copies there are of a card, the more of the pie I own.

Total card ownership when concluding the project. View from inside app to the right, showing number of cards owned by one of the accounts.

By the end of the project my ownership percentage was particularly high for certain rare cards, sometimes exceeding 50% of all existing copies!

Results

The scale became problematic. The server would timeout (with a 6-second limit) when requesting details for accounts with too many cards. This suggests some of the accounts had collected so many cards that the backend itself started to give up. Responses that were usually minimal in size would now be several megabytes in size, containing lists of the millions of cards owned by the account.

One aspect was the trading system that allows you to send cards to any registered user (friend or not). When receiving cards, you're forced to open each one individually before accessing the trading tab, and each card takes about 200ms to open. Doing the math, if someone was to receive all 303,042,778 cards I collected, it would take them 60,608,555 seconds (or 1.92 years) of continuous clicking just to open them all.

This is part one of a two-part series. The second part will cover the security vulnerabilities I discovered during this project and reported to the Melodifestivalen/SVT technical team.

Note: This project was conducted from 2022-2023, and the vulnerabilities described above have since been patched by the Melodifestivalen team.