Суть проста: ты отправляешь JSON на сервер, а он автоматически маппит все поля на модель. И если разработчик забыл поставить фильтр, любое лишнее поле - например, "is_admin": true - становится частью твоей учётной записи. И вот ты уже не просто пользователь, а администратор. Или можешь менять цены. Или читать чужие данные. В общем, полный фарш.
Казалось бы, в 2026 году об этом должны знать все. Но нет. Баг-баунти платформы ломятся от таких отчётов, а крупные проекты продолжают платить за эту детскую ошибку. И дело не в глупости разработчиков, а в системных причинах: наследие, скорость, недостаток знаний, слепая вера в автоматизацию.
В этой статье мы разберём масс-ассигнмент под микроскопом. Мы покажем, как его искать на разных языках и фреймворках, какие инструменты юзать, чтобы фаззить параметры, и как от него защищаться так, чтобы спать спокойно. Поговорим о вложенных атрибутах, JSONB полях, GraphQL мутациях - обо всех тёмных уголках, где может прятаться эта зараза.
Глава 1. Исповедь бывшего админа, или Как я перестал бояться и полюбил блек-лист
Знаешь, в чём главная причина масс-ассигнмента? В том, что разработчики мыслят в категориях "удобство кода", а не "безопасность данных". Когда ты пишешь User.create(params[:user]), тебе кажется, что это красиво, лаконично, по-рельсовому. Ты не думаешь о том, что клиент может прислать что-то лишнее. Ты доверяешь клиенту. А доверять клиенту в вебе - последнее дело.Вторая причина - фреймворки сами провоцируют такое мышление. Они рекламируют "convention over configuration", "scaffolding", "rapid development". И это круто для прототипов. Но когда прототип становится продакшеном, а код остаётся тем же, начинаются проблемы. Разработчики просто забывают выключить "режим бога".
Третья причина - отсутствие культуры code review и тестирования безопасности. Если в команде нет человека, который хоть раз в жизни видел масс-ассигнмент, он пройдёт код ревью. Все посмотрят на красивый контроллер и скажут: "Вау, как чисто! Мержим".
Эпидемия масс-ассигнмента в 2010-х
В начале 2010-х, когда Rails был на пике популярности, масс-ассигнмент был буквально эпидемией. Каждый второй стартап на Rails имел эту дыру.Rails достаточно быстро среагировал. В версиях 2.x и 3.x появились методы attr_accessible и attr_protected в моделях.
- attr_accessible - белый список. Ты перечисляешь поля, которые можно менять через масс-ассигнмент.
- attr_protected - чёрный список. Ты перечисляешь поля, которые нельзя менять.
Ruby:
class User < ActiveRecord::Base
attr_protected :admin
end
И думали, что защитились. Но проходило время, в модель добавляли поле superadmin, про него забывали, и оно оставалось незащищённым. Хакер отправлял superadmin: true и получал супер-пупер права. Именно поэтому белый список всегда лучше: ты явно говоришь, что можно, а всё остальное по умолчанию запрещено.
Но и у attr_accessible были проблемы. Во-первых, он жил в модели. А модель не знает контекста. Например, в админке ты хочешь менять поле admin, а в обычном API - нет. С одним attr_accessible это не сделать, приходилось городить костыли.
Во-вторых, многие просто забывали его добавлять. И тогда по умолчанию все поля были доступны. Опять дыра.
В статье: "Тестирование REST API на безопасность" мы рассматрели практический сценарий тестирования REST‑API, где помимо других уязвимостей была обнаружена возможность массового присвоения параметров, позволяющая обычному пользователю зарегистрировать себя как админа.
Инцидент с GitHub (2012) - момент истины
Ты наверняка слышал про этот случай. В марте 2012 года хакер Егор Хомзаков (Homakov) нашёл масс-ассигнмент в GitHub. Он отправил запрос на изменение публичного ключа, добавив поле, которое делало ключ публичным (хотя должен был быть приватным). GitHub использовал Rails 3.0.x, где ещё не было strong parameters, и полагался на attr_protected. Но они забыли защитить одно поле.Хомза не просто нашёл баг - он устроил скандал. Он написал пост, где объяснил проблему и критиковал подход Rails к безопасности. Многие разработчики встали на его сторону, многие - против. Но факт остаётся фактом: после этого инцидента в Rails 4 появились strong parameters, которые вынесли фильтрацию параметров из модели в контроллер.
Strong parameters - это когда ты в контроллере пишешь:
Ruby:
def user_params
params.require(:user).permit(:email, :password)
end
И потом используешь User.new(user_params). Это гениально просто: ты явно видишь, что разрешено, прямо в месте использования. И нет никакой магии в модели.
Эволюция: от Rails к другим языкам
После того как Rails показал пример, другие фреймворки тоже подтянулись. Laravel ввёл $fillable и $guarded в моделях. Django REST Framework требует явно указывать поля в сериализаторах. Spring (Java) - там всё сложнее, потому что используется десериализация Jackson'ом, и защита ложится на DTO или аннотации. Но везде принцип один: белый список рулит.Однако, чёрт возьми, сколько ещё легаси-проектов живёт с открытыми ранами? Я сам на баг-баунти нахожу такие вещи до сих пор. И каждый раз удивляюсь: как, ну как в 2026 году можно оставлять User.create(request.body) без фильтрации? А вот можно. И оставляют.
Почему я люблю масс-ассигнмент (как хакер)
Для меня масс-ассигнмент - это как отмычка. Ты приходишь к API, смотришь на стандартные запросы, ищешь, где можно добавить что-то своё. Иногда это прямо на поверхности: поле role, is_admin, verified. Иногда нужно копать глубже: вложенные атрибуты, JSONB поля. Но когда находишь - чувствуешь себя Шерлоком Холмсом. Особенно если это баг-баунти с хорошей наградой.Я помню один случай: тестировал API крупного банка (по программе баг-баунти, всё легально). У них была система переводов между счетами. При создании перевода нужно было указать сумму, валюту, счёт получателя. Я добавил в JSON поле "commission": 0 (комиссия обычно списывалась отдельно). И оно сработало! Перевод прошёл без комиссии. Я мог бы переводить миллионы и не платить ни копейки. Конечно, я сразу сообщил, мне заплатили вознаграждение. А ведь это тоже масс-ассигнмент, просто поле не "is_admin", а "commission".
Масс-ассигнмент - это не просто уязвимость, это симптом. Симптом того, что разработка велась в спешке, без понимания основ безопасности. Симптом того, что разработчик доверился фреймворку больше, чем здравому смыслу. Симптом того, что код не проходил нормальное ревью.Глава 2. Что такое Mass Assignment и почему это не баг, а фича (которую мы сломали)
Знаешь, в мире разработки полно терминов, которые звучат сложно, а на деле оказываются простыми, как три копейки. Mass assignment - как раз такой случай. Если совсем по-простому: это механизм, который позволяет установить несколько атрибутов объекта одной операцией, обычно мапя входящий хеш (JSON, XML, form-data) на поля модели.Фреймворки это обожают. И не зря. Представь, что у тебя модель пользователя с десятью полями. Без масс-ассигнмента тебе пришлось бы писать что-то вроде:
Python:
user = User()
user.username = request.json['username']
user.email = request.json['email']
user.password = request.json['password']
user.first_name = request.json['first_name']
user.last_name = request.json['last_name']
user.phone = request.json['phone']
# ... и так далее, пока не надоест
user.save()
С масс-ассигнментом ты пишешь:
Python:
user = User(**request.json)
user.save()
Или в Laravel:
PHP:
User::create($request->all());
Ruby:
User.create(user_params)
Красота? Да, пока злой хакер не отправит "is_admin": true. И вот тут начинается самое интересное: а кто решил, что is_admin - это поле, которое нельзя менять через API? Никто. Просто разработчик забыл его исключить. Или вообще не знал, что так можно.
Анатомия масс-ассигнмента: как это работает под капотом
Чтобы понять уязвимость, нужно понять механику. Когда клиент отправляет HTTP-запрос (например, PUT /api/users/123) с телом в формате JSON, сервер выполняет следующие шаги:- Парсинг тела запроса. Фреймворк или веб-сервер преобразует JSON в структуру данных родного языка (хеш, словарь, объект). Обычно это делается автоматически с помощью middleware.
- Извлечение параметров. В зависимости от фреймворка, эти данные попадают в специальный объект (например, request в Express, params в Rails, $request в Laravel).
- Маппинг на модель. Здесь и происходит магия. Разработчик вызывает метод вроде User.create(data) или user.update(data), и фреймворк итерирует по всем ключам в переданном хеше и для каждого вызывает сеттер в модели. Если у модели есть поле с таким именем - значение присваивается. Если нет - обычно игнорируется (но не всегда, зависит от фреймворка).
Почему это фича (и очень удобная)
Давай честно: масс-ассигнмент придумали не злые хакеры, а умные разработчики фреймворков, чтобы упростить жизнь другим разработчикам. Вот его основные плюсы:- Скорость разработки. Не нужно писать кучу бойлерплейта для каждого поля. Особенно когда модель большая.
- Читаемость кода. Одна строка вместо десяти - это красиво.
- Автоматическая поддержка новых полей. Добавил поле в модель и в миграцию - оно сразу начинает работать в API (если, конечно, не забыл про фильтры). Удобно для прототипов.
- Интеграция с формами. Во многих фреймворках масс-ассигнмент тесно связан с HTML-формами, где все поля формы автоматически попадают в модель.
Почему это баг (когда забыли про безопасность)
Масс-ассигнмент становится уязвимостью, когда он позволяет изменить поля, которые не должны быть доступны клиенту. Это нарушение принципа наименьших привилегий на уровне данных. Пользователь должен иметь возможность менять только те поля, которые ему разрешены (например, email, пароль, имя), а не все подряд (роль, права доступа, внутренние флаги).Вот типичные сценарии, где это выстреливает:
1. Повышение привилегий (Privilege Escalation)
Самый классический случай. Поле is_admin, role, group_id - если его можно изменить через API, пользователь становится администратором.
2. Изменение финансовых параметров
Поля balance, discount, price, commission. Представь, что в интернет-магазине можно изменить цену товара в корзине перед покупкой. Или в банковском приложении - уменьшить комиссию за перевод.
3. Доступ к чужим данным (IDOR + Mass Assignment)
Иногда масс-ассигнмент позволяет изменить не только свои данные, но и чужие, если есть поле вроде user_id. Например, при создании комментария можно подставить чужой user_id и комментарий запишется от имени другого пользователя.
4. Изменение системных флагов
Поля verified, email_confirmed, active, banned. Если хакер может сам себя верифицировать или разбанить - это проблема.
5. Модификация защищённых атрибутов
Например, поле encrypted_password - обычно не должно меняться напрямую, только через специальный метод. Но если оно доступно для масс-ассигнмента, можно установить свой хеш пароля и зайти без ведома системы.
Классификация масс-ассигнмента (чтобы знать врага в лицо)
Масс-ассигнмент бывает разный, и не все его виды одинаково очевидны. Давай разложим по полочкам.1. Прямой масс-ассигнмент
Самый простой и распространённый. Когда поля модели напрямую маппятся из запроса. Пример: отправили {"name": "Hacker", "is_admin": true} - и оба поля применились.
Ruby:
# Rails
User.create(params[:user])
PHP:
// Laravel
$user->update($request->all());
Python:
# Django REST Framework
serializer = UserSerializer(data=request.data)
if serializer.is_valid():
serializer.save() # сохранит всё, что есть в data, если поля разрешены
2. Вложенный масс-ассигнмент (nested attributes)
Это когда через масс-ассигнмент можно менять не только атрибуты самой модели, но и связанные с ней объекты. Например, у пользователя есть профиль. Если в модели разрешены accepts_nested_attributes_for
JSON:
{
"user": {
"email": "test@test.com",
"profile_attributes": {
"bio": "new bio",
"is_public": true
}
}
}
Если в профиле есть поле is_admin - опять беда. Или можно изменить user_id в профиле, чтобы привязать его к другому пользователю.
В Laravel это называется "отношения" и массовое присвоение тоже работает, если разрешить.
3. Масс-ассигнмент через параметры с точкой (dot notation)
В некоторых фреймворках (особенно старых или при кастомном парсинге) параметры вида user[admin] преобразуются во вложенный хеш. Если бэкенд ожидает плоский JSON, а ты отправляешь form-data с такими ключами, может сработать.Например, в PHP суперглобальный массив $_POST превращает user[admin] в $_POST['user']['admin']. Если разработчик использует extract() или просто подставляет такие значения, возможна атака.
4. Масс-ассигнмент в связках many-to-many
Когда есть связь многие-ко-многим, часто можно передать массив идентификаторов. Например, у пользователя есть группы. Если разрешено массовое присвоение для group_ids, можно добавить себя в группу администраторов, отправив {"group_ids": [1, 2, 3]}.
JSON:
{
"user": {
"email": "test@test.com",
"group_ids": [1, 42] // 42 - группа админов
}
}
5. Масс-ассигнмент с JSON/JSONB полями
Современные базы данных поддерживают JSON-типы. В модели может быть поле settings типа JSONB. Если оно доступно для масс-ассигнмента, можно записать туда что угодно, включая потенциально опасные данные. Например:
JSON:
{
"user": {
"email": "test@test.com",
"settings": {
"theme": "dark",
"is_admin": true // а вот это уже интерпретируется где-то в коде
}
}
}
Хотя само по себе JSONB поле не даёт прав, если приложение читает оттуда is_admin и доверяет ему - это уязвимость.
6. Масс-ассигнмент через кастомные сеттеры
В моделях часто определяют кастомные методы-сеттеры. Например, в Rails:
Ruby:
def admin=(value)
# какая-то логика
write_attribute(:admin, value)
end
Если такой сеттер существует, масс-ассигнмент вызовет его. И если в сеттере нет проверок, поле изменится.
7. Непрямой масс-ассигнмент через параметры запроса
Иногда API использует query parameters для обновления (хотя это не REST-стайл). Например:
Код:
PUT /api/users/123?email=new@test.com&is_admin=true
Масс-ассигнмент при создании (create) и обновлении (update)
Важно понимать, что уязвимость может быть как при создании новых записей, так и при обновлении существующих.- Create: при регистрации нового пользователя можно добавить поле role: admin и сразу стать админом.
- Update: при редактировании профиля можно добавить поле is_admin: true и повысить права.
Масс-ассигнмент и другие уязвимости: гремучая смесь
Масс-ассигнмент редко ходит один. Часто он комбинируется с другими дырами, усиливая их.+ IDOR (Insecure Direct Object References)
Если есть масс-ассигнмент и можно изменить поле user_id в каком-то объекте, то это уже IDOR: ты можешь присвоить объект другому пользователю.
+ NoSQL инъекции
В MongoDB если передать в запросе операторы типа $set, $inc, и бэкенд не фильтрует ключи, можно выполнить произвольные операции. Это уже не совсем масс-ассигнмент, но похоже.
+ SQL инъекции через масс-ассигнмент
Бывает редко, но если поле маппится на имя столбца без экранирования, теоретически возможно. Но это экзотика.
Почему фреймворки не защищают по умолчанию?
Хороший вопрос. Казалось бы, сделай по умолчанию белый список пустым - и никаких проблем. Но тогда сломается всё удобство масс-ассигнмента. Разработчику пришлось бы каждый раз явно перечислять все поля, даже для простых форм. Это бесит.Поэтому фреймворки идут на компромисс: они предлагают механизмы защиты (strong parameters, fillable, DTO), но не включают их насильно. Разработчик сам должен их применить. А если не применил - получает дыру.
Масс-ассигнмент в дикой природе: что говорят цифры
По данным OWASP, масс-ассигнмент (иногда называют "Mass Assignment" или "Auto-binding") входит в различные списки рисков для API. В OWASP API Security Top 10 есть отдельная категория - "Mass Assignment" (API8:2019 - Mass Assignment). В 2023 году она трансформировалась, но суть та же: небезопасное массовое присвоение остаётся проблемой.Статистика баг-баунти платформ показывает, что масс-ассигнмент стабильно входит в топ-10 уязвимостей по частоте. Особенно много его в:
- Старых проектах (написанных до 2015 года).
- Проектах, где используется несколько фреймворков или самописные решения.
- API, которые быстро развивались и не проходили security-ревью.
Глава 3. Исторический экскурс: от Rails до наших дней
Эпоха "дикого запада" (Rails 1.x - 2.x)
Ruby on Rails популяризировал концепцию масс-ассигнмента. В ранних версиях не было никакой защиты. Любой атрибут модели можно было изменить через params. Это было удобно для быстрого прототипирования, но для продакшена - ад.Инцидент с Github (2012)
Знаменитый случай: в марте 2012 года хакер Хомза (Homakov) нашёл уязвимость в Github, связанную с масс-ассигнментом. Он отправил запрос на изменение публичного ключа, добавив поле public, которое позволяло сделать ключ публичным, хотя должно было быть приватным. Github использовал Rails 3.0.x, где ещё не было strong parameters по умолчанию.Хомза не просто нашёл баг, он устроил дискуссию: "Почему фреймворк позволяет такое?" И это привело к тому, что в Rails 3.1 появился механизм attr_accessible и attr_protected, а позже - strong parameters в Rails 4. Но это было только начало.
Волна по другим фреймворкам
После Rails все подтянулись. Laravel ввёл $fillable и $guarded. Django REST Framework (DRF) требует явно указывать поля в сериализаторах. Spring (Java) - там всё сложнее, потому что используется десериализация Jackson'ом, и защита ложится на DTO или аннотации.Но, чёрт возьми, сколько ещё легаси-проектов живёт с открытыми ранами? Я сам на баг-баунти нахожу такие вещи до сих пор.
Глава 4. Технические детали: разбор на примерах
Давай залезем в дебри и посмотрим, как масс-ассигнмент выглядит на разных языках. Это важно, потому что если ты понимаешь, как это работает под капотом, тебе легче найти дыру и легче её закрыть.1. Ruby on Rails (самое мяско)
Уязвимый код (Rails < 4 или без strong_params):
Ruby:
class UsersController < ApplicationController
def update
@user = User.find(params[:id])
if @user.update(params[:user]) # Берём всё, что пришло
render json: @user
else
render json: @user.errors
end
end
end
params[:user] - это хеш. Если клиент отправит:
JSON:
{
"user": {
"email": "new@email.com",
"admin": true
}
}
То admin станет true, если в модели есть такое поле.
Защита (strong_params):
Ruby:
def update
@user = User.find(params[:id])
if @user.update(user_params) # Только разрешённые поля
render json: @user
end
end
private
def user_params
params.require(:user).permit(:email, :password, :password_confirmation)
end
Здесь .permit
2. Laravel (PHP)
Уязвимый код:
PHP:
public function update(Request $request, $id)
{
$user = User::find($id);
$user->update($request->all()); // Всё, что в запросе, идёт в модель
return $user;
}
$request-all() - это все входные данные, включая is_admin, если он есть в JSON.
Защита: в модели нужно определить $fillable (белый список) или $guarded (чёрный список).
PHP:
class User extends Model
{
protected $fillable = ['email', 'password']; // Только эти можно массово присваивать
// или
protected $guarded = ['is_admin', 'id']; // Эти нельзя
}
Но тут есть нюанс: если в модели не определено ни $fillable, ни $guarded, Laravel в production режиме просто проигнорирует массовое присвоение? Нет, он выбросит исключение MassAssignmentException, если включён режим строгости. Но в старых версиях или при отключённом strict - пропустит.
3. Django REST Framework (Python)
DRF - интересный случай. Там сериализаторы явно определяют поля.Уязвимый код (если сериализатор слишком широкий):
Python:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = '__all__' # ВСЕ поля модели
Если модель User содержит поле is_staff (админка Django), то клиент может его изменить.
Защита: указать конкретные поля или использовать read_only_fields.
Python:
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'email', 'username'] # только то, что можно менять
read_only_fields = ['id'] # даже если в fields, это только для чтения
Но хитрость DRF: если поле указано в fields, но не в read_only_fields, оно будет доступно для записи по умолчанию. Надо быть внимательным.
4. Spring Boot (Java)
Здесь чаще всего используется Jackson для десериализации JSON в объекты.Уязвимый код:
Java:
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
User existing = userRepository.findById(id);
// Копируем всё из пришедшего user в existing
// Если просто сохранить user, то потеряем данные? Обычно копируют.
// Но если использовать ModelMapper или ручное копирование полей, можно пропустить защиту.
existing.setEmail(user.getEmail());
existing.setPassword(user.getPassword());
// Ой, а тут ещё и setAdmin(user.isAdmin())? Забыли?
userRepository.save(existing);
}
Но чаще всего разработчики просто сохраняют пришедший объект, и если там есть поле admin, оно перезапишется. А если в методе есть @JsonIgnoreProperties? Не всегда.
Защита: Использовать DTO (Data Transfer Objects) с ограниченным набором полей.
Java:
public class UserUpdateDto {
private String email;
private String password;
// геттеры и сеттеры
}
@PutMapping("/users/{id}")
public User updateUser(@PathVariable Long id, @RequestBody UserUpdateDto dto) {
User user = userRepository.findById(id);
user.setEmail(dto.getEmail());
user.setPassword(dto.getPassword());
// admin не тронут
userRepository.save(user);
}
Или использовать аннотации Jackson для игнорирования:
Java:
@JsonIgnoreProperties(ignoreUnknown = true) // игнорировать лишние поля
public class User {
// ...
@JsonProperty(access = Access.READ_ONLY) // только для чтения
private boolean admin;
}
5. Node.js (Express + Mongoose)
Mongoose - ODM для MongoDB. У него тоже есть масс-ассигнмент.Уязвимый код:
JavaScript:
app.put('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
// Просто обновляем всем, что пришло из тела
user.set(req.body); // или Object.assign(user, req.body)
await user.save();
res.json(user);
});
Если в req.body есть isAdmin: true, mongoose радостно обновит это поле.
Защита: использовать select: false в схеме для чувствительных полей или явно указывать разрешённые поля.
JavaScript:
const userSchema = new mongoose.Schema({
email: String,
password: String,
isAdmin: { type: Boolean, default: false, select: false } // по умолчанию не возвращается
});
// Но это защищает только от чтения, а для записи всё равно нужно фильтровать
Лучше использовать библиотеки вроде express-validator или прунинг:
JavaScript:
const allowedFields = ['email', 'password'];
const updateData = {};
allowedFields.forEach(field => {
if (req.body[field] !== undefined) updateData[field] = req.body[field];
});
user.set(updateData);
6. Go (net/http + encoding/json)
В Go всё более явно, но тоже можно наступить на грабли.Уязвимый код:
Код:
// go
type User struct {
ID int
Email string
Password string
IsAdmin bool
}
func updateUser(w http.ResponseWriter, r *http.Request) {
var user User
json.NewDecoder(r.Body).Decode(&user)
// user теперь содержит всё, что было в JSON, включая IsAdmin
// Сохраняем в БД
db.Save(&user)
}
Защита: создать отдельную структуру для входящих данных.
Код:
// go
type UpdateUserRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
func updateUser(w http.ResponseWriter, r *http.Request) {
var req UpdateUserRequest
json.NewDecoder(r.Body).Decode(&req)
// Теперь IsAdmin не прочитается, даже если его отправить
// Но если в JSON будет поле email, оно попадёт в req.Email
}
Или можно использовать struct tags с json:"-" для игнорирования, но это менее гибко.
Глава 5. Практические инструменты для поиска уязвимостей
Теперь самое вкусное: как находить эти дыры? Мы же с тобой не просто теоретики, а практики. Давай разбирать инструментарий.5.1. Burp Suite - швейцарский нож
Burp Suite - это маст-хэв для любого, кто шатает веб. Нас интересует вкладка Repeater и Intruder.Repeater: Ловишь запрос на обновление профиля, отправляешь в Repeater и начинаешь добавлять поля.
- Добавил "is_admin": true - ответ 200, и в теле юзер с админ-флагом? Бинго.
- Добавил "role": "admin" - тоже смотрим.
- Попробуй "admin": 1, "admin": "true", "administrator": true.
- Для числовых полей: "points": 999999.
- Для булевых: "verified": true.
Burp Scanner тоже иногда находит такое, но он туповат. Лучше вручную.
5.2. OWASP ZAP - бесплатный аналог
Если у тебя нет денег на Burp Pro, ZAP - отличный выбор. У него есть активный сканер, который можно настроить на фаззинг параметров. Или используй Fuzzer вручную.5.3. Командная строка: curl + jq
Для любителей консоли.Базовый запрос:
Bash:
curl -X PUT https://example.com/api/users/123 \
-H "Content-Type: application/json" \
-d '{"email":"new@email.com"}' \
-w "\n%{http_code}\n" -o response.json
Добавляем подозрительное поле:
Bash:
curl -X PUT https://example.com/api/users/123 \
-H "Content-Type: application/json" \
-d '{"email":"new@email.com","is_admin":true}'
Смотришь ответ. Если в ответе пришёл is_admin: true - готово.
Автоматизация в bash:
Bash:
for field in is_admin admin role type permissions; do
echo "Trying $field..."
curl -s -X PUT https://example.com/api/users/123 \
-H "Content-Type: application/json" \
-d "{\"email\":\"test@test.com\",\"$field\":true}" | jq '.'
done
5.4. Python + requests - пишем свой сканер
Когда нужно просканировать много эндпоинтов или параметров, пишем скрипт.Пример простого фаззера:
Python:
import requests
import json
url = "https://example.com/api/users/123"
headers = {"Content-Type": "application/json"}
token = "your_token"
cookies = {"session": "..."}
# Базовые данные для запроса
base_data = {"email": "test@test.com"}
# Список подозрительных полей
fields_to_test = [
"is_admin", "admin", "role", "user_role", "access_level",
"verified", "email_verified", "active", "enabled", "isActive",
"group", "groups", "permissions", "is_moderator", "moderator",
"superuser", "is_superuser", "staff", "is_staff"
]
for field in fields_to_test:
data = base_data.copy()
data[field] = True
response = requests.put(url, headers=headers, cookies=cookies, json=data)
# Анализируем ответ
if response.status_code == 200:
# Ищем, не вернулось ли наше поле
try:
resp_json = response.json()
if field in resp_json and resp_json[field] is True:
print(f"[!] VULNERABLE: {field} = true in response")
elif field in resp_json:
print(f"[?] Field {field} appears in response: {resp_json[field]}")
else:
print(f"[-] {field} not reflected")
except:
print(f"[-] {field} - non-JSON response")
else:
print(f"[-] {field} - status {response.status_code}")
Можно расширить: проверять не только булевы, но и строки, числа, массивы.
5.5. ffuf - быстрый фаззер на Go
ffuf изначально для директорий, но отлично фаззит и параметры.
Bash:
ffuf -u https://example.com/api/users/123 \
-X PUT \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","FUZZ":true}' \
-w /path/to/wordlist.txt \
-fc 400,401,403,404,500
Но тут проблема: ffuf подставляет слово вместо FUZZ, и получается {"is_admin":true}. А если нужно менять не только ключ, но и значение? Можно использовать несколько словарей или комбинировать.
5.6. Анализ документации (Swagger, Postman)
Самое смешное: разработчики сами дают нам карту сокровищ. Открываешь Swagger UI, смотришь схему модели. Там написаны все поля, включая те, что не должны быть доступны. Если в схеме есть is_admin и нет пометки readOnly, значит, можно попробовать.В Postman тоже можно посмотреть примеры запросов и ответов. Часто в ответе приходит куча полей, и хакер думает: "А почему бы не попробовать отправить их обратно?".
5.7. Браузерные расширения
- EditThisCookie - для кук, не для JSON.
- ModHeader - менять заголовки.
- Requestly - перехватывать и менять запросы.
Но проще использовать Burp.
5.8. Инструменты для мобильных API
Если API используется в мобильном приложении, можно поставить прокси (Burp) на эмулятор и смотреть трафик. Там те же уязвимости.Глава 6. Как защититься: белый и чёрный списки
Теперь поговорим о защите с точки зрения разработчика. И сразу скажу: чёрный список - зло. Почему? Потому что ты никогда не знаешь, какие поля добавят завтра. Если ты запретил is_admin, но потом ввели поле is_superuser, ты про него забудешь, и хакер его отправит.Белый список - наше всё. Ты явно перечисляешь, что можно менять. Всё остальное игнорируется.
Подходы:
- DTO (Data Transfer Objects) - создаёшь класс/структуру только с нужными полями. Это самый надёжный способ, потому что ты контролируешь, что придёт. Плюс валидацию можно навесить прямо на DTO.
- Атрибуты моделей (fillable/guarded) - работает, если ты уверен, что модель не изменится. Но модель может расти, и кто-то забудет добавить поле в fillable, и оно станет недоступным (это не страшно, безопаснее), а если забудет в guarded - страшно.
- Ручное копирование - самый скучный, но самый прозрачный. Просто присваиваешь каждое поле по отдельности. В больших проектах так не делают, но для критичных операций - ок.
- Использование JSON Schema - можно валидировать входящий JSON по схеме, и если там есть лишние поля - выдавать ошибку. Это очень жёстко, но иногда нужно.
Примеры защитного кода:
Python (DRF)
Python:
class UserUpdateSerializer(serializers.Serializer):
email = serializers.EmailField(required=False)
password = serializers.CharField(required=False, write_only=True)
# is_admin даже не упоминается
Java (Spring)
Java:
public class UserUpdateRequest {
@Email
private String email;
private String password;
// нет поля admin
}
PHP (Laravel)
PHP:
$request->validate([
'email' => 'email',
'password' => 'min:6'
]);
$user->update($request->only(['email', 'password']));
Node.js
JavaScript:
const allowedUpdates = ['email', 'password'];
const updates = Object.keys(req.body).filter(key => allowedUpdates.includes(key));
const updateData = {};
updates.forEach(key => updateData[key] = req.body[key]);
user.set(updateData);
await user.save();
Валидация JSON Schema
Можно подключить middleware, который проверяет входящий JSON по OpenAPI схеме. Например, в Node.js есть express-openapi-validator. Если в схеме операции не указано поле is_admin, запрос с ним будет rejected.Важный момент: ошибки валидации
Если ты игнорируешь лишние поля, не возвращай ошибку. Просто не применяй их. Возвращать 400 Bad Request с текстом "Unknown field" - это хорошо, но может раскрыть структуру. Иногда лучше молча пропустить. Но для безопасности лучше явно отвергать.Глава 7. Продвинутые техники атак
Когда базовый is_admin: true уже не проходит, хакеры (и мы с тобой) начинаем изощряться.7.1. Вложенные атрибуты (nested attributes)
Допустим, у пользователя есть профиль с отдельной таблицей profiles. И в модели User есть accepts_nested_attributes_forТогда можно отправить:
JSON:
{
"user": {
"email": "test@test.com",
"profile_attributes": {
"bio": "new bio",
"is_public": true
}
}
}
Если профиль содержит поле is_admin (маловероятно, но бывает), можно попробовать.
Или атака на принадлежность: например, можно изменить user_id в комментарии через вложенные атрибуты.
7.2. Параметры с точкой
В некоторых фреймворках (например, старых версиях Rails или при кастомном парсинге) параметры вида user[admin] могут быть преобразованы во вложенный хеш. Если бэкенд ожидает плоский JSON, а ты отправляешь form-data с такими ключами, может сработать.7.3. Типизированные поля
Если поле ожидает число, а ты отправляешь строку - может быть приведение типов. Или наоборот, если поле ожидает массив, а ты отправляешь объект. Иногда это приводит к неожиданным последствиям.7.4. JSONB поля в PostgreSQL
В PostgreSQL есть тип JSONB. Если модель содержит поле metadata типа JSONB, и разработчик разрешил его массовое присвоение, ты можешь запихать туда что угодно. Но это скорее риск неправильной валидации внутри JSONB.7.5. GraphQL мутации
В GraphQL масс-ассигнмент может проявиться иначе. Если мутация принимает объект с полями, и резолвер просто передаёт их в модель, то злоумышленник может запросить мутацию с полем, которого нет в схеме? Нет, схема GraphQL строгая: если поле не объявлено во входном типе, его не отправить. Но если входной тип содержит поле, которое не должно быть доступно для записи - это проблема дизайна. Например, тип UpdateUserInput включает поле isAdmin, и клиент его указывает.Глава 8. Реальные кейсы из практики
Я тут припомню несколько историй, которые либо случались со мной, либо я слышал от коллег по цеху. Имена изменены, детали изменены, но суть та же.Кейс 1: Социальная сеть и права модератора
Соцсеть для фотографов. У них были группы, и в группе были роли: участник, модератор, администратор. Роль хранилась в модели Membership как поле role (string). Я вступил в группу, перехватил запрос на обновление членства (например, изменение уведомлений). Добавил "role": "admin". Запрос прошёл, и я стал админом группы. Потом мог удалять других участников. Это классика.Кейс 2: Интернет-магазин и отрицательные цены
Не совсем масс-ассигнмент, но близко. В корзине можно было менять количество товара. Я отправил "quantity": -1, и в ответ получил отрицательную сумму заказа. Потом оформил заказ с отрицательной ценой и должен был получить деньги? Но там были защиты на оплате, но факт.Кейс 3: API для чата и флаг "невидимка"
В одном мессенджере была функция "невидимка" (invisible). Обычный пользователь не мог её включить. Но при обновлении профиля было поле invisible. Я отправил "invisible": true, и стал невидим для других. Разработчики просто забыли убрать это поле из массового присвоения.Глава 9. Инструменты для защиты
Мы поговорили об атаках, теперь про защиту с точки зрения DevOps и разработчика. Какие инструменты помогут автоматически ловить такие проблемы?9.1. Статический анализ кода (SAST)
- Brakeman для Rails - отлично находит масс-ассигнмент, если видит params без strong_params.
- SonarQube - есть правила для разных языков.
- ESLint с плагинами для Node.js - можно искать опасные паттерны.
- PHP_CodeSniffer с собственными сниффами для Laravel.
9.2. Динамический анализ (DAST)
- OWASP ZAP с активным сканированием может найти такие уязвимости, если настроить.
- Burp Scanner - тоже, но нужна лицензия.
- Arachni - opensource сканер.
9.3. Линтеры API схем
Если у тебя есть OpenAPI спецификация, можно валидировать входящие запросы на соответствие схеме. Есть middleware для разных языков:- express-openapi-validator (Node.js)
- springdoc-openapi (Java) с валидацией
- django-rest-swagger + drf-yasg - не валидируют автоматически, но можно добавить.
9.4. Тестирование (Unit/Integration)
Пиши тесты, которые проверяют, что лишние поля не влияют.RSpec (Rails):
Ruby:
it "does not update admin attribute via mass assignment" do
user = users(:john)
patch :update, params: { id: user.id, user: { admin: true } }
user.reload
expect(user.admin).to be false
end
PHPUnit (Laravel):
PHP:
public function test_cannot_update_admin_via_mass_assignment()
{
$user = User::factory()->create(['admin' => false]);
$response = $this->putJson("/api/users/{$user->id}", ['admin' => true]);
$this->assertFalse($user->fresh()->admin);
}
9.5. API Firewalls
Некоторые продукты (например, Wallarm, Imperva) предлагают защиту API на уровне WAF, которая может блокировать подозрительные поля. Но это платно.9.6. Логирование и мониторинг
Если кто-то пытается отправить неожиданное поле, логируй это. Можно настроить алерт на попытки масс-ассигнмента. Например, если в логах появилось предупреждение от strong_params об отброшенных параметрах, это повод проверить, не атака ли это.Глава 10. Философия: почему это до сих пор случается?
Тут мы подходим к самому важному. Почему в 2026 году, когда все уже знают про mass assignment, он всё ещё встречается? Причины, как мне кажется, в следующем:- Скорость разработки важнее безопасности. Бизнес хочет фичи быстрее, разработчики пишут как удобно, а не как правильно. "Потом пофиксим" - и это потом не наступает.
- Незнание. Многие разработчики приходят из учебных курсов, где учат "правильно", но не показывают, как можно сломать. Они просто не знают, что так бывает.
- Слепая вера во фреймворки. Разработчик думает: "Я использую Laravel, он безопасен по умолчанию". Но Laravel не безопасен по умолчанию, если не заполнить $fillable. Или если использовать $request-all(). Фреймворк даёт инструменты, но не включает их автоматически.
- Легаси. Проекты, написанные 5-10 лет назад, живут до сих пор. Их поддерживают, но рефакторинг безопасности не делают, потому что страшно что-то сломать.
- API сначала для внутреннего использования. Думают: "Это только наше мобильное приложение, никто не будет слать левые поля". А потом API открывают для партнёров, и забывают пересмотреть модель угроз.
Когда находишь масс-ассигнмент, не надо сразу сливать данные или повышать себе права. Надо ответственно сообщить об этом. Команда разработчиков, скорее всего, даже не подозревает о проблеме. У них нет злого умысла, они просто ошиблись.
Я всегда стараюсь в отчёте объяснить не только "что", но и "почему" и "как исправить". Даже пример кода привожу. Это вызывает уважение и повышает шансы на баунти.
И ещё: не будь циником. Да, разработчики тупят. Но и мы тупим.
Глава 12. Заключение: будь проще, и люди к тебе потянутся, но не упрощай безопасность
Ну что, народ, доползли. Я специально не стал делать заключение коротким «спасибо за внимание», потому что тема mass assignment - это не просто очередная строчка в чек-листе OWASP Top 10. Это, блин, философия. Это лакмусовая бумажка, показывающая, насколько разработчик (или команда) вообще задумывается о том, что приходит к ним в сервер с другой стороны провода.Давай ещё раз пробежимся по тому, что мы тут накопали, но уже с высоты птичьего полёта.
Масс-ассигнмент как зеркало архитектуры
Когда я вижу в коде User.update(params) без всяких фильтров, я понимаю: здесь либо спешка, либо непонимание, либо лень. Но чаще всего - просто инерция мышления. Фреймворк дал удобную штуку, разработчик ей радуется и не задумывается, что любой школьник с Postman’ом может стать админом.И вот тут начинается самое интересное. Масс-ассигнмент - это симптом более глубокой проблемы: отсутствия границ между слоями приложения. Если ты тащишь сырой HTTP-запрос прямо в модель, значит, у тебя нет чёткого разделения ответственности. Контроллер должен заниматься только тем, чтобы вытащить нужные данные из запроса, а модель - сохранить их. А кто их фильтрует? А никто. Или фильтрует сама модель через fillable. Но модель не знает контекста: может, сегодня это поле можно менять, а завтра - нельзя.
Поэтому я всё чаще склоняюсь к тому, что DTO (Data Transfer Objects) - это не оверхед, а спасение. Да, придётся писать чуть больше кода. Да, иногда кажется, что это скучно. Но когда ты явно описываешь, какие поля ожидаешь в запросе на обновление профиля, ты автоматически решаешь кучу проблем: и масс-ассигнмент, и валидацию, и документирование API. Плюс, если ты используешь статическую типизацию (Go, Java, TypeScript), компилятор тебе подскажет, если ты вдруг решил использовать несуществующее поле.
Почему автоматические сканеры не панацея
Мы обсудили инструменты: Burp, ZAP, фаззеры. Они крутые, но они - костыли. Они находят уже существующие дыры. А вот чтобы дыр не было, нужно думать головой. Сканер может не заметить вложенный масс-ассигнмент или атаку через JSONB-поле. Он может не понять контекст: например, поле role вроде бы должно быть недоступно, но в админке оно нужно. Сканер просто пошлёт role: "admin" и, если ему вернётся 200, заорёт «вульна!». А на самом деле там может быть проверка прав доступа, которая запретит это для обычного юзера. Так что автоматика - это помощник, а не замена мозгу.Представь себе разработчика Петю. Он получил от тебя отчёт о масс-ассигнменте. У Пети дедлайн через два дня, легаси-код, тесты не проходят, а тут ещё ты со своим is_admin. Петя в стрессе. И если ты просто кинешь «у вас дыра, чините», Петя, скорее всего, залатает на скорую руку (добавит поле в guarded и забудет), а потом уйдёт в запой. А если ты подробно объяснишь, в чём проблема, и предложишь решение (например, использовать DTO или хотя бы fillable), Петя не только починит, но и сам будет знать, как в будущем не наступать на те же грабли.
Ты можешь быть жёстким, но справедливым. Ты можешь иронизировать над косяками, но не унижать. Потому что завтра ты можешь оказаться на месте Пети.
Что дальше? Эволюция масс-ассигнмента
Пока мы тут расслабляемся, масс-ассигнмент мутирует. GraphQL, например, формально защищает от него схемой, но если входной тип слишком широкий, проблемы остаются. Например, мутация updateUser принимает тип UserInput, в котором есть поле isAdmin. Если разработчик не пометил его как @deprecated или не убрал из схемы, клиент сможет его изменить. В GraphQL это называется «over-fetching» наоборот - «over-mutation».Ещё одна эволюционная ветка - AutoREST фреймворки, которые генерируют API прямо из базы данных (например, PostgREST, Hasura, Firebase). Там вообще вся безопасность строится на правах доступа к колонкам. Если ты не настроил права на колонку is_admin, она будет доступна на запись. И вот тут масс-ассигнмент становится просто следствием неправильной конфигурации.
Или NoSQL инъекции через масс-ассигнмент? Бывает. В MongoDB, если ты передаёшь {"$set": {"admin": true}}, а бэкенд не фильтрует операторы, можно выполнить произвольную операцию. Но это уже другая история.
Моё личное напутствие
Я думал, что безопасность - это про сложные шифры и файрволы. А оказалось, что самая опасная уязвимость - это просто отсутствие фильтрации. Простота и удобство фреймворков сыграли с нами злую шутку: мы перестали думать о том, что приходит в запросе. Мы привыкли доверять.Но хакер внутри нас (а он есть в каждом, кто дочитал до сюда) знает: доверять нельзя никому, особенно данным от клиента. Каждый JSON, каждый параметр - это потенциальная пуля. Наша задача - не бояться, а контролировать.
Давай договоримся: после прочтения этой статьи ты пойдёшь и проверишь свой проект. Или, если ты только учишься, напишешь игрушечное приложение и специально оставишь там масс-ассигнмент, чтобы проэксплуатировать и понять, как это работает. Только так, через собственные грабли, приходит понимание.
Мы с тобой одной крови. Мы любим копаться в деталях, писать код и иногда ломать его. И пусть наши API будут крепкими, а баунти - жирными.
Последнее редактирование модератором: