Статья Prototype Pollution: Client-side и Server-side атаки

1775432563235.webp

Prototype Pollution редко выглядит как громкая уязвимость. Обычно всё начинается с куска кода, который никто не считает опасным: функция слияния объектов, разбор параметров в структуру настроек, универсальный helper для deep copy, пустой config-объект, который потом уходит в рендер, загрузчик скрипта, шаблонизатор или системный вызов. До первого инцидента это воспринимается как нормальная инженерная обвязка. Проблема в том, что ошибка живёт не в одном конкретном месте. Один фрагмент принимает данные, другой пишет по ключу, третий позже читает унаследованное свойство как штатный параметр. В итоге баг возникает не на уровне одной функции, а на стыке нескольких безобидных решений.

Этим Prototype Pollution и неприятна. По отдельности JSON.parse(), Object.assign(), разбор location.hash, рендер компонента или вызов child_process могут выглядеть совершенно спокойно. Но если пользовательское значение однажды доехало до прототипа, приложение начинает работать с чужими свойствами как со своими. На клиенте это выливается в DOM XSS, подмену src, srcdoc, обработчиков и конфигурации виджетов. На сервере - в подмену параметров фреймворка, шаблонизатора, промежуточного обработчика или системного API. Именно поэтому Prototype Pollution плохо ловится интуицией: источник находится в одном слое, эффект проявляется в другом, а между ними часто лежит обычный служебный код, который годами не вызывал подозрений.

Эта уязвимость редко живёт как один баг в одном месте. Обычно это цепочка из трёх частей:
  • источник управляемых данных;
  • путь записи в прототип;
  • участок кода, который позже использует загрязнённое свойство.

1.webp


Где ломается модель объектов​

Почему пустой объект не пустой​

У обычного объекта в JavaScript есть собственные поля и прототип. Когда код читает свойство, движок сначала ищет его в самом объекте, потом поднимается по цепочке прототипов. Поэтому пустой {} на деле не совсем пустой - он наследует поведение от Object.prototype.

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

Если загрязнён Object.prototype, эффект расходится по большому куску приложения сразу. Если меняется прототип конкретного объекта настроек, масштаб меньше, но для эксплуатации этого часто уже хватает. Уязвимости не обязательно нужно испортить весь рантайм. Иногда достаточно сломать один объект, который потом уйдёт в чувствительный код.

proto, prototype и constructor.prototype​

На этой теме часто спотыкаются даже те, кто уже сталкивался с Prototype Pollution.

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

2.webp


Откуда берётся Prototype Pollution​

Небезопасное рекурсивное слияние​

Самый частый источник - самописное глубокое слияние объектов. Код выглядит настолько обычным, что его почти не замечают на ревью.
JavaScript:
function isPlainObject(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    const src = source[key];
    const dst = target[key];

    if (isPlainObject(src) && isPlainObject(dst)) {
      deepMerge(dst, src);
    } else {
      target[key] = src;
    }
  }

  return target;
}
На нормальных данных это просто служебная функция. Проблема начинается, когда входной объект содержит специальный путь к прототипу.
JavaScript:
const payload = JSON.parse('{"__proto__":{"visible":true}}');

const state = {};
deepMerge(state, payload);

console.log(state.visible);     // true
console.log(({}).visible);      // true
console.log({ test: 1 }.visible); // true
Здесь полезно остановиться на механике. Сам JSON.parse() ещё не делает ничего “магического”. Он всего лишь создаёт объект, у которого есть собственное поле proto. Опасность появляется позже, когда этот объект подхватывает код, который рекурсивно пишет по ключам и тем самым сворачивает обычную строку proto в переход к прототипу.

Это и есть одна из причин, почему баг живёт так долго. Разработчик смотрит на JSON.parse() - там всё спокойно. Смотрит на deepMerge() - тоже вроде без криминала. Проблема рождается на стыке.

Параметры URL и разбор пути​

Во фронтенде источник pollution часто сидит в коде, который вообще не воспринимается как опасный. Например, страница умеет превращать query string во вложенный объект настроек.
JavaScript:
function setByPath(target, path, value) {
  const parts = path.split(".");
  let cursor = target;

  for (let i = 0; i < parts.length - 1; i++) {
    const key = parts[i];
    if (!cursor[key] || typeof cursor[key] !== "object") {
      cursor[key] = {};
    }
    cursor = cursor[key];
  }

  cursor[parts[parts.length - 1]] = value;
}

