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.
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.
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:
The flag is flag{e1c96ccca8777b15bd0b0c7795d018ed}
.