xark
In Square CTF 2022, 250 points
Guest challenge by Alok Menghrajani.
Challenge files: xark.zip
The website allows a user to query a database via the query builder library knex.
knex.schema.hasTable('crushes').then(function(exists) {
if (!exists) {
console.log("crushes table doesn't exist, initializing...");
knex.schema.createTable('crushes', function(table) {
table.increments('id').primary();
table.string('from').notNullable();
table.string('to').notNullable();
table.string('message').notNullable();
table.index(['to']);
}).then();
knex('crushes').insert({
from: config.init.flag,
to: config.init.flag,
message: 'This is the flag!',
}).then();
}
});
app.post('/data', async (req, res) => {
if (req.body.to) {
const crushes = await knex('crushes')
.select()
.where({
to: req.body.to
})
.limit(50);
res.send(crushes);
} else {
res.status(400).send({});
}
});
The app employs two express plugins to parse HTTP request bodies:
app.use(express.json());
app.use(express.urlencoded({
extended: false
}));
While we are not able to send complex objects (anything other than a string) to the app via urlencoded bodies (as express.urlencoded
extended mode is turned off), we can do so using JSON bodies instead.
Notably, as we have seen in mongodb nosql injection challenges, passing complex objects to database query engines usually turn out to be a bad idea, especially in weakly and dynamically typed languages like javascript. However, I had never seen such a vulnerability outside of mongodb.
The issue here arises from the combination of knex and the mysqljs library.
Let's look at the query code:
const crushes = await knex('crushes')
.select()
.where({
to: req.body.to
})
.limit(50);
Suppose req.body.to
is an array, like [0]
. Then knex would generate a 'parameterized' SQL query like this:
query: select * from `crushes` where `to` = ? limit ?
bindings: [ [0], 50 ]
which would be passed to the underlying database driver library to handle.
When MySQLjs is used, the Connection.query
function is used, which calls Connection.format
to combine the SQL query and bound values to a raw SQL query.
Connection.format
eventually calls SqlString.format
which calls SqlString.escape
on each of the bound values. If the bound value is an array, SqlString.arrayToList
is called:
SqlString.arrayToList = function arrayToList(array, timeZone) {
var sql = '';
for (var i = 0; i < array.length; i++) {
var val = array[i];
if (Array.isArray(val)) {
sql += (i === 0 ? '' : ', ') + '(' + SqlString.arrayToList(val, timeZone) + ')';
} else {
sql += (i === 0 ? '' : ', ') + SqlString.escape(val, true, timeZone);
}
}
return sql;
};
In our case, the array [0]
is simply converted to 0
.
This seems slightly odd, but I think MySQLjs expects that array parameters be enclosed in parentheses in the SQL query. See this stackoverflow answer.
After all this, the raw SQL query returned by MySQLjs is
select * from `crushes` where `to` = 0 limit 50
Since to
is a string field, and comparing strings to 0
or false
returns true
in MySQL, the query is equivalent to
select * from `crushes` where 1=1 limit 50
which returns the flag.
Interestingly this is already a known issue which has been open for over 6 years.