Research Blog & News

Prepared Statements? Prepared to Be Vulnerable.

Balazs Bucsay / 2025-11-19 09:53:09

Personally, I’ve delivered a number of secure‑coding trainings and advised hundreds of companies to use prepared statements, often suggesting that they solve the majority of SQL Injection issues. Of course, there are always situations where a developer can misuse prepared statements and accidentally introduce vulnerabilities, but recently we discovered a new edge case that even we were not aware of.

One of the latest web applications we tested handled a significant amount of Protected Health Information (PHI). It is fair to say that this was a critical application requiring a high level of protection. The assessment was performed in a white‑box fashion, where the client shared their codebase to ensure we could cover as much of the application as possible and strengthen its resilience against attacks. At first glance, it was clear that they were using prepared statements exclusively (no raw queries) and none of the SQL queries incorporated user input in an unsanitized form. The tech stack consisted of Linux, MySQL, and Node.js, which is a very common combination.

Even though we had the source code, we approached parts of the assessment as a black‑box test to be as thorough as possible. Regardless of knowing that prepared statements were used, we tested all potential injection points with a variety of inputs, using both manual and automated fuzzing. This is when we noticed something unusual, a response pattern that at first looked unexploitable. The application returned verbose error messages and partial SQL queries whenever special, JSON‑formatted values were submitted instead of the expected strings. Initially, this looked like a system‑specific quirk, but it later turned out to be a special (and surprisingly common) edge case that can make prepared statements vulnerable.

The vulnerability described in this blog post affects the mysql and mysql2 NPM packages commonly used as MySQL connectors in Node.js applications (more details on these packages and the impact will follow). As it turns out, the default configuration of these libraries transforms JavaScript objects into valid SQL fragments, making it possible to alter the structure or logic of a MySQL query, even when prepared statements are being used correctly.

Let’s look at a simple Node.js example:

app.post('/api/login', (req, res) => {
  const { email, password } = req.body;
  
  const query = 'SELECT * FROM users WHERE email = ?';
  
  db.query(query, [email], async (err, results) => {
    if (err) {
      return res.status(500).json({ error: 'Database error' });
    }
    
    if (results.length === 0) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const validPassword = await argon2.verify(results[0].password, password);
    
    if (!validPassword) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    res.json({ 
      message: 'Login successful',
      email: user.email 
    });
  });
});

The code above shows the logic behind a login form that uses prepared statements, so one would normally assume this is secure. When a user attempts to log in with the email "test@example.com", the following HTTP JSON object is sent in the POST body:

{"email":"test@example.com","password":"Password1"}

As a result, the backend assembles and executes the expected query:

SELECT * FROM users WHERE email = 'test@example.com'

However, things change when we modify the login request and replace the email with a JSON object:

{"email":{"foo":"bar"},"password":"Password1"}

Unexpectedly, the JSON object is converted into a SQL fragment:

`foo` = 'bar'

leading to this final query:

SELECT * FROM users WHERE email = `foo` = 'bar'

In MySQL, this means the email column is compared to the foo column, and 'bar' is interpreted as a boolean (0 or false). This is already strange behavior, but with a bit of knowledge about the internal schema, it becomes exploitable.

For example:

{"email":{"email":1},"password":"Password1"}

is transformed into:

SELECT * FROM users WHERE email = `email` = 1

This returns all users from the users table. In theory, an attacker could log in as any user whose password is "Password1", but even if that fails, the bigger issue is that a prepared statement has now been manipulated into an unintended, attacker-controlled query.

And the problem doesn’t stop with JSON objects. Arrays are also converted in a dangerous way. For example, the following request might return the details of every user:

https://example.com/api/getuser?id[id]=1

which produces:

SELECT * FROM users WHERE id = `id` = 1


The Impact

Where can this type of vulnerability be exploited with higher impact?

Anywhere the application performs mass selection, deletion or updates, this flaw can become extremely dangerous. Consider a feature that allows deleting a specific entry:

{"id":{"id":true}}

This would be transformed into the following SQL:

DELETE FROM entries WHERE id = `id` = true

This effectively deletes all entries from the entries table.

Another example is data altering, for example a user-profile update feature. Suppose the application allows changing a user’s surname:

{"userid":{"userid":true},"surname":"Foobar"}

This results in:

UPDATE users SET surname='Foobar' WHERE userid = `userid` = true

This would update every user’s surname to “Foobar”.

These queries are used everywhere, yet the opportunities for abusing them stretch far beyond what most developers imagine.


A Real World Example

