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 focuses on ASP.NET Core and newer. Always use ViewModels/DTOs with only permitted properties, apply [Bind] attributes to restrict binding, and validate all model state.
Primary Defence: Use ViewModels/DTOs with only user-modifiable properties, apply [Bind] attribute allowlists, check ModelState.IsValid, and never bind directly to entity models.
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
using Microsoft.AspNetCore.Mvc;
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;
public UsersController(AppDbContext db)
{
_db = 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 automatically maps all request properties to matching entity properties
- Attackers can include extra fields in JSON/form data (
IsAdmin,Balance) that aren't in the UI - Enables privilege escalation by setting
IsAdmin=true - Allows setting arbitrary account balances or modifying any entity property
Using TryUpdateModel Without Property Allowlist
// VULNERABLE - TryUpdateModel without explicit property list
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUser(int id)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
// Dangerous: binds all properties from request
await TryUpdateModelAsync(user);
await _db.SaveChangesAsync();
return Ok(user);
}
// Attack: PUT /api/users/123
// { "Email": "newemail@example.com", "IsAdmin": true, "Balance": 500000 }
// Updates Email (intended) AND IsAdmin + Balance (unintended)
Why this is vulnerable:
TryUpdateModelAsyncwithout a property allowlist binds all request fields to the model- Attackers submit hidden fields to set security-critical properties
- Bypasses intended business logic
- Essentially mass assignment with no protection
Dynamic Property Setting with Reflection
// VULNERABLE - Reflection-based property assignment
public class ProfileUpdater
{
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 based on user-provided keys allows attackers to modify any writable property
- Security-critical fields can be modified without restriction
- Code treats all properties equally without access control checks
ASP.NET MVC Model Binding Without Protection
// VULNERABLE - Classic ASP.NET MVC without Bind attribute
public class AccountsController : Controller
{
private readonly IAccountService _accountService;
[HttpPost]
public ActionResult Register(Account account)
{
if (ModelState.IsValid)
{
// Account entity includes: Username, Email, Password, IsVerified, Credits
_accountService.Create(account);
return RedirectToAction("Success");
}
return View(account);
}
}
// Attack: POST with form data
// Username=hacker&Email=h@evil.com&Password=pass&IsVerified=true&Credits=10000
Why this is vulnerable:
- ASP.NET MVC model binding maps form fields to all public properties
- Attackers add hidden fields to the form or craft requests with extra parameters
- Allows modifying fields like
IsVerifiedorCreditsthat should be server-controlled
JSON Deserialization to Entities
// VULNERABLE - Deserializing JSON directly to entities
[HttpPost("import")]
public IActionResult ImportUsers([FromBody] List<User> users)
{
// Users can contain IsAdmin, Balance, or any other property
_db.Users.AddRange(users);
_db.SaveChanges();
return Ok();
}
// Attack: POST /api/users/import
// [{ "Username": "user1", "Email": "u1@example.com", "IsAdmin": true }]
Why this is vulnerable:
- JSON deserialization populates all properties that exist in the JSON payload
- Attackers craft JSON with additional fields to set administrative privileges
- Allows manipulating sensitive data during batch operations
Secure Patterns
Use ViewModels/DTOs for Input
// SECURE - ViewModel with 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 user
}
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _db;
[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,
Password = HashPassword(model.Password),
IsAdmin = false, // Explicitly set secure defaults
Balance = 0m,
CreatedDate = DateTime.UtcNow
};
_db.Users.Add(user);
_db.SaveChanges();
return Ok(new { user.Id, user.Username, user.Email });
}
}
Why this works:
- ViewModel exposure control:
CreateUserViewModelonly exposesUsername,Email,Password- extra request fields likeIsAdminare ignored - No binding surface: Security-critical properties (
IsAdmin,Balance) don't exist on ViewModel, eliminating over-posting attack vector - Explicit mapping: Manual property assignment makes security-sensitive defaults visible and auditable
- ModelState validation: Input validation runs on ViewModel properties before any entity is created
- Defense-in-depth: Even if attacker sends
IsAdmin=true, no such property exists to bind to - Clear intent: Code explicitly shows which fields are user-controlled vs. server-controlled
Use Bind Attribute for Allowlist Protection
// SECURE - Bind attribute restricts which properties can be set
public class User
{
public int Id { get; set; }
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public bool IsAdmin { get; set; }
public decimal Balance { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly AppDbContext _db;
[HttpPost]
public IActionResult CreateUser(
[Bind("Username,Email,Password")] User user)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Only Username, Email, Password are bound
// IsAdmin and Balance remain at default values (false, 0)
user.IsAdmin = false; // Explicit secure defaults
user.Balance = 0m;
_db.Users.Add(user);
_db.SaveChanges();
return Ok(user);
}
}
Why this works:
- Property allowlist:
[Bind("Username,Email,Password")]explicitly limits binding to safe properties only - Ignored extra fields: Request fields not in allowlist (
IsAdmin,Balance) are not bound and remain at default values - Defense-in-depth: Explicit default assignment after binding ensures values are correct even if binding behavior changes
- ASP.NET Core compatibility: Works in both ASP.NET MVC and ASP.NET Core
- Simple protection: Single attribute provides mass assignment protection without creating separate ViewModels
- Audit trail: Allowlist makes security-critical decision visible at method signature
Note: While
[Bind]provides protection, ViewModels/DTOs are preferred for better separation of concerns and explicit contract definition.
TryUpdateModel with Property Allowlist
// SECURE - TryUpdateModel with explicit property list
// Request body contains the properties to update:
// PUT /api/users/123
// { "Email": "newemail@example.com", "Username": "newusername" }
[HttpPut("{id}")]
public async Task<IActionResult> UpdateUser(int id)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
// TryUpdateModelAsync reads from ASP.NET Core value providers
// (route/query/form) and only binds the specified properties.
// JSON body binding typically requires a separate DTO via [FromBody].
await TryUpdateModelAsync(
user,
"", // Prefix (empty for root)
u => u.Email,
u => u.Username
);
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// IsAdmin, Balance cannot be updated through this endpoint
// Even if included in request: { "Email": "...", "IsAdmin": true }
await _db.SaveChangesAsync();
return Ok(user);
}
Why this works:
- Allowlist via parameters: The lambda expressions (
u => u.Email,u => u.Username) form the allowlist - these are the ONLY properties thatTryUpdateModelAsyncwill bind from the request - Without parameters = vulnerable: Calling
TryUpdateModelAsync(user)without property expressions binds ALL properties (see vulnerable pattern above) - Type safety: Compiler catches typos or invalid property names, preventing configuration errors
- Explicit control: Only listed properties can be modified - all others retain current values
- Validation integration:
ModelState.IsValidstill validates the properties that were bound - Clear intent: Property list documents exactly what can be changed through this endpoint
Separate Update ViewModels
// SECURE - Separate ViewModels for different operations
public class UpdateProfileViewModel
{
[Required]
[StringLength(100)]
public string DisplayName { get; set; }
[StringLength(500)]
public string Bio { get; set; }
[Url]
public string Website { get; set; }
}
public class UpdateEmailViewModel
{
[Required]
[EmailAddress]
public string NewEmail { get; set; }
[Required]
public string CurrentPassword { get; set; } // Require authentication
}
public class UpdateRoleViewModel // Admin-only endpoint
{
[Required]
public string Role { get; set; }
[Required]
public string Reason { get; set; } // Audit trail
}
[ApiController]
[Route("api/users")]
public class UserProfileController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IAuthorizationService _auth;
[HttpPut("{id}/profile")]
[Authorize]
public async Task<IActionResult> UpdateProfile(
int id,
UpdateProfileViewModel model)
{
var user = await GetAuthorizedUser(id);
if (user == null) return Forbid();
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
user.DisplayName = model.DisplayName;
user.Bio = model.Bio;
user.Website = model.Website;
await _db.SaveChangesAsync();
return Ok();
}
[HttpPut("{id}/email")]
[Authorize]
public async Task<IActionResult> UpdateEmail(
int id,
UpdateEmailViewModel model)
{
var user = await GetAuthorizedUser(id);
if (user == null) return Forbid();
// Verify password before allowing email change
if (!VerifyPassword(user, model.CurrentPassword))
{
return Unauthorized();
}
user.Email = model.NewEmail;
user.EmailVerified = false; // Require re-verification
await _db.SaveChangesAsync();
// Send verification email...
return Ok();
}
[HttpPut("{id}/role")]
[Authorize(Roles = "Admin")] // Admin-only
public async Task<IActionResult> UpdateRole(
int id,
UpdateRoleViewModel model)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return NotFound();
// Audit role changes
_auditLog.LogRoleChange(
userId: id,
newRole: model.Role,
reason: model.Reason,
changedBy: User.Identity.Name
);
user.Role = model.Role;
await _db.SaveChangesAsync();
return Ok();
}
private async Task<User> GetAuthorizedUser(int id)
{
var user = await _db.Users.FindAsync(id);
if (user == null) return null;
// Users can only update their own profile
if (user.Id.ToString() != User.FindFirst(ClaimTypes.NameIdentifier)?.Value)
{
return null;
}
return user;
}
}
Why this works:
- Operation-specific ViewModels: Each operation (
UpdateProfile,UpdateEmail,UpdateRole) has its own ViewModel exposing only relevant properties - Minimal exposure: Each endpoint can only modify specific fields - profile updates can't change email, email updates can't change role
- Authorization layers: Regular users update profiles, email changes require password, role changes require admin privileges
- Re-authentication for sensitive changes: Email updates require password verification to prevent session hijacking abuse
- Audit trail: Critical changes (role modifications) are logged with reason and actor for accountability
- Clear separation: Different security requirements for different operations are enforced at endpoint level
Property Access Control with Custom Binder
// SECURE - Custom model binder with property-level authorization
public class SecureModelBinder<T> : IModelBinder where T : class
{
private readonly HashSet<string> _allowedProperties;
public SecureModelBinder(params string[] allowedProperties)
{
_allowedProperties = new HashSet<string>(
allowedProperties,
StringComparer.OrdinalIgnoreCase
);
}
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var model = Activator.CreateInstance<T>();
var modelType = typeof(T);
foreach (var property in modelType.GetProperties())
{
// Only bind allowed properties
if (!_allowedProperties.Contains(property.Name))
{
continue;
}
var valueProviderResult = bindingContext.ValueProvider
.GetValue(property.Name);
if (valueProviderResult != ValueProviderResult.None)
{
try
{
var value = Convert.ChangeType(
valueProviderResult.FirstValue,
property.PropertyType
);
property.SetValue(model, value);
}
catch
{
// Log binding error
bindingContext.ModelState.TryAddModelError(
property.Name,
$"Invalid value for {property.Name}"
);
}
}
}
bindingContext.Result = ModelBindingResult.Success(model);
return Task.CompletedTask;
}
}
// Usage with attribute + provider registration
public class AllowedPropertiesAttribute : Attribute
{
public IReadOnlyList<string> AllowedProperties { get; }
public AllowedPropertiesAttribute(params string[] properties)
{
AllowedProperties = properties;
}
}
public class AllowedPropertiesModelBinderProvider : IModelBinderProvider
{
public IModelBinder GetBinder(ModelBinderProviderContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
var attr = context.Metadata
.ParameterInfo?
.GetCustomAttributes(typeof(AllowedPropertiesAttribute), false)
.FirstOrDefault() as AllowedPropertiesAttribute;
if (attr == null)
{
return null;
}
var binderType = typeof(SecureModelBinder<>)
.MakeGenericType(context.Metadata.ModelType);
return (IModelBinder)Activator.CreateInstance(
binderType,
new object[] { attr.AllowedProperties.ToArray() }
);
}
}
// Controller using custom binder
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult CreateProduct(
[AllowedProperties("Name", "Description", "Price")]
Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Only Name, Description, Price are bound
// Other properties remain at defaults
product.CreatedDate = DateTime.UtcNow;
product.IsApproved = false;
_db.Products.Add(product);
_db.SaveChanges();
return Ok(product);
}
}
Register the provider in ASP.NET Core so the allowlist is enforced:
services.AddControllers(options => options.ModelBinderProviders.Insert(0, new AllowedPropertiesModelBinderProvider()));
Why this works:
- Centralized enforcement: Custom binder provides reusable mass assignment protection across controllers
- Explicit allowlist:
AllowedPropertiesAttributedeclares permitted properties at method level - Runtime validation: Binder checks each property against allowlist before setting values
- Graceful handling: Invalid property values generate model errors instead of exceptions
- Case-insensitive: Property name comparison is case-insensitive for flexibility
- Type safety: Proper type conversion with error handling for invalid types
Reflection-Based Assignment Protection
// SECURE - Safe reflection-based property setting with allowlist
public class SecurePropertyUpdater
{
private readonly HashSet<string> _allowedProperties;
public SecurePropertyUpdater(params string[] allowedProperties)
{
_allowedProperties = new HashSet<string>(
allowedProperties,
StringComparer.OrdinalIgnoreCase
);
}
public void UpdateProperties<T>(
T target,
Dictionary<string, object> updates) where T : class
{
if (target == null)
throw new ArgumentNullException(nameof(target));
var type = typeof(T);
foreach (var kvp in updates)
{
// Check allowlist
if (!_allowedProperties.Contains(kvp.Key))
{
throw new SecurityException(
$"Property '{kvp.Key}' is not allowed to be modified"
);
}
var property = type.GetProperty(
kvp.Key,
BindingFlags.Public | BindingFlags.Instance
);
if (property == null)
{
throw new ArgumentException($"Property '{kvp.Key}' not found");
}
if (!property.CanWrite)
{
throw new InvalidOperationException(
$"Property '{kvp.Key}' is read-only"
);
}
// Validate type compatibility
if (kvp.Value != null &&
!property.PropertyType.IsAssignableFrom(kvp.Value.GetType()))
{
throw new ArgumentException(
$"Value for '{kvp.Key}' is not of expected type"
);
}
property.SetValue(target, kvp.Value);
}
}
}
// Usage
public class UserService
{
private readonly SecurePropertyUpdater _updater =
new SecurePropertyUpdater("Email", "DisplayName", "Bio");
public void UpdateUserProfile(User user, Dictionary<string, object> changes)
{
// Only Email, DisplayName, Bio can be updated
_updater.UpdateProperties(user, changes);
}
}
// Attempt to update IsAdmin will throw SecurityException
// var changes = new Dictionary<string, object> { { "IsAdmin", true } };
// _updater.UpdateProperties(user, changes); // THROWS!
Why this works:
- Allowlist enforcement: Properties must be in
_allowedPropertiesset orSecurityExceptionis thrown - No implicit binding: Unlike model binders, this makes reflection-based updates explicit and controlled
- Type validation: Checks value type compatibility before assignment to prevent type confusion
- Read-only protection: Prevents updates to read-only properties
- Exception on violation: Security violations throw exceptions with clear messages for logging/alerting
- Reusable component: Single class can be configured with different allowlists for different contexts
JSON Deserialization with DTOs
// SECURE - Deserialize to DTO, map to entity with validation
public class ImportUserDto
{
[Required]
[StringLength(50)]
public string Username { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[StringLength(100)]
public string FullName { get; set; }
// No IsAdmin, Balance, or other sensitive properties
}
[ApiController]
[Route("api/users")]
public class UserImportController : ControllerBase
{
private readonly AppDbContext _db;
[HttpPost("import")]
[Authorize(Roles = "Admin")]
public IActionResult ImportUsers([FromBody] List<ImportUserDto> userDtos)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var users = new List<User>();
foreach (var dto in userDtos)
{
// Validate business rules
if (_db.Users.Any(u => u.Email == dto.Email))
{
return BadRequest($"Email {dto.Email} already exists");
}
// Map DTO to Entity with secure defaults
var user = new User
{
Username = dto.Username,
Email = dto.Email,
FullName = dto.FullName,
IsAdmin = false, // Always false for imports
Balance = 0m,
IsVerified = false,
CreatedDate = DateTime.UtcNow
};
users.Add(user);
}
_db.Users.AddRange(users);
_db.SaveChanges();
return Ok(new { imported = users.Count });
}
}
Why this works:
- DTO contract: JSON deserialization targets DTO with only safe properties - extra JSON fields are ignored
- Schema enforcement: Data annotations validate structure before any database interaction
- Explicit mapping: Manual conversion from DTO to Entity makes defaults visible
- Business validation: Email uniqueness and other rules checked before entity creation
- Secure defaults: All security-critical properties explicitly set to safe values
- Authorization: Import endpoint requires admin role, limiting who can bulk-create users
- Audit-friendly: Import count returned allows tracking of bulk operations