Post

Unfurl

Unfurl

Description

We’ve been working on a little side project - it’s a URL unfurler! Punch in any site you’d like and you’ll get the metadata, main image, the works. We’re publishing it open source soon, so we figured we’d let you take a shot at testing its security first!

Author: @HuskyHacks

Solution

First Look

Taking a first look at the source code, we can see that there are two node applications running. The first one is the main application, which is the one we interact with, and the second one is only running internally and the port is chosen randomly.

alt text

app.js

The main application is a simple express application that serves the static files in the public directory and has a single endpoint /unfurl that takes a URL as input and returns the metadata of the URL. The metadata includes the title, description, html, and the image.

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
router.post('/unfurl', async (req, res) => {
    const { url } = req.body;

    if (!url) {
        return res.status(400).json({ error: 'No URL provided!' });
    }

    try {
        const response = await axios.get(url);
        const html = response.data;
        const $ = cheerio.load(html);

        const title = $('title').text() || 'No title found';
        const description = $('meta[name="description"]').attr('content') || 'No description found';

        let image = $('meta[property="og:image"]').attr('content') || $('link[rel="icon"]').attr('href') || '';
        if (image && !image.startsWith('http')) {
            const urlObj = new URL(url);
            image = `${urlObj.origin}${image}`;
        }

        console.log(`[INFO] Unfurled metadata: Title: "${title}", Description: "${description}", Image: "${image}"`);

        res.json({ title, description, html, image });
    } catch (error) {
        console.error(`[ERROR] Failed to unfurl URL: ${error.message}`);
        res.status(404).json({ error: 'Failed to unfurl the URL.' });
    }
});

admin.js

The second application is a simple express application that has many endpoints, but the one that is interesting is the /execute endpoint. This endpoint takes a command as input and executes it on the server. The command is executed using the exec function from the child_process module. There is no input validation, so we can execute any command on the server, but the request must come from the localhost.

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
...
router.get('/execute', (req, res) => {
    // This isn't terribly secure, but we're only going to bind this app to the localhost so you'd need to be on the actual host to run any commands.
    // So I think we're good!
    const clientIp = req.ip;

    // Definitely making sure to lock this down to the localhost
    if (clientIp !== '127.0.0.1' && clientIp !== '::1') {
        console.warn(`[WARN] Unauthorized access attempt from ${clientIp}`);
        return res.status(403).send('Forbidden: Access is restricted to localhost.');
    }

    const cmd = req.query.cmd;

    if (!cmd) {
        return res.status(400).send('No command provided!');
    }

    exec(cmd, (error, stdout, stderr) => {
        if (error) {
            console.error(`[ERROR] Command execution failed: ${error.message}`);
            return res.status(500).send(`Error: ${error.message}`);
        }

        console.log(`[INFO] Command executed: ${cmd}`);
        res.send(`
            <h1>Command Output</h1>
            <pre>${stdout || stderr}</pre>
            <a href="/admin">Back to Admin Panel</a>
        `);
    });
});

Finding the Port

The port that the second application is running on is chosen randomly using the following code:

1
2
3
4
5
6
7
8
9
function getRandomPort() {
    const MIN_PORT = 1024;
    const MAX_PORT = 4999;
    let port;
    do {
        port = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
    } while (port === 5000);
    return port;
}

We can see that the port is between 1024 and 4999, and it is not 5000. We can use burp intruder to find the port.

alt text

alt text

The port is 2240.

Getting the Flag

Now that we know the port, we can use the /execute endpoint to get the flag. Taking a look at the Dockerfile we can see that the flag is in the /usr/src/app/flag.txt file. We can send the following request to get the flag:

alt text

The flag is flag{e1c96ccca8777b15bd0b0c7795d018ed}.

This post is licensed under CC BY 4.0 by the author.