Going back to the original target (the application handling a significant amount of PHI) although the code initially appeared secure, it turned out to be easily compromised. As expected, the webapp included a “forgotten password” feature that allowed users to reset their passwords without knowing the old one. A user would submit their email address and the system would send a password-reset email containing a link with a randomly generated token. This token acted as a one-time secret with a short, one-hour expiry. Since we treat email addresses as trusted contact channels and the token was randomly generated, it is reasonable to assume an attacker would be unable to guess or obtain it.

When the user clicked the link, the system queried the MySQL database to find the user associated with the provided token, and then displayed a form to set a new password. If the token was invalid or expired, an error was returned. Can you see where this is heading?

The forgotten-password feature was available to everyone, including unauthenticated attackers. If an attacker knew any email address registered in the system (emphasising the importance of avoiding user-enumeration flaws), they could initiate a reset process on behalf of that user. This would cause the system to generate a token, store it in the database and send it to the rightful user via email.

Here is the vulnerable code as an example:

app.get('/api/forgotten-password', async (req, res) => {
  const { token, newPassword } = req.query;
  
  if (!token || !newPassword) {
    return res.status(400).json({ error: 'Token and new password required' });
  }
  
  const query = 'SELECT * FROM users WHERE reset_token = ? AND reset_token_expiry > NOW()';
  
  db.query(query, [token], async (err, results) => {
    if (err) {
      return res.status(500).json({ error: 'Database error' });
    }
    
    if (results.length === 0) {
      return res.status(400).json({ error: 'Invalid or expired token' });
    }
    
    const hashedPassword = await argon2.hash(newPassword, {
        type: argon2.argon2id,
        memoryCost: 2 ** 16,   // 64 MB
        timeCost: 3,
        parallelism: 1
      });
    const updateQuery = 'UPDATE users SET password = ?, reset_token = NULL, reset_token_expiry = NULL WHERE reset_token = ?';
    
    db.query(updateQuery, [hashedPassword, token], (err) => {
      if (err) {
        return res.status(500).json({ error: 'Database error' });
      }
      res.json({ message: 'Password reset successful' });
    });
  });
});

Without knowing the freshly generated token, the attacker could still exploit the previously described vulnerability by opening a URL such as:

https://example.com/api/forgotten-password?token[token]=1&newPassword=Mantra

As noted earlier, arrays were treated the same way as JSON objects, so the package converted the user input into the following SQL expression:

SELECT * FROM users 
WHERE reset_token = `token` = 1 
  AND reset_token_expiry > NOW()

The follow-up UPDATE query reused the same user-controlled input and was transformed into:

UPDATE users
SET password = '[HASH_PLACEHOLDER]',
    reset_token = NULL,
    reset_token_expiry = NULL
WHERE reset_token = `reset_token` = 1

This caused the token comparison to match any user who had a reset token stored in the database. And since the attacker had just triggered the forgotten-password process for a victim, the system had inserted such a token, allowing the attacker to reset that user’s password without ever knowing the real token.

This vulnerability was rated as critical risk, as it effectively enabled an authentication bypass and a privilege escalation to administrator level, granting full access to all patient information stored in the system.


The Issue

The problem is twofold. First, this vulnerability affects both of the most popular MySQL connector NPM packages, mysql and mysql2. The mysql package is heavily outdated, it has not been updated in over six years, yet it remains widely used, with around one million downloads per week over the past year. The mysql2 package is even more popular, downloaded approximately 3.5 million times per week.

Second, the developers of these packages intentionally left this behaviour in the code, exposing a configuration option called stringifyObjects. According to the documentation:

Stringify objects instead of converting to values. (Default: false)

While the option is disabled by default, this means that any code using these packages without explicit configuration could be vulnerable. This is exactly why our client’s system was affected and it also highlights the potential risk to the millions of developers and projects that download these packages weekly.


The Remediation

To prevent this vulnerability, you should enable the stringifyObjects option in your MySQL connection. When enabled, any arrays or JSON objects passed as query parameters will be converted to strings instead of being interpreted as SQL fragments, which mitigates the risk of unintended query manipulation.

Example:

const db = mysql.createConnection({
  host: 'localhost',
  user: 'mantra',
  password: 'MANTRA_INFORMATION_SECURITY_SECURE_PASSWORD',
  database: 'infosec',
  stringifyObjects: true // converts objects to strings to prevent query injection
});

Additional advice: If you are still using the legacy mysql package, consider switching to mysql2, and always choose packages that are actively maintained, widely used and reputable.


Previous Research

Although our team was initially unaware of this “feature” in the MySQL packages, and the resulting vulnerability affecting prepared statements in Node.js, we only fully realized the extent of the issue while conducting deeper research as part of a client engagement. A well-targeted Google search, however, revealed that this issue had already been identified by Flatt Security Inc. in early 2022



Contact

Contact Us

Location:

London, United Kingdom

Loading
Your message has been sent. Thank you!