Prototype Pollution редко выглядит как громкая уязвимость. Обычно всё начинается с куска кода, который никто не считает опасным: функция слияния объектов, разбор параметров в структуру настроек, универсальный helper для deep copy, пустой config-объект, который потом уходит в рендер, загрузчик скрипта, шаблонизатор или системный вызов. До первого инцидента это воспринимается как нормальная инженерная обвязка. Проблема в том, что ошибка живёт не в одном конкретном месте. Один фрагмент принимает данные, другой пишет по ключу, третий позже читает унаследованное свойство как штатный параметр. В итоге баг возникает не на уровне одной функции, а на стыке нескольких безобидных решений.
Этим Prototype Pollution и неприятна. По отдельности JSON.parse(), Object.assign(), разбор location.hash, рендер компонента или вызов child_process могут выглядеть совершенно спокойно. Но если пользовательское значение однажды доехало до прототипа, приложение начинает работать с чужими свойствами как со своими. На клиенте это выливается в DOM XSS, подмену src, srcdoc, обработчиков и конфигурации виджетов. На сервере - в подмену параметров фреймворка, шаблонизатора, промежуточного обработчика или системного API. Именно поэтому Prototype Pollution плохо ловится интуицией: источник находится в одном слое, эффект проявляется в другом, а между ними часто лежит обычный служебный код, который годами не вызывал подозрений.
Эта уязвимость редко живёт как один баг в одном месте. Обычно это цепочка из трёх частей:
- источник управляемых данных;
- путь записи в прототип;
- участок кода, который позже использует загрязнённое свойство.
Где ломается модель объектов
Почему пустой объект не пустой
У обычного объекта в JavaScript есть собственные поля и прототип. Когда код читает свойство, движок сначала ищет его в самом объекте, потом поднимается по цепочке прототипов. Поэтому пустой {} на деле не совсем пустой - он наследует поведение от Object.prototype.Это нормальная механика языка. Проблема начинается, когда приложение даёт записать данные не в сам объект, а в его прототип. Тогда новые объекты начинают наследовать уже не только штатные свойства, но и то, что туда кто-то подложил.
Если загрязнён Object.prototype, эффект расходится по большому куску приложения сразу. Если меняется прототип конкретного объекта настроек, масштаб меньше, но для эксплуатации этого часто уже хватает. Уязвимости не обязательно нужно испортить весь рантайм. Иногда достаточно сломать один объект, который потом уйдёт в чувствительный код.
proto, prototype и constructor.prototype
На этой теме часто спотыкаются даже те, кто уже сталкивался с Prototype Pollution.__proto__ - исторический доступ к прототипу объекта.prototype - свойство функции-конструктора, из которого создаются прототипы экземпляров.
constructor.prototype - обходной путь к тому же объекту.
Откуда берётся 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() - там всё спокойно. Смотрит на 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");
Отдельный класс таких багов живёт в старых разборщиках 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";
}
JavaScript:
const payload = JSON.parse('{"__proto__":{"html":"<img src=x onerror=alert(1)>"}}');
deepMerge({}, payload);
renderCard({});
На ревью такие места проще узнавать по трём признакам сразу:
- создаётся пустой объект настроек;
- часть полей не инициализируется явно;
- чтение идёт как обычное 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);
}
JavaScript:
const payload = JSON.parse('{"__proto__":{"src":"https://example.invalid/widget.js"}}');
deepMerge({}, payload);
loadWidget({});
Обработчики событий и поля обратного вызова
Есть ещё один класс гаджетов, который часто упускают. Приложение не пишет в 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);
}
}
Как это выглядит в коде 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({});
}
- данные пришли из URL;
- разложились по путям;
- слились в объект настроек;
- другой участок кода прочитал “пустой” объект, унаследовавший загрязнённое поле.
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);
Это очень полезный практический кусок по двум причинам. Во-первых, он показывает 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
);
}
Шаблонизаторы и скрытая конфигурация
С шаблонизаторами история похожая. Проблема обычно не в том, что приложению напрямую подсовывают произвольный шаблон. Проблема в том, что в render(), compile() или renderFile() передаётся объект настроек, часть которого должна была быть пустой или безопасной по умолчанию.
JavaScript:
function renderPage(engine, template, data, options = {}) {
return engine.render(template, data, options);
}
Особенно больно это находить в больших Node.js-приложениях, где шаблонизатор обёрнут несколькими внутренними слоями, а источник pollution сидит вообще в другой части сервиса.
Если хочется подробнее разобраться с темой безопасности SPA, загляните в нашу статью: "Тестирование безопасности SPA: уязвимости React, Vue, Angular"
Инструменты, которые реально помогают
Для клиентской части хорошо работают инструменты, которые умеют быстро находить источник pollution и проверять, доезжает ли значение до опасного места в DOM. Здесь особенно полезны DOM Invader и похожие средства для анализа клиентских цепочек. Для первичного скрининга встречаются и более узкие утилиты вроде ppmap.Для серверной части автоматизация полезна, но не стоит ждать от неё чуда. Наружный скан может подсветить безопасные индикаторы - странный код ответа, поведение JSON, неожиданные заголовки. Настоящая ценность обычно появляется при разборе зависимостей, обвязок над child_process, шаблонизаторов и функций слияния данных.
Инструменты здесь экономят время, но не заменяют понимание формы бага. Prototype Pollution слишком часто живёт в стыках между слоями, чтобы её можно было полностью отдать одному сканеру.
Как чинить это без косметики
Не делать “универсальное” глубокое слияние пользовательских объектов
Первое и самое полезное исправление - перестать бездумно вливать произвольный пользовательский объект в структуру приложения. Вместо “умного” общего 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
};
}
Не использовать обычные объекты как словари
Если нужна структура “ключ-значение” с пользовательскими ключами, лучше брать 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";
}
Почему заморозка прототипа не поможет
Идея с Object.freeze(Object.prototype) регулярно всплывает как быстрое средство защиты. Как дополнительный барьер она полезна, но не решает архитектурную проблему сама по себе. У неё есть цена: совместимость, порядок инициализации, поведение полифилов, неожиданные побочные эффекты в старом коде.Если приложение уже массово опирается на универсальные merge helper’ы, пустые объекты настроек и динамическую запись по путям, заморозка прототипа будет скорее аварийным тормозом, чем нормальным исправлением.
Вместо заключения
Как только в приложении есть путь записи в прототип и участок кода, который читает несуществующее свойство как допустимое значение по умолчанию, дальше уже начинается не “теоретическая особенность JavaScript”, а вполне рабочая поверхность атаки. На клиенте она уходит в DOM XSS и подмену загрузки. На сервере - в управление поведением процесса, фреймворка и системных вызовов.Хорошая защита начинается не с поиска одного запретного ключа. Она начинается там, где команда перестаёт воспринимать универсальный объект как безопасный контейнер для всего подряд.
Последнее редактирование: