Combine IDOR et mass assignment pour accéder et modifier des attributs protégés d'un objet d'un autre utilisateur en une seule requête.
TL;DR
role, is_admin, balanceLe mass assignment IDOR survient quand un framework web lie automatiquement les paramètres du corps de requête HTTP aux propriétés d'objets internes sans restreindre quelles propriétés sont inscriptibles. Quand un développeur écrit new User(req.body) en Node.js, User.create(params) en Rails, ou utilise fields = '__all__' dans Django REST Framework, chaque champ du corps de requête est mappé à la propriété de modèle correspondante. Un attaquant qui injecte un champ privilégié — is_admin, role, balance, owner_id, tenant_id — écrase la valeur côté serveur, obtenant souvent une escalade de privilèges immédiate.
CWE-915 (Modification incorrectement contrôlée d'attributs d'objets déterminés dynamiquement) est la classification précise. L'OWASP API Security Top 10:2023 positionne cela comme API3:2023 BOPLA — Broken Object Property Level Authorization — fusionnant les anciennes catégories API6:2019 (Mass Assignment) et API3:2019 (Exposition excessive de données) car elles partagent une cause racine et un correctif identiques : autorisation au niveau propriété via listes d'autorisation explicites de champs.
L'incident GitHub 2012 a établi cette classe de vulnérabilité comme une préoccupation industrielle critique. Le chercheur Egor Homakov a exploité le mass assignment Rails pour définir user_id à l'ID utilisateur de l'organisation Rails via une action de contrôleur non protégée, uploadant une clé SSH publique scopée à l'org Rails et obtenant un accès push au projet. Cela a déclenché l'introduction de Strong Parameters dans Rails 4 comme valeur par défaut obligatoire, déplaçant la charge du refus implicite à l'autorisation explicite pour tout le binding de paramètres HTTP.
La vulnérabilité a deux causes racines qui apparaissent ensemble : l'auto-binding du framework (le mécanisme) et l'autorisation au niveau propriété manquante (le gap d'autorisation).
Requête HTTP avec champs contrôlés par l'attaquant :
PATCH /api/utilisateurs/moi
Content-Type: application/json
Authorization: Bearer <token_utilisateur>
{
"nom_affiche": "Alice",
"is_admin": true, ← champ privilégié
"role": "admin", ← champ privilégié
"credits": 99999, ← champ financier
"tenant_id": "org_B" ← violation de frontière tenant
}
Sans restriction de champs :
user = User.findById(id)
Object.assign(user, req.body) ← tous les champs appliqués y compris les privilégiés
user.save()Le pattern de confirmation de détection nécessite une re-lecture GET — un champ reflété dans la réponse POST peut être un artefact d'affichage sans persistance en base de données :
Étape 1 — Injecter :
PATCH /api/utilisateurs/moi
{"is_admin": true, "role": "admin", "credits": 99999}
Étape 2 — Re-lire (CRITIQUE — confirme la persistance) :
GET /api/utilisateurs/moi
→ is_admin: true → CONFIRMÉ
→ role: "admin" → CONFIRMÉ
→ credits: 99999 → CONFIRMÉ
Étape 3 — Vérification de privilège (optionnel, confiance maximale) :
GET /api/admin/utilisateurs # avec le même token
→ 200 OK → CONFIRMÉ avec action de privilège vérifiée (sévérité CRITIQUE)| Framework | Pattern vulnérable | Pattern sûr |
|---|---|---|
| Rails | params.permit! / User.update(params[:user]) | params.require(:user).permit(:email, :name) |
| Laravel | protected $guarded = [] / forceFill() | protected $fillable = ['name', 'email'] |
| Django DRF | fields = '__all__' dans ModelSerializer | fields = ['name', 'email'] + read_only_fields = ['is_staff'] |
| Express/Mongoose | new User(req.body) / findByIdAndUpdate(id, req.body) | _.pick(req.body, ['name', 'email']) ou schéma Zod |
| Spring Boot | @ModelAttribute User user bindant tous les champs | @InitBinder setAllowedFields(...) ou pattern DTO |
| FastAPI/Pydantic | class Config: extra = 'allow' | Schéma avec champs explicites uniquement, extra = 'forbid' |
La liste d'injection minimale pour les tests de mass assignment (30+ champs) :
role, isAdmin, is_admin, admin, is_staff, is_superuser, superuser,
is_moderator, permissions, capabilities, scope, groups,
credits, balance, account_balance, available_credit, wallet,
email_verified, verified, is_verified, kyc_level, kyc_status,
subscription_tier, plan, is_premium, premium,
active, is_active, locked, banned, blocked,
created_at, owner_id, user_id, account_id, price, discount| Variante | Technique | Impact |
|---|---|---|
| Escalade de rôle | {"role": "admin", "is_admin": true} | Escalade de privilèges complète |
| Fraude financière | {"balance": 99999, "credits": 99999} | Crédits gratuits, prix négatifs |
| Contournement tenant | {"tenant_id": "org_B"} | Accès aux données inter-tenant |
| Déblocage de compte | {"locked": false, "banned": false} | Contournement de suspension de compte |
| Contournement de vérification email | {"email_verified": true} | Ignorer l'étape de vérification |
| Transfert de propriété | {"owner_id": 1338} | Prendre la propriété des objets d'un autre utilisateur |
| Contournement OpenAPI readOnly | Injecter des champs readOnly: true de la spec | Champs documentés dans le schéma acceptés en écriture |
GitHub 2012 — L'incident de mass assignment canonique — Le chercheur Egor Homakov a découvert qu'un contrôleur GitHub acceptait user_id comme paramètre lors de la création de clés SSH publiques. Le mass assignment Rails liait le paramètre directement au modèle PublicKey sans liste d'autorisation, permettant à Homakov de définir user_id au compte utilisateur de l'organisation Rails. Il a uploadé une clé SSH scopée à l'org Rails, obtenant un accès push au dépôt Rails — le framework utilisé par GitHub lui-même. L'incident a directement causé l'introduction de Strong Parameters de Rails 4.0 comme valeur par défaut obligatoire, remplaçant le pattern opt-in attr_accessible.
CVE-2026-29056 — Kanboard User Invite Mass Assignment (Élevé) — Kanboard ≤ 1.2.50 contenait une vulnérabilité classique d'inconsistance développeur. Le UserController standard pour les paramètres de compte appelait correctement $this->userModel->update() avec une liste d'autorisation de champs explicite excluant role. La méthode UserInviteController::register(), ajoutée plus tard par un développeur différent, appelait $this->userModel->create($this->request->getValues()) — passant tous les paramètres du corps POST sans filtrage. Un destinataire d'invitation email incluait role=app-admin dans le corps du formulaire, s'inscrivant comme administrateur complet. Les droits d'installation de plugin suivaient, créant un chemin vers l'exécution de code à distance. Corrigé par ajout de unset($values['role']) avant la création du modèle.
CVE-2024-7297 — Escalade Super-Admin Langflow (CVSS 8,8, juillet 2024) — Les versions Langflow (le constructeur d'applications LLM basé sur LangChain) antérieures à 1.0.13 acceptaient {"is_superuser": true} via PATCH /api/v1/users/<USER_ID>. Tout utilisateur authentifié à faible privilège obtenait un accès super-admin immédiat à tous les flux AI, credentials API et paramètres d'infrastructure. Découvert par Tenable Research (TRA-2024-26) et remédié dans la version 1.0.13 avec filtrage de champs côté serveur.
CVE-2022-22968 — Contournement de liste de blocage WebDataBinder Spring Framework (CVSS 5,3, avril 2022) — Les versions Spring Framework antérieures à 5.3.19 et 6.0.0 utilisaient WebDataBinder.setDisallowedFields("balance") comme approche par liste de blocage. L'implémentation était sensible à la casse : balance était bloqué mais Balance (B majuscule) ne l'était pas. Les clients pouvaient soumettre Balance=99999 et le champ serait lié. Le correctif a amélioré la correspondance de pattern dans WebDataBinder pour être insensible à la casse. Ce CVE démontre pourquoi les défenses basées sur des listes de blocage sont structurellement fragiles : tout vecteur de contournement — casse, encodage, nom de champ alternatif — neutralise la protection.
CVE-2023-4836 — WordPress User Private Files IDOR — Les abonnés et utilisateurs WordPress à faible privilège construisaient des requêtes pour accéder aux fichiers privés uploadés par n'importe quel autre utilisateur. Le chevauchement mass assignment : le plugin construisait des chemins de fichiers directement depuis des paramètres contrôlés par l'utilisateur sans valider que l'utilisateur demandeur possédait le fichier référencé. Cela combinait une référence indirecte à un objet (nom de fichier) avec une autorisation insuffisante au niveau propriété sur l'accès aux fichiers.
readOnly: true, et les injecter comme candidats en écriture.is_admin, isAdmin, IsAdmin, ISADMIN.# Python — boucle de test de mass assignment automatisée
import httpx
import asyncio
CHAMPS_PRIVILEGES = [
"role", "is_admin", "isAdmin", "admin", "is_staff", "is_superuser",
"credits", "balance", "account_balance", "email_verified", "verified",
"subscription_tier", "plan", "owner_id", "tenant_id", "price"
]
async def tester_mass_assignment(base_url: str, token: str, endpoint: str):
async with httpx.AsyncClient() as client:
for champ in CHAMPS_PRIVILEGES:
# Injecter le champ privilégié
payload = {champ: True} # tentative d'escalade booléenne
reponse_patch = await client.patch(
f"{base_url}{endpoint}",
json=payload,
headers={"Authorization": f"Bearer {token}"}
)
# Re-lecture GET — signaler uniquement si le champ a persisté
reponse_get = await client.get(
f"{base_url}{endpoint}",
headers={"Authorization": f"Bearer {token}"}
)
if reponse_get.status_code == 200:
corps = reponse_get.json()
if corps.get(champ) is True:
print(f"CONFIRMÉ : {champ} persisté sur {endpoint}")La détection de mass assignment de BreachVex injecte une large liste de champs de privilège dans les endpoints POST/PUT/PATCH. Patterns d'endpoints prioritaires : chemins contenant /users, /accounts, /profile, /register, /signup, /settings, /orders avec des types de contenu JSON. Elle cible aussi spécifiquement les champs readOnly: true OpenAPI — la spec documentée est parsée, les champs readOnly sont extraits comme candidats, et ils sont injectés comme cibles en écriture.
Niveaux de confiance : CONFIRMÉ quand la re-lecture GET montre la persistance du champ ; POTENTIEL quand le champ apparaît dans la réponse POST mais que la re-lecture GET est non concluante ; INFORMATIONNEL quand le serveur retourne 422 Unprocessable Entity en nommant le champ inattendu (mode extra="forbid" de Pydantic — indique que le champ a été rejeté, pas accepté).
Ne jamais signaler le mass assignment comme CONFIRMÉ sans vérification par re-lecture GET. Pydantic (Python) et Zod (TypeScript) reflètent par défaut les champs inconnus dans les réponses d'erreur sans les persister. Une réponse 422 nommant le champ injecté n'est pas une vulnérabilité — c'est le comportement de rejet correct. Seule la persistance confirmée via GET indépendant qualifie comme finding.
Le pattern le plus robuste : déclarer exactement quels champs sont inscriptibles. Tout champ absent du schéma ne peut pas être soumis quel que soit ce qu'envoie le client :
from pydantic import BaseModel
from typing import Optional
# Schéma d'entrée — uniquement les champs inscriptibles par l'utilisateur
class DemandeMAJUtilisateur(BaseModel):
nom_affiche: str
bio: Optional[str] = None
# is_admin, role, credits, tenant_id : NON déclarés → rejetés automatiquement
# Schéma de sortie — inclut les champs que les utilisateurs peuvent lire mais pas écrire
class ReponseUtilisateur(BaseModel):
id: int
nom_affiche: str
bio: Optional[str]
is_admin: bool # lisible dans la réponse
credits: int # lisible dans la réponse
# Ces champs sont dans ReponseUtilisateur mais PAS dans DemandeMAJUtilisateur = jamais inscriptibles
@router.patch("/utilisateurs/moi", response_model=ReponseUtilisateur)
async def maj_utilisateur(user_id: str = Depends(get_current_user_id),
data: DemandeMAJUtilisateur):
# data.model_dump() contient uniquement les champs déclarés — is_admin ne peut pas être là
await db.users.update(id=user_id, **data.model_dump(exclude_unset=True))
return await db.users.get(id=user_id)# VULNÉRABLE — expose tous les champs du modèle y compris is_staff, is_superuser
class UserSerializer(ModelSerializer):
class Meta:
model = User
fields = '__all__' # chaque champ est inscriptible
# VULNÉRABLE — exclude ne protège que les champs nommés ; les nouveaux champs sont auto-exposés
class UserSerializer(ModelSerializer):
class Meta:
model = User
exclude = ['password'] # is_staff, is_superuser toujours inscriptibles
# SÉCURISÉ — liste d'autorisation explicite ; tout nouveau champ de modèle nécessite un ajout explicite
class UserUpdateSerializer(ModelSerializer):
class Meta:
model = User
fields = ['display_name', 'bio', 'email']
read_only_fields = ['id', 'is_staff', 'is_superuser', 'date_joined', 'credits']import { z } from 'zod';
// Le schéma déclare exactement ce qui est inscriptible
const SchemaMAJUtilisateur = z.object({
displayName: z.string().min(1).max(100),
bio: z.string().max(500).optional(),
// isAdmin, role, credits : pas dans le schéma → rejetés par .strict()
}).strict(); // .strict() lève une erreur sur les champs supplémentaires
app.patch('/api/utilisateurs/moi', authentifier, async (req, res) => {
const resultat = SchemaMAJUtilisateur.safeParse(req.body);
if (!resultat.success) {
return res.status(400).json({ erreur: resultat.error.flatten() });
}
// resultat.data contient uniquement les champs déclarés dans le schéma
await User.update({ id: req.user.id }, resultat.data);
res.json(await User.findById(req.user.id));
});// VULNÉRABLE — @ModelAttribute lie tous les champs de requête à l'entité User
@PatchMapping("/utilisateurs/moi")
public ResponseEntity<User> mettreAJourUtilisateur(@ModelAttribute User user) {
userRepository.save(user); // isAdmin, role, balance tous acceptés
return ResponseEntity.ok(user);
}
// SÉCURISÉ — DTO restreint les champs bindables ; l'entité User n'est jamais directement liée
public class UserUpdateDTO {
private String displayName;
private String bio;
// isAdmin, role, balance : PAS dans DTO — Spring ne peut pas les lier
// Getters/setters uniquement pour les champs ci-dessus
}
@PatchMapping("/utilisateurs/moi")
public ResponseEntity<UserResponse> mettreAJourUtilisateur(
@RequestBody UserUpdateDTO dto,
@AuthenticationPrincipal UserDetails principal) {
User user = userRepository.findByUsername(principal.getUsername());
user.setDisplayName(dto.getDisplayName()); // mapping de champ explicite
user.setBio(dto.getBio());
// isAdmin n'est jamais touché — il n'est pas dans UserUpdateDTO
return ResponseEntity.ok(toResponse(userRepository.save(user)));
}Le mass assignment IDOR survient quand un framework mappe automatiquement les champs du corps de requête HTTP aux propriétés du modèle ORM sans restreindre quels champs sont inscriptibles. Un attaquant injecte des champs sensibles — is_admin, role, balance, owner_id — que l'application n'a jamais voulu rendre contrôlables par l'utilisateur, les modifiant en une seule requête.
CWE-915 (Modification incorrectement contrôlée d'attributs d'objets déterminés dynamiquement) est la classification précise du mass assignment. L'OWASP le reclasse comme API3:2023 BOPLA (Broken Object Property Level Authorization) — à la fois le mass assignment API6:2019 et l'exposition excessive de données API3:2019 partagent le même correctif : autorisation au niveau propriété via listes d'autorisation explicites.
Le chercheur Egor Homakov a exploité le mass assignment Rails pour uploader une clé SSH vers le dépôt de l'organisation officielle Rails sans autorisation. Il a défini user_id à l'ID utilisateur de l'org Rails via un contrôleur non protégé, obtenant un accès push au code source Rails. Cela a déclenché Strong Parameters de Rails 4 comme protection par défaut et est devenu l'étude de cas canonique de mass assignment.
Dans DRF, utiliser des listes de champs explicites dans les serializers au lieu de fields='__all__'. Marquer les champs sensibles comme read_only_fields. Éviter les patterns exclude=[password] — tout nouveau champ de modèle ajouté ultérieurement est automatiquement exposé. Le pattern sûr : fields=['id','username','email'] avec read_only_fields=['id','is_staff','is_superuser'].
CVE-2022-22968 dans Spring Framework montrait que setDisallowedFields('balance') ne bloquait PAS 'Balance' (B majuscule) en raison de la sensibilité à la casse dans WebDataBinder. Les champs destinés à être bloqués pouvaient être soumis via une casse alternative. Les approches par liste de blocage sont fondamentalement fragiles — les listes d'autorisation sont la seule défense fiable.
Les spécifications OpenAPI marquent certains champs comme readOnly: true — ils apparaissent dans les réponses GET mais ne devraient pas être acceptés dans les corps POST/PUT/PATCH. Certains serveurs respectent le schéma dans la documentation mais pas dans la validation, acceptant les champs readOnly des clients et les appliquant au modèle. BreachVex teste spécifiquement les champs readOnly de la spec comme candidats d'injection.
Dans Kanboard ≤ 1.2.50, le contrôleur de paramètres de compte standard filtrait correctement les champs en excluant le paramètre 'role'. Le contrôleur d'inscription par invitation (UserInviteController::register()) passait tous les paramètres POST directement à UserModel::create() sans le même filtre. Un destinataire d'invitation incluait role=app-admin dans le corps du formulaire, s'inscrivant comme administrateur complet — une vulnérabilité classique d'inconsistance développeur entre chemins de code.
Le mass assignment ne doit jamais être signalé comme CONFIRMÉ sans confirmation par re-lecture GET pour éviter les faux positifs. Après avoir injecté un champ privilégié (ex. PATCH /users/me avec {is_admin: true}), émettre GET /users/me et vérifier que le champ a persisté. Si is_admin est vrai dans la réponse GET, le finding est CONFIRMÉ. Si le champ est reflété dans la réponse PATCH mais absent après GET, c'est au plus POTENTIEL.