iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Time-based

Time-based blind SQLi means the page’s output never reveals the answer, but the server pauses when an attacker-injected condition is true. Detection: a response that takes 5+ seconds when a probing query asks for a SLEEP only on a match. Mitigation: the same as every other injection class — parameterized queries, no string concatenation.

Vulnerable vs prepared, plus controls

EXAMPLE
// SCENARIO — a search endpoint, defensive perspective

// ─── VULNERABLE — DO NOT SHIP ──────────────────────────────────

// Node + raw MySQL string concat
app.get('/products', async (req, res) => {
    const q = req.query.q || '';
    const rows = await conn.query(`SELECT id, name FROM products WHERE name LIKE '%${q}%'`);
    res.json(rows);
    // ❌ A scanner can append: ' AND IF(SUBSTRING(@@version,1,1)='8', SLEEP(5), 0)-- -
    //    If the response takes ~5s, the version starts with 8.
    //    Repeat character-by-character to extract entire tables.
});

// ─── FIX 1 — Parameterized queries ─────────────────────────────
app.get('/products', async (req, res) => {
    const q = req.query.q || '';
    const rows = await conn.execute(
        'SELECT id, name FROM products WHERE name LIKE ?',
        [`%${q}%`],
    );
    res.json(rows);
    // ✓ The driver sends the query and bound value separately to the DB.
    //   The DB never parses user input as SQL syntax.
});

// PostgreSQL (pg)
await pg.query('SELECT id, name FROM products WHERE name ILIKE $1', [`%${q}%`]);

// SQLite (better-sqlite3)
db.prepare('SELECT id, name FROM products WHERE name LIKE ?').all(`%${q}%`);

// Python (psycopg)
cur.execute('SELECT id, name FROM products WHERE name ILIKE %s', (f'%{q}%',))

// PHP (PDO)
$stmt = $pdo->prepare('SELECT id, name FROM products WHERE name LIKE ?');
$stmt->execute(["%{$q}%"]);

// Laravel query builder / Eloquent
Product::where('name', 'like', "%{$q}%")->get();

// ─── FIX 2 — Identifiers (table/column names) ──────────────────
// Parameter binding works for VALUES, not identifiers.
// If users pick the sort column, use an allowlist:

const SORT_COLS = new Set(['name', 'price', 'created_at']);
const sort = SORT_COLS.has(req.query.sort) ? req.query.sort : 'created_at';
await conn.execute(`SELECT * FROM products ORDER BY ${sort} LIMIT 50`);
// ✓ \\${sort} only ever comes from the allowlist.

// ─── FIX 3 — Least-privilege DB account ────────────────────────
// The app's DB user should not have:
//   • DROP, ALTER, TRUNCATE on production tables
//   • Access to system tables it doesn't read
//   • Permission to write to its own auth tables (use a separate signup role)
// A SQL injection on a low-priv account is far less catastrophic.

// ─── FIX 4 — Statement timeouts ────────────────────────────────
// Time-based exfil relies on long-running SLEEP. Cap query duration.

// Postgres
await pg.query(`SET statement_timeout = '2s'`);          // per session
// or per-database: ALTER DATABASE app SET statement_timeout = '2s';

// MySQL
await conn.execute('SET SESSION MAX_EXECUTION_TIME=2000');

// Express request timeout — kill stuck requests too
app.use((req, res, next) => {
    req.setTimeout(5000, () => res.status(504).end());
    next();
});

// ─── FIX 5 — WAF / detection signals ───────────────────────────
// Watch for these patterns in access logs (high false-positive rate, use carefully):
//   • SLEEP(, BENCHMARK(, pg_sleep(, WAITFOR DELAY
//   • Response times > 3s on endpoints that normally return in < 200 ms
//   • Same client IP making many slow requests with varying ?q= values
//   • Single-quote or comment markers (--, /*) in fields that don't need them

const SUSPICIOUS = /\b(sleep|benchmark|pg_sleep|waitfor)\s*\(/i;
app.use((req, res, next) => {
    const blob = JSON.stringify({ q: req.query, b: req.body });
    if (SUSPICIOUS.test(blob)) {
        log.warn({ ip: req.ip, blob }, 'possible sqli probe');
        // Don't block silently — return a normal 400 so probing data is noisy
    }
    next();
});

// ─── FIX 6 — ORM-only repository layer ─────────────────────────
// If a repository module is the ONLY place that touches the DB, raw concat
// can't sneak in from a feature module by accident.

class ProductRepository {
    async search(q) {
        return Product.query()
            .where('name', 'ilike', `%${q}%`)
            .orderBy('created_at', 'desc')
            .limit(50);
    }
}

// ─── REGRESSION TESTS ──────────────────────────────────────────
import request from 'supertest';

test('search endpoint rejects sleep-style probes safely', async () => {
    const probes = [
        "' OR SLEEP(2)-- -",
        "%' AND pg_sleep(2)-- -",
        "1; WAITFOR DELAY '0:0:2'-- ",
    ];
    for (const p of probes) {
        const start = Date.now();
        const res = await request(app).get('/products').query({ q: p });
        const ms  = Date.now() - start;
        expect(res.status).toBeLessThan(500);
        expect(ms).toBeLessThan(1000);             // no time-based signal
    }
});

// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. ALL queries use bind parameters; lint for string concat in SQL
// 2. Identifiers come from allowlists, never user input
// 3. DB account has read-only / write-narrow privileges per service
// 4. statement_timeout set globally (1-5s for OLTP)
// 5. Request timeout on the web layer
// 6. Logging + alerting on slow responses to normally-fast endpoints

Why it matters

Time-based blind SQLi is detectable from the outside even when the page reveals nothing — the fix is the same as for every other injection class: parameterized queries everywhere, allowlisted identifiers, and a low-privilege database account so a successful injection has nothing valuable to reach.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// Attacker triggers SLEEP() to detect injectability via response time.
// Defence is unchanged — parameterise + limit query-time at the gateway.
Try it Yourself »

Discussion

Loading…