Armaxis

INTRODUCTION

Starting the challenge, we’re given two ports, corresponding to two components: a website and an web-based email client. The website presents a simple login/registration form, and the email client brings us straight into an inbox without any authentication:

email

A quick skim through the website code suggests that this email client will probably be used for password resets.

CODE ANALYSIS

As usual, I like to read through the code sequentially, starting from startup/initialization code (the Dockerfile, then index.js, etc) and ending with the views. Thankfully, this one didn’t take a lot of reading before I discovered a couple vulnerable sections of code 🤓

A lot of this challenge is very well written, and follows decent coding practices (kudos to the challenge author!). However there are a few outliers…

/challenge/database.js

const insertUser = db.prepare(
	`INSERT INTO users (email, password, role) VALUES (?, ?, ?)`,
);
const runInsertUser = promisify(insertUser.run.bind(insertUser));

await runInsertUser(
    "admin@armaxis.htb",
    `${crypto.randomBytes(69).toString("hex")}`,
    "admin",
);
insertUser.finalize();
console.log("Seeded initial users.");

The admin password is properly randomized and we can consider it unguessable. However,

💡 …we can see the hardcoded email address for the admin account.

/challenge/routes/index.js

router.post("/reset-password", async (req, res) => {
  const { token, newPassword, email } = req.body; // Added 'email' parameter
  if (!token || !newPassword || !email)
    return res.status(400).send("Token, email, and new password are required.");

  try {
    const reset = await getPasswordReset(token);
    if (!reset) return res.status(400).send("Invalid or expired token.");

    const user = await getUserByEmail(email);
    if (!user) return res.status(404).send("User not found.");

    await updateUserPassword(user.id, newPassword);
    await deletePasswordReset(token);

    res.send("Password reset successful.");
  } catch (err) {
    console.error("Error resetting password:", err);
    res.status(500).send("Error resetting password.");
  }
});

Pay close attention to the parts that interact with the database:

    const reset = await getPasswordReset(token);
    if (!reset) return res.status(400).send("Invalid or expired token.");

    const user = await getUserByEmail(email);
    if (!user) return res.status(404).send("User not found.");

See how each query only requires one parameter? Without even re-reading the database code, we know that there is no way that the code is verifying that the token is for the email that they’re looking up - All it’s checking is that (1) the token exists and (2) the email exists.

  • There is no check that the provided token actually corresponds to the provided email.

💡 This means that we could reset another user’s password and hijack their account. All we need is their email.

/challenge/markdown.js

function parseMarkdown(content) {
    if (!content) return '';
    return md.render(
        content.replace(/\!\[.*?\]\((.*?)\)/g, (match, url) => {
            try {
                const fileContent = execSync(`curl -s ${url}`);
                const base64Content = Buffer.from(fileContent).toString('base64');
                return `<img src="data:image/*;base64,${base64Content}" alt="Embedded Image">`;
            } catch (err) {
                console.error(`Error fetching image from URL ${url}:`, err.message);
                return `<p>Error loading image: ${url}</p>`;
            }
        })
    );
}

First, consider the regex line:

content.replace(/\!\[.*?\]\((.*?)\)/g, (match, url) => {

It is searching content for some markdown like this: ![match](url)

Then it takes the url and performs a web request to it, but does no validation on either the URL or the response:

💡 W​e​ ​do​n​’t​ e​v​e​n ​ne​e​d ​t​o ​us​e ​the​ ​​h​t​t​p:/​/​​ ​p​ro​to​c​o​l​ for this URL…

const fileContent = execSync(`curl -s ${url}`);

Lastly, it plops the response (base64 encoded) into some HTML as image data:

return `<img src="data:image/*;base64,${base64Content}" alt="Embedded Image">`;

It’s handy that the data is b64-encoded because it’s guaranteed to not break the HTML.

Summary

To summarize, we found three main things:

  • The administrator is a hardcoded account, and we know their email
  • The password reset mechanism only checks that a token is valid - NOT that a token actually corresponds to the provided email.
  • The markdown parser checks for text like this using a regex: ![image alt text](url)
    • It loads the URL then performs a web request to obtain the “image”
    • It blindly loads a base64 version of the response from the web request into the image data, without even checking if it is an image

EXPLOIT

Strategy

The high-level steps of this are:

  • We can leak the flag by using loading it using the markdown parser. The markdown parser is invoked when we “dispatch” a weapon.
  • To dispatch a weapon, we must be the administrator.
  • We should be able to abuse the password reset mechanism to become the administrator

In more detail, we’ll need to do the following:

  1. Register a user corresponding to the provided email
  2. Perform a password reset on yourself, receive the reset token in your inbox
  3. Manually send a POST /reset-password request, specifying your reset token but the administrator’s email address
  4. Login to dashboard as the admin
  5. Dispatch a weapon to yourself (the admin) with a payload for the markdown parser
    • Use a file:// protocol instead of the usual http://
    • The flag should be loaded as base64
  6. Go back to dashboard and read the base64 flag off of the list of “dispatched weapons”

Execution

When we start the challenge, we don’t actually have a user. We need to register a user with our provided email, so that we’ll be able to receive a password reset token:

The initial password is irrelevant; we just need the account tied to the provided email address.

register a user

Immediately after registration, we can initiate a password reset by choosing Forgot Password. Entering our test email test@email.htb again, we should receive a reset token:

received reset token

We’re presented with a simple password reset form to enter the token, but this UI has our email address pre-loaded:

password reset form

We want to provide our reset token but the administrator’s email address, so we’ll bypass this form by performing the request via cURL instead:

curl http://94.237.48.175:32314/reset-password --json '{"token":"8308b1a2338540a2d1ecd0f9466828a3", "newPassword":"password", "email":"admin@armaxis.htb"}' 
# Password reset successful.

The API reports success, so let’s go log in with our new credentials: admin@armaxis.htb : password from the regular login form at /. It lets us in with no fuss at all, and now we can see the dashboard from the administrator’s role:

admin dashboard

We needed to log in as admin because, unlike ordinary users, this user can “dispatch weapon”. The vulnerable code in parseMarkdown() is called by submitting this form (POST /weapons/dispatch - see /challenge/routes/index.js):

dispatch weapon

☝️ Note: we could have used either email, either the administrator’s or test@email.htb. We only need to send it to an account that we have access to, so that we can see the weapon appear on their dashboard home.

If we send it to the admin’s, we can see it without having to log in again.

The weapon appears on the dashboard, and we can Inspect the embedded image;

inspect embedded imaeg

The flag is inside the <img> tag as base64 data:

<img src="data:image/*;base64,[BASE64_FLAG_REDACTED]" alt="Embedded Image">

Go ahead and copy the base64 data to the clipboard, and decode it using bash:

echo -n '[PASTE]' | base64 -d
flag

There’s the flag! 🎉

That was fun, and pretty easy 😄 Thanks, @Xclow3n


Thanks for reading

🤝🤝🤝🤝
@4wayhandshake