corCTF 2024: web/rock-paper-scissors
Solution writeup
Starting point:
can you beat fizzbuzz at rock paper scissors?
- link to instancer
- download link rock-paper-scissors.tar.gz
First impressions
The challenge app is a small rock, paper, scissors game against the server. Users are prompted to enter a name, then presented with an animation, “rock,” “paper,” and “scissors,” buttons, and a scoreboard link. Fun!
Name submission is via POST with a JSON body like:
{"username": "your_name_here"}
Gameplay involves pressing buttons, and receiving a point for each win. The score resets and gets registered on the scoreboard when the player loses a round.
Moves are submitted by POST-ing a symbol to the server.
Inside the app
The TAR archive contains the app’s source code, package manifest, and a Dockerfile for reproducible local testing.
It’s a simple NodeJs app with a single index.js
file and a few static assets for the frontend. It’s built using the Fastify framework and uses Redis to persist game state and the scoreboard.
The app has a hidden /flag endpoint that will return the flag… but only if the current player has a score higher than FizzBuzz101’s impressive 1336 points!
Bug hunting
A couple plausible solve paths can be eliminated quickly:
- Log in as FizzBuzz101: The only way to do this is via /new, which always sets up a game with 0 points, so there’s no advantage to this username.
- JWT forgery: The app seems to handle JWTs securely, and again, there’s no advantage to using the scoreboard leader’s name.
The app never increments scores on the scoreboard–it only sets them on a “loss” and reads them to display the scoreboard. While FizzBuzz101’s scoreboard record is initialized to 1336 points, there’s no game with that score to hijack. Resetting FizzBuzz101’s score to zero also doesn’t help, because the check in /flag is hard-coded to compare against the number 1336.
So what’s left? Well, the only place the app takes arbitrary user input without validation or comparing against what passes for an enum in Javascript is the username submission in /new.
The username is used in two places:
- in the session JWT alongside the game ID
- in the scoreboard
Scores are recorded to the scoreboard using the Redis ZADD command:
await redis.zadd('scoreboard', score, username);
Okay. I don’t know a ton about Redis, but I know it has documentation. A quick read of the Redis docs reveals that ZADD is variadic. It can set one or more (score, name) pairs.
The client library used by this app is ioredis
. How does its API handle variadic Redis commands? Off to the docs!
It looks like it might accept arrays in the scoremembers
arguments?
zadd(
...args: [
key: RedisKey,
...scoreMembers: (string | number | Buffer)[],
callback: Callback<number>
]
): Promise<number>
Interestingly, the README also has a section on transforming arguments.
This smells exploitable!
The solve
The docs indicate ioredis
would be happy to accept a call like this:
redis.zadd('scoreboard', [1, 'user1', 27, 'user2'])
Unfortunately, we only control the 3rd argument, so passing an array looks like this:
redis.zadd('scoreboard', 0, [...])
Hunch: What if the array is just “flattened” into the argument list for ZADD?
POST /new
{"username": ["nobody", 1337, "winner"]}
Play and lose a game.
Success! The scoreboard now shows “winner” with 1337 points at the top!
Get the flag by logging in as “winner” and going directly to /flag.
Final thoughts
This was a fun challenge! The bug was subtle enough to feel satisfying to find and exploit, and forced me to learn about a Redis command I’ve never used.
I’m still mystified at why ioredis
is that flexible about arguments. But “automagic is often harder to work with than strict types” is a rant for another day :)