By providing a random wrong sitekey, reCAPTCHA will call the function in the attribute data-error-callback.
It's important that DOMPurify allows any attributes that start with data- by default, and also both class and id are permitted.
I learned this nifty trick from TSJ CTF 2022 - web/Nim Notes, but it seems that it's from another XSS challenge made by @terjanq in TokyoWesterns CTF 2020.
Now, we can call a function by injecting HTML. The question is, what function should we call?
Both loadScript and reloadRecaptchaScript are suspicious, but loadScript might not be a good target because we can't control the arguments.
How about reloadRecaptchaScript?
function reloadRecaptchaScript(index) {
// delay for a bit to not block main thread
setTimeout(() => {
console.log('reload', index, document.scripts[index])
const element = document.scripts[index]
const src = element.getAttribute('src')
if (!src.startsWith('https://www.google.com/recaptcha/')) {
throw new Error('reload failed, invalid src')
}
element.parentNode.removeChild(element)
loadScript(src)
}, 1000)
}
If we can control document.scripts[index], we can load another script from https://www.google.com.
When the reCAPTCHA calls a function, it passes no argument, so the index will be undefined, so we need to override document.scripts['undefined']
Can we control it? Sure, it's DOM clobbering time!
DOM clobbering
Usually, we can override the attribute on document by providing a embed, form, input, object or img with name, like this:
<img name="scripts">
// document.scripts => <img>
Combining with form element, we can clobber document.scripts['undefined']:
Now, we have control on document.scripts[index], but we still need to bypass another check:
function reloadRecaptchaScript(index) {
setTimeout(() => {
console.log('reload', index, document.scripts[index])
const element = document.scripts[index]
const src = element.getAttribute('src')
if (!src.startsWith('https://www.google.com/recaptcha/')) {
throw new Error('reload failed, invalid src')
}
element.parentNode.removeChild(element)
loadScript(src)
}, 1000)
}
The src should start with https://www.google.com/recaptcha/, how to overcome this?
There is a subtle difference between element.src and element.getAttribute('src'), the former returns the formatted value, while the latter returns raw value:
We can't run arbitrary JS because callback is restricted, it won't work if you pass something like alert(document.domain), but we have the ability to call a function with controlled arguments.
The idea is simple, we can use it to load AngularJS from cdn.js by leverage the classic ..%2f trick.
It requires no user interaction, perfect! But there are two other issues we need to address.
First, ng-app and ng-csp will be removed by DOMPurify. Second, there is no prototype.js.
For the first issue, we can use data-ng-app and data-ng-csp instead of ng-app and ng-csp, because AngularJS will normalize attribute names, and remove x- and data- prefixes.
For the second issue, we need to know why prototype.js is needed.
It's needed because prototype.js adds a few methods to different prototype, like Function.prototype:
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);
}
}
The first argument of fn.call() is this, if you call this function without providing this, the default value of this is window in non-strict mode.
So, any_function.curry.call() will return this which is window, that's why we need prototype.js.
If you look at the source code again, you can find a similar pattern:
String.prototype.encode = function(type) {
if (!type) return this
if (type === 'uri') return encodeURIComponent(this)
if (type === 'json') return JSON.stringify(this)
if (type === 'base64') return atob(this)
}
That is to say, we can get window via "any_string".encode.call().
Piece all together
The full exploit including:
DOM clobbering window.defaultOptions.allowDOM to allow clobber document
DOM clobbering document.scripts['undefined']
Call loadData via reCAPTCHA
Call reloadRecaptchaScript via reCAPTCHA
Load AngularJS from cdn.js by classic google gadget and ..%2f trick
Use data-ng-app instead of ng-app to bypass DOMPurify
Use "".encode.call() to get window object
Here is the final payload for the intended solution:
reCAPTCHA triggers reloadRecaptchaScript(), will be run after 1s
reCAPTCHA triggers reloadRecaptchaScript(), will be run after 1s
reCAPTCHA triggers loadData(), run immediately and pollute document.scripts['undefined']
Run the function in step 1, load src from <input>, call loadScript('https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.0/purify.min.js')
Run the function in step 2, load src from <img>, call loadScript('https://www.google.com/recaptcha/../jsapi?callback=loadData') to trigger loadData again
Old version of DOMPurify has loaded (the lib we load in step 4), override latest one
Script at step 5 also loaded, loadData has been called again
Now, the DOMPurify is the old and flawed version, so we can bypass it easily
It sometimes fails because of race conditions.
For example, if script at step 5 is loaded(called loadDate) before DOMPurify, we still use the latest version, so there is no way to bypass it.
The author created an HTML page and embedded a few <iframe> to load the URL many times to solve the issue.
Takeaways
Everything can be abuse
Existing JS code might be helpful sometimes
Knowing the default behavior of third party libraries is helpful
I hope you did learn something new and enjoyed this challenge, thanks for playing the game!
Last month, I created an XSS challenge and hosted it on my GitHub: https://aszx87410.github.io/xss-challenge/notes/
This is the writeup about the challenge and solutions, including intended and unintended.
I will start from the intended one.
Overview
Let's look at the challenge, it's a simple but compacted app with ~100 lines JS and not-so-strict CSP:
app.js:
When the app is loaded, in
loadData
function, it reads data from the URL and then renders it toinnerHTML
after sanitized, that's all.For sanitizing, the config is pretty much the default one, so you can't perform XSS directly unless you find a 0-day bypass:
What can we do by injecting a harmless HTML? Not much, unless you leverage another functionality.
reCAPTCHA to the rescue
Somehow, the challenge uses Google reCAPTCHA, and from their docs we know that we can trigger a function call by injecting the following HTML:
By providing a random wrong sitekey, reCAPTCHA will call the function in the attribute
data-error-callback
.It's important that DOMPurify allows any attributes that start with
data-
by default, and also bothclass
andid
are permitted.I learned this nifty trick from TSJ CTF 2022 - web/Nim Notes, but it seems that it's from another XSS challenge made by @terjanq in TokyoWesterns CTF 2020.
Now, we can call a function by injecting HTML. The question is, what function should we call?
Both
loadScript
andreloadRecaptchaScript
are suspicious, butloadScript
might not be a good target because we can't control the arguments.How about
reloadRecaptchaScript
?If we can control
document.scripts[index]
, we can load another script fromhttps://www.google.com
.When the reCAPTCHA calls a function, it passes no argument, so the
index
will beundefined
, so we need to overridedocument.scripts['undefined']
Can we control it? Sure, it's DOM clobbering time!
DOM clobbering
Usually, we can override the attribute on document by providing a
embed
,form
,input
,object
orimg
withname
, like this:Combining with
form
element, we can clobberdocument.scripts['undefined']
:But, it's not working because DOMPurify prevents this behavior by default: https://github.com/cure53/DOMPurify/blob/main/src/purify.js#L1015
Fortunately, there is another vulnerability in the code:
When calling
sanitize
withoutoptions
, the default value will bedefaultOptions
, so we can clobberdefaultOptions.allowDOM
to makeSANITIZE_DOM
falsy.Also, we need to call
loadData()
again without any arguments to letsanitizeOptions
beundefined
.To sum up, we can control
document.scripts['undefined']
by providing below HTML:Load external script
Now, we have control on
document.scripts[index]
, but we still need to bypass another check:The
src
should start withhttps://www.google.com/recaptcha/
, how to overcome this?There is a subtle difference between
element.src
andelement.getAttribute('src')
, the former returns the formatted value, while the latter returns raw value:By using
../
, we can load any scripts fromhttps://www.google.com
.It's easy to find a useful gadget from JSONBee:
https://www.google.com/complete/search?client=chrome&q=hello&callback=alert#1
The response is like:
We can't run arbitrary JS because
callback
is restricted, it won't work if you pass something likealert(document.domain)
, but we have the ability to call a function with controlled arguments.The idea is simple, we can use it to load AngularJS from cdn.js by leverage the classic
..%2f
trick.Response:
AngularJS CSP bypass
Here comes the last part of the challenge. The goal is to find an AngularJS CSP bypass and XSS without user interaction.
There is a classic payload as described in:
It requires no user interaction, perfect! But there are two other issues we need to address.
First,
ng-app
andng-csp
will be removed by DOMPurify. Second, there is noprototype.js
.For the first issue, we can use
data-ng-app
anddata-ng-csp
instead ofng-app
andng-csp
, because AngularJS will normalize attribute names, and removex-
anddata-
prefixes.For the second issue, we need to know why prototype.js is needed.
It's needed because prototype.js adds a few methods to different prototype, like
Function.prototype
:The first argument of
fn.call()
isthis
, if you call this function without providingthis
, the default value ofthis
iswindow
in non-strict mode.So,
any_function.curry.call()
will returnthis
which iswindow
, that's why we need prototype.js.If you look at the source code again, you can find a similar pattern:
That is to say, we can get
window
via"any_string".encode.call()
.Piece all together
The full exploit including:
window.defaultOptions.allowDOM
to allow clobber documentdocument.scripts['undefined']
loadData
via reCAPTCHAreloadRecaptchaScript
via reCAPTCHA..%2f
trickdata-ng-app
instead ofng-app
to bypass DOMPurify"".encode.call()
to get window objectHere is the final payload for the intended solution:
[link](https://aszx87410.github.io/xss-challenge/notes/?name=&content=%3Cdiv%3E%0A%20%20%3Cform%3E%3C%2Fform%3E%0A%20%20%3Cform%20name%3D%22scripts%22%3E%0A%20%20%20%20%3Cimg%20name%3D%22undefined%22%20src%3D%22https%3A%2F%2Fwww.google.com%2Frecaptcha%2F..%2Fcomplete%2Fsearch%3Fclient%3Dchrome%26q%3Dhttps%3A%2F%2Fcdnjs.cloudflare.com%2Fajax%2Flibs%2Fdompurify%2F..%25252fangular.js%2F1.8.2%2Fangular.js%2523%26callback%3DloadScript%22%3E%0A%20%20%3C%2Fform%3E%0A%20%20%3Cform%20id%3DdefaultOptions%3E%0A%20%20%20%20%3Cimg%20name%3DallowDOM%3E%0A%20%20%3C%2Fform%3E%0A%20%20%20%20%3Cdiv%20class%3D%22g-recaptcha%22%20data-sitekey%3D%22AAA%22%20data-error-callback%3D%22reloadRecaptchaScript%22%20data-size%3D%22invisible%22%3E%3C%2Fdiv%3E%0A%20%20%3Cdiv%20class%3D%22g-recaptcha%22%20data-sitekey%3D%22B%22%20data-error-callback%3D%22loadData%22%20data-size%3D%22invisible%22%3E%3C%2Fdiv%3E%0A%20%20%0A%20%20%3Cdiv%20data-ng-app%20data-ng-csp%3E%0A%20%20%20%20%7B%7B%20%22abc%22.encode.call().alert(%22abc%22.encode.call().document.domain)%20%7D%7D%0A%20%20%3C%2Fdiv%3E%0A%3C%2Fdiv%3E)
Unintended
Besides the intended solution, there are 4 amazing unintended solutions.
Unintended #1 by @maple3142
At first, I didn't know there was a
jsonp
argument inhttps://www.google.com/complete/search
endpoint, so there was no check forjsonp
.It's east to get a XSS by loading something like
https://www.google.com/complete/search?client=chrome&q=123&jsonp=alert(document.domain)//
Later on, I implemented a check for
jsonp
inreloadRecaptchaScript
:Unintended #2 by @smaury92
It turns out that I implemented a flawed check, can you spot the bug?
You can bypass the check by open redirect and double encoded the
https://google.com/complete/search
call, like this:So it passed the check for
reloadRecaptchaScript
.I decided the move the check from
reloadRecaptchaScript
toloadScript
: https://github.com/aszx87410/xss-challenge/commit/7382e9b48721b1dd9edcd21675e1e7f56d171c2cUnintended #3 by @lbrnli1234
The check failed again.
payload:
I was aware of google open redirect but I didn't notice a subtle difference. When I tried google open redirect, it returned 200 in that case and used client side redirect: https://www.google.com/url?q=https%3A%2F%2Ftech-blog.cymetrics.io&sa=D&sntz=1&usg=AFQjCNHyq6urHn6HLwj8RP09GANAlymZug
So I thought it was impossible to leverage this open redirect.
But for some other cases, it returns 302: https://www.google.com/url?sa=t&url=http://example.org/&usg=AOvVaw1YigBkNF7L7D2x2Fl532mA
Anyway, I didn't fix this unintended in the end because I don't have a good solution at the moment.
Unintended #4 by @lbrnli1234
Another dope unintended has been found:
The content of
srcdoc
is the classic angularJS CSP bypass payload we mentionedThe flow is like:
reloadRecaptchaScript()
, will be run after 1sreloadRecaptchaScript()
, will be run after 1sloadData()
, run immediately and pollutedocument.scripts['undefined']
<input>
, callloadScript('https://cdnjs.cloudflare.com/ajax/libs/dompurify/2.0.0/purify.min.js')
<img>
, callloadScript('https://www.google.com/recaptcha/../jsapi?callback=loadData')
to triggerloadData
againloadData
has been called againIt sometimes fails because of race conditions.
For example, if script at step 5 is loaded(called
loadDate
) before DOMPurify, we still use the latest version, so there is no way to bypass it.The author created an HTML page and embedded a few
<iframe>
to load the URL many times to solve the issue.Takeaways
I hope you did learn something new and enjoyed this challenge, thanks for playing the game!