Injection de champ JSON (CWE-915) : propriétés non documentées dans le corps d'une requête API qui passent au travers du filtrage lors de la désérialisation complète par le serveur.
TL;DR
readOnly dans OpenAPI sont encore écribles si le code ne l'applique pasis_admin, isAdmin, IsAdmin, ROLE atteignent la même propriété du modèlereadOnly: true en écriturefirst_name: CANARY avec role: admin — seule la persistance du rôle confirme le bugL'injection de champ JSON exploite le mappage entre les propriétés du corps de la requête HTTP et les attributs du modèle côté serveur. Quand un serveur désérialise un corps JSON et mappe chaque clé sur un champ du modèle, un attaquant injecte des clés correspondant à des attributs privilégiés. La vulnérabilité se concentre sur la couche JSON — comment les noms de champs sont découverts, comment les conventions de nommage affectent la liaison, et comment les champs de documentation OpenAPI (marqués readOnly) ne sont pas automatiquement appliqués.
La surface d'attaque est plus large qu'elle n'y paraît. Une réponse GET montrant "role": "user" indique à l'attaquant qu'un champ role existe sur le modèle. Un schéma OpenAPI montrant "readOnly": true sur id documente que le serveur l'a calculé — mais ne prévient pas l'écriture. Des messages d'erreur comme "unknown field: subscription_tier" confirment que le nom de champ est reconnu même si le filtre actuel a rejeté la tentative.
# Extraire tous les champs readOnly de la spec OpenAPI
curl https://cible.com/openapi.json | jq '.components.schemas.User.properties |
to_entries[] | select(.value.readOnly == true) | .key'
# Sortie : "id", "role", "created_at", "is_admin", "subscription_tier"
# Tester chacun de ces champs en écriturearjun -u "https://cible.com/api/v1/users/me" \
-m PATCH \
--data '{"email": "test@example.com"}' \
--headers "Authorization: Bearer <token>" \
-w /usr/share/wordlists/arjun/large.txtx8 -u "https://cible.com/api/v1/users/me" \
-X PATCH \
-b '{"email": "test@example.com", "W": "FUZZ"}' \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
--wordlist /usr/share/wordlists/mass-assignment.txtPATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"role": "admin",
"Role": "admin",
"ROLE": "admin",
"is_admin": true,
"isAdmin": true,
"is-admin": true,
"IsAdmin": true
}PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"role": {"$ne": "user"},
"$set": {"role": "admin"},
"$unset": {"mfa_required": ""}
}PATCH /api/v1/teams/123 HTTP/1.1
Content-Type: application/merge-patch+json
Authorization: Bearer <token_utilisateur>
{
"name": "Mon équipe",
"member_acl": null,
"private": false,
"owner_id": null
}Per RFC 7396, les valeurs null suppriment la clé. member_acl: null efface la liste de contrôle d'accès de l'équipe.
CVE-2025-64459 — Injection kwargs ORM Django (CVSS 9.1, 2025)
Les vues Django utilisant Model.objects.filter(**request.GET.dict()) passaient les paramètres GET contrôlés par l'utilisateur comme arguments ORM. Un attaquant spécifiant is_superuser=True comme paramètre d'URL filtrait les enregistrements de niveau superutilisateur. CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N.
CVE-2024-39689 — Traversal de chemin populate Mongoose (CVSS 7.5, 2024)
findByIdAndUpdate de Mongoose avec des chemins de champs contrôlés par l'utilisateur permettait d'injecter des directives populate Mongoose comme clés JSON, traversant vers des champs sur des modèles liés hors de la portée de mise à jour prévue.
CVE-2024-32887 — NocoDB (CVSS 8.8, 2024)
La mise à jour de vue de table NocoDB acceptait les champs JSON role et visibility sans couche DTO. N'importe quel utilisateur authentifié modifiait les permissions au niveau de la table. CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N.
HackerOne #1069904 — GitLab (6 337 $)
GitLab API PATCH /api/v4/users/:id acceptait admin: true depuis des tokens non-admin — un utilisateur normal s'escaladait en administrateur d'instance GitLab via une seule propriété JSON injectée.
PATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{
"first_name": "CANARY-48291",
"role": "admin",
"is_admin": true
}GET la ressource après :
first_name = "CANARY-48291" ET role = "admin" : vrai positiffirst_name = "CANARY-48291" ET role = "user" (inchangé) : filtrage actif — faux positifPATCH /api/v1/users/me HTTP/1.1
Content-Type: application/json
{"role": "ce-role-n-existe-pas-xyz-42"}BreachVex détecte l'injection de champ JSON en combinant la découverte de paramètres Arjun avec l'extraction des champs readOnly OpenAPI, l'envoi de sondes avec toutes les conventions de nommage, et la confirmation inter-session de toute persistance.
# SÉCURISÉ — les champs inconnus retournent 422
class UserUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
email: EmailStr | None = None
first_name: str | None = None
last_name: str | None = None
# role, is_superuser — non définis, rejetés automatiquementimport { z } from 'zod';
// .strict() fait échouer toute clé supplémentaire
const UserUpdateSchema = z.object({
email: z.string().email().optional(),
firstName: z.string().max(50).optional(),
lastName: z.string().max(50).optional(),
}).strict();
app.patch('/api/users/me', async (req, res) => {
const result = UserUpdateSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
await User.findByIdAndUpdate(req.user.id, result.data, { new: true });
});@JsonIgnoreProperties(ignoreUnknown = false)
public class UserUpdateDto {
@NotBlank @Email private String email;
@Size(max = 50) private String firstName;
// role, authorities — absent ; clés inconnues lèvent HttpMessageNotReadableException
}def user_params
params.require(:user).permit(:email, :first_name, :last_name, :bio)
end
config.action_controller.action_on_unpermitted_parameters = :raiseL'injection de champ JSON exploite la couche de désérialisation : quand le serveur reçoit un corps JSON et mappe chaque propriété sur un champ du modèle, un attaquant injecte des noms de propriétés correspondant à des attributs de modèle privilégiés. Cette variante exploite souvent l'imbrication profonde, les variations de convention de nommage (camelCase vs snake_case), ou le bypass des champs readOnly de l'OpenAPI.
Quatre méthodes de découverte : (1) GET la ressource et examiner chaque champ de réponse. (2) Vérifier la spec OpenAPI/Swagger pour les champs marqués readOnly: true — ce sont les cibles. (3) Vérifier les messages d'erreur qui nomment les champs non reconnus. (4) Utiliser Arjun ou x8 avec un dictionnaire de noms de champs privilégiés courants pour découvrir les paramètres cachés par différentiel de réponse.
Les specs OpenAPI marquent les champs calculés côté serveur comme id, role, created_at avec readOnly: true. C'est de la documentation — cela n'applique pas de comportement. Si le code serveur n'applique pas aussi une liste blanche, ces champs readOnly sont toujours écribles. OpenAPI-fuzzer et APIKit (Burp) extraient automatiquement les champs readOnly et tentent de les écrire.
Certains WAF filtrent sur les noms de champs exacts. Envoyer role est bloqué. Mais isAdmin, is_admin, is-admin, IsAdmin, ROLE, RoLe peuvent chacun contourner le filtre si le WAF utilise la correspondance exacte de chaîne tandis que l'ORM backend normalise les noms de clés avant la liaison. Spring DataBinder accepte camelCase et snake_case pour la même propriété Java.
CVE-2025-64459 (Django, CVSS 9.1) — injection kwargs via des champs JSON passés à ORM filter(). CVE-2024-32887 (NocoDB, CVSS 8.8) — champs JSON role et visibility acceptés dans le corps de mise à jour. CVE-2024-39689 (Mongoose, CVSS 7.5) — traversal de chemin populate via injection de noms de champs.
Envoyez un payload avec à la fois un champ légitime (first_name: 'CANARY-12345') et un champ privilégié (role: 'admin') dans la même requête. GET après. Si first_name a été écrit mais pas role, le serveur a un filtrage de champs — le 200 OK est un faux positif pour le mass assignment. Si les deux ont été écrits, c'est un vrai positif.
Envoyez le champ privilégié avec une valeur qui ne peut pas exister dans le domaine de l'application : role: 'ce-role-n-existe-pas-xyz-42'. Si le GET montre cette valeur absurde persistée, le serveur stocke des valeurs de champs arbitraires — mass assignment confirmé. Si vous recevez une erreur 422, le serveur valide la valeur mais peut encore lier le champ.
RFC 7396 JSON Merge Patch utilise Content-Type: application/merge-patch+json. La sémantique de fusion spécifie que les valeurs null suppriment la clé de l'objet cible. Envoyer {member_acl: null, private: false} à un endpoint d'équipe efface la liste de contrôle d'accès par spec RFC. Cela weaponise un comportement RFC correct comme attaque de mass assignment.
x8 est un outil de découverte de paramètres en Rust qui supporte le corps JSON, le JSON imbriqué et GraphQL. Il envoie des requêtes avec des noms de champs candidats et détecte les différences de réponse. Contrairement à Arjun, x8 gère les chemins JSON profondément imbriqués — essentiel pour trouver des champs privilégiés enfermés dans des objets comme user.account.role.
Les vues Django utilisant Model.objects.filter(**request.GET.dict()) passent les paramètres GET contrôlés par l'utilisateur comme arguments ORM. Un attaquant spécifiant is_superuser=True comme paramètre d'URL filtre pour les enregistrements superutilisateurs — et combiné à un endpoint de création/mise à jour, injecte des noms de champs ORM comme clés de corps. CVSS 9.1.