Загрязнение Прототипа [101]

D2

Администратор
Регистрация
19 Фев 2025
Сообщения
4,380
Реакции
0
В этой статье, мы разберём уязвимости связанные с загрязнением прототипа. Также поймём то как работают готовые пэйлоады для библиотек/шаблонов и научимся создавать PoC.

Типо интро​

В отличие от многих языков, которые пошли по классической (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
Здесь person - это объект со свойствами (ключами) name, age и greet. Когда мы вызываем person.greet(), интерпретатор ищет свойство greet, находит функцию и выполняет её.

Когда мы говорим о «свойствах» и «методах» объекта, по сути мы имеем в виду одно и то же под капотом: именованные слоты, прикреплённые к объекту. «Метод» — это просто свойство (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. Если в childObj нет свойства baseProp, то оно ищется в baseObj. Если там тоже нет - поиск пойдёт дальше по цепочке прототипов вплоть до Object.prototype.

1736074903121.png


В итоге цепочка выглядит так:
Код: Скопировать в буфер обмена
[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
Именно эта цепочка (chain) определяет порядок и логику поиска свойств в JavaScript.

Конструктор (Constructor) и proto​

Свойство __proto__ - это внутреннее свойство любого объекта. Оно указывает на прототип этого объекта, позволяя ему наследовать свойства и методы. Ключевое слово здесь - ОБЪЕКТ.

Конструктор-функция - это любая функция, которую вызывают через new. Такие функции используются для создания новых объектов. У каждой конструктор-функции есть свойство prototype:
Код: Скопировать в буфер обмена
Код:
function Car(brand) {
  this.brand = brand;
}

const myCar = new Car("Toyota");
Что делает new Car("Toyota")?
Сначала оператор new создаёт пустой объект {}. Прототип этого нового объекта указывается на свойство Car.prototype. Это значит, что объект будет наследовать всё, что есть в Car.prototype. Затем вызывается сама конструктор-функция (Car), и this внутри неё ссылается на только что созданный объект. Все свойства и методы, которые мы определяем в Car, записываются в этот объект (например, this.brand = brand).

По умолчанию у функции-конструктора Car есть свойство Car.prototype, которое само по себе является объектом. Любое что создано через new Car(), будет иметь __proto__, указывающий на этот объект.

1736074924141.png


Главная идея в том, что __proto__ - это механизм, который есть у любого объекта, а у конструктор-функций есть свойство prototype, которое тоже является объектом. Несмотря на относительную простоту, многие разработчики не до конца понимают, как именно здесь происходит наследование.

Class​

Примерно в 2015 году в JavaScript появился синтаксис class:
Код: Скопировать в буфер обмена
Код:
class Car {
  constructor(brand) {
    this.brand = brand;
  }
 
  startEngine() {
    return `The ${this.brand} engine is on.`;
  }
}
Хотя это выглядит похоже на классы в Java, сам язык всё ещё использует прототипы «под капотом». Поэтому часто говорят, что классы в JavaScript — это всего лишь «синтаксический сахар». Под капотом движок фактически создаёт функцию-конструктор с именем Car, а в Car.prototype записывает метод startEngine.

1736074947590.png


Разница между 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`);
};
Здесь мы добавляем метод showSpecs в ElectronicDevice.prototype. Теперь любой объект, созданный через new ElectronicDevice(), будет иметь доступ к этому методу.
Код: Скопировать в буфер обмена
Код:
function Phone(brand, batteryLife, cameraCount) {
  ElectronicDevice.call(this, brand, batteryLife);
  this.cameraCount = cameraCount;
}
ElectronicDevice.call(this, brand, batteryLife) вызывает ElectronicDevice с this, указывающим на новый объект Phone. Это обеспечивает наследование свойств brand и batteryLife.
Код: Скопировать в буфер обмена
Phone.prototype = Object.create(ElectronicDevice.prototype);
Так мы говорим, что Phone.prototype будет объектом, унаследованным от ElectronicDevice.prototype. Благодаря этому все объекты Phone будут иметь доступ к методам ElectronicDevice.prototype.
Код: Скопировать в буфер обмена
Phone.prototype.constructor = Phone;
При использовании Object.create(...) свойство constructor прототипа указывает на родительский конструктор (ElectronicDevice). Здесь мы явно меняем constructor, чтобы он указывал на Phone.

1736074966157.png


Код: Скопировать в буфер обмена
Код:
Phone  -->  Phone.prototype  -->  ElectronicDevice.prototype  -->  showSpecs
                                      |
                                      v
                           ElectronicDevice.constructor
Phone — это функция-конструктор, у которой есть свойство Phone.prototype. Phone.prototype — это объект, а у объектов есть свойство __proto__, которое может указывать на ElectronicDevice.prototype.

1736074992572.png


Переопределение методов и объектов​

В 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() }

1736075012904.png


Возникает вопрос: зачем переопределять toString? Это базовый метод, который часто где-то используется. Когда для myCar вызовут toString(), в цепочке (myCar -> Car.prototype -> Car.prototype.__proto__) найдётся переопределённая версия вместо оригинальной, лежащей на следующем уровне (Car.prototype.__proto__.__proto__).

В Node.js есть функция merge, которая делает примерно то же самое, что и Object.assign:
Код: Скопировать в буфер обмена
Код:
function merge(a, b) {
  if (a && b) {
    for (var key in b) {
      a[key] = b[key];
    }
  }
  return a;
}
Эта функция мутирует (изменяет) объект a, добавляя или обновляя в нём свойства из объекта b. Работает только для перечислимых свойств, и если ключ из b уже есть в 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)

1736075038993.png



1736075058454.png


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' ]
В атаке типа «prototype pollution» используется пустой объект, потому что прототип пустого объекта — это Object.prototype, являющийся корнем для всех объектов.

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();
Здесь используется функция defaultsDeep, а нагрузка - это constructor->prototype->a0. Как уже говорилось, у любой функции-конструктора есть свойство prototype. Тот факт, что здесь загрязняют прототип пустого объекта, не делает саму функцию напрямую уязвимой. Давайте проверим это сами:
Код: Скопировать в буфер обмена
npm install lodash@4.17.11
Теперь просто откроем VSCode и запустим отладку.
Первое, что происходит: к аргументам добавляется значение при помощи push. Изначально у нас есть пустой объект и наш payload. Затем добавляются undefined и customDefaultsMerge.

1736075078229.png


Вместо пошагового прохода (stepping into) каждой функции в VSCode, мы можем посмотреть, какая функция вызывает какую, и остановиться там, где ничего, кроме стандартных функций JavaScript, не используется.

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;
}
Как видно, тут вызывается baseMerge:
Код: Скопировать в буфер обмена
Код:
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);
}
Обе эти функции рекурсивно вызывают сами себя. Функция baseMerge использует baseMergeDeep:
Код: Скопировать в буфер обмена
Код:
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);
  }
mergeFunc здесь — это baseMerge. Всё, что делают эти функции, — рекурсивно вызывают друг друга и затем выполняют stack.set. Функция stackSet добавляет ключ-значение во внутреннюю структуру данных объекта. Сначала она проверяет, хранится ли всё в ListCache (который является массивом пар ключ-значение):
Код: Скопировать в буфер обмена
Код:
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;
Теперь мы знаем, где происходит рекурсия. Остаётся понять, какая функция формирует значения для stackSet и где происходит само присвоение.

Я использую PoC, чтобы понять порядок исполнения функций и какие значения передаются. Источником (source) является наш payload, а объектом — пустой объект.

На этот раз копнём глубже, чтобы понять порядок, в котором вызываются функции:
Код: Скопировать в буфер обмена
Код:
var defaultsDeep = baseRest(function(args) {
  args.push(undefined, customDefaultsMerge);
  return apply(mergeWith, undefined, args);
});
defaultsDeep вызывает apply. Как уже сказано, thisArg включает пустой объект, payload, undefined и customDefaultsMerge. Длина массива - 4, значит ни один из кейсов не подходит, и мы перейдём напрямую к return.
Код: Скопировать в буфер обмена
Код:
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);
}
func в нашем случае — это mergeWith, значит вышеуказанный apply вызовет mergeWith с массивом аргументов.

1736075099843.png


baseMerge проверяет, равен ли объект source. Если нет, то вызывается baseMergeDeep с ключом 'constructor'. baseMergeDeep получает objValue и srcValue с помощью safeGet. Самый интересный момент здесь:

1736075119989.png


Конструктор пустого объекта — это функция Object, и теперь это objValue.
Код: Скопировать в буфер обмена
Код:
function safeGet(object, key) {
  if (key == '_proto_') {
    return;
  }

  return object[key];
}
Таким образом, safeGet возвращает ключ (constructor) пустого объекта. Настоящая уязвимость кроется именно в этой функции.
srcValue — это наш payload без самого конструктора.
Теперь вызывается «кастомайзер» (customizer), которым является customDefaultsMerge:

1736075155816.png


customDefaultsMerge будет вызван два раза до тех пор, пока objValue не станет undefined, так как у него нет a0, а srcValue равно a0: true. После этого в конце baseMerge формируется newValue как результат customDefaultsMerge, вернувший objValue (который был undefined):

1736075168597.png


assignMergeValue проверяет, равно ли в объекте (который теперь Object.constructor.prototype) a0 = true. Поскольку объекта a0 ещё нет и значение — true, будет выполнен baseAssignValue:
Код: Скопировать в буфер обмена
Код:
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;
  }
}
Эта функция фактически создаёт в Object.prototype свойство a0 и присваивает ему true. После этого нам уже не важно, что происходит дальше, и я прервал отладку.

1736075190682.png



Вкратце​

Понимаю, что это всё запутанно, поэтому давайте подытожим. 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!
 
Сверху Снизу