D2
Администратор
- Регистрация
- 19 Фев 2025
- Сообщения
- 4,380
- Реакции
- 0
В этой статье, мы разберём уязвимости связанные с загрязнением прототипа. Также поймём то как работают готовые пэйлоады для библиотек/шаблонов и научимся создавать PoC.
Код: Скопировать в буфер обмена
Здесь person - это объект со свойствами (ключами) name, age и greet. Когда мы вызываем person.greet(), интерпретатор ищет свойство greet, находит функцию и выполняет её.
Когда мы говорим о «свойствах» и «методах» объекта, по сути мы имеем в виду одно и то же под капотом: именованные слоты, прикреплённые к объекту. «Метод» — это просто свойство (property), значение которого является функцией. JavaScript не различает «поля» и «методы» - всё является свойствами, в которых может храниться либо значение, либо функция.
Код: Скопировать в буфер обмена
Мы создали childObj, чей прототип - это baseObj. Если в childObj нет свойства baseProp, то оно ищется в baseObj. Если там тоже нет - поиск пойдёт дальше по цепочке прототипов вплоть до Object.prototype.
В итоге цепочка выглядит так:
Код: Скопировать в буфер обмена
Рассмотрим более сложный пример, чтобы увидеть, как работает поиск свойств:
Код: Скопировать в буфер обмена
Именно эта цепочка (chain) определяет порядок и логику поиска свойств в JavaScript.
Конструктор-функция - это любая функция, которую вызывают через new. Такие функции используются для создания новых объектов. У каждой конструктор-функции есть свойство prototype:
Код: Скопировать в буфер обмена
Что делает new Car("Toyota")?
Сначала оператор new создаёт пустой объект {}. Прототип этого нового объекта указывается на свойство Car.prototype. Это значит, что объект будет наследовать всё, что есть в Car.prototype. Затем вызывается сама конструктор-функция (Car), и this внутри неё ссылается на только что созданный объект. Все свойства и методы, которые мы определяем в Car, записываются в этот объект (например, this.brand = brand).
По умолчанию у функции-конструктора Car есть свойство Car.prototype, которое само по себе является объектом. Любое что создано через new Car(), будет иметь __proto__, указывающий на этот объект.
Главная идея в том, что __proto__ - это механизм, который есть у любого объекта, а у конструктор-функций есть свойство prototype, которое тоже является объектом. Несмотря на относительную простоту, многие разработчики не до конца понимают, как именно здесь происходит наследование.
Код: Скопировать в буфер обмена
Хотя это выглядит похоже на классы в Java, сам язык всё ещё использует прототипы «под капотом». Поэтому часто говорят, что классы в JavaScript — это всего лишь «синтаксический сахар». Под капотом движок фактически создаёт функцию-конструктор с именем Car, а в Car.prototype записывает метод startEngine.
__proto__ - это ссылка, принадлежащая каждому конкретному объекту и указывающая на его прототип.
Prototype определяет свойства и методы, которые будут у всех экземпляров, созданных этой конструктор-функцией, а __proto__ - это уже унаследованная связь конкретного объекта с прототипом. Советую остановиться тут и проверить у себя то как они работают.
Рассмотрим чуть более продвинутый пример:
Код: Скопировать в буфер обмена
Здесь мы добавляем метод showSpecs в ElectronicDevice.prototype. Теперь любой объект, созданный через new ElectronicDevice(), будет иметь доступ к этому методу.
Код: Скопировать в буфер обмена
ElectronicDevice.call(this, brand, batteryLife) вызывает ElectronicDevice с this, указывающим на новый объект Phone. Это обеспечивает наследование свойств brand и batteryLife.
Код: Скопировать в буфер обмена
Так мы говорим, что Phone.prototype будет объектом, унаследованным от ElectronicDevice.prototype. Благодаря этому все объекты Phone будут иметь доступ к методам ElectronicDevice.prototype.
Код: Скопировать в буфер обмена
При использовании Object.create(...) свойство constructor прототипа указывает на родительский конструктор (ElectronicDevice). Здесь мы явно меняем constructor, чтобы он указывал на Phone.
Код: Скопировать в буфер обмена
Phone — это функция-конструктор, у которой есть свойство Phone.prototype. Phone.prototype — это объект, а у объектов есть свойство __proto__, которое может указывать на ElectronicDevice.prototype.
Код: Скопировать в буфер обмена
Допустим, мы решили переопределить этот метод несколькими способами:
Код: Скопировать в буфер обмена
Возникает вопрос: зачем переопределять toString? Это базовый метод, который часто где-то используется. Когда для myCar вызовут toString(), в цепочке (myCar -> Car.prototype -> Car.prototype.__proto__) найдётся переопределённая версия вместо оригинальной, лежащей на следующем уровне (Car.prototype.__proto__.__proto__).
В Node.js есть функция merge, которая делает примерно то же самое, что и Object.assign:
Код: Скопировать в буфер обмена
Эта функция мутирует (изменяет) объект a, добавляя или обновляя в нём свойства из объекта b. Работает только для перечислимых свойств, и если ключ из b уже есть в a, то значение будет перезаписано.
Код: Скопировать в буфер обмена
Когда дело доходит до обычных объектов, всё ещё проще:
Код: Скопировать в буфер обмена
Существует два типа прототипного загрязнения. Client-side (на стороне клиента), когда эксплуатируются уязвимости в браузере, «чистом» JavaScript на фронтенде. Server-side (на стороне сервера), когда подобные проблемы возникают в коде, исполняющемся на сервере (например, в Node.js).
Найти CVE для этой уязвимости стало сложнее, потому что какой-то г-н включил автоскан на все уязвимости и начал массово репортить их в mitre, которые приняли их, не проверяя (как всегда). Один из примеров — вот здесь:
Таким образом, {} одновременно представляет и пустой объект, и Object.prototype:
Код: Скопировать в буфер обмена
В атаке типа «prototype pollution» используется пустой объект, потому что прототип пустого объекта — это Object.prototype, являющийся корнем для всех объектов.
Код: Скопировать в буфер обмена
Здесь используется функция defaultsDeep, а нагрузка - это constructor->prototype->a0. Как уже говорилось, у любой функции-конструктора есть свойство prototype. Тот факт, что здесь загрязняют прототип пустого объекта, не делает саму функцию напрямую уязвимой. Давайте проверим это сами:
Код: Скопировать в буфер обмена
Теперь просто откроем VSCode и запустим отладку.
Первое, что происходит: к аргументам добавляется значение при помощи push. Изначально у нас есть пустой объект и наш payload. Затем добавляются undefined и customDefaultsMerge.
Вместо пошагового прохода (stepping into) каждой функции в VSCode, мы можем посмотреть, какая функция вызывает какую, и остановиться там, где ничего, кроме стандартных функций JavaScript, не используется.
defaultsDeep -> customDefaultsMerge
Код: Скопировать в буфер обмена
Как видно, тут вызывается baseMerge:
Код: Скопировать в буфер обмена
Обе эти функции рекурсивно вызывают сами себя. Функция baseMerge использует baseMergeDeep:
Код: Скопировать в буфер обмена
mergeFunc здесь — это baseMerge. Всё, что делают эти функции, — рекурсивно вызывают друг друга и затем выполняют stack.set. Функция stackSet добавляет ключ-значение во внутреннюю структуру данных объекта. Сначала она проверяет, хранится ли всё в ListCache (который является массивом пар ключ-значение):
Код: Скопировать в буфер обмена
Теперь мы знаем, где происходит рекурсия. Остаётся понять, какая функция формирует значения для stackSet и где происходит само присвоение.
Я использую PoC, чтобы понять порядок исполнения функций и какие значения передаются. Источником (source) является наш payload, а объектом — пустой объект.
На этот раз копнём глубже, чтобы понять порядок, в котором вызываются функции:
Код: Скопировать в буфер обмена
defaultsDeep вызывает apply. Как уже сказано, thisArg включает пустой объект, payload, undefined и customDefaultsMerge. Длина массива - 4, значит ни один из кейсов не подходит, и мы перейдём напрямую к return.
Код: Скопировать в буфер обмена
func в нашем случае — это mergeWith, значит вышеуказанный apply вызовет mergeWith с массивом аргументов.
baseMerge проверяет, равен ли объект source. Если нет, то вызывается baseMergeDeep с ключом 'constructor'. baseMergeDeep получает objValue и srcValue с помощью safeGet. Самый интересный момент здесь:
Конструктор пустого объекта — это функция Object, и теперь это objValue.
Код: Скопировать в буфер обмена
Таким образом, safeGet возвращает ключ (constructor) пустого объекта. Настоящая уязвимость кроется именно в этой функции.
srcValue — это наш payload без самого конструктора.
Теперь вызывается «кастомайзер» (customizer), которым является customDefaultsMerge:
customDefaultsMerge будет вызван два раза до тех пор, пока objValue не станет undefined, так как у него нет a0, а srcValue равно a0: true. После этого в конце baseMerge формируется newValue как результат customDefaultsMerge, вернувший objValue (который был undefined):
assignMergeValue проверяет, равно ли в объекте (который теперь Object.constructor.prototype) a0 = true. Поскольку объекта a0 ещё нет и значение — true, будет выполнен baseAssignValue:
Код: Скопировать в буфер обмена
Эта функция фактически создаёт в Object.prototype свойство a0 и присваивает ему true. После этого нам уже не важно, что происходит дальше, и я прервал отладку.
Код: Скопировать в буфер обмена
В нашем случае object — это Object.constructor.prototype, key — a0, а value — true.
Хотя я не упоминал функцию safeGet особо, именно она вернула конструктор пустого объекта и его прототип. Если бы в safeGet была чёрный список для constructor и prototype, мы бы не смогли добраться до «корня» и сделать pollution. Это подтверждается и патчем:
Типо интро
В отличие от многих языков, которые пошли по классической (class-based) модели (таких как Java, C++ или даже Python со своей системой классов), в JavaScript используется прототипный (prototypal) подход. Объекты наследуют функциональность напрямую от других объектов. Это решение было отчасти вдохновлено языком Self, известным благодаря прототипному наследованию.Objects 101
Объект - это динамическая коллекция пар «ключ-значение». Каждый ключ может быть строкой или символом, а значение может быть чем угодно: числом, строкой или даже другим объектом. Например:Код: Скопировать в буфер обмена
Код:
const person = {
name: "Alice",
age: 25,
greet: function() {
console.log("Hello, my name is " + this.name);
}
};
person.greet() // Hello, my name is Alice
Когда мы говорим о «свойствах» и «методах» объекта, по сути мы имеем в виду одно и то же под капотом: именованные слоты, прикреплённые к объекту. «Метод» — это просто свойство (property), значение которого является функцией. JavaScript не различает «поля» и «методы» - всё является свойствами, в которых может храниться либо значение, либо функция.
Прототип (Prototype)
Прототип — это «запасной» объект. Когда вы обращаетесь к свойству объекта, которого у него нет, JavaScript автоматически проверяет прототип данного объекта в поисках этого свойства. Если свойство всё ещё не найдено, проверяется прототип прототипа, и так далее, пока не будет достигнут финальный объект в цепочке (Object.prototype). Object.prototype - это объект-прототип, от которого наследуют все объекты в JavaScript; это верхний уровень в цепочке наследования.Код: Скопировать в буфер обмена
Код:
const baseObj = { baseProp: 42 };
const childObj = Object.create(baseObj);
console.log(childObj.baseProp); // 42
Код: Скопировать в буфер обмена
[childObj] ——> [baseObj] ——> [Object.prototype] ——> null
Рассмотрим более сложный пример, чтобы увидеть, как работает поиск свойств:
Код: Скопировать в буфер обмена
Код:
const animal = { breathes: true };
const dog = Object.create(animal);
dog.barks = true;
const myPet = Object.create(dog);
myPet.name = "Rex";
console.log(myPet.name); // "Rex" (в myPet)
console.log(myPet.barks); // true (в dog)
console.log(myPet.breathes); // true (в animal)
console.log(myPet.toString()); // в Object.prototype
Конструктор (Constructor) и proto
Свойство __proto__ - это внутреннее свойство любого объекта. Оно указывает на прототип этого объекта, позволяя ему наследовать свойства и методы. Ключевое слово здесь - ОБЪЕКТ.Конструктор-функция - это любая функция, которую вызывают через new. Такие функции используются для создания новых объектов. У каждой конструктор-функции есть свойство prototype:
Код: Скопировать в буфер обмена
Код:
function Car(brand) {
this.brand = brand;
}
const myCar = new Car("Toyota");
Сначала оператор new создаёт пустой объект {}. Прототип этого нового объекта указывается на свойство Car.prototype. Это значит, что объект будет наследовать всё, что есть в Car.prototype. Затем вызывается сама конструктор-функция (Car), и this внутри неё ссылается на только что созданный объект. Все свойства и методы, которые мы определяем в Car, записываются в этот объект (например, this.brand = brand).
По умолчанию у функции-конструктора Car есть свойство Car.prototype, которое само по себе является объектом. Любое что создано через new Car(), будет иметь __proto__, указывающий на этот объект.
Class
Примерно в 2015 году в JavaScript появился синтаксис class:Код: Скопировать в буфер обмена
Код:
class Car {
constructor(brand) {
this.brand = brand;
}
startEngine() {
return `The ${this.brand} engine is on.`;
}
}
Разница между proto и prototype
prototype - это свойство, используемое именно конструкторами (функциями-конструкторами).__proto__ - это ссылка, принадлежащая каждому конкретному объекту и указывающая на его прототип.
Prototype определяет свойства и методы, которые будут у всех экземпляров, созданных этой конструктор-функцией, а __proto__ - это уже унаследованная связь конкретного объекта с прототипом. Советую остановиться тут и проверить у себя то как они работают.
Рассмотрим чуть более продвинутый пример:
Код: Скопировать в буфер обмена
Код:
function ElectronicDevice(brand, batteryLife) {
this.brand = brand;
this.batteryLife = batteryLife;
}
ElectronicDevice.prototype.showSpecs = function() {
console.log(`Brand: ${this.brand}, Battery: ${this.batteryLife} hours`);
};
Код: Скопировать в буфер обмена
Код:
function Phone(brand, batteryLife, cameraCount) {
ElectronicDevice.call(this, brand, batteryLife);
this.cameraCount = cameraCount;
}
Код: Скопировать в буфер обмена
Phone.prototype = Object.create(ElectronicDevice.prototype);
Так мы говорим, что Phone.prototype будет объектом, унаследованным от ElectronicDevice.prototype. Благодаря этому все объекты Phone будут иметь доступ к методам ElectronicDevice.prototype.
Код: Скопировать в буфер обмена
Phone.prototype.constructor = Phone;
При использовании Object.create(...) свойство constructor прототипа указывает на родительский конструктор (ElectronicDevice). Здесь мы явно меняем constructor, чтобы он указывал на Phone.
Код:
Phone --> Phone.prototype --> ElectronicDevice.prototype --> showSpecs
|
v
ElectronicDevice.constructor
Переопределение методов и объектов
В JavaScript можно переопределять методы, даже те, что лежат в {Object}.prototype:Код: Скопировать в буфер обмена
Код:
function Car(brand) {
this.brand = brand;
}
const myCar = new Car("Toyota");
Car.prototype.works = function(){ console.log("Obviously doesn't") }
myCar.works() // "Obviously doesn't"
Код: Скопировать в буфер обмена
Код:
//1
Car.prototype.works = function(){ console.log("Neta") }
myCar.works() // "Neta"
//2
works2 = function works2() { console.log("Rabotaet") }
Object.assign(Car.prototype, { works: works2 });
myCar.works() // "Rabotaet"
//3
sample = function() { console.log("Iiiiiii") }
Object.setPrototypeOf(Car.prototype, { toString: sample }); // меняем toString
Car.prototype.__proto__ // Object { toString: sample() }
В Node.js есть функция merge, которая делает примерно то же самое, что и Object.assign:
Код: Скопировать в буфер обмена
Код:
function merge(a, b) {
if (a && b) {
for (var key in b) {
a[key] = b[key];
}
}
return a;
}
Код: Скопировать в буфер обмена
Код:
Советую остановиться тут и проверить у себя то как они работают.
works4 = function works4() { console.log("Lol") }
merge(Car.prototype, { works: works4 })
Код: Скопировать в буфер обмена
Код:
const a = ["test1","test2"]
const b = {"damaga":"kruto"}
//1
Object.assign(a,b)
//2 С нуля
const a = ["test1","test2"]
const b = {"toString":"not a function"}
Object.setPrototypeOf(a,b)
//3
merge(a,b)
Prototype Pollution
«Загрязнение прототипа» (Prototype Pollution) возникает, когда прототип объекта (особенно Object.prototype) напрямую модифицируется путём добавления или замены свойств. Так как все объекты в JavaScript наследуют от базовых прототипов, любые внесённые туда изменения будут затрагивать сразу все объекты, что может привести к повышению привилегий, отказу в обслуживании (DoS), выполнению произвольного кода (RCE) и т. д.Существует два типа прототипного загрязнения. Client-side (на стороне клиента), когда эксплуатируются уязвимости в браузере, «чистом» JavaScript на фронтенде. Server-side (на стороне сервера), когда подобные проблемы возникают в коде, исполняющемся на сервере (например, в Node.js).
Найти CVE для этой уязвимости стало сложнее, потому что какой-то г-н включил автоскан на все уязвимости и начал массово репортить их в mitre, которые приняли их, не проверяя (как всегда). Один из примеров — вот здесь:
Перед тем как продолжить
В большинстве JavaScript-консолей объекты отображаются, перечисляя только их собственные перечисляемые свойства (enumerables) (те ключи, которые вы увидите, если вызвать Object.keys(obj)). Поскольку у Object.prototype нет собственных перечисляемых свойств (такие свойства, как toString() и hasOwnProperty(), унаследованы и не перечисляемы), в консоли он выглядит пустым — просто {}.Таким образом, {} одновременно представляет и пустой объект, и Object.prototype:
Код: Скопировать в буфер обмена
Код:
> Object.getOwnPropertyNames({})
[]
> Object.getOwnPropertyNames(Object.prototype)
[ 'constructor',
'_defineGetter_',
'_defineSetter_',
'hasOwnProperty',
'_lookupGetter_',
'_lookupSetter_',
'isPrototypeOf',
'propertyIsEnumerable',
'toString',
'valueOf',
'_proto_',
'toLocaleString' ]
CVE-2019-10744
Lodash довольно известен, и я не видел особо подробного анализа этой уязвимости, поэтому, думаю, стоит написать об этом. Начнём с PoC от Snyk:Код: Скопировать в буфер обмена
Код:
const mergeFn = require('lodash').defaultsDeep;
const payload = '{"constructor": {"prototype": {"a0": true}}}'
function check() {
mergeFn({}, JSON.parse(payload));
if (({})[a0] === true) {
console.log(Vulnerable to Prototype Pollution via ${payload});
}
}
check();
Код: Скопировать в буфер обмена
npm install lodash@4.17.11
Теперь просто откроем VSCode и запустим отладку.
Первое, что происходит: к аргументам добавляется значение при помощи push. Изначально у нас есть пустой объект и наш payload. Затем добавляются undefined и customDefaultsMerge.
defaultsDeep -> customDefaultsMerge
Код: Скопировать в буфер обмена
Код:
function customDefaultsMerge(objValue, srcValue, key, object, source, stack) {
if (isObject(objValue) && isObject(srcValue)) {
// Рекурсивно сливаем объекты и массивы (уязвимо к переполнению стека).
stack.set(srcValue, objValue);
baseMerge(objValue, srcValue, undefined, customDefaultsMerge, stack);
stack['delete'](srcValue);
}
return objValue;
}
Код: Скопировать в буфер обмена
Код:
function baseMerge(object, source, srcIndex, customizer, stack) {
if (object === source) {
return;
}
baseFor(source, function(srcValue, key) {
if (isObject(srcValue)) {
stack || (stack = new Stack);
baseMergeDeep(object, source, key, srcIndex, baseMerge, customizer, stack);
}
else {
var newValue = customizer
? customizer(safeGet(object, key), srcValue, (key + ''), object, source, stack)
: undefined;
if (newValue === undefined) {
newValue = srcValue;
}
assignMergeValue(object, key, newValue);
}
}, keysIn);
}
Код: Скопировать в буфер обмена
Код:
function baseMergeDeep(object, source, key, srcIndex, mergeFunc, customizer, stack) {
var objValue = safeGet(object, key),
srcValue = safeGet(source, key),
stacked = stack.get(srcValue);
...
if (isCommon) {
// Рекурсивно сливаем объекты и массивы (уязвимо к переполнению стека).
stack.set(srcValue, newValue);
mergeFunc(newValue, srcValue, srcIndex, customizer, stack);
stack['delete'](srcValue);
}
Код: Скопировать в буфер обмена
Код:
function stackSet(key, value) {
var data = this._data_;
if (data instanceof ListCache) {
var pairs = data._data_;
if (!Map || (pairs.length < LARGE_ARRAY_SIZE - 1)) {
pairs.push([key, value]);
this.size = ++data.size;
return this;
}
data = this._data_ = new MapCache(pairs);
}
data.set(key, value);
this.size = data.size;
return this;
}
....
Stack.prototype.set = stackSet;
Я использую PoC, чтобы понять порядок исполнения функций и какие значения передаются. Источником (source) является наш payload, а объектом — пустой объект.
На этот раз копнём глубже, чтобы понять порядок, в котором вызываются функции:
Код: Скопировать в буфер обмена
Код:
var defaultsDeep = baseRest(function(args) {
args.push(undefined, customDefaultsMerge);
return apply(mergeWith, undefined, args);
});
Код: Скопировать в буфер обмена
Код:
function apply(func, thisArg, args) {
switch (args.length) {
case 0: return func.call(thisArg);
case 1: return func.call(thisArg, args[0]);
case 2: return func.call(thisArg, args[0], args[1]);
case 3: return func.call(thisArg, args[0], args[1], args[2]);
}
return func.apply(thisArg, args);
}
Код: Скопировать в буфер обмена
Код:
function safeGet(object, key) {
if (key == '_proto_') {
return;
}
return object[key];
}
srcValue — это наш payload без самого конструктора.
Теперь вызывается «кастомайзер» (customizer), которым является customDefaultsMerge:
Код: Скопировать в буфер обмена
Код:
function assignMergeValue(object, key, value) {
if ((value !== undefined && !eq(object[key], value)) ||
(value === undefined && !(key in object))) {
baseAssignValue(object, key, value);
}
}
function baseAssignValue(object, key, value) {
if (key == '_proto_' && defineProperty) {
defineProperty(object, key, {
'configurable': true,
'enumerable': true,
'value': value,
'writable': true
});
} else {
object[key] = value;
}
}
Вкратце
Понимаю, что это всё запутанно, поэтому давайте подытожим. defaultsDeep вызывает apply, который вызывает mergeWith, который в свою очередь вызывает baseMerge. baseMerge дважды вызывает baseMergeDeep, а каждый вызов baseMergeDeep снова вызывает baseMerge. При каждом таком вызове вызывается и customDefaultsMerge, который в свою очередь вызывает baseMerge (так как оба значения в первый и второй раз являются объектами: в первый раз это Object.constructor и наш payload без constructor, во второй раз — Object.constructor.prototype и a0: true). В первый раз customDefaultsMerge вызывает baseMerge, который через baseFor (createBaseFor) получает {'a0': true}, во второй раз видит, что srcValue — это true, а значит, это не объект. Поэтому baseMergeDeep и customDefaultsMerge вызываются ещё раз. На этот третий раз customDefaultsMerge возвращает undefined (так как objValue не существует), и внутри baseMergeDeep мы проверяем, если newValue === undefined, то присваиваем ему srcValue, равное true. Затем вызывается assignMergeValue, который зовёт baseAssignValue, где происходит:Код: Скопировать в буфер обмена
object[key] = value;
В нашем случае object — это Object.constructor.prototype, key — a0, а value — true.
Хотя я не упоминал функцию safeGet особо, именно она вернула конструктор пустого объекта и его прототип. Если бы в safeGet была чёрный список для constructor и prototype, мы бы не смогли добраться до «корня» и сделать pollution. Это подтверждается и патчем:
Автор grozdniyandy
Источник https://xss.is/
View hidden content is available for registered users!