You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This project is a bit complex because there are two frames and a lot of postMessage and onmessage, make it harder to know how it works at first glance.
To find a XSS vulnerability, there must be a place to inject malicious payload, like innerHTML or eval, so I started from finding this place.
There are three pages:
index.html
htmledit.php
console.php
Let's check it one by one.
index.html
<divclass="card-container"><divclass="card-header-small">Your payloads:</div><divclass="card-content"><script>// redirect all htmledit messages to the consoleonmessage=e=>{if(e.data.fromIframe){frames[0].postMessage({cmd:"log",message:e.data.fromIframe},'*');}}/* var DEV = true; var store = { users: { admin: { username: 'inti', password: 'griti' }, moderator: { username: 'root', password: 'toor' }, manager: { username: 'andrew', password: 'hunter2' }, } } */</script><divclass="editor"><spanid="bin"><aonclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a></span><iframeclass=consolesrc="./console.php"></iframe><iframeclass=codeFramesrc="./htmledit.php?code=<img src=x>"></iframe><textareaoninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><imgsrc=x></textarea></div></div></div>
Besides the weird variable in the comment, DEV and store, nothing special.
htmledit.php
<!-- <img src=x> --><!DOCTYPE html><htmllang="en"><head><metacharset="UTF-8"><title>Native HTML editor</title><scriptnonce="d8f00e6635e69bafbf1210ff32f96bdb">window.addEventListener('error',function(e){letobj={type:'err'};if(e.message){obj.text=e.message;}else{obj.text=`Exception called on ${e.target.outerHTML}`;}top.postMessage({fromIframe:obj},'*');},true);onmessage=(e)=>{top.postMessage({fromIframe:e.data},'*')}</script></head><body><imgsrc=x></body></html><!-- /* Page loaded in 0.000024 seconds */ -->
htmledit.php reflects the query string code but there is a strict CSP: script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';, it's impossible to run JS. But it's worth noting that embed an iframe is allow. Maybe it's a hint for the player?
console.php
<!DOCTYPE html><htmllang="en"><head><metacharset="UTF-8"><scriptnonce="c4936ad76292ee7100ecb9d72054e71f">name='Console'document.title=name;if(top===window){document.head.parentNode.remove();// hide code if not on iframe}</script><style>body,ul {
margin:0;
padding:0;
}
ul#console {
background: lightyellow;
list-style-type: none;
font-family:'Roboto Mono', monospace;
font-size:14px;
line-height:25px;
}
ul#consoleli {
border-bottom: solid 1px#80808038;
padding-left:5px;
}
</style></head><body><ulid="console"></ul><scriptnonce="c4936ad76292ee7100ecb9d72054e71f">leta=(s)=>s.anchor(s);lets=(s)=>s.normalize('NFC');letu=(s)=>unescape(s);lett=(s)=>s.toString(0x16);letparse=(e)=>(typeofe==='string') ? s(e) : JSON.stringify(e,null,4);// make object look like stringletlog=(prefix,data,type='info',safe=false)=>{letline=document.createElement("li");letprefix_tag=document.createElement("span");lettext_tag=document.createElement("span");switch(type){case'info':{line.style.backgroundColor='lightcyan';break;}case'success':{line.style.backgroundColor='lightgreen';break;}case'warn':{line.style.backgroundColor='lightyellow';break;}case'err':{line.style.backgroundColor='lightpink';break;}default:{line.style.backgroundColor='lightcyan';}}data=parse(data);if(!safe){data=data.replace(/</g,'<');}prefix_tag.innerHTML=prefix;text_tag.innerHTML=data;line.appendChild(prefix_tag);line.appendChild(text_tag);document.querySelector('#console').appendChild(line);}log('Connection status: ',window.navigator.onLine?"Online":"Offline")onmessage=e=>{switch(e.data.cmd){case"log": {log("[log]: ",e.data.message.text,type=e.data.message.type);break;}case"anchor": {log("[anchor]: ",s(a(u(e.data.message))),type='info')break;}case"clear": {document.querySelector('#console').innerHTML="";break;}default: {log("[???]: ",`Wrong command received: "${e.data.cmd}"`)}}}</script><scriptnonce="c4936ad76292ee7100ecb9d72054e71f">try{if(!top.DEV)thrownewError('Production build!');letcheckCredentials=(username,password)=>{try{letusers=top.store.users;letaccess=[users.admin,users.moderator,users.manager];if(!users||!password)returnfalse;for(xofaccess){if(x.username===username&&x.password===password)returntrue}}catch{returnfalse}returnfalse}let_onmessage=onmessage;onmessage=e=>{letm=e.data;if(!m.credentials||!checkCredentials(m.credentials.username,m.credentials.password)){return;// do nothing if unauthorized}switch(m.cmd){case"ping": {// check the connectione.source.postMessage({message:'pong'},'*');break;}case"logv": {// display variable's value by its namelog("[logv]: ",window[m.message],safe=false,type='info');break;}case"compare": {// compare variable's value to a given onelog("[compare]: ",(window[m.message.variable]===m.message.value),safe=true,type='info');break;}case"reassign": {// change variable's valueleto=m.message;try{letRegExp=/^[s-zA-Z-+0-9]+$/;if(!RegExp.test(o.a)||!RegExp.test(o.b)){thrownewError('Invalid input given!');}eval(`${o.a}=${o.b}`);log("[reassign]: ",`Value of "${o.a}" was changed to "${o.b}"`,type='warn');}catch(err){log("[reassign]: ",`Error changing value (${err.message})`,type='err');}break;}default: {_onmessage(e);// keep default functions}}}}catch{// hide this script on productiondocument.currentScript.remove();}</script><scriptsrc="./analytics/main.js?t=1627610836"></script></body></html>
It's the most interesting one.
First, I found a eval command here for changing variable's value:
let_onmessage=onmessage;onmessage=e=>{letm=e.data;if(!m.credentials||!checkCredentials(m.credentials.username,m.credentials.password)){return;// do nothing if unauthorized}switch(m.cmd){// ...case"reassign": {// change variable's valueleto=m.message;try{letRegExp=/^[s-zA-Z-+0-9]+$/;if(!RegExp.test(o.a)||!RegExp.test(o.b)){thrownewError('Invalid input given!');}eval(`${o.a}=${o.b}`);log("[reassign]: ",`Value of "${o.a}" was changed to "${o.b}"`,type='warn');}catch(err){log("[reassign]: ",`Error changing value (${err.message})`,type='err');}break;}default: {_onmessage(e);// keep default functions}}}
Is it where I can inject my payload? Probably not, because it allows limited alphanumeric and symbol(only - and +).
Another interesting part is here:
letlog=(prefix,data,type='info',safe=false)=>{letline=document.createElement("li");letprefix_tag=document.createElement("span");lettext_tag=document.createElement("span");switch(type){// not important}data=parse(data);if(!safe){data=data.replace(/</g,'<');}prefix_tag.innerHTML=prefix;text_tag.innerHTML=data;line.appendChild(prefix_tag);line.appendChild(text_tag);document.querySelector('#console').appendChild(line);}
If safe is true, the data won't be sanitized and we can inject arbitrary HTML. I believe here is the key, so my goal is to execute log function with arbitrary data and let safe be true.
Before that, we need to know that JavaScript has no named parameters, don't be confused!
For example, when we call log("[logv]: ", window[m.message], safe=false, type='info');, the argument is actually by order, so prefix is "[logv]: ", data is window[m.message], type is false and safe is 'info'
Anyway, I decided to start from find a way to run log function, and it's obviously that I can postMessage to it's window to run the command.
But I need to bypass some checks first.
Bypass "top" check
First, I need to embed this page in an iframe:
name='Console'document.title=name;if(top===window){document.head.parentNode.remove();// hide code if not on iframe}
Second, there are two more checks I need to bypass:
try{if(!top.DEV)thrownewError('Production build!');letcheckCredentials=(username,password)=>{try{letusers=top.store.users;letaccess=[users.admin,users.moderator,users.manager];if(!users||!password)returnfalse;for(xofaccess){if(x.username===username&&x.password===password)returntrue}}catch{returnfalse}returnfalse}let_onmessage=onmessage;onmessage=e=>{letm=e.data;if(!m.credentials||!checkCredentials(m.credentials.username,m.credentials.password)){return;// do nothing if unauthorized}// ...}}catch{// hide this script on productiondocument.currentScript.remove();}
top.DEV should be truthy, and the credentials I send in should match top.store.users.admin.username and top.store.users.admin.password.
It's easy, just write my own HTML page and set these variables, embed console.php in an iframe, and then post message to it, right?
Nope, it's not gonna work because of Same-Origin Policy. When console.php tries to access top.DEV, it's blocked by browser because top window is in another domain.
So we need a same origin page where we can embed an iframe and also set global variables. htmledit.php is the one.
DOM clobbering
There is a technique called DOM clobbering, it utilizes a feature which turns a DOM element with id to global variable.
For example, when you have <div id="a"></div> in your HTML, you can access it in JS via window.a or just a.
So top.DEV is a element, store is the iframe, store.users is HTML collections of <a>, store.users.admin is the a, and store.users.admin.username is the URL username in href, which is a, it's the same for password.
I built a simple page to open a new window, so that htmledit.php is the top window and I can still post message to it:
By far, I can send message to console.php. But, it's only the beginning.
Pass arbitrary data and safe=true
In order to let safe be true, I need to find a function call with 4 parameters:
case"logv": {// display variable's value by its namelog("[logv]: ",window[m.message],safe=false,type='info');break;}case"compare": {// compare variable's value to a given onelog("[compare]: ",(window[m.message.variable]===m.message.value),safe=true,type='info');break;}
log("[logv]: ", window[m.message], safe=false, type='info') is what I need, the fourth parameter is info which is truthy. data is window[m.message], so I need to set my payload to a global variable.
I stuck here for a long time because I can't find one. window.name is usually a good candidate but this page set it's window name so I can't use it.
location is another candidate but log checks if data is string, if not, it turns it into a string via JSON.stringify, which encoded <>.
I checked the code again and again, try to find out the missing puzzle. Finally, I found one.
for (x of access) {, it's a common bug for newbie, when you forgot to declare x, it will be a global variable. In this case, x is top.store.users.admin , which is the <a> element.
Build payload
If we cast an <a> element to string, the return value is a.href. It's a common technique in DOM clobbering. So we can pass our payload inside href.
But, remember that log checks the type of data? The type of x is DOM element, hence failed the check. I need to find a way to make it a string.
Fortunately, there is another command I can utilize:
case"reassign": {// change variable's valueleto=m.message;try{letRegExp=/^[s-zA-Z-+0-9]+$/;if(!RegExp.test(o.a)||!RegExp.test(o.b)){thrownewError('Invalid input given!');}eval(`${o.a}=${o.b}`);log("[reassign]: ",`Value of "${o.a}" was changed to "${o.b}"`,type='warn');}catch(err){log("[reassign]: ",`Error changing value (${err.message})`,type='err');}break;}
There is no unsafe-inline so inline event won't work. https://challenge-0721.intigriti.io/analytics/ is suspicious, what is this?
This JS https://challenge-0721.intigriti.io/analytics/main.js is included but almost nothing inside.
Actually, when I saw this CSP rule, I know what to do instantly. Because I know there is a way to bypass CSP path using %2f(url encoded /).
Take this URL: https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php as an example, to browser, it's under analytics path so pass CSP, but for server it's analytics/../htmledit.php, so we actually load resource from different path!
But what should I include? htmledit.php is HTML, not JS...really?
If you look carefully, htmledit.php prints escaped input in HTML comment, like this:
<!-- <img src=x> -->
....
In some cases, HTML comment is also a valid JS comment, as per ECMAScript:
In other words, we can make this HTML a valid JS script!
Here is the url I used: https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*, it respond following HTML:
<!-- 1; this line is comment as welltop.alert(document.domain);/* --><!DOCTYPE html><htmllang="en"><head>
...not important because it's all comment
After /* it's all comment, so the whole script is top.alert(document.domain); basically. So now, I can include this url as JS script to run arbitrary code and bypass CSP.
Please note that the content type of htmledit.php is still text/html, but it's fine since it's same origin. If you want to include a page with content type text/html as JS , you will get a CORB error.
It seems great, now we can inject an script to pop alert, right?
Unfortunately, not yet.
Final step
I thought I solve the challenge after I found this clear way to inject script, but somehow it doesn't work.
According to this stack overflow thread, the <script> tag won't load if you inserted with innerHTML.
I don't know how to do so I googled innerhtml import script, innerhtml script run and so on, but found nothing useful.
After a while, It occurred to me that how about our old friend <iframe srcdoc>? What if I put the script tag inside srcdoc?
So, I tried this way and it works like a charm.
Put it all together
Just one small thing to say, before I submit the answer I found that my exploit doesn't work on Firefox.
It's a great and awesome challenge, to me it's like a game with 5 levels, I need to solve every levels and put it together to really win this game.
I spent about 2 days on this challenge and every time I stuck, I checked the source code again, reviewed one line after another until I found something new. Surprisingly, there is always something new!
Thanks @RootEval for creating such amazing challenge, and also thanks Intigriti for hosting this event.
The text was updated successfully, but these errors were encountered:
Challenge link: https://challenge-0721.intigriti.io/
Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html
Analysis
This project is a bit complex because there are two frames and a lot of
postMessage
andonmessage
, make it harder to know how it works at first glance.To find a XSS vulnerability, there must be a place to inject malicious payload, like
innerHTML
oreval
, so I started from finding this place.There are three pages:
Let's check it one by one.
index.html
Besides the weird variable in the comment,
DEV
andstore
, nothing special.htmledit.php
htmledit.php
reflects the query stringcode
but there is a strict CSP:script-src 'nonce-...';frame-src https:;object-src 'none';base-uri 'none';
, it's impossible to run JS. But it's worth noting that embed an iframe is allow. Maybe it's a hint for the player?console.php
It's the most interesting one.
First, I found a
eval
command here for changing variable's value:Is it where I can inject my payload? Probably not, because it allows limited alphanumeric and symbol(only
-
and+
).Another interesting part is here:
If
safe
is true, thedata
won't be sanitized and we can inject arbitrary HTML. I believe here is the key, so my goal is to execute log function with arbitrary data and letsafe
be true.Before that, we need to know that JavaScript has no named parameters, don't be confused!
For example, when we call
log("[logv]: ", window[m.message], safe=false, type='info');
, the argument is actually by order, soprefix
is"[logv]: "
,data
iswindow[m.message]
,type
isfalse
andsafe
is'info'
Anyway, I decided to start from find a way to run
log
function, and it's obviously that I canpostMessage
to it's window to run the command.But I need to bypass some checks first.
Bypass "top" check
First, I need to embed this page in an iframe:
Second, there are two more checks I need to bypass:
top.DEV
should be truthy, and the credentials I send in should matchtop.store.users.admin.username
andtop.store.users.admin.password
.It's easy, just write my own HTML page and set these variables, embed
console.php
in an iframe, and then post message to it, right?Nope, it's not gonna work because of Same-Origin Policy. When
console.php
tries to accesstop.DEV
, it's blocked by browser because top window is in another domain.So we need a same origin page where we can embed an iframe and also set global variables.
htmledit.php
is the one.DOM clobbering
There is a technique called DOM clobbering, it utilizes a feature which turns a DOM element with id to global variable.
For example, when you have
<div id="a"></div>
in your HTML, you can access it in JS viawindow.a
or justa
.If you can read Mandarin, you can check my blog post 淺談 DOM Clobbering 的原理及應用 and another great article by Zeddy: 使用 Dom Clobbering 扩展 XSS. If you can't, check this: DOM Clobbering strikes back
It's a little bit troublesome to achieve multi-level DOM clobbering, you need to use iframe + srcdoc, here is my payload:
So
top.DEV
isa
element,store
is the iframe,store.users
is HTML collections of<a>
,store.users.admin
is thea
, andstore.users.admin.username
is the URL username inhref
, which isa
, it's the same for password.I built a simple page to open a new window, so that
htmledit.php
is the top window and I can still post message to it:By far, I can send message to
console.php
. But, it's only the beginning.Pass arbitrary data and safe=true
In order to let
safe
be true, I need to find a function call with 4 parameters:log("[logv]: ", window[m.message], safe=false, type='info')
is what I need, the fourth parameter isinfo
which is truthy.data
iswindow[m.message]
, so I need to set my payload to a global variable.I stuck here for a long time because I can't find one.
window.name
is usually a good candidate but this page set it's window name so I can't use it.location
is another candidate butlog
checks ifdata
is string, if not, it turns it into a string viaJSON.stringify
, which encoded<>
.I checked the code again and again, try to find out the missing puzzle. Finally, I found one.
Can you find a bug in the code above?
for (x of access) {
, it's a common bug for newbie, when you forgot to declarex
, it will be a global variable. In this case,x
istop.store.users.admin
, which is the<a>
element.Build payload
If we cast an
<a>
element to string, the return value isa.href
. It's a common technique in DOM clobbering. So we can pass our payload insidehref
.But, remember that
log
checks the type of data? The type ofx
is DOM element, hence failed the check. I need to find a way to make it a string.Fortunately, there is another command I can utilize:
I can do this:
Because of JS "coercion",
x+1
returns a string, so nowZ
is a string contains ourhref
. Now, I can send whatever data I want.But wait, it's encoded because it's a URL,
<
will be%3C
.What should I do?
In
log
function, there is one linedata = parse(data)
, and here is the parse function:If
e
is string, it returnss(e)
where s islet s = (s) => s.normalize('NFC');
When I reviewed the source code of reassign command, I noticed this regexp:
RegExp = /^[s-zA-Z-+0-9]+$/;
, and I also noticed these four functions:s
,u
andt
is allowed to use. So, we can utilizereassign
command again, to lets=u
, so our data can be unescaped!Full source code is like this:
So the data is
ftp://a:a@a#<img src=x onerror=alert(1)>
, and the data is assigned totext_tag.innerHTML
, XSS triggered!Oh...not that easy, I forgot CSP.
Bypass CSP
Indeed, I can inject anything to HTML for now, but there is one more thing I need to do: bypass CSP.
The CSP is:
There is no
unsafe-inline
so inline event won't work.https://challenge-0721.intigriti.io/analytics/
is suspicious, what is this?This JS
https://challenge-0721.intigriti.io/analytics/main.js
is included but almost nothing inside.Actually, when I saw this CSP rule, I know what to do instantly. Because I know there is a way to bypass CSP path using
%2f
(url encoded/
).Take this URL:
https://challenge-0721.intigriti.io/analytics/..%2fhtmledit.php
as an example, to browser, it's underanalytics
path so pass CSP, but for server it'sanalytics/../htmledit.php
, so we actually load resource from different path!But what should I include?
htmledit.php
is HTML, not JS...really?If you look carefully,
htmledit.php
prints escaped input in HTML comment, like this:In some cases, HTML comment is also a valid JS comment, as per ECMAScript:
In other words, we can make this HTML a valid JS script!
Here is the url I used:
https://challenge-0721.intigriti.io/htmledit.php?code=1;%0atop.alert(document.domain);/*
, it respond following HTML:After
/*
it's all comment, so the whole script istop.alert(document.domain);
basically. So now, I can include this url as JS script to run arbitrary code and bypass CSP.Please note that the content type of
htmledit.php
is stilltext/html
, but it's fine since it's same origin. If you want to include a page with content typetext/html
as JS , you will get a CORB error.It seems great, now we can inject an script to pop alert, right?
Unfortunately, not yet.
Final step
I thought I solve the challenge after I found this clear way to inject script, but somehow it doesn't work.
According to this stack overflow thread, the
<script>
tag won't load if you inserted with innerHTML.I don't know how to do so I googled
innerhtml import script
,innerhtml script run
and so on, but found nothing useful.After a while, It occurred to me that how about our old friend
<iframe srcdoc>
? What if I put the script tag inside srcdoc?So, I tried this way and it works like a charm.
Put it all together
Just one small thing to say, before I submit the answer I found that my exploit doesn't work on Firefox.
For
window.users
, Chrome returns HTMLCollection while Firefox returns first<a>
only, sousers.admin
is undefined on Firefox.It's not a big deal, just use another iframe:
Following is my exploit in the end:
Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html
It's a great and awesome challenge, to me it's like a game with 5 levels, I need to solve every levels and put it together to really win this game.
I spent about 2 days on this challenge and every time I stuck, I checked the source code again, reviewed one line after another until I found something new. Surprisingly, there is always something new!
Thanks @RootEval for creating such amazing challenge, and also thanks Intigriti for hosting this event.
The text was updated successfully, but these errors were encountered: