Prototype pollution без загрязнения: как атакуют прототипы JavaScript

📋 Кратко

Prototype pollution обычно связывают с изменением глобального прототипа, но опасная логика может проявляться и без нового загрязнения. Унаследованное свойство, проверка через in или неверная работа с constructor и prototype способны изменить поведение приложения. Разбираем механизм, безопасное воспроизведение, последствия и защиту JavaScript-кода.

⏱ 11 минут чтениясложность

Prototype pollution — уязвимость, при которой недоверенные данные меняют свойства общего прототипа, а затем влияют на множество объектов приложения. В JavaScript это особенно важно из-за модели наследования: объект получает свойства не только из собственной записи, но и из цепочки прототипов. Если код принимает унаследованное значение за собственное, обычная настройка превращается в источник логической ошибки.

Формулировка «prototype pollution без загрязнения» требует уточнения. Это не отдельный общепринятый класс уязвимостей. Так условно называют сценарии, где атакующий использует уже существующее свойство прототипа, ошибочную проверку или прямой доступ к цепочке наследования, не добавляя новое поле в Object.prototype. Риск остаётся реальным, но его механизм отличается от классического загрязнения.

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

🔍 Как работает прототипная цепочка

Прототип — объект, свойства которого доступны другому объекту через механизм наследования. Внутренняя связь обычно обозначается как [[Prototype]], а поле __proto__ предоставляет к ней доступ в распространённых сценариях. Когда JavaScript ищет свойство, он сначала проверяет сам объект. Если поля нет, поиск продолжается в его прототипе, затем в следующем звене цепочки.

Уязвимость требует источника, обработки и рабочего гаджета
Уязвимость требует источника, обработки и рабочего гаджета

Простая модель хорошо видна на наследовании классов. Базовый класс Pizza задаёт свойства base и cheese. Класс Pepperoni наследует их и добавляет sausage. Объект Pepperoni получает доступ ко всем трём значениям, хотя часть из них находится не в собственной записи экземпляра. Класс Marinara может переопределить cheese, а VeganPepperoni — изменить sausage и добавить собственное поле.

Цепочка становится проблемой, когда бизнес-логика считает доступное значение доверенным. Например, проверка параметра конфигурации может увидеть enabled в прототипе и решить, что пользователь явно включил функцию. Сам объект при этом не содержит такого поля. Разница между «поле найдено» и «поле принадлежит объекту» лежит в основе многих ошибок.

🧩 Загрязнение и злоупотребление наследованием

Классический prototype pollution начинается с источника — недоверенных данных, которые попадают в функцию слияния, разбора пути или динамической записи свойств. Затем нужен опасный участок кода, который допускает изменение общего прототипа. Наконец, приложение должно использовать изменённое поле без достаточной фильтрации. В англоязычной терминологии эти роли часто описывают как source, sink и exploitable gadget.

Динамические пути превращают служебные имена в риск
Динамические пути превращают служебные имена в риск

В уязвимом сценарии атакующий не получает эффект автоматически. Одного факта изменения прототипа недостаточно. Нужен «гаджет» — безопасный на вид участок приложения, который читает унаследованное поле и использует его в чувствительной операции. Таким гаджетом может стать проверка разрешения, создание объекта с параметрами, выбор режима обработки или формирование ответа.

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

Не смешивайте два диагноза: прямое изменение прототипа и чтение унаследованного свойства требуют разных исправлений. В первом случае закрывают путь записи и опасные ключи. Во втором — меняют проверки, структуру данных и правила доверия к полям.

⚙️ Почему опасны Object.prototype, constructor и prototype

Object.prototype находится в верхней части обычной цепочки объектов. Свойство, добавленное туда, потенциально становится видимым для большого числа объектов, которые не создаются через Object.create(null) и не переопределяют это имя. Поэтому глобальный прототип рассматривают как чувствительную область, а не как обычный словарь приложения.

Безопасный PoC проверяет гипотезу без вредного воздействия
Безопасный PoC проверяет гипотезу без вредного воздействия

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

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

Нельзя автоматически переносить последствия на серверную платформу или фреймворк. Prototype pollution относится к поведению JavaScript-кода и используемых библиотек. Конкретный эффект зависит от того, как приложение создаёт объекты, какие функции выполняют слияние и где находятся гаджеты. Сервер, библиотека и фреймворк могут усиливать риск, но не являются взаимозаменяемыми причинами.

