The endpoint https://challenge.intigriti.io/ is vulnerable to a DOM-based XSS which allows the attacker to execute arbitrary JavaScript in the context of the page's top-level window.
Since the attacker can execute arbitrary JavaScript in the context of the page, he would be able to steal the user's credentials, or any other secrets contained in the webpage, and therefore hijack his session. He would also be able to redress the UI in order to deceive the user further.
The page https://challenge.intigriti.io/ parses the fragment part of its URL,
replacing the strings <
, >
and script
with forbidden
. It then tries
validating it as a URL by passing it to new URL(...)
.
If the above call succeeds, the page then creates an iframe
with its src
set to
the fragment and appends the iframe to the document. Finally, it sets up
a message event listener on the page's window
object with the function
executeCtx
as its handler.
In the POC, the string-based filtering is easily circumvented by using a data
URI to mask the fact that it contains HTML containing a <script>
tag. The
initial field of the data URI specifies that the payload is of mediatype
text/html
so that the browser interprets it as such. Ignoring for a moment the
next two fields (because the browser does too), the last one specifies a base64
encoded string containing (a part of) our payload.
Decoded, the payload reads:
<script>setTimeout(() => { parent.postMessage({text: 1, html: 1}, "*") }, 1000)</script>
This code will be executed once the iframe is loaded, making the iframe send
a message containing the object {text: 1, html: 1}
after 1 second. The intended
target of the message is left unspecified with *
.
After a second, the message is received by the message event handler on the
parent window of the iframe. Due to the Object.assign(...)
call, we are able to
set the text
and html
variables to numeric values. Now the url
variable
containing our initial fragment is eval
-ed, this time being interpreted as
JavaScript code.
eval
ignores anything up to and including the colon, so data:
is skipped.
It then tries dividing text
with html
, which now succeeds because we
previously set them to 1. Then the first ignored field is run, containing our
actual payload. This executes alert(document.domain)
in the context of the
top-level page. Finally, the second ignored field contains //
to make the
rest of the data URI a JavaScript comment.