9 minutes
RedPwnCTF 2020, part 2
Part 2 of my writeup series for RedPwnCTF 2020!
Let’s Begin!
Tux-Fanpage
- points: 464
Ignoring the 1990’s aesthetic of the page, observe the provided script:
const express = require('express')
const path = require('path')
const app = express()
//Don't forget to redact from published source
const flag = '[REDACTED]'
app.get('/', (req, res) => {
res.redirect('/page?path=index.html')
})
app.get('/page', (req, res) => {
let path = req.query.path
//Handle queryless request
if(!path || !strip(path)){
res.redirect('/page?path=index.html')
return
}
path = strip(path)
path = preventTraversal(path)
res.sendFile(prepare(path), (err) => {
if(err){
if (! res.headersSent) {
try {
res.send(strip(req.query.path) + ' not found')
} catch {
res.end()
}
}
}
})
})
//Prevent directory traversal attack
function preventTraversal(dir){
if(dir.includes('../')){
let res = dir.replace('../', '')
return preventTraversal(res)
}
//In case people want to test locally on windows
if(dir.includes('..\\')){
let res = dir.replace('..\\', '')
return preventTraversal(res)
}
return dir
}
//Get absolute path from relative path
function prepare(dir){
return path.resolve('./public/' + dir)
}
//Strip leading characters
function strip(dir){
const regex = /^[a-z0-9]$/im
//Remove first character if not alphanumeric
if(!regex.test(dir[0])){
if(dir.length > 0){
return strip(dir.slice(1))
}
return ''
}
return dir
}
app.listen(3000, () => {
console.log('listening on 0.0.0.0:3000')
})
From this, the functions Strip()
and preventTraversal()
are important:
//Prevent directory traversal attack
function preventTraversal(dir){
if(dir.includes('../')){
let res = dir.replace('../', '')
return preventTraversal(res)
}
//In case people want to test locally on windows
if(dir.includes('..\\')){
let res = dir.replace('..\\', '')
return preventTraversal(res)
}
return dir
}
//Strip leading characters
function strip(dir){
const regex = /^[a-z0-9]$/im
//Remove first character if not alphanumeric
if(!regex.test(dir[0])){
if(dir.length > 0){
return strip(dir.slice(1))
}
return ''
}
return dir
}
This gives away the attack for this challenge: a directory traversal attack. We would try to force access into folders/directories typically inaccessible from the webpage’s root folder. Specifically, the provided script were given hints us to try and traverse our way onto the server’s version of index.js
, which likely has the flag that isn’t redacted (“Dont forget to redact from published source”). We want to use a URL like
https://tux-fanpage.2020.redpwnc.tf/page?path=../
Individually, the above functions can be bypassed:
- for
strip()
, it simply checks if the first character of the argument is alphanumeric, and removes that char if it isn’t. In the get request to/page
, it callsstrip()
first. Therefore, we can bypass that check if our URL looks like
https://tux-fanpage.2020.redpwnc.tf/page?path=j&path=../
- for
preventTraversal()
, the function will recursively check for and remove any instance of../
in our input. However, the input in this case, dir, is set as an array. Using theincludes()
function on an array will return true only if an element of that array exactly matches whateverincludes()
is comparing it too. So, the function will allow../blah
to pass through, as it doesn’t exactly match../
. We can bypass this check if our url looks like
https://tux-fanpage.2020.redpwnc.tf/page?path=j&path=../blah
It was mentioned in my DE1CTF writeup of “Hard_Pentest_1” that javascript shares PHP’s ability to combine arrays (of certain types) and strings together. To this end, consider the following example:
const myStr = "hello";
var array = [" ", "World", "!"];
const res = myStr + array;
print res;
----------
"hello, , World, !"
Javascript will concatenate the elements of the array to the provided string. Observe, then, how the tux-fanpage script does this:
//Get absolute path from relative path
function prepare(dir){
return path.resolve('./public/' + dir)
}
In this case the string is './public/'
and the array is dir
. Keeping in mind the ways to bypass the two filter functions above, our directory traversing URL will look like
https://tux-fanpage.2020.redpwnc.tf/page?path=j&path=../../../index.js
Going to this URL will take us to the published source’s javascript file where the flag is found.
cookie-recipes-v2
- points: 488
The exploit here is slightly complicated. To summarize, we will need to do a CSRF attack, but theres alot more to this challenge than what I originally thought.
The site will ask us to register, and then we will come to the landing page where we can buy 3 recipes. The last one is the flag for this challenge, which costs 1000 credits - currently we only have 100. Additionally, there exists a “RECIPE” submission bar where you send a URL of a recipe to the admin for them to review.
Several exploits come to mind:
- The ability to send a URL to admin allows the possibility to perform XSS or CSRF attacks. Hopefully, being admin will have special privileges that allow us to obtain the flag.
- The main thing barring us from obtaining the flag is the lack of credits we have. If we could find a way to add more credits to our account, we could purchase the flag.
The challenge is accompanied by their source script. While long, several things are of note:
In the last few lines of the source, we see that the ID of the admin is 0.
// Add admin account if it does not exist yet
if (!database.hasUser('admin')) {
// Admin gets id 0
database.register('admin', process.env.ADMIN_PASS, 1, '0', process.env.ADMIN_TOKEN);
statements.setIp.run('::1', '0');
}
Additionally, we see a list of SQL statements.
const statements = {
resetUsers: database.db.prepare('DROP TABLE IF EXISTS users;'),
fromUsername: database.db.prepare('SELECT * FROM users WHERE username = ?;'),
fromId: database.db.prepare('SELECT * FROM users WHERE id = ?;'),
fromToken: database.db.prepare('SELECT user_id FROM tokens WHERE token = ?;'),
isAdmin: database.db.prepare('SELECT admin FROM users WHERE id = ?;'),
register: database.db.prepare(`INSERT INTO
users (id, username, password, balance, received_gift, admin)
VALUES (?, ?, ?, ?, ?, ?);`),
addToken: database.db.prepare(`INSERT INTO
tokens (token, user_id)
VALUES (?, ?);`),
getToken: database.db.prepare('SELECT token FROM tokens WHERE user_id = ?;'),
login: database.db.prepare('SELECT id FROM users WHERE username = ? AND password = ?;'),
setBalance: database.db.prepare('UPDATE users SET balance = ? WHERE id = ?'),
purchaseRecipe: database.db.prepare(`INSERT INTO
purchases (user_id, recipe_id)
VALUES (?, ?);`),
ownsRecipe: database.db.prepare('SELECT * FROM purchases WHERE user_id = ? AND recipe_id = ?;'),
getPurchases: database.db.prepare('SELECT * FROM purchases WHERE user_id = ?;'),
receivedGift: database.db.prepare('SELECT received_gift FROM users WHERE id = ?;'),
checkPassword: database.db.prepare('SELECT * FROM users WHERE id = ? AND password = ?;'),
setReceived: database.db.prepare('UPDATE users SET received_gift = 1 WHERE id = ?;'),
allowedIp: database.db.prepare('SELECT allowed_ip FROM users WHERE username = ?;'),
setIp: database.db.prepare('UPDATE users SET allowed_ip = ? WHERE id = ?;')
};
From the first snippet of code above, we know that admin’s username is admin
and their id is 0. If we can somehow call the methods that subsequently call these statements, we could retrieve the credentials of the admin. As tokens are generated unique to the user, we won’t be able to spoof as admin without their cookie token, and so we need to find a way to get the admin info that doesn’t rely on using the cookie-generated token. To this end, fromId
is attractive, as it requires just the id of the user.
I am utilizing burp’s proxy service. I register an account as usual, and notice the api call to /userInfo
, which, according to the javascript source, will deliver the info of a specific user based on their ID - likely preparing the fromId
SQL statement.
['userInfo', async (req, res) => {
const result = {
'success': false
};
if (req.method !== 'GET') {
res.writeHead(405);
res.end();
return;
}
// Get target user id from url params
const id = util.parseParams(req).id;
if (typeof id !== 'string') {
res.writeHead(400);
res.end();
return;
}
// Get info from database
let info;
try {
info = database.getInfo(id);
} catch (error) {
res.writeHead(500);
res.end();
return;
}
if (!info) {
result.error = 'ID not found';
util.respondJSON(res, 404, result);
return;
}
result.success = true;
result.info = info;
util.respondJSON(res, 200, result);
}]
Knowing that the admin’s ID is 0, modify the api call request to /userInfo
to get the info of the admin’s account, not our own.
Our edited request succesfully grabs the info of the admin account.
However, it is shown that the admin account also doesn’t have enough credits, and they haven’t purchased it before so its not in their account. Additionally, you can’t login as it also checks the ip of the session - but this can be bypassed by converting our request into JSON, as they parse xml requests and bypass checks if not in xml. Despite that, it appears as though this is a dead end - so let’s consider our other avenue of attack: adding more credits to our account.
Another function of importance is the API call to /gift
:
['gift', async (req, res) => {
if (req.method !== 'POST') {
res.writeHead(405);
res.end();
return;
}
const result = {
'success': false
};
// Get token from cookie
const cookies = util.parseCookies(req.headers.cookie);
if (!cookies.has('token')) {
util.respondJSON(res, 200, result);
return;
}
const token = cookies.get('token');
if (typeof(token) !== 'string') {
result.error = 'Invalid token';
util.respondJSON(res, 401, result);
return;
}
// Get id from token
let id;
try {
id = database.getId(token);
} catch (error) {
res.writeHead(500);
res.end();
return;
}
if (!id) {
result.error = 'Invalid token';
util.respondJSON(res, 401, result);
return;
}
// Make sure request is from admin
try {
if (!database.isAdmin(id)) {
res.writeHead(403);
res.end();
return;
}
} catch (error) {
res.writeHead(500);
res.end();
return;
}
// Get target user id from url
const user_id = util.parseParams(req).id;
if (typeof user_id !== 'string') {
res.writeHead(400);
res.end();
return;
}
// Make sure user exists
try {
if (!database.getInfo(user_id)) {
res.writeHead(400);
res.end();
return;
}
} catch (error) {
res.writeHead(500);
res.end();
return;
}
// Make sure user has not already received a gift
try {
if (database.receivedGift(user_id)) {
util.respondJSON(res, 200, result);
return;
}
} catch (error) {
res.writeHead(500);
res.end();
return;
}
// Check admin password to prevent CSRF
let body;
try {
body = await util.parseRequest(req);
} catch (error) {
res.writeHead(400);
res.end();
return;
}
const password = body.password;
try {
if (!database.checkPassword(id, password)) {
res.writeHead(401);
res.end();
return;
}
} catch (error) {
res.writeHead(500);
res.end();
return;
}
// Give user 10 credits
try {
if (!database.changeBalance(user_id, 150)) {
// How did we get here
res.writeHead(500);
res.end();
return;
}
} catch (error) {
res.writeHead(500);
res.end();
return;
}
// User can only receive one gift
try {
database.setReceived(user_id);
} catch (error) {
res.writeHead(500);
res.end();
}
result.success = true;
util.respondJSON(res, 200, result);
}]
The admin has the ability to gift a user some credits. This function is designed to only occur once, but we can fool the server into doing this several times with a race condition, as there is some time between the request being made and served, and when our “recievedGift” check changes. The important filter to bypass is the fact that the function grabs the id from the user’s token in order to verify that the request has actually been made by the admin. Since we don’t have the admin token, we must forcethe admin to make the request without knowing - a CSRF attack. We simply need to host a script on our server (that sends multiple requests at once) on the behalf of the admin to gift our account as many credits as we need. Going back to the URL submission, we send the admin the url of our server hosting that script, and when they visit - we can make the requests as needed with the admin’s token. We should then recieve plenty of credits, and therefore purchase the flag for ourselves.
flag{n0_m0r3_gu3551ng}
Summary
These were interesting problems that allowed me to explore some more unique web exploits! I still hope to discuss more redpwn problems, but nonetheless I wanted to share my writeups for the challenges that were incredibly enjoyable to do!
Jam