Здравствуйте, меня зовут Дмитрий Карловский и я хочу сыграть с вами в игру. Правила её очень просты, но их нарушение приведёт вас к победе. Почувствуйте себя в роли хакера выбирающегося из JavaScript песочницы с целью прочитать куки, намайнить биткоины, сделать дефейс или ещё что-нибудь интересное.
А далее я расскажу как работает песочница и подкину несколько идей для взлома.
Как это работает
Итак, первым делом нам надо спрятать все глобальные переменные. Сделать это просто достаточно замаскировать их одноимёнными локальными переменными:
for( let name in window ) { context_default[ name ] = undefined}
Однако, многие свойства (например,
window.constructor
) являются неитерируеммыми. Поэтому
необходимо итерироваться по всем пропертям объекта:
for( let name of Object.getOwnPropertyNames( window ) ) { context_default[ name ] = undefined}
Но Object.getOwnPropertyNames
возвращает лишь
собственные свойства объекта, игнорируя всё, что он наследует от
прототипа. Поэтому надо аналогичным образом пройтись по всей
цепочке прототипов и собрать имена всех возможных свойств
глобального объекта:
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 )
И всё бы хорошо, да только этот код падает, ибо в строгом режиме
нельзя объявлять локальную переменную с именем
eval
:
'use strict'var eval // SyntaxError: Unexpected eval or arguments in strict mode
А вот использовать пожалуйста:
'use strict'eval('document.cookie') // password=P@zzW0rd
Ну, ничего, благо глобальный eval
можно просто
удалить:
'use strict'delete window.evaleval('document.cookie') // ReferenceError: eval is not defined
А для надёжности лучше пройтись по всем собственным свойствам и всё поудалять:
for( const key of Object.getOwnPropertyNames( window ) ) delete window[ key ]
Зачем нам вообще строгий режим? Да потому что без него можно
использовать arguments.callee.caller
чтобы получить
любую функцию выше по стеку и натворить дел:
function unsafe(){ console.log( arguments.callee.caller ) }function safe(){ unsafe() }safe() // safe(){ unsafe() }
Кроме того, в нестрогом режиме легко получить глобальный
неймспейс просто взяв this
при вызове функции не как
метода:
function get_global() { return this }get_global() // window
Ладно, все глобальные переменные мы замаскировали. Но их значения всё ещё можно получить из примитивов языка. Например:
var Function = ( ()=>{} ).constructorvar hack = new Function( 'return document.cookie' )hack() // password=P@zzW0rd
Что делать? Удаляем небезопасные конструкторы:
Object.defineProperty( Function.prototype , 'constructor' , { value : undefined } )
Этого было бы достаточно для какого-то древнего яваскрипта, но сейчас у нас есть разные виды функций и каждый вариант следует обезопасить:
var Function = Function || ( function() {} ).constructorvar AsyncFunction = AsyncFunction || ( async function() {} ).constructorvar GeneratorFunction = GeneratorFunction || ( function*() {} ).constructor
В одной песочнице могут запускаться разные скрипты и будет не хорошо, если они смогут влиять на работоспособность друг друга, поэтому замораживаем все объекты, доступные через примитивы языка:
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 )}
Ок, тотальное огораживание мы реализовали, но цена этому жёсткое надругательство над рантаймом, что может сломать и наше собственное приложение. То есть нам нужен отдельный рантайм для песочницы, где можно творить любые непотребства. Получить его можно двумя способами: через скрытый фрейм или через веб-воркер.
Особенности воркера:
- Полная изоляция памяти. Из воркера невозможно сломать рантайм основного приложения.
- В воркер нельзя передать свои функции, что часто необходимо. Это ограничение можно частично обойти через реализацию RPC.
- Воркер можно прибить по таймауту, если злодей напишет там бесконечный цикл.
- Вся коммуникация строго асинхронная, что не очень быстро.
Особенности фрейма:
- Можно передавать во фрейм любые объекты и функции, но по ссылкам можно случайно предоставить доступ к тому, к чему не стоило бы.
- Бесконечный цикл в песочнице вешает всё приложение.
- Вся коммуникация строго синхронная.
Реализация RPC для воркера дело не хитрое, но его ограничения не всегда приемлемы. Поэтому рассмотрим вариант с фреймом.
Если в песочницу передать какой-либо объект из которого по ссылкам доступен хоть какой-то изменяемый объект, то из песочницы можно будет его поменять и сломать наше приложение:
numbers.toString = ()=> { throw 'lol' }
Но это ещё цветочки. Передача в во фрейм любой функции тут же откроет кулхацкеру настеж все двери:
var Function = random.constructorvar hack = new Function( 'return document.cookie' )hack() // password=P@zzW0rd
Ну ничего, прокси спешит на помощь:
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})
То есть мы разрешаем обращаться к свойствам, вызывать функции и конструировать объекты, но запрещаем все инвазивные операции. Соблазнительно возвращаемые значения тоже заворачивать в такие прокси, но тогда можно будет добраться по ссылкам до объекта у которого есть какой-либо мутирующий метод и воспользоваться им:
config.__proto__.__defineGetter__( 'toString' , ()=> ()=> 'rofl' )({}).toString() // rofl
Поэтому все значения принудительно прогоняем через промежуточную сериализацию в JSON:
const SafeJSON = frame.contentWindow.JSONconst safe_value = ( val : any ) : any => { const str = JSON.stringify( val ) if( !str ) return str val = SafeJSON.parse( str ) return val}
Таким образом из песочницы будут доступны только объекты и функции которые мы передали туда явно. Но порой нужно и неявно передавать некоторые объекты. Для них заведём whitelist в который будем автоматически добавлять все объекты, что заворачиваются в безопасный прокси, проходят обезвреживание или приходят из песочницы:
const whitelist = new WeakSetconst 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}
И на случай, если разработчик по невнимательности предоставит доступ к какой-либо функции позволяющей интерпретировать строку как код, заведём ещё blacklist, с перечислением того, что в песочницу нельзя передавать ни при каких обстоятельствах:
const blacklist = new Set([ ( function() {} ).constructor , ( async function() {} ).constructor , ( function*() {} ).constructor , eval , setTimeout , setInterval ,])
В результате у нас получилась довольно безопасная песочница со следующими характеристиками:
- Можно исполнять произвольный JS код.
- Код исполняется синхронно и не требует делать все функции выше по стеку асинхронными.
- Нельзя прочитать данные к которым не предоставлен доступ.
- Нельзя изменить поведение использующего песочницу приложения.
- Нельзя сломать работоспособность собственно песочницы.
- Можно подвесить приложение бесконечным циклом.
Если есть идеи как это можно улучшить или хотите вступить в ТехноГильдию пишите телеграммы.
Ссылочки
- https://sandbox.js.hyoo.ru/ онлайн песочница с примерами потенциально опасного кода.
- https://calc.hyoo.ru/ электронная таблица, позволяющая использовать в ячейках произвольный JS код.
- https://t.me/mol_news канал с новостями об экосистеме $mol и открытых проектах ТехноГильдии.