🎯 Как обходят проверки через унаследованные поля

Одна из типичных ошибок — проверка принадлежности через оператор in. Выражение key in object возвращает истину, если поле найдено в самом объекте или где-либо в его прототипной цепочке. Для проверки именно собственной записи применяется Object.hasOwn(object, key). Он явно показывает намерение: приложение принимает только поле, записанное в текущий объект.

Строгая схема данных отделяет ввод от служебного состояния
Строгая схема данных отделяет ввод от служебного состояния

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

Похожая ситуация возникает при объединении пользовательских параметров с настройками по умолчанию. Разработчик ожидает, что отсутствующее поле получит безопасное значение. Но чтение через цепочку может вернуть значение из прототипа. В результате приложение использует неявную настройку, которую пользователь не передавал напрямую.

Проверку нужно проводить не только на границе HTTP-запроса. Объект может прийти из JSON, хранилища, очереди сообщений, параметров командной строки или внутреннего преобразователя. На каждом этапе важно понимать, какие поля являются собственными, какие допустимы по схеме и может ли структура сохранить ссылку на неожиданный прототип.

🛠️ Опасные конструкции в прикладном коде

Наиболее рискованная конструкция — рекурсивное слияние объектов без списка разрешённых ключей. Функция проходит по входным свойствам, создаёт вложенные объекты и записывает значения по динамическим именам. Если она не исключает служебные имена и не контролирует целевой объект, пользовательские данные начинают влиять на структуру, которой должен управлять только код приложения.

Аудит подтверждает реальный путь риска, а не подозрение
Аудит подтверждает реальный путь риска, а не подозрение

Отдельно проверяют функции, которые принимают путь вроде последовательности ключей и записывают значение в конечный узел. Такой механизм удобен для обновления настроек, но опасен при произвольном вводе. Без схемы путей разработчик не знает, останется ли запись в пределах ожидаемого объекта или затронет наследование.

Проблемным становится и копирование через цикл for...in. Этот цикл перечисляет не только собственные, но и перечисляемые унаследованные свойства. Если задача состоит в переносе данных из одного объекта в другой, цикл может скопировать больше полей, чем предполагалось. Для доверенного формата используют явный список ключей либо проверяют каждое поле через Object.hasOwn().

Нельзя считать безопасным простое удаление поля после слияния. К моменту очистки функция уже могла прочитать значение, вызвать обработчик или передать объект другому компоненту. Защиту размещают до опасной операции: проверяют схему, запрещают непредусмотренные имена и выбирают контейнер, который не наследует свойства от обычного объекта.

🔬 Безопасный PoC в изолированной среде

Для проверки гипотезы достаточно локального примера без сетевого доступа, реальных учётных данных и вредоносных полезных нагрузок. Цель такого PoC — показать разницу между собственной и унаследованной записью. Тест запускают в отдельном процессе или учебном проекте, который не подключён к рабочим сервисам и не обрабатывает внешние запросы.

Безопасная демонстрация может выглядеть так:

const settings = Object.create(null);
const inherited = Object.create({ enabled: true });

console.log("enabled" in inherited);        // проверка цепочки
console.log(Object.hasOwn(inherited, "enabled")); // собственное поле
console.log(Object.hasOwn(settings, "enabled"));   // отдельный словарь

В примере не изменяется Object.prototype, не используется динамическая запись по пользовательскому пути и не затрагивается сеть. Он показывает, почему две проверки дают разные результаты. В реальном тесте добавляют только безвредный маркер, контролируют версию среды и фиксируют состояние прототипов до и после выполнения.

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

Критерии безопасного воспроизведения: только локальный стенд, синтетические данные, отсутствие сетевого доступа, отсутствие команд на стороне системы, фиксация исходного состояния прототипов и очистка процесса после теста.

⚠️ Какие последствия возникают у приложения

Последствия определяет не название уязвимости, а цепочка source → обработка объекта → gadget. При ошибке в проверке приложение может неверно определить роль пользователя, применить неподходящий параметр конфигурации или пропустить обязательное поле. Это приводит к логическим ошибкам и обходу отдельных ограничений, но не доказывает обход всей модели авторизации.

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

