Navigating to the provided url we are presented with a simple login page:
Since we’re just presented with a login page, my first thought is that this is going to be some sort of SQL injection challenge. Trying the simple ' OR 1=1 --
and other variations, just returns us to the login page, so let’s take a look at the index.js file to get a better idea of what’s going on:
const crypto = require('crypto');
const db = require('better-sqlite3')('db.sqlite3')
// remake the `users` table
db.exec(`DROP TABLE IF EXISTS users;`);
db.exec(`CREATE TABLE users(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT,
password TEXT
);`);
// add an admin user with a random password
db.exec(`INSERT INTO users (username, password) VALUES (
'admin',
'${crypto.randomBytes(16).toString('hex')}'
)`);
const express = require('express');
const bodyParser = require('body-parser');
const app = express();
// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
app.use(express.static('static'));
// login route
app.post('/login', (req, res) => {
if (!req.body.username || !req.body.password) {
return res.redirect('/');
}
if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
return res.redirect('/');
}
// see if user is in database
const query = `SELECT id FROM users WHERE
username = '${req.body.username}' AND
password = '${req.body.password}'
`;
let id;
try { id = db.prepare(query).get()?.id } catch {
return res.redirect('/');
}
// correct login
if (id) return res.sendFile('flag.html', { root: __dirname });
// incorrect login
return res.redirect('/');
});
app.listen(3000);
Ok, so some things I noticed right off the bat:
const db = require('better-sqlite3')('db.sqlite3')
...
const express = require('express');
db.exec(`INSERT INTO users (username, password) VALUES (
'admin',
'${crypto.randomBytes(16).toString('hex')}'
)`);
'
) we are redirected back to the login page:if ([req.body.username, req.body.password].some(v => v.includes('\''))) {
return res.redirect('/');
}
Cool, so this is almost certainly a SQL injection challenge as originally predicted. Since the single quote is being sanitized, I tried a few different things such as URL encoding (%27
) and HTML character codes ('
) but with no luck.
It was at this point that I decided to take a break from the challenge and look at other challenges. Coming back the next day and analyzing the index.js a little more closely, I recognized something interesting, the body-parser
middleware that parses login queries has extended mode enabled!
// parse json and serve static files
app.use(bodyParser.urlencoded({ extended: true }));
I was initially introduced to this misconfiguration by a LiveOverflow video on the Google CTF 2020 challenge, “Pasteurize.” This misconfiguration means that the query string will be parsed with the qs library
which allows us to parse that string as a rich object or array:
So for example, if we have a query like a[]=b
in normal mode this is parsed as {"a[]": "b"}
but if extended mode is enabled the same string is parsed as {"a": ["b"]}
.
In the context of this challenge, this means that instead of the username/password query being parsed as a string, we can make it be parsed as an array. So, instead of the program looking at each character for the single quote ('
), it looks at each element of the array as a whole and thus we are able to bypass that check.
Great! We now have a good understanding of what is going on and how to bypass the single quote check that has been disrupting our initial SQL injections. So now let’s try manipulating the initial request body:
username:admin&password=password
→ username:admin&password[]=' or 1=1 --
Doing so, we bypass the login and get the flag!