Skip to content

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 IsAdmin from 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:

  • TryUpdateModelAsync without 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 IsVerified or Credits that 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: CreateUserViewModel only exposes Username, Email, Password - extra request fields like IsAdmin are 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 that TryUpdateModelAsync will 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.IsValid still 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: AllowedPropertiesAttribute declares 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 _allowedProperties set or SecurityException is 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

Additional Resources