В клиентском приложении неправильно обработанное свойство способно изменить поведение интерфейса или подготовку запроса. На сервере эффект может затронуть обработку параметров, формирование ответа и работу middleware. Удалённое выполнение команд не следует считать неизбежным результатом prototype pollution: для него нужна отдельная уязвимая цепочка и соответствующий гаджет.

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

🛡️ Защита на уровне данных и объектов

Первый защитный слой — строгая схема входных данных. Приложение заранее задаёт допустимые поля, типы и глубину вложенности. Неизвестные ключи отклоняются или удаляются до передачи в функции слияния. Имена __proto__, constructor и prototype блокируются там, где они не нужны по модели данных.

Для словарей без поведения прототипа применяют Object.create(null). Такой объект не наследует свойства от Object.prototype, поэтому он лучше подходит для таблиц ключ-значение. Однако это не универсальная замена обычным объектам: код должен корректно работать с отсутствующими методами и использовать безопасные операции чтения.

При проверке собственных полей используют Object.hasOwn(). При перечислении данных избегают безусловного for...in и явно проверяют принадлежность каждого ключа. Для чувствительных структур полезно разделять пользовательские данные и служебные настройки, а не объединять их в один изменяемый объект.

В подходящих сценариях прототипы и конфигурацию можно замораживать после инициализации. Этот метод ограничивает изменения, но требует проверки совместимости: некоторые библиотеки рассчитывают на возможность расширения объектов. Заморозка не заменяет валидацию входа и не исправляет небезопасное чтение унаследованных полей.

🔒 Безопасное слияние и проверка зависимостей

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

Готовая библиотека не становится безопасной только из-за популярности. Команда проверяет документацию, историю исправлений и тесты на опасные имена. В проекте фиксируют версии зависимостей, включают анализ состава программного обеспечения и отслеживают сообщения о prototype pollution в используемых пакетах. Обновление выполняют после проверки совместимости и собственных регрессионных тестов.

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

Статический анализ и автоматическое сканирование помогают найти подозрительные места, но не доказывают эксплуатируемость. Сканер может обнаружить опасную зависимость, а ручная проверка должна подтвердить наличие source, sink и gadget. И наоборот, отсутствие сигнала в автоматическом отчёте не исключает логическую ошибку в собственной проверке приложения.

📋 Чек-лист аудита JavaScript-приложения

Аудит начинают с инвентаризации всех мест, где создаются и объединяются объекты. Ищут динамическую запись ключей, рекурсивные слияния, обработку пользовательских путей и циклы перечисления. Затем прослеживают данные от источника до функции, которая использует результат в авторизации, конфигурации, сериализации или выборе обработчика.

  • Проверить, отличает ли код собственные поля от унаследованных.
  • Заменить проверки через in там, где требуется именно принадлежность объекту.
  • Проверить использование for...in при копировании и фильтрации.
  • Найти динамические обращения к __proto__, constructor и prototype.
  • Ограничить входные ключи схемой и запретить неизвестные имена.
  • Отделить пользовательские данные от служебных настроек.
  • Проверить функции слияния на изменение общих прототипов.
  • Добавить тесты на унаследованные поля и неожиданные типы.
  • Проверить зависимости и зафиксировать безопасные версии.
  • Подтвердить реальный gadget, а не только наличие подозрительной функции.

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

Практическое правило: безопасным считается не тот код, где «нет подозрительного ключа», а тот, где входные данные проходят схему, объектные операции имеют явные границы, а приложение не принимает наследуемые значения за настройки пользователя.

✅ Как выстроить исправление и контроль

Исправление начинают с минимального безопасного изменения: закрывают конкретный путь записи или чтения, добавляют тест, а затем проверяют все аналогичные места. Если проблема связана с зависимостью, обновляют пакет до версии с исправлением и анализируют собственные вызовы. Простая замена одной функции без проверки цепочки данных может оставить тот же gadget в другом модуле.

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

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

Prototype pollution без нового загрязнения напоминает о более общем принципе: наследование — часть логики безопасности, а не только удобный механизм программирования. Контроль собственных свойств, строгие схемы и изоляция словарей снижают риск даже там, где классический сценарий изменения глобального прототипа уже закрыт.

📚 Читайте также

📖 Термины

OWASP · Proof-of-Concept (PoC) · Автоматизированное сканирование · Безопасность приложений

🔗 Источники