CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes - C#
Overview
Mass assignment vulnerabilities in C# occur when ASP.NET model binding automatically maps user input to object properties, allowing attackers to modify security-critical fields like IsAdmin, Role, or Balance. This guidance targets ASP.NET Core.
Primary Defence: Use ViewModels/DTOs with only user-modifiable properties, check ModelState.IsValid, and never bind directly to entity models. The binding-level allowlists - [Bind] and TryUpdateModelAsync's property expressions - are narrower than they look: both act on form, query, and route values only, and neither has any effect on a JSON request body. On a JSON API the DTO is the whole fix.
Defense-in-depth: CWE-915 (mass assignment) and CWE-1174 (model validation) work together - CWE-915 controls which properties can be set (e.g., excluding
IsAdminfrom ViewModels), while CWE-1174 validates the values of allowed properties (e.g., using[Required],[EmailAddress],[Range]). Both protections are essential.
Common Vulnerable Patterns
Direct Entity Binding in Controllers
// VULNERABLE - direct entity binding allows over-posting
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public bool IsAdmin { get; set; } // Security-critical!
public decimal Balance { get; set; } // Should not be user-modifiable!
}
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _db;
[HttpPost]
public IActionResult CreateUser(User user)
{
// No restriction on which properties can be set!
_db.Users.Add(user);
_db.SaveChanges();
return Ok(user);
}
}
// Attack: POST /api/users
// { "username": "attacker", "email": "attacker@evil.com", "isAdmin": true, "balance": 999999 }
Why this is vulnerable: ASP.NET model binding and JSON deserialization automatically map every matching request field to the entity, including extra properties (IsAdmin, Balance) that never appear in the UI. The payload above creates an administrator account with a balance of 999999, and binds the same way whether it arrives as form data or JSON.
TryUpdateModel Without a Property Allowlist
// VULNERABLE - TryUpdateModel without explicit property list
[HttpPost("{id}/edit")]
public async Task<IActionResult> UpdateUser(int id)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
// Dangerous: binds every form/query/route value the request carries
await TryUpdateModelAsync(user);
await _db.SaveChangesAsync();
return Ok(user);
}
// Attack: POST /api/users/123/edit
// Content-Type: application/x-www-form-urlencoded
// email=newemail@example.com&isAdmin=true&balance=500000
// Updates Email (intended) AND IsAdmin + Balance (unintended)
Why this is vulnerable: TryUpdateModelAsync called without an explicit property list binds every value the request carries to the model - it is mass assignment with no protection, even though the code looks like a validated update. Note the scope: TryUpdateModelAsync reads the registered value providers - form fields, query string, and route values - not the request body. A JSON payload binds nothing at all through it, so a form post is what makes this exploitable, and a JSON endpoint that appears to use TryUpdateModelAsync for its update is silently doing nothing.
Dynamic Property Setting with Reflection
// VULNERABLE - reflection-based property assignment
public void UpdateProfile(object profile, Dictionary<string, object> updates)
{
foreach (var kvp in updates)
{
// No validation - sets any property!
var property = profile.GetType().GetProperty(kvp.Key);
if (property != null && property.CanWrite)
{
property.SetValue(profile, kvp.Value);
}
}
}
// Attack: updates = { { "IsAdmin", true }, { "Role", "Administrator" } }
Why this is vulnerable: Using reflection to set properties by a user-supplied key treats every writable property equally, with no allowlist or access control check, so an attacker can name any property - including security-critical ones - as an update key.
Secure Patterns
ViewModels/DTOs for Input
// SECURE - ViewModel exposes only user-modifiable properties
public class CreateUserViewModel
{
[Required]
[StringLength(50, MinimumLength = 3)]
public string Username { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
[StringLength(100, MinimumLength = 8)]
public string Password { get; set; }
// IsAdmin, Balance NOT included - cannot be set by the caller
}
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IPasswordHasher<User> _hasher;
[HttpPost]
public IActionResult CreateUser(CreateUserViewModel model)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Map ViewModel to entity with explicit property assignment
var user = new User
{
Username = model.Username,
Email = model.Email,
IsAdmin = false, // Explicitly set secure defaults
Balance = 0m,
CreatedDate = DateTime.UtcNow
};
user.PasswordHash = _hasher.HashPassword(user, model.Password);
_db.Users.Add(user);
_db.SaveChanges();
return Ok(new { user.Id, user.Username, user.Email });
}
}
Why this works: CreateUserViewModel only exposes Username, Email, and Password; there is no property on the model for IsAdmin or Balance, so extra request fields have nothing to bind to and are silently dropped. Manual mapping from ViewModel to entity keeps security-sensitive defaults explicit and auditable, and ModelState.IsValid runs validation before any entity is created.
TryUpdateModelAsync with an Explicit Property List
// SECURE - TryUpdateModelAsync restricted to a named property list
[HttpPost("{id}/edit")]
public async Task<IActionResult> UpdateUser(int id)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
// The lambda expressions ARE the allowlist - only these are bound
await TryUpdateModelAsync(
user,
"",
u => u.Email,
u => u.Username
);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// IsAdmin and Balance cannot be updated through this endpoint,
// even if the form posts them
await _db.SaveChangesAsync();
return Ok(user);
}
Why this works: The property expressions passed to TryUpdateModelAsync are the only properties it will bind; calling it without any expressions (the vulnerable form above) binds everything. The compiler also catches typos in property names, which a string-based allowlist cannot.
This fix only applies where TryUpdateModelAsync was doing the binding in the first place - form, query, and route values. It is not the fix for a JSON API: there the body is deserialized by an input formatter that TryUpdateModelAsync never sees, and the ViewModel above is the control.
Reflection-Based Assignment with an Allowlist
// SECURE - reflection-based updates checked against an allowlist
public class SecurePropertyUpdater<T> where T : class
{
private readonly HashSet<string> _allowedProperties;
public SecurePropertyUpdater(params string[] allowedProperties) =>
_allowedProperties = new HashSet<string>(allowedProperties, StringComparer.OrdinalIgnoreCase);
public void UpdateProperties(T target, Dictionary<string, object> updates)
{
foreach (var (key, value) in updates)
{
if (!_allowedProperties.Contains(key))
{
throw new InvalidOperationException($"Property '{key}' is not allowed to be modified");
}
var property = typeof(T).GetProperty(key, BindingFlags.Public | BindingFlags.Instance);
if (property == null || !property.CanWrite)
{
throw new InvalidOperationException($"Property '{key}' cannot be updated");
}
property.SetValue(target, value);
}
}
}
// Usage: only Email, DisplayName, Bio can ever be updated this way
var updater = new SecurePropertyUpdater<User>("Email", "DisplayName", "Bio");
updater.UpdateProperties(user, updates);
// updates containing "IsAdmin" throws InvalidOperationException
Why this works: Every property name is checked against an explicit allowlist before reflection is used to set it, so dynamic, dictionary-driven updates - which are sometimes unavoidable for generic profile-editing endpoints - can no longer reach security-critical properties.
Framework-Specific Guidance
ASP.NET Core [Bind] Attribute
[Bind] restricts model binding to a named property list on the existing entity type. It applies only to value-provider binding - form fields, query string, and route values - so the parameter has to be bound from one of those for the attribute to have any effect:
// SECURE - [Bind] constrains form binding; [FromForm] keeps it out of the body binder
[HttpPost]
public IActionResult CreateUser([Bind("Username,Email,Password")][FromForm] User user)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
user.IsAdmin = false; // Explicit secure defaults
user.Balance = 0m;
_db.Users.Add(user);
_db.SaveChanges();
return Ok(user);
}
[Bind] does nothing on a JSON endpoint. On a controller marked [ApiController], a complex-type parameter is inferred as [FromBody] and deserialized by the JSON input formatter, which never consults [Bind]. Posting {"username":"attacker","isAdmin":true,"balance":999999} to CreateUser([Bind("Username,Email")] User user) sets IsAdmin and Balance exactly as if the attribute were absent - the code reads as though it has an allowlist and has none. For a JSON API the ViewModel/DTO is not the preferred option of the two - it is the only one that works.
Even where [Bind] does apply, ViewModels/DTOs are preferred: they give a separate, explicit contract instead of relying on a string list staying in sync with the entity.
Testing
- Normal input: submit only the intended fields and confirm the entity updates as expected.
- Boundary input: submit fields with unexpected casing, nested objects, or duplicate keys, and confirm behavior is consistent.
- Malicious input: add
IsAdmin,Role,Balance, or an ownership field (UserId,TenantId) to the request body and confirm the value is ignored, not persisted. - Re-scan with the security scanner to confirm the finding is resolved.
Common Pitfalls
- Adding a
CreateUserViewModelfor the create endpoint but leaving a separatePATCH/bulk-import action that still callsTryUpdateModelAsync(user)with no property list, or binds the rawUserentity - the ViewModel protects one action; every other action that binds the same entity needs its own fix. - Adding
[Bind("Username,Email")]to a[ApiController]action that takes its input as JSON - the attribute is silently ignored by the body binder, so the endpoint is exactly as over-postable as before while the source now reads as though it were fixed. Confirm which binder is in play before treating[Bind]as the control: it is only meaningful on a parameter bound from form, query, or route values. - Listing property names in a string-based
[Bind("Username,Email")]allowlist that silently drops typos - unlikeTryUpdateModelAsync's lambda-expression form,[Bind]'s string list has no compile-time check, so a misspelled property name is quietly ignored rather than flagged, and the developer may assume it's protected when it was never bound in the first place. - Setting
IsAdmin = falseas a "safe default" only in the create path, while anAutoMapperprofile used elsewhere maps the full entity (includingIsAdmin) from a DTO that does include the field for an unrelated admin-tooling use case - if that same mapping profile is reused for a user-facing endpoint, the admin-only field becomes attacker-settable again. - Relying on Entity Framework's change tracking to "only save what changed" as a substitute for restricting which properties can be set - EF still applies whatever value was assigned to the tracked entity before
SaveChanges(), so an over-permissive binder still results in the malicious value being persisted.