const params = {};
setByPath(params, "__proto__.theme", "dark");
Такой код очень любят за универсальность. Он умеет раскладывать a.b.c=value, работает для фильтров, настроек и форм. Проблема в том, что без фильтрации специальных ключей он умеет не только строить вложенные объекты, но и уходить в прототипную цепочку.

Отдельный класс таких багов живёт в старых разборщиках query string и location.hash, особенно если проекту много лет и часть клиентских зависимостей никто давно не пересматривал.

postMessage, JSON и глубокие копии​

Ещё одна неприятная поверхность - сообщения между окнами, iframe и виджетами. postMessage сам по себе не делает Prototype Pollution, но как только полученные данные попадают в универсальную функцию слияния или раскладку по путям, история повторяется.

Типовой сценарий я виж так:
  • виджет принимает JSON из postMessage;
  • данные превращаются в объект;
  • объект сливается с текущими настройками компонента;
  • один из ключей уводит запись в прототип.
В больших фронтенд-сборках это особенно неудобно расследовать, потому что источник находится в одном месте, а эффект проявляется в другом, иногда через несколько библиотечных слоёв.

Как pollution становится клиентской эксплуатацией​

Prototype Pollution сама по себе ещё не XSS. Чтобы она стала XSS, нужен участок кода, который прочитает загрязнённое свойство и передаст его в чувствительное место. Такой участок обычно и называют гаджетом.

DOM XSS через innerHTML​

Самый понятный пример - конфигурационный объект, в котором приложение читает html, если оно задано.
JavaScript:
function renderCard(options = {}) {
  const container = document.getElementById("card");

  if (options.html) {
    container.innerHTML = options.html;
    return;
  }

  container.textContent = "Default content";
}
С виду это обычный код. Разработчик ждёт, что options будет либо пустым, либо с явно заданными полями. Но если раньше в приложении уже произошло загрязнение прототипа, пустой объект перестаёт быть по-настоящему пустым.
JavaScript:
const payload = JSON.parse('{"__proto__":{"html":"<img src=x onerror=alert(1)>"}}');
deepMerge({}, payload);

renderCard({});
Ключевая проблема тут не в innerHTML как таковом. Она в том, что код читает необязательное свойство у объекта настроек, не проверяя, откуда это свойство пришло - из самого объекта или из прототипа.

На ревью такие места проще узнавать по трём признакам сразу:
  • создаётся пустой объект настроек;
  • часть полей не инициализируется явно;
  • чтение идёт как обычное if (options.html), без проверки принадлежности самому объекту.
В статье: "XSS-атака без секретов: от простого alert до захвата сессии" мы подробнее разобрали тему DOM XSS: какие бывают XSS-цепочки, чем они отличаются и какие защитные меры реально работают.

Подмена script.src​

Во фронтенде опасны не только HTML-вставки. Нередко загрязнение доезжает до загрузки скриптов.
JavaScript:
function loadWidget(options = {}) {
  const script = document.createElement("script");
  script.src = options.src || "/static/widget.js";
  document.head.appendChild(script);
}
Такой фрагмент часто выглядит даже аккуратно: есть значение по умолчанию, есть отдельная функция загрузки. Но если options.src подхватится из прототипа, код поведёт себя так, будто адрес скрипта задан штатным образом.
JavaScript:
const payload = JSON.parse('{"__proto__":{"src":"https://example.invalid/widget.js"}}');
deepMerge({}, payload);

loadWidget({});
Это полезный пример не только для охоты за XSS. Тут хорошо показано, почему Prototype Pollution опасна для фронтенда с большим количеством сторонних библиотек, аналитики, рекламных скриптов и виджетов. Там конфигурационные объекты встречаются везде, а многие поля остаются необязательными.

Обработчики событий и поля обратного вызова​

Есть ещё один класс гаджетов, который часто упускают. Приложение не пишет в DOM напрямую, но использует значение из объекта настроек как функцию, имя обработчика, строку для таймера или параметр инициализации сторонней библиотеки.

Можно так:
JavaScript:
function setupTimer(options = {}) {
  if (options.delayHandler) {
    setTimeout(options.delayHandler, 100);
  }
}
Или так:
JavaScript:
function bindAction(button, options = {}) {
  if (options.onClick) {
    button.setAttribute("onclick", options.onClick);
  }
}
Чем больше в приложении самописных виджетов, обвязок над сторонними библиотеками и “гибких” конфигурационных слоёв, тем больше шансов найти именно такие гаджеты, а не учебный innerHTML.

Как это выглядит в коде SPA​

Если свести типовой клиентский сценарий к минимуму, получится что-то такое:
JavaScript:
function parseHash() {
  const result = {};
  const raw = location.hash.slice(1);

  for (const pair of raw.split("&")) {
    const [key, value] = pair.split("=");
    if (key) {
      setByPath(result, decodeURIComponent(key), decodeURIComponent(value || ""));
    }
  }

  return result;
}

function initWidget() {
  const userOptions = parseHash();
  const defaults = { theme: "light" };

  deepMerge(defaults, userOptions);
  renderCard({});
}
Нам интересна не сама функция parseHash(), а общий рисунок:
  • данные пришли из URL;
  • разложились по путям;
  • слились в объект настроек;
  • другой участок кода прочитал “пустой” объект, унаследовавший загрязнённое поле.
Именно так Prototype Pollution и живёт в реальных интерфейсах. Не в одном эффектном фрагменте, а в нескольких обычных функциях, каждая из которых по отдельности выглядит безобидно.

Server-side: где начинается опасная часть​

На сервере Prototype Pollution неприятнее по двум причинам. Во-первых, загрязнение живёт в процессе до перезапуска. Во-вторых, даже аккуратная проверка может случайно превратиться в отказ в обслуживании, если задеть не тот путь и не тот гаджет.

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

Безопасный индикатор на Express​

Ниже минимальный пример, где pollution сначала попадает в процесс, а потом проявляется в другом обработчике.
JavaScript:
const express = require("express");
const app = express();

app.use(express.json());

function isPlainObject(value) {
  return value !== null && typeof value === "object" && !Array.isArray(value);
}

function deepMerge(target, source) {
  for (const key of Object.keys(source)) {
    const src = source[key];
    const dst = target[key];

    if (isPlainObject(src) && isPlainObject(dst)) {
      deepMerge(dst, src);
    } else {
      target[key] = src;
    }
  }

  return target;
}

app.post("/api/profile", (req, res) => {
  const profile = {};
  deepMerge(profile, req.body);
  res.json({ saved: true });
});

app.get("/api/ping", (req, res) => {
  const options = {};
  res.status(options.status || 200).json({ ok: true });
});

app.listen(3000);
Если POST /api/profile доедет до прототипа и положит туда status, следующий запрос к GET /api/ping внезапно начнёт отвечать уже не тем кодом, который ждали в приложении.

Это очень полезный практический кусок по двум причинам. Во-первых, он показывает server-side pollution без опасной операционки. Во-вторых, сразу видно главное отличие от клиентской части: загрязнение не исчезает после одного рендера. Оно остаётся жить в процессе и начинает влиять на последующие запросы.

Почему child_process становится гаджетом​

Теперь можно переходить к серверным эффектам посерьёзнее.

Опасность child_process не в том, что разработчик обязательно запускает внешнюю команду с пользовательской строкой. Для Prototype Pollution это даже не нужно. Проблема начинается там, где код передаёт в API запуска процесса пустой или неполный объект опций и рассчитывает, что недостающие поля просто останутся не заданными.
JavaScript:
const { execFile } = require("node:child_process");

function convertImage(inputPath, outputPath, options = {}) {
  return execFile(
    "convert",
    [inputPath, outputPath],
    options
  );
}
Снаружи это выглядит как чистая функция-обёртка. Но если options получит чувствительные поля из прототипа, поведение вызова может измениться. Именно поэтому в server-side разборе Prototype Pollution так много внимания уделяют не строкам команд, а конфигурационным объектам вокруг них.

Шаблонизаторы и скрытая конфигурация​

С шаблонизаторами история похожая. Проблема обычно не в том, что приложению напрямую подсовывают произвольный шаблон. Проблема в том, что в render(), compile() или renderFile() передаётся объект настроек, часть которого должна была быть пустой или безопасной по умолчанию.
JavaScript:
function renderPage(engine, template, data, options = {}) {
  return engine.render(template, data, options);
}
Если раньше в процессе уже произошло загрязнение прототипа, options может внезапно получить поля, которые влияют на работу шаблонизатора - поиск файлов, поведение рендера, режимы компиляции, экранирование или другие параметры.

Особенно больно это находить в больших Node.js-приложениях, где шаблонизатор обёрнут несколькими внутренними слоями, а источник pollution сидит вообще в другой части сервиса.

Если хочется подробнее разобраться с темой безопасности SPA, загляните в нашу статью: "Тестирование безопасности SPA: уязвимости React, Vue, Angular"

Инструменты, которые реально помогают​

Для клиентской части хорошо работают инструменты, которые умеют быстро находить источник pollution и проверять, доезжает ли значение до опасного места в DOM. Здесь особенно полезны DOM Invader и похожие средства для анализа клиентских цепочек. Для первичного скрининга встречаются и более узкие утилиты вроде ppmap.

Для серверной части автоматизация полезна, но не стоит ждать от неё чуда. Наружный скан может подсветить безопасные индикаторы - странный код ответа, поведение JSON, неожиданные заголовки. Настоящая ценность обычно появляется при разборе зависимостей, обвязок над child_process, шаблонизаторов и функций слияния данных.

Инструменты здесь экономят время, но не заменяют понимание формы бага. Prototype Pollution слишком часто живёт в стыках между слоями, чтобы её можно было полностью отдать одному сканеру.

Как чинить это без косметики​

3.webp

Не делать “универсальное” глубокое слияние пользовательских объектов​

Первое и самое полезное исправление - перестать бездумно вливать произвольный пользовательский объект в структуру приложения. Вместо “умного” общего deepMerge() почти всегда лучше работает явное извлечение разрешённых полей.
JavaScript:
function normalizeProfile(input) {
  return {
    displayName: typeof input.displayName === "string" ? input.displayName : "",
    theme: input.theme === "dark" ? "dark" : "light",
    pageSize: Number.isInteger(input.pageSize) ? input.pageSize : 20
  };
}
Такой код менее “изящный”, чем универсальный merge helper, зато здесь нет магии. Разрешённые поля видны сразу, остальные просто не попадут дальше.

Не использовать обычные объекты как словари​

Если нужна структура “ключ-значение” с пользовательскими ключами, лучше брать Map или объект без прототипа.
JavaScript:
const safeDict = Object.create(null);
safeDict.userTheme = "dark";
Или так:
JavaScript:
const cache = new Map();
cache.set("user:42", { theme: "dark" });
Обычный объект слишком легко превращается из удобного контейнера в поверхность атаки.

Проверять принадлежность поля самому объекту​

Если приложение читает необязательные свойства у объекта настроек, полезно проверять, что поле реально принадлежит самому объекту, а не прототипу.
JavaScript:
function renderCard(options = {}) {
  const container = document.getElementById("card");

  if (Object.hasOwn(options, "html")) {
    container.innerHTML = options.html;
    return;
  }

  container.textContent = "Default content";
}
Это не универсальная защита от всех проблем вокруг Prototype Pollution, но очень полезный барьер в местах, где код использует конфигурационные объекты.

Почему заморозка прототипа не поможет​

Идея с Object.freeze(Object.prototype) регулярно всплывает как быстрое средство защиты. Как дополнительный барьер она полезна, но не решает архитектурную проблему сама по себе. У неё есть цена: совместимость, порядок инициализации, поведение полифилов, неожиданные побочные эффекты в старом коде.

Если приложение уже массово опирается на универсальные merge helper’ы, пустые объекты настроек и динамическую запись по путям, заморозка прототипа будет скорее аварийным тормозом, чем нормальным исправлением.

Вместо заключения​

Как только в приложении есть путь записи в прототип и участок кода, который читает несуществующее свойство как допустимое значение по умолчанию, дальше уже начинается не “теоретическая особенность JavaScript”, а вполне рабочая поверхность атаки. На клиенте она уходит в DOM XSS и подмену загрузки. На сервере - в управление поведением процесса, фреймворка и системных вызовов.

Хорошая защита начинается не с поиска одного запретного ключа. Она начинается там, где команда перестаёт воспринимать универсальный объект как безопасный контейнер для всего подряд.
 
Последнее редактирование:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

Похожие темы