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 calls strip() 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 the includes() function on an array will return true only if an element of that array exactly matches whatever includes() 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.

Login
  • 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:

  1. 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.
  2. 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.

Login

Our edited request succesfully grabs the info of the admin account.

Login

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.

Login

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