Cross-Site Scripting (XSS)
Inject JavaScript code on victims to perform actions on their behalf
# Related Pages
JavaScriptDescription
Cross-Site Scripting (XSS) is a very broad topic, but it revolves around one idea: executing malicious JavaScript. This is often from an attacker's site, hence "Cross-Site" scripting. A common distinction made between types of XSS is:
Reflected XSS: Inject HTML as some content from a parameter that is reflected directly on the target page. This payload is not stored and is seen only if the malicious URL is visited
Stored XSS: Store a payload somewhere, which is later loaded insecurely which places the injected HTML directly onto the page. The difference here is that the payload is saved on the server side in some way, and is later retrieved by a victim
DOM XSS: A special variant not using HTML, but rather the Document Object Model (DOM) in JavaScript code itself. When malicious data ends up in JavaScript "sinks" that are able to execute code, such as
location = "javascript:..."
, the payload is triggered via the DOM. The payload may still be either reflected or stored, but it is often called DOM XSS
The most basic form of XSS looks like this. Imagine a page that takes some parameter as input, and reflects it back in the response without any filtering:
The intention might be that we can write some styled code like <b>hello</b>
to write in bold, but instead, an attacker can use a tag like <script>
to include JavaScript code:
This will place the document.cookie
value (all your Cookies, like session tokens) in a simple alert()
box that pops up on your screen. This is a common proof-of-concept to show an attacker is able to access and possibly exfiltrate a user's cookies in order to impersonate them.
Contexts
There are a few different places where your input might end up inside HTML to create dynamic pages. Here are a few common ones for example:
Depending on the context, you will need different syntax to do the following steps:
Escape the original code, by closing tags (eg.
</textarea>
) or strings ("
or'
)Write the JavaScript payload that will execute
Possibly fixing the rest of the code that normally comes after, to prevent errors
For the Attribute context as an example, we could exploit it by 1. Escaping by starting with a "
that will close off the string, then 2. Add our own attribute like onerror=alert()
to execute a function when the image fails to load, and finally 3. Close off the last quote by ending with something meaningless like x="
that will be closed by a quote. Altogether it could look like this:
When this is rendered to the page, the image with src=""
will likely fail to load as the current page is not an image. Then the onerror=
handler is triggered to pop an alert box open, and the tag is closed cleanly. This is the basic idea for all JavaScript Injections. The following sections will explore the various contexts in more detail.
HTML Injection
With zero protections, the simplest-to-understand injection is:
This starts JavaScript syntax using the <script>
tag, and executes the alert()
function. There are however a few caveats that will result in this payload not always working. The most important is the difference between server-inserted code and client-inserted code.
When the server inserts your script into the HTML, the browser doesn't know any better and trusts the code so it will be run as if it is part of the first original page.
When instead the code is possibly fetched and then inserted by some other client-side JavaScript code like element.innerHTML = "<script>..."
, it will be inserted after the document has already loaded, and follow some different rules. For one, inline scripts like these won't execute directly, as well as some other elements that are not directly loaded after they have been inserted into the DOM.
Because of the above reasons, it is often a safer idea to use a common payload like:
The special thing about this payload is that an image should be loaded, which the browser really wants to do as soon as it is inserted, even on the client side. This causes the onerror=
handler to instantly trigger consistently, no matter how it is inserted (read more details in #triggers).
In some cases a common variation is the following:
The small difference between these two payloads is that the first works everywhere except Firefox client-inserted, and the second works everywhere while remaining relatively short.
Special Tags
When inserted into the content of a <textarea>
, JavaScript code won't be directly executed in any way. Therefore you need to first close this specific tag using </textarea>
, and then continue with a regular XSS payload like normal.
Common Filter Bypasses
While the above are simple, they are also the most common, and many filters already recognize these patterns as malicious and block or sanitize your payload in some way that will try to make it safe. This topic is explored more in Filter Bypasses, but a few of the best tricks are displayed here. The first is when a RegEx pattern like <[^>]>
expects a >
to close a tag, which can be omitted often because another future tag will close it for you:
It is common for dangerous tags to be blacklisted, and any event handler attributes like onload
and onerror
to be blocked. There are some payloads however that can encode data to hide these obligatory strings (n
= HTML-encoded n
, CyberChef):
One last payload is a less well-known tag called <base>
which takes an href=
attribute that will decide where any relative URLs will start from. If you set this to your domain for example, and later in the document a <script src="/some/file.js">
is loaded, it will instead be loaded from your website at the path of the script.
To exploit and show a proof of concept of the above trick, I set up this domain that returns the same script for every path with any payload you put into that URL hash. This means you can include this injection anywhere, and put a JavaScript payload after the #
symbol of the target URL which will then be executed:
See Filter Bypasses for a more broad approach to make your own bypass.
Alternative Impact
If inserting tags to achieve XSS is really not possible, due to a string filter or some other restriction, you can always try to get other impact using an HTML injection as they can be very powerful.
One idea is to use DOM Clobbering, which is a technique that uses id
's and other attributes of tags that make them accessible from JavaScript with the document.<name>
syntax. The possibility of this depends on what sinks are available, and should be evaluated case-by-case:
If <iframe>
tags are allowed, you may be able to load an iframe of your malicious site. This can then access the top
variable in its JavaScript code to do some light interaction with the target page like top.location = "..."
to redirect it, or top.postMessage()
to send messages to "message"
event listeners on the target page, which may have sinks for XSS or other impact like stealing secrets. These could be vulnerable if the listeners don't check the origin or a message, and is even possible if X-Frame-Options
are denied as this happens on the target site itself.
Styles using CSS can also be dangerous. Not only to restyle the page, but with selectors and URLs any secrets on the page like CSRF tokens or other private data can be exfiltrated. For details on exploiting this, see this introduction, an improved version using @import
, and finally this tool.
As a last resort, Phishing can always be done using HTML to convince a user to input some credentials or perform some other action. Combining an <iframe>
with <style>
one can create a full-screen phishing page on the target domain, that may fool any user coming across it as the domain seems correct.
Attribute Injection
While HTML Injection is easy when you are injecting directly into a tag's contents, sometimes the injection point is inside a tag's attribute instead:
This is a blessing and a curse because it might look harder at first, but this actually opens up some new attack ideas that might not have been possible before. Of course, the same HTML Injection idea from before works just as well, if we close the attribute and start writing HTML:
However, this is not always possible as the <
and >
characters are often HTML encoded like <
and >
to make them represent data, not code. This would not allow us to close the <img>
tag or open a new tag to add an event handler to, but in this case we don't need it! Since we are already in an <img>
tag, we can simply add an attribute to it with a JavaScript event handler that will trigger:
The same goes for '
single quotes and no quotes at all, which just need spaces to separate attributes. Using the PortSwigger XSS Cheat Sheet you can filter for possible triggers of JavaScript using attributes on your specific tag by filtering it and looking at the payloads. Some of these will require some user interaction like onclick=
, but others won't.
A useful trick with <input>
tags specifically is the onfocus=
attribute, together with the autofocus
attribute which will combine to make it into a payload not requiring user interaction.
Script Injection
A special case is when the injection is found inside of a <script>
tag. This may be done by developers when they want to give JavaScript access to some data, often JSON or a string, without requiring another request to fetch that data. When implemented without enough sanitization, however, this can be very dangerous as tags might not even be needed to reach XSS.
As always, a possibility is simply closing the context and starting an HTML Injection:
If these <
or >
characters are blocked or encoded however, we need to be more clever. Similarly to Attribute Injection, we can close only this string, and then write out arbitrary JavaScript code because are already in a <script>
block. Using the -
subtract symbol, JavaScript needs to evaluate both sides of the expression, and after seeing the empty ""
string, it will run the alert()
function. Finally, we need to end with a comment to prevent SyntaxError
s:
Another special place you might find yourself injecting into is template literals, surrounded by `
backticks, which allow variables and expressions to be evaluated inside of the string. This opens up more possible syntax to run arbitrary JavaScript without even having to escape the string:
Double Injection \
backslash trick
\
backslash trickOne last trick is useful when you cannot escape the string with just a "
quote, but when you do have two injections on the same line.
The important piece of knowledge is that any character escaped using a \
backslash character, which will interpret the character as data instead of code (see here for a table of all special backslash escapes).
With this knowledge, we know a \"
character will continue the string and not stop it. Therefore if we end our input with a \
character, a "
quote will be appended to it which would normally close the string, but because of our injection cause it to continue and mess up the syntax:
The critical part here is that the 2nd string that would normally start the string is now stopping the first string instead. Afterwards, it switches to regular JavaScript context starting directly with our second input, which no longer needs to escape anything. If we now write valid JavaScript here, it will execute (note that we also have to close the }
):
Escaped /
bypass using <!--
comment
/
bypass using <!--
commentWhen injecting into a script tag that disallows quotes ("
), you may quickly jump to injecting </script>
to close the whole script tag and start a new one with your payload. If the /
character is not allowed, however, you cannot close the script tag in this way.
Instead, we can abuse a lesser-known feature of script contents (spec), where for legacy reasons,
<!--
and <script
strings need to be balanced. When opening a HTML comment inside a script tag, any closing script tags before a closing comment tag will be ignored. If another later input of ours contains -->
, only then will the script tag be allowed to close via a closing script tag again.
This can cause all sorts of problems as shown in the example below (source, another example):
Notice that the closing script tag on line 3 doesn't close it anymore, but instead, only after the closing comment inside of the attribute, it is allowed to again. By there closing it ourselves from inside the attribute, we are in an HTML context and can write any XSS payload!
For more advanced tricks and information, check out the JavaScript page!
DOM XSS
This is slightly different than previous "injection" ideas and is more focussed on what special syntax can make certain "sinks" execute JavaScript code.
The Document Object Model (DOM) is JavaScript's view of the HTML on a page. To create complex logic and interactivity with elements on the page there are some functions in JavaScript that allow you to interact with it. As a simple example, the document.getElementById()
function can find an element with a specific id=
attribute, on which you can then access properties like .innerHTML
:
DOM XSS is where an attacker can abuse the interactivity with HTML functions from within JavaScript by providing sources that contain a payload, which end up in sinks where a payload may trigger. A common example is setting the .innerHTML
property of an element, which replaces all HTML children of that element with the string you set. If an attacker controls any part of this without sanitization, they can perform HTML Injection just as if it was reflected by the server. A payload like the following would instantly trigger an alert()
:
Sources are where data comes from, and there are many for JavaScript. There might be a URL parameter from URLSearchParams
that is put in some HTML code, location.hash
for #...
data after a URL, simply a fetch()
, document.referrer
, and even "message"
listeners which allow postMessage()
communication between origins.
When any of this controllable data ends up in a sink without enough sanitization, you might have an XSS on your hands. Just like contexts, different sinks require different payloads. A location =
sink for example can be exploited using the javascript:alert()
protocol to evaluate the code, and an eval()
sink could require escaping the context like in Script Injection.
Note: A special less-known property is window.name
which is surprisingly also cross-origin writable. If this value is used in any sink, you can simply open it in an iframe or window like shown below and set the .name
property on it!
JQuery - $()
$()
A special case is made for JQuery as it is still to this day a popular library used by many applications to ease DOM manipulation from JavaScript. The $()
selector can find an element on the page with a similar syntax to the more verbose but native document.querySelector()
function (CSS Selectors). It would make sense that these selectors would be safe, but if unsanitized user input finds its way into the selector string of this $
function, it will actually lead to XSS as .innerHTML
is used under the hood!
A snippet like the following was very commonly exploited (source):
Here the location.hash
source is put into the vulnerable sink, which is exploitable with a simple #<img src onerror=alert()>
payload. In the snippet, this is called on the hashchange
event it is not yet triggered on page load, but only after the hash has changed. In order to exploit this, we need to load the page normally first, and then after some time when the page has loaded we can replace the URL of the active window which will act as a "change". Note that reading a location is not allowed cross-origin, but writing a new location is, so we can abuse this.
If the target allows being iframed, a simple way to exploit this is by loading the target and changing the src=
attribute after it loads:
Otherwise, you can still load and change a URL by open()
'ing it in a new window, waiting some time, and then changing the location of the window you held on to (note that the open()
method requires user interaction like an onclick=
handler to be triggered):
Important to note is that the vulnerable code above with $(location.hash)
above is not vulnerable anymore with recent versions of JQuery because an extra rule was added that selectors starting with #
are not allowed to have HTML, but anything else is still vulnerable. A snippet like below will still be vulnerable in modern versions because it is not prefixed with #
, and it URL decodes the payload allowing the required special characters. Context does not matter here, simply <img src onerror=alert()>
anywhere in the selector will work.
JQuery also has many other methods and CVEs if malicious input ends up in specific functions. Make sure to check all functions your input travels through for possible DOM XSS.
Triggers (HTML sinks)
- .innerHTML
- .innerHTML + DOM
- write()
- open() write() close()
When placing common XSS payloads in the triggers above, it becomes clear that they are not all the same. Most notably, the <img src onerror=alert()>
payload is the most universal as it works in every situation, even when it is not added to the DOM yet. The common and short <svg onload=alert()>
payload is interesting as it is only triggered via .innerHTML
on Chome, and not Firefox. Lastly, the <script>
tag does not load when added with .innerHTML
at all.
Client-Side Template Injection
Templating frameworks help fill out HTML with user data and try to make interaction easier. While this often helps with auto-escaping special characters, it can hurt in some other ways when the templating language itself can be injected without HTML tags, or using normally safe HTML that isn't sanitized.
AngularJS is a common web framework for the frontend. It allows easy interactivity by adding special attributes and syntax that it recognizes and executes. This also exposes some new ways for HTML/Text injections to execute arbitrary JavaScript if regular ways are blocked. One caveat is that all these injections need to happen inside an element with an ng-app
attribute to enable this feature.
When this is enabled, however, many possibilities open up. One of the most interesting is template injection using {{
characters inside a text string, no HTML tags are needed here! This is a rather well-known technique though, so it may be blocked. In cases of HTML injection with strong filters, you may be able to add custom attributes bypassing filters like DOMPurify. See this presentation by Masato Kinugawa for some AngularJS tricks that managed to bypass Teams' filters.
Here are a few examples of how it can be abused on the latest version. All alerts fire on load:
Warning: In some older versions of AngularJS, there was a sandbox preventing some of these arbitrary code executions. Every version has been bypassed, however, leading to how it is now without any sandbox. See the following page for a history of these older sandboxes: https://portswigger.net/research/dom-based-angularjs-sandbox-escapes
Newer versions of Angular (v2+) instead of AngularJS (v1) are not vulnerable in this way.
Note: Injecting content with .innerHTML
does not always work, because it is only triggered when AngularJS loads. If you inject content later from a fetch, for example, it would not trigger even if a parent contains ng-app
.
You may still be able to exploit this by slowing down the AngularJS script loading by filling up the browser's connection pool. See this challenge writeup for details.
Alternative Charsets
Note: In this section, some ESC characters are replaced with \x1b
for clarity. You can copy a real ESC control character from the code block below:
If a response contains any of the following two lines, it is safe from the following attack.
If this charset is missing, however, things get interesting. Browsers automatically detect encodings in this scenario. The ISO-2022-JP encoding has the following special escape sequences:
\x1b(B
switch to ASCII (default)
\x1b(J
switch to JIS X 0201 1976 (backslash swapped)
\x1b$@
switch to JIS X 0201 1978 (2 bytes per char)
\x1b$B
switch to JIS X 0201 1983 (2 bytes per char)
These sequences can be used at any point in the HTML context (not JavaScript) and instantly switch how the browser maps bytes to characters. JIS X 0201 1976 is almost the same as ASCII, except for \
being replaced with ยฅ
, and ~
replaced with โพ
.
1. Negating Backslash Escaping
For the first attack, we can make \
characters useless after having written \x1b(J
. Strings inside <script>
tags are often protected by escaping quotes with backslashes, so this can bypass such protections:
2. Breaking HTML Context
The JIS X 0201 1978 and JIS X 0201 1983 charsets are useful for a different kind of attack. They turn sequences of 2 bytes into 1 character, effectively obfuscating any characters that would normally come after it. This continues until another escape sequence to reset the encoding is encountered like switching to ASCII.
An example is if you have control over some value in an attribute that is later closed with a double quote ("
). By inserting this switching escape sequence, the succeeding bytes including this closing double quote will become invalid Unicode, and lose their meaning.
By later in a different context ending the obfuscation with a reset to ASCII escape sequence, we will still be in the attribute context for HTML's sake. The text that was sanitized as text before, is now put into an attribute which can cause all sorts of issues.
With the next image tag being created, it creates an unexpected scenario where the opening tag is actually still part of the attribute, and the opening of its first attribute instead closes the existing one.
The 1.png
string is now syntax-highlighted as red, meaning it is now the name of an attribute instead of a value. If we write onerror=alert(1)//
here instead, a malicious attribute is added that will execute JavaScript without being sanitized:
Note: It is not possible to abuse JIS X 0201 1978 or JIS X 0201 1983 (2 bytes per char) encoding to write arbitrary ASCII characters instead of Unicode garbage. Only some Japanese characters and ASCII full-width alternatives can be created (source), except for two unique cases that can generate a $
and (
character found using this fuzzer:
https://shazzer.co.uk/vectors/66efda1eacb1e3c22aff755c
This technique can also trivially bypass any server-side XSS protection (eg. DOMPurify) such as in the following challenge:
https://gist.github.com/kevin-mizu/9b24a66f9cb20df6bbc25cc68faf3d71
Exploitation
Making an alert()
pop up is cool, but to show the impact it might be necessary to exploit what an XSS or JavaScript execution gives you. The summary is that you can do almost everything a user can do themselves, but do this for them. You can click buttons, request pages, post data, etc. which open up a large field of impact, depending on what an application lets the user do.
From another site
The Cross-Site in XSS means that it should be exploitable from another malicious site, which can then perform actions on the victim's behalf on the target site. It is always a good idea to test exploits locally first with a simple web server like php -S 0.0.0.0:8000
, and when you need to exploit something remotely it can be hosted temporarily with a tool like ngrok, or permanently with a web server of your own.
The easiest is Reflected XSS, which should trigger when a specific URL is triggered. If someone visits your page, you can simply redirect them to the malicious URL with any payload to trigger the XSS:
Note that URL Encoding might be needed on parameters to make sure special characters are not part of the URL, or to simply obfuscate the payload
For Stored XSS, a more likely scenario might be someone else stumbling upon the payload by using the site normally, but if the location is known by the attacker they can also redirect a victim to it in the same way as Reflected XSS as shown above.
Some exploits require more complex interaction between the attacker and the target site, like <iframe>
'ing (only if Content Security Policy (CSP) and X-Frame-Options
allows) or opening windows (only when handling user interaction like pressing a button with onclick=
).
Stealing Cookies
In the early days of XSS, this was often the target vector for exploitation, as session cookies could be stolen and exfiltrated to an attacker to later impersonate them on demand. This is done with the document.cookie
variable that contains all cookies as a string. Then using fetch()
a request containing this data can be made to the attacker's server to read remotely:
Pretty often, however, modern frameworks will set the httpOnly
flag on cookies which means they will not be available for JavaScript, only when making HTTP requests. This document.cookie
variable will simply not contain the cookie that the flag is on, meaning it cannot be exfiltrated directly. But the possibilities do not end here, as you can still make requests using the cookies from within JavaScript, just not directly read them.
In very restricted scenarios you might not be able to make an outbound connection due to the connect-src
Content Security Policy (CSP) directive. See that chapter for ideas on how to still exfiltrate data
Forcing requests - fetch()
fetch()
When making a fetch()
request to the same domain you are on, cookies are included, even if httpOnly
is set. This opens up many possibilities by requesting data and performing actions on the application. When making a request, the response is also readable because of the Same-Origin Policy, as we are on the same site as the request is going to.
One idea to still steal cookies would be to request a page that responds with the cookie information in some way, like a debug or error page. You can then request this via JavaScript fetch()
and exfiltrate the response:
Tip: For more complex data, you can use btoa(res)
to Base64 encode the data which makes sure no special characters are included, which you can later decode
A more common way of exploitation is by requesting personal data from a settings page or API route, which works in a very similar way as shown above.
Performing actions
Performing actions on the victim's behalf can is also common and can result in a high impact, depending on their capabilities. These are often done using POST requests and may contain extra data or special headers. Luckily, fetch()
allows us to do all that and more! Its second argument contains options
with keys like method:
, headers:
, and body:
just to name a few:
doDue to fetch()
only being a simple function call, you can create a very complex sequence of actions in JavaScript code to execute on the victim, as some actions require some setup. You could create an API token using one request, and then use it in the next to perform some API call. Or a more common example is fetching a CSRF token from some form, and then using that token to POST data if it is protected in that way. As you can see, CSRF tokens do not protect against XSS:
Phishing (+ Password Managers)
XSS gives you complete control over the JavaScript execution on a page, meaning you can also control everything that is on the page, under the regular target's domain. This can create phishing pages indistinguishable from the real login page because the content can be controlled as well as the domain in the navigation bar. As shown inAlternative Impact, just using an <iframe>
and a <style>
tag you can take over the whole page with your own. One slight problem password managers will notice is the fact that the form itself is on another domain, meaning saved passwords will not automatically be filled or suggested.
If you instead use full JavaScript capabilities and overwrite the HTML on a page, you can create a form on the real domain that password managers will happily fill out and make the victim trust:
Since you are executing code on the same origin, you can even open windows to the same domain, and control their HTML content. With this, you could create a popup window showing the real domain in the address bar, with some login form or other data.
Masking a suspicious URL
A last tricky part is the URL shown in the address bar at the time of your XSS, which may make your URL like "http://target.com/endpoint?url=javascript:document.body.innerHTML=..." showing a login page very suspicious to people who check the URL. Luckily, the same origin policy comes to the rescue once again because using the History API we can overwrite the path of a URL. Not its origin, but its path. That means we can change the suspicious URL to something expected like "/login", making it way more believable:
Protections
XSS is a well-known issue, and many protections try to limit its possibility on websites. There are basically two cases a website needs to handle when reflecting a user's content:
Content, but no HTML is allowed (almost all data)
Limited HTML tags are allowed (rich text like editors)
The 1st is very easily protected by using HTML Encoding. Many frameworks already do this by default, and explicitly have you write some extra code to turn it off. Most often this encodes only the special characters like <
to <
, >
to >
, and "
to "
. While this type of protection is completely safe in most cases, some situations exist where these specific characters are not required to achieve XSS. We've seen examples of Attribute Injection where a '
single quote is used instead, which may not be encoded and thus can be escaped. Or when your attribute is not enclosed at all and a simple
space character can add another malicious attribute. With Script Injection this is a similar story, as well as DOM XSS.
The 2nd case is very hard to protect securely. First, because many tags have unexpected abilities, like the <a href=javascript:alert()>
protocol. If posting links is allowed, they need to think about preventing the javascript:
protocol specifically and allowing regular https://
links. There exist a ton of different tags and attributes that can execute JavaScript (see the Cheat Sheet) making a blocklist almost infeasible, and an allowlist should be used.
The second reason this is hard is because browsers are weird, like really weird. The HTML Specification contains a lot of rules and edge cases a filter should handle. If a filter parses a specially crafted payload differently from a browser, the malicious data might go unnoticed and end up executing in the victim's browser.
Content Security Policy (CSP)
A more modern protection against XSS and some other attacks is the Content Security Policy. This is a Header (Content-Security-Policy:
) or <meta>
value in a response that tells the browser what should be allowed, and what shouldn't. An important directive that can be set using this header is script-src
, defining where JavaScript code may come from:
With the above policy set, any <script src=...>
that is not from the current domain or "example.com" will be blocked. When you explicitly set a policy like this it also disables inline scripts like <script>alert()</script>
or event handlers like <style onload=alert()>
from executing, even ones from the server itself as there is no way to differentiate between intended and malicious. This possibly breaking change where all scripts need to come from trusted URLs is sometimes "fixed" by adding a special 'unsafe-inline'
string that allows inline script tags and event handlers to execute, which as the name suggests, is very unsafe.
A different less-common way to allow inline scripts without allowing all inline scripts is with nonces, random values generated by the server. This nonce is put inside of the script-src
directive like 'nonce-2726c7f26c'
, requiring every inline script to have a nonce=
attribute equaling the specified random value. In theory, an attacker should not be able to predict this random value as it should be different for every request. This works in a similar way to CSRF tokens and relies on secure randomness by the server. If implemented well, this is a very effective way of preventing XSS.
The last important string in this directive is 'unsafe-eval'
which is disabled by default, blocking several functions that can execute code from a string:
eval()
Function()
Passing a string to
setTimeout()
,setInterval()
orwindow.setImmediate()
(for example:setTimeout("alert()", 500)
)
Note however that this does not prevent all methods of executing code from a string. If 'unsafe-inline'
allows it, you can still write to the DOM with event handlers if required:
To easily evaluate and find problems with a CSP header, you can use Google's CSP Evaluator which tells you for every directive what potential problems it finds:
Hosting JavaScript on 'self'
'self'
The URLs and 'self'
trust all scripts coming from that domain, meaning in a secure environment no user data should be stored under those domains, like uploaded JavaScript files. If this is allowed, an attacker can simply upload and host their payload on an allowed website and it is suddenly trusted by the CSP.
For more complex scenarios where you cannot directly upload .js
files, the Content-Type:
header comes into play. The browser decides based on this header if the requested file is likely to be a real script, and if the type is image/png
for example, it will simply refuse to execute it:
Some more ambiguous types are allowed, however, like text/plain
, text/html
or no type at all. These are especially useful as commonly a framework will decide what Content-Type
to add based on the file extension, which may be empty in some cases causing it to choose a type allowed for JavaScript execution. This ambiguity is prevented however with an extra
X-Content-Type-Options: nosniff
header that is sometimes set, making the detection from the browser a lot more strict and only allowing real application/javascript
files (full list).
An application may sanitize uploaded files by checking for a few signatures if it looks like a valid PNG, JPEG, GIF, etc. file which can limit exploitability as it still needs to be valid JavaScript code without SyntaxError
s. In these cases, you can try to make a "polyglot" that passes the validation checks of the server, while remaining valid JavaScript by using the file format in a smart way and language features like comments to remove unwanted code.
Another idea instead of storing data, is reflecting data. If there is any page that generates a response you can turn into valid JavaScript code, you may be able to abuse it for your payload. JSONP or other callback endpoints are also useful here as they always have the correct Content-Type
, and may allow you to insert arbitrary code in place of the ?callback=
parameter, serving as your reflection of valid JavaScript code.
Exfiltrating with strict connect-src
connect-src
This directive defines which hosts can be connected to, meaning if your attacker's server is not on the list, you cannot make a fetch()
request like normal to your server in order to exfiltrate any data. While there is no direct bypass for this, you may be able to still connect to any origin allowed to exfiltrate data by storing it, and later retrieving it as the attacker at a place you can find. By Forcing requests - fetch(), you could, for example, make a POST request that changes a profile picture, or some other public data, while embedding the data you want to exfiltrate. This way the policy is not broken, but the attacker can still find the data on the website itself.
With this technique, remember that even one bit of information is enough, as you can often repeat it to reveal a larger amount of information.
A more general bypass for this is to redirect the user fully using JavaScript, as browsers do not prevent this. Then in the URL, you put the data you want to exfiltrate to receive it in a request:
Another useful method is WebRTC which bypasses connect-src
. The DNS lookup is not blocked and allows for dynamically inserting data into the subdomain field. These names are case-insensitive so an encoding scheme like Base32 can be used to exfiltrate arbitrary data (max ~100 characters per request). Using interactsh
it is easy to set up a domain to exfiltrate from:
Then we use the WebRTC trick to exfiltrate any data over DNS:
Finally, we receive DNS requests on the interactsh-client
that we can decode:
CDNs in script-src
(AngularJS Bypass + JSONP)
script-src
(AngularJS Bypass + JSONP)Every origin in this directive is trusted with all URLs it hosts. A common addition here is CDN (Content Delivery Network) domains that host many different JavaScript files for libraries. While in very unrestricted situations a CDN like unpkg.com will host every file on NPM, even malicious ones, others are less obvious.
The cdnjs.cloudflare.com or ajax.googleapis.com domains for example host only specific popular libraries which should be secure, but some have exploitable features. The most well-known is AngularJS, which a vulnerable site may also host themselves removing the need for a CDN. This library searches for specific patterns in the DOM that can define event handlers without the regular inline syntax. This bypasses the CSP and can allow arbitrary JavaScript execution by loading such a library, and including your own malicious content in the DOM:
Loading any of these blocks in a CSP that allows it, will trigger the alert(document.domain)
function. A common pattern for finding these bypasses is using Angular to create an environment where code can be executed from event handlers, and then another library or callback function to click on the element, triggering the handler with your malicious code.
See jsonp.txt for a not-so-updated list of public JSONP endpoints you may find useful.
See AngularJS for more complex AngularJS injections that bypass filters. Also, note that other frameworks such as VueJS or HTMX may allow similar bypasses if they are accessible when unsafe-eval
is set in the CSP.
Redirect to upper directory
URLs in a CSP may be absolute, not just an origin. The following example provides a full URL to base64.min.js
, and you would expect only that script could be loaded from the cdn.js.cloudflare.com
origin.
This is not entirely true, however. If another origin, like 'self'
contains an Open Redirect vulnerability, you may redirect a script URL to any path on cdnjs.cloudflare.com
!
The following script would be allowed by the CSP spec, note that the angular.js
path is not normally allowed, but it is through the redirect because its origin is allowed. This can be abused with some HTML that executes arbitrary JavaScript, even if 'unsafe-eval'
is not set:
Nonce without base-src
If a CSP filters scripts based on a nonce, and does not specify a base-src
directive, you may be able to hijack relative URLs after your injection point.
Let's say the target page with an HTML-injection looks as follows:
The relative <script>
tag can be redirect to another domain using the <base>
tag as follows:
Now, the script with a valid nonce is loaded from https://attacker.com/script.js
instead of the target website!
Filter Bypasses
Some of the most useful and common filter bypasses are shown in Common Filter Bypasses.
If a server is checking your input for suspicious strings, they will have a hard time as there are many ways to obfuscate your payloads. Even a simple <a href=...>
tag has many places where the browser allows special and unexpected characters, which may break the pattern the server is trying to search for. Here is a clear diagram showing where you can insert what characters:
The XSS Cheat Sheet by PortSwigger has an extremely comprehensive list of all possible tags, attributes, and browsers that allow JavaScript execution, with varying levels of user interaction:
You can use the above list to filter certain tags you know are allowed/blocked, and copy all payloads for fuzzing using a tool to find what gets through a filter.
JavaScript payload
In case you are able to inject JavaScript correctly but are unable to exploit it due to the filter blocking your JavaScript payload, there are many tricks to still achieve code execution. One of them is using the location
variable, which can be assigned to a javascript:
URL just like in DOM XSS, but this is now a very simple function call trigger as we don't need parentheses or backticks, as we can escape them in a string like \x28
and \x29
.
In fact, we can even go one step further and use the global name
variable which is controllable by an attacker. So global, that it persists between navigations. When a victim visits our site like in an XSS scenario, we can set the name
variable to any payload we like and redirect to the vulnerable page to trigger it (see this video for more info and explanation):
Mutation XSS & DOMPurify
Mutation XSS is a special kind of XSS payload where you are abusing a difference in the checking environment vs. the destination environment. There are some special browser rules for when HTML finds itself in certain tags, that are different from inside other tags. This difference can sometimes be abused to create a benign payload in the checking context but will be mutated by the browser in a different context into a malicious payload.
Let's take the following example: The DOMPurify sanitizer is used to filter out malicious content that could trigger JavaScript execution, which it does perfectly on the following string:
There is a <p>
tag with "</title><img src=x onerror=alert()>"
as its id=
attribute, nothing more, and nothing that would trigger JavaScript surely. But then comes along the browser, which sees this payload placed into the DOM, inside the existing <title>
tag:
Perhaps surprisingly, it is parsed differently now that it is inside of the <title>
tag. Instead of a simple <p>
tag with an id=
attribute, this turned into the following after mutation:
See what happened here? It suddenly closed with the </title>
tag and started an <img>
tag with the malicious onerror=
attribute, executing JavaScript, and causing XSS! This means in the following example, alert(1)
fires but alert(2)
does not:
DOMPurify does not know of the <title>
tag the application puts it in later, so it can only say if the HTML is safe on its own. In this case, it is, so we bypass the check through Mutation XSS.
A quick for-loop later we can find that this same syntax works for all these tags:
iframe
, noembed
, noframes
, noscript
, script
, style
, textarea
, title
, xmp
These types of Mutation XSS tricks are highly useful in bypassing simpler sanitizer parsers because DOMPurify had to really put in some effort to get this far. Writing payloads that put the real XSS in an attribute and use mutation to escape out of it can be unexpected and the developers may not have thought about the possibility, and only use some regexes or naive parsing.
Where this gets really powerful is using HTML encoding if the sanitizer parses the payload, and then reassembles the HTML afterward, for example:
@kevin_mizu showed another interesting exploitable scenario, where your input is placed inside an <svg>
tag after sanitization:
This is another DOMPurify "bypass" with a more common threat, all a developer needs to do is put your payload inside of an <svg>
tag, without sanitizing it with the <svg>
tag. This payload is a bit more complicated as you'll see, but here's a breakdown:
The trick is the difference between SVG parsing and HTML parsing. In HTML which DOMPurify sees, the <style>
tag is special as it switches the parsing context to CSS, which doesn't support comments like <!--
and it won't be interpreted as such. Therefore the </style>
closes it and the <a id="...">
opens another innocent tag and attribute. DOMPurify doesn't notify anything wrong here and won't alter the input.
In SVG, however, the <style>
tag doesn't exist and it is interpreted as any other invalid tag in XML. The children inside might be more tags, a <!--
comment in this case. This only ends at the start of the <a id="--!>
attribute and that means after the comment comes more raw HTML. Then our <img onerror=>
tag is read for real and the JavaScript is executed!
Tip: Instead of a comment, another possibility is using the special <![CDATA[
... ]]
syntax in SVGs that abuses a similar parsing difference:
DOMPurify outdated versions
While the abovementioned tricks can get around specific situations, an outdated version of the dompurify
library can cause every output to be vulnerable by completely bypassing DOMPurify in a regular context. The latest vulnerable version is 2.2.3 with a complete bypass found by @TheGrandPew in dec. 2020. The following payload will trigger alert()
when sanitized and put into a regular part of the DOM:
Earlier Proof of Concepts:
< 2.2.4 - TheGrandPew < 2.2.3 - TheGrandPew < 2.2.2 - Daniel Santos < 2.1 - Gareth Heyes < 2.0.17 - Michaล Bentkowski
Resources
Easy-to-follow Google Search mXSS: https://www.acunetix.com/blog/web-security-zone/mutation-xss-in-google-search/
Finding a custom variation of an outdated DOMPurify bypass: https://blog.vidocsecurity.com/blog/hacking-swagger-ui-from-xss-to-account-takeovers
More complex Universal mXSS: https://twitter.com/garethheyes/status/1723047393279586682
For more tricks and finding your own custom vectors, check out the following cheatsheet and tool:
Last updated