DEV Community

Cover image for Always Winning at Juwenalia: Hacking Rewards from the Festival App's Mini-Games
Jakub Kopańko
Jakub Kopańko

Posted on • Originally published at kopanko.com

Always Winning at Juwenalia: Hacking Rewards from the Festival App's Mini-Games

Ah, Juwenalia. If you're a student in Poland, particularly here in Kraków, you know exactly what that means. It's that time of the year when the city transforms into a student playground. Uni classes are suspended and replaced by days filled with concerts, events, and a significant amount of beer.

The backstory

Last year, alongside the usual festivities, the organizers introduced "JuweAppka". Developed by a third-party contractor, the app promised drops of Juwenalia merchandise — tote bags, t-shirts, in-house Juwe beer, and other cool prizes. The mechanic was simple — each device could claim a single "lottery ticket" per drop. At a specific time of the day, you'd tap a button in the app, and if luck was on your side, you'd win a prize tied to your device ID.

This presented an interesting opportunity. Since prizes were linked only to a device ID, and reinstalling the app would generate a new ID, I realized I could automate this process. A quick peek with mitmproxy, revealed the simplicity of the backend. The endpoints were completely unprotected - they consumed the device ID and returned either a the details of the prize if you won, or an empty array if you didn't. There was no real complex authentication or validation.

I quickly whipped up a small Node.js script that would generate random device IDs, send a request to the lottery endpoint at designated time and see if it won. By running this script many times with different IDs, I could accumulate numerous, real, prizes across these virtual devices.

But how to collect all these prizes on my actual phone? This is where mitmproxy came back into play, this time for its ability to modify traffic in-flight. I could intercept the request the real app made to fetch the list of won prizes for my real device ID. Then, I could modify the response from the server, injecting all the prize data I had collected from my multitude of virtual devices. The app, none the wiser, would display this combined list of prizes, allowing me to claim them all at once. It felt less like cheating and more like... efficient prize collection, leveraging the system's design. After all, anyone could have reinstalled the app repeatedly; I just automated the process.

Fast forward to this year. The JuweAppka is back, but this time it's an in-house production, built with React Native and backed by Firebase. The simple lottery is gone, replaced by more engaging mini-games — think cookie clicker mechanics or falling fruit challenges, complete with leaderboards. The top players at the end of the day win the coveted prizes.


A screenshot showing one of the mini-games.

Crucially, they've implemented an account system. This is a significant change. While it prevents the simple device ID spoofing of last year, it also simplifies the prize collection. I can now potentially manipulate my score within an emulator and then simply log into my account on my physical phone to collect any prizes won, without needing to spoof the final prize list request.

My initial thought process was straightforward: the app must send my score to the server via a POST request to update the leaderboard. If I could intercept this request using mitmproxy, I could simply edit the score value before it reached the server, artificially boosting my rank on the leaderboard. Easy, right?

While I'll walk you through my process, this post is for educational purposes and isn't a step-by-step guide for replication.

The setup

Ok, so let's get into this. Here's the list of ingredients you'll need to follow along:

  1. Android Emulator: You'll need a rooted Android emulator, preferably running a release image with Google Play services installed. I would target older, but still supported Android versions. Installing Magisk on the emulator is highly recommended. You can use MagiskOnEmulator to do this.
  2. mitmproxy and frida tools: Ensure you have both installed on your host machine. For mitmproxy you'll also need to install its CA cert into the Android system certificate store on your emulator. Magisk makes this significantly easier with a module. You'll also need to configure your emulator to route all traffic through the proxy running on your machine. While there are multiple ways to approach MITM attacks, I generally add the CA to the system store and use Frida to bypass SSL certificate pinning anyway as it's often surprisingly effortless. Magisk is also invaluable here, use magisk-frida module.

Note that the following can also be done on a non-rooted, stock Android device using Frida Gadget patched directly into the APK.

The execution

The goal was simple: play a game, capture the network traffic when the score was submitted, and identify the relevant request.

I fired up one of the mini-games, played for a bit to get a non-zero score, and finished the round. As expected, mitmproxy immediately showed network activity. Sifting through the requests, I quickly spotted a POST request sent a Firebase endpoint, likely containing my score update.

POST https://REDACTED_URL/result HTTP/2.0
authorization: Bearer REDACTED_JWT
authtoken: f9c32a74560ef09523a21f7798c836dcc17448a7f0bc7b03d121015779922bf4
game: lap_co_leci
content-type: application/json
content-length: 12
accept-encoding: gzip
user-agent: okhttp/4.9.2

{"score":91}
Enter fullscreen mode Exit fullscreen mode

My initial thought was confirmed: there's a score field in the JSON payload. Modifying that with the proxy is trivially easy. But then I noticed it — the authtoken header. There already is the Authorization JWT header and this looks... different. A long string of hex characters.

After playing the game a couple more times with different scores, I observed a pattern: the authtoken value changed whenever the score changed, but two games with the exact same score resulted in the exact same authtoken. This strongly suggested the authtoken was not a random session token, but rather a value derived directly from the score itself, likely some form of hash. Given its length and appearance, a SHA-256 hash seemed like a prime candidate.

This is a common, albeit often insufficient, security pattern. The server likely recalculates the hash on its end using the received score and a secret key (a salt or a prefix) and compares it to the authtoken provided by the client. If they match, the score is considered valid.

My simple plan of just changing the score in the request body hit a roadblock. If I just changed the score, the authtoken wouldn't match the server's calculation, and the request would be rejected. I needed to generate a valid authtoken for my desired, artificially high score.

This is precisely where Frida shines — it allows us to peer inside the running application process. Since the app is performing this hashing operation locally before sending the request, I could use Frida to hook into the native crypto functions being called by the React Native application. This approach is quite elegant in that we don't have to modify or even read messy compiled Hermes bytecode — we modify the platform instead!

By intercepting the call to the hashing function (like a SHA-256 implementation), I could log the input data being fed into it — this input would likely be the score combined with the secret salt/prefix. Once I had that input format, I could replicate the hashing process myself and generate valid authtoken for any score I wanted.

Writing a Frida script to hook native crypto functions is surprisingly straightforward once you know what to look for. Frida injects a full JavaScript engine into the target process, giving you incredible power to inspect, modify, and trace function calls at runtime.

Here's the script I wrote that got injected into the app:

function bytesToString(buffer) {
  var str = '';
  for (var i = 0; i < buffer.length; i++) {
    str += String.fromCharCode(buffer[i]);
  }
  return str;
}

Java.perform(function () {
  // Find the class that we want to hook
  var MessageDigest = Java.use('java.security.MessageDigest');

  // Replace native implementation with our JS hook
  MessageDigest.update.overload('[B').implementation = function (inputBytes) {
    var algorithm = this.getAlgorithm();
    if (algorithm.toUpperCase() === "SHA-256") {
      console.log("[*] MessageDigest.update called with SHA-256 algorithm.");

      var stringInput = bytesToString(inputBytes);
      console.log("[*] SHA-256 input (string): " + stringInput);
    }

    // Call the original method
    this.update(inputBytes);
  };

  console.log("[*] MessageDigest hooks installed for SHA-256.");
});
Enter fullscreen mode Exit fullscreen mode

Immediately after injecting the script and playing the game, the console output from Frida revealed the secret:

$ frida -U -f com.kuvus.juwenalia_krakowskie -l unpin.js -l sha_hook.js
Spawning `com.kuvus.juwenalia_krakowskie`...
Spawned `com.kuvus.juwenalia_krakowskie`. Resuming main thread!
[Android Emulator 5554::com.kuvus.juwenalia_krakowskie ]-> [*] MessageDigest hooks installed for SHA-256.
(...)

[*] MessageDigest.update called with SHA-256 algorithm. 
[*] SHA-256 input (string): jsjkek-91
Enter fullscreen mode Exit fullscreen mode

There it was! The input string to the SHA-256 hash was the literal string "jsjkek-" concatenated with the score.

sha256("jsjkek-91") ->
"f9c32a74560ef09523a21f7798c836dcc17448a7f0bc7b03d121015779922bf4"
Enter fullscreen mode Exit fullscreen mode

This was the key. With mitmproxy to intercept and modify the request body (setting the desired high score) and the ability to generate the correct authtoken using the discovered prefix, I could now, in theory, submit any score I wanted to the leaderboard. This effectively bypasses the score validation mechanism and allows for arbitrary score submission.

As for whether or not I actually used this method to climb the leaderboard and claim prizes... well, I've been advised not to comment on that.

Top comments (0)