SQL Server LIMIT / OFFSET SQL Injection in laravel/framework and illuminate/database
Research is free — Hunters explains how the bug works, the root-cause code pattern, how the fix addresses it, and how to test whether a target is affected, in chat. Investigate & write exploit is a paid run — the engine reads the advisory and fix commits, then builds and validates a working proof-of-concept exploit with reproduction steps.
Affected versions
Details
### Impact Those using SQL Server with Laravel and allowing user input to be passed directly to the `limit` and `offset` functions are vulnerable to SQL injection. Other database drivers such as MySQL and Postgres are not affected by this vulnerability. ### Patches This problem has been patched on Laravel versions 6.20.26, 7.30.5, and 8.40.0. ### Workarounds You may workaround this vulnerability by ensuring that only integers are passed to the `limit` and `offset` functions, as well as the `skip` and `take` functions.
The fix
Release delta 8.0.0 → 8.40.0 (contains the fix)
src/Illuminate/Database/Eloquent/Factories/Factory.php+18 −2
@@ -5,6 +5,7 @@use Closure;use Faker\Generator;use Illuminate\Container\Container;+use Illuminate\Database\Eloquent\Collection as EloquentCollection;use Illuminate\Database\Eloquent\Model;use Illuminate\Support\Collection;use Illuminate\Support\Str;@@ -182,7 +183,7 @@ public function createOne($attributes = [])** @param array $attributes* @param \Illuminate\Database\Eloquent\Model|null $parent-* @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|mixed+* @return EloquentCollection|\Illuminate\Database\Eloquent\Model|mixed*/public function create($attributes = [], ?Model $parent = null){@@ -205,6 +206,21 @@ public function create($attributes = [], ?Model $parent = null)return $results;}+/**+* Create a collection of models and persist them to the database.+*+* @param iterable $records+* @return \Illuminate\Database\Eloquent\Collection|mixed+*/+public function createMany(iterable $records)+{+return new EloquentCollection(+array_map(function ($attribute) {+return $this->create($attribute);+}, $records)+);+}+/*** Set the connection name on the results and store them.*@@ -255,7 +271,7 @@ public function makeOne($attributes = [])** @param array $attributes* @param \Illuminate\Database\Eloquent\Model|null $parent-* @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|mixed+* @return EloquentCollection|\Illuminate\Database\Eloquent\Model|mixed*/public function make($attributes = [], ?Model $parent = null){
tests/Auth/AuthTokenGuardTest.php+11 −11
@@ -27,10 +27,10 @@ public function testUserCanBeRetrievedByQueryStringVariable()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);$this->assertTrue($guard->check());$this->assertFalse($guard->guest());-$this->assertEquals(1, $guard->id());+$this->assertSame(1, $guard->id());}public function testTokenCanBeHashed()@@ -45,10 +45,10 @@ public function testTokenCanBeHashed()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);$this->assertTrue($guard->check());$this->assertFalse($guard->guest());-$this->assertEquals(1, $guard->id());+$this->assertSame(1, $guard->id());}public function testUserCanBeRetrievedByAuthHeaders()@@ -61,7 +61,7 @@ public function testUserCanBeRetrievedByAuthHeaders()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);}public function testUserCanBeRetrievedByBearerToken()@@ -74,7 +74,7 @@ public function testUserCanBeRetrievedByBearerToken()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);}public function testValidateCanDetermineIfCredentialsAreValid()@@ -124,7 +124,7 @@ public function testItAllowsToPassCustomRequestInSetterAndUseItForValidation()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);}public function testUserCanBeRetrievedByBearerTokenWithCustomKey()@@ -137,7 +137,7 @@ public function testUserCanBeRetrievedByBearerTokenWithCustomKey()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);}public function testUserCanBeRetrievedByQueryStringVariableWithCustomKey()@@ -152,10 +152,10 @@ public function testUserCanBeRetrievedByQueryStringVariableWithCustomKey()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);$this->assertTrue($guard->check());$this->assertFalse($guard->guest());-$this->assertEquals(1, $guard->id());+$this->assertSame(1, $guard->id());}public function testUserCanBeRetrievedByAuthHeadersWithCustomField()@@ -168,7 +168,7 @@ public function testUserCanBeRetrievedByAuthHeadersWithCustomField()$user = $guard->user();-$this->assertEquals(1, $user->id);+$this->assertSame(1, $user->id);}public function testValidateCanDetermineIfCredentialsAreValidWithCustomKey()(#34198)src/Illuminate/Pagination/resources/views/tailwind.blade.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Database/Schema/PostgresSchemaState.php+2 −2
@@ -51,7 +51,7 @@ protected function appendMigrationData(string $path)*/public function load($path){-$process = $this->makeProcess('PGPASSWORD=$LARAVEL_LOAD_PASSWORD psql --file=$LARAVEL_LOAD_PATH --host=$LARAVEL_LOAD_HOST --port=$LARAVEL_LOAD_PORT --username=$LARAVEL_LOAD_USER --dbname=$LARAVEL_LOAD_DATABASE');+$process = $this->makeProcess('PGPASSWORD=$LARAVEL_LOAD_PASSWORD pg_restore --host=$LARAVEL_LOAD_HOST --port=$LARAVEL_LOAD_PORT --username=$LARAVEL_LOAD_USER --dbname=$LARAVEL_LOAD_DATABASE $LARAVEL_LOAD_PATH');$process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), ['LARAVEL_LOAD_PATH' => $path,@@ -65,7 +65,7 @@ public function load($path)*/protected function baseDumpCommand(){-return 'PGPASSWORD=$LARAVEL_LOAD_PASSWORD pg_dump --host=$LARAVEL_LOAD_HOST --port=$LARAVEL_LOAD_PORT --username=$LARAVEL_LOAD_USER $LARAVEL_LOAD_DATABASE';+return 'PGPASSWORD=$LARAVEL_LOAD_PASSWORD pg_dump -Fc --host=$LARAVEL_LOAD_HOST --port=$LARAVEL_LOAD_PORT --username=$LARAVEL_LOAD_USER $LARAVEL_LOAD_DATABASE';}/**src/Illuminate/Database/Eloquent/Factories/Factory.php | 3 +--1 file changed, 1 insertion(+), 2 deletions(-)
tests/Database/DatabaseEloquentFactoryTest.php+21 −0
@@ -125,6 +125,27 @@ public function test_make_creates_unpersisted_model_instance()$this->assertCount(0, FactoryTestUser::all());}+public function test_basic_model_attributes_can_be_created()+{+$user = FactoryTestUserFactory::new()->raw();+$this->assertIsArray($user);++$user = FactoryTestUserFactory::new()->raw(['name' => 'Taylor Otwell']);+$this->assertIsArray($user);+$this->assertEquals('Taylor Otwell', $user['name']);+}++public function test_expanded_model_attributes_can_be_created()+{+$post = FactoryTestPostFactory::new()->raw();+$this->assertIsArray($post);++$post = FactoryTestPostFactory::new()->raw(['title' => 'Test Title']);+$this->assertIsArray($post);+$this->assertIsInt($post['user_id']);+$this->assertEquals('Test Title', $post['title']);+}+public function test_after_creating_and_making_callbacks_are_called(){$user = FactoryTestUserFactory::new()src/Illuminate/Database/Eloquent/Factories/Factory.php | 6 ++++--1 file changed, 4 insertions(+), 2 deletions(-)
src/Illuminate/Database/Schema/MySqlSchemaState.php+1 −1
@@ -67,7 +67,7 @@ protected function appendMigrationData(string $path)*/public function load($path){-$process = $this->makeProcess('mysql --host=$LARAVEL_LOAD_HOST --port=$LARAVEL_LOAD_PORT --user=$LARAVEL_LOAD_USER --password=$LARAVEL_LOAD_PASSWORD --database=$LARAVEL_LOAD_DATABASE < $LARAVEL_LOAD_PATH');+$process = $this->makeProcess('mysql --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" --database="${:LARAVEL_LOAD_DATABASE}" < "${:LARAVEL_LOAD_PATH}"');$process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), ['LARAVEL_LOAD_PATH' => $path,.../Database/Eloquent/Factories/HasFactory.php | 14 +++++++++++++-1 file changed, 13 insertions(+), 1 deletion(-)
src/Illuminate/Database/Schema/SqliteSchemaState.php+94 −0
@@ -0,0 +1,94 @@+<?php++namespace Illuminate\Database\Schema;++use Exception;+use Illuminate\Support\Str;+use Symfony\Component\Process\Process;++class SqliteSchemaState extends SchemaState+{+/**+* Dump the database's schema into a file.+*+* @param string $path+*+* @return void+*/+public function dump($path)+{+with($process = $this->makeProcess(+$this->baseCommand().' .schema'+))->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [+//+]));++$migrations = collect(preg_split("/\r\n|\n|\r/", $process->getOutput()))->filter(function ($line) {+return stripos($line, 'sqlite_sequence') === false &&+strlen($line) > 0;+})->all();++$this->files->put($path, implode(PHP_EOL, $migrations).PHP_EOL);++$this->appendMigrationData($path);+}++/**+* Append the migration data to the schema dump.+*+* @return void+*/+protected function appendMigrationData(string $path)+{+with($process = $this->makeProcess(+$this->baseCommand().' ".dump \'migrations\'"'+))->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [+//+]));++$migrations = collect(preg_split("/\r\n|\n|\r/", $process->getOutput()))->filter(function ($line) {+return preg_match('/^\s*(--|INSERT\s)/iu', $line) === 1 &&+strlen($line) > 0;+})->all();++$this->files->append($path, implode(PHP_EOL, $migrations).PHP_EOL);+}++/**+* Load the given schema file into the database.+*+* @param string $path+*+* @return void+*/+public function load($path)+{+$process = $this->makeProcess($this->baseCommand().' < $LARAVEL_LOAD_PATH');++$process->mustRun(null, array_merge($this->baseVariables($this->connection->getConfig()), [+'LARAVEL_LOAD_PATH' => $path,+]));+}++/**+* Get the base sqlite command arguments as a string.+*+* @return string+*/+protected function baseCommand()+{+return 'sqlite3 $LARAVEL_LOAD_DATABASE';+}++/**+* Get the base variables for a dump / load command.+*+* @return array+*/+protected function baseVariables(array $config)+{+return [+'LARAVEL_LOAD_DATABASE' => $config['database'],+];+}+}src/Illuminate/Database/Console/Migrations/MigrateCommand.php | 1 -src/Illuminate/Database/SQLiteConnection.php | 1 -src/Illuminate/Database/Schema/SqliteSchemaState.php | 4 ----3 files changed, 6 deletions(-)
src/Illuminate/Database/Schema/MySqlSchemaState.php+2 −2
@@ -17,7 +17,7 @@ class MySqlSchemaState extends SchemaStatepublic function dump($path){$this->executeDumpProcess($this->makeProcess(-$this->baseDumpCommand().' --routines --result-file=$LARAVEL_LOAD_PATH --no-data'+$this->baseDumpCommand().' --routines --result-file="${:LARAVEL_LOAD_PATH}" --no-data'), $this->output, array_merge($this->baseVariables($this->connection->getConfig()), ['LARAVEL_LOAD_PATH' => $path,]));@@ -83,7 +83,7 @@ protected function baseDumpCommand(){$gtidPurged = $this->connection->isMaria() ? '' : '--set-gtid-purged=OFF';-return 'mysqldump '.$gtidPurged.' --column-statistics=0 --skip-add-drop-table --skip-add-locks --skip-comments --skip-set-charset --tz-utc --host=$LARAVEL_LOAD_HOST --port=$LARAVEL_LOAD_PORT --user=$LARAVEL_LOAD_USER --password=$LARAVEL_LOAD_PASSWORD $LARAVEL_LOAD_DATABASE';+return 'mysqldump '.$gtidPurged.' --column-statistics=0 --skip-add-drop-table --skip-add-locks --skip-comments --skip-set-charset --tz-utc --host="${:LARAVEL_LOAD_HOST}" --port="${:LARAVEL_LOAD_PORT}" --user="${:LARAVEL_LOAD_USER}" --password="${:LARAVEL_LOAD_PASSWORD}" "${:LARAVEL_LOAD_DATABASE}"';}/**(#34271)src/Illuminate/Collections/Collection.php | 2 +-tests/Support/SupportCollectionTest.php | 12 ++++++++++++2 files changed, 13 insertions(+), 1 deletion(-)
tests/Database/DatabaseEloquentFactoryTest.php+8 −0
@@ -4,6 +4,7 @@use Illuminate\Container\Container;use Illuminate\Database\Capsule\Manager as DB;+use Illuminate\Database\Eloquent\Collection;use Illuminate\Database\Eloquent\Factories\Factory;use Illuminate\Database\Eloquent\Factories\HasFactory;use Illuminate\Database\Eloquent\Factories\Sequence;@@ -95,6 +96,13 @@ public function test_basic_model_can_be_created()$this->assertInstanceOf(Eloquent::class, $user);$this->assertEquals('Taylor Otwell', $user->name);+$users = FactoryTestUserFactory::new()->createMany([+['name' => 'Taylor Otwell'],+['name' => 'Jeffrey Way'],+]);+$this->assertInstanceOf(Collection::class, $users);+$this->assertCount(2, $users);+$users = FactoryTestUserFactory::times(10)->create();$this->assertCount(10, $users);}src/Illuminate/Database/Eloquent/Factories/Factory.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Database/Eloquent/Factories/Factory.php+1 −1
@@ -183,7 +183,7 @@ public function createOne($attributes = [])** @param array $attributes* @param \Illuminate\Database\Eloquent\Model|null $parent-* @return EloquentCollection|\Illuminate\Database\Eloquent\Model|mixed+* @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|mixed*/public function create($attributes = [], ?Model $parent = null){src/Illuminate/Database/Eloquent/Factories/Factory.php | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Database/Eloquent/Factories/Factory.php+1 −1
@@ -271,7 +271,7 @@ public function makeOne($attributes = [])** @param array $attributes* @param \Illuminate\Database\Eloquent\Model|null $parent-* @return EloquentCollection|\Illuminate\Database\Eloquent\Model|mixed+* @return \Illuminate\Database\Eloquent\Collection|\Illuminate\Database\Eloquent\Model|mixed*/public function make($attributes = [], ?Model $parent = null){view (#34287)src/Illuminate/Foundation/Console/OptimizeCommand.php | 1 +1 file changed, 1 insertion(+)
src/Illuminate/Database/Eloquent/Factories/Factory.php+15 −15
@@ -178,6 +178,21 @@ public function createOne($attributes = [])return $this->count(null)->create($attributes);}+/**+* Create a collection of models and persist them to the database.+*+* @param iterable $records+* @return \Illuminate\Database\Eloquent\Collection|mixed+*/+public function createMany(iterable $records)+{+return new EloquentCollection(+array_map(function ($record) {+return $this->state($record)->create();+}, $records)+);+}+/*** Create a collection of models and persist them to the database.*@@ -206,21 +221,6 @@ public function create($attributes = [], ?Model $parent = null)return $results;}-/**-* Create a collection of models and persist them to the database.-*-* @param iterable $records-* @return \Illuminate\Database\Eloquent\Collection|mixed-*/-public function createMany(iterable $records)-{-return new EloquentCollection(-array_map(function ($record) {-return $this->state($record)->create();-}, $records)-);-}-/*** Set the connection name on the results and store them.*AssertableJsonString (#34284)src/Illuminate/Testing/AssertableJsonString.php | 13 ++++++++++++-1 file changed, 12 insertions(+), 1 deletion(-)
tests/Auth/AuthDatabaseUserProviderTest.php+2 −2
@@ -28,7 +28,7 @@ public function testRetrieveByIDReturnsUserWhenUserIsFound()$user = $provider->retrieveById(1);$this->assertInstanceOf(GenericUser::class, $user);-$this->assertEquals(1, $user->getAuthIdentifier());+$this->assertSame(1, $user->getAuthIdentifier());$this->assertSame('Dayle', $user->name);}@@ -98,7 +98,7 @@ public function testRetrieveByCredentialsReturnsUserWhenUserIsFound()$user = $provider->retrieveByCredentials(['username' => 'dayle', 'password' => 'foo', 'group' => ['one', 'two']]);$this->assertInstanceOf(GenericUser::class, $user);-$this->assertEquals(1, $user->getAuthIdentifier());+$this->assertSame(1, $user->getAuthIdentifier());$this->assertSame('taylor', $user->name);}
src/Illuminate/Database/Console/Factories/FactoryMakeCommand.php+10 −5
@@ -66,7 +66,14 @@ protected function buildClass($name)$model = class_basename($namespaceModel);+if (Str::startsWith($namespaceModel, 'App\\Models')) {+$namespace = Str::beforeLast('Database\\Factories\\'.Str::after($namespaceModel, 'App\\Models\\'), '\\');+} else {+$namespace = 'Database\\Factories';+}+$replace = [+'{{ factoryNamespace }}' => $namespace,'NamespacedDummyModel' => $namespaceModel,'{{ namespacedModel }}' => $namespaceModel,'{{namespacedModel}}' => $namespaceModel,@@ -88,13 +95,11 @@ protected function buildClass($name)*/protected function getPath($name){-$name = str_replace(-['\\', '/'], '', $this->argument('name')-);+$name = Str::replaceFirst('App\\', '', $name);-$name = Str::finish($name, 'Factory');+$name = Str::finish($this->argument('name'), 'Factory');-return $this->laravel->databasePath()."/factories/{$name}.php";+return $this->laravel->databasePath().'/factories/'.str_replace('\\', '/', $name).'.php';}/**
src/Illuminate/Database/Eloquent/Factories/Factory.php+12 −13
@@ -166,6 +166,18 @@ public function configure()return $this;}+/**+* Get the raw attributes generated by the factory.+*+* @param array $attributes+* @param \Illuminate\Database\Eloquent\Model|null $parent+* @return array+*/+public function raw($attributes = [], ?Model $parent = null)+{+return $this->state($attributes)->getExpandedAttributes($parent);+}+/*** Create a single model and persist it to the database.*@@ -282,19 +294,6 @@ public function make($attributes = [], ?Model $parent = null)return $instances;}-/**-* Get the raw attributes generated from the factory.-*-* @param array $attributes-* @param \Illuminate\Database\Eloquent\Model|null $parent-* @return array-*/-public function raw($attributes = [], ?Model $parent = null)-{-return $this->state($attributes)-->getExpandedAttributes($parent);-}-/*** Make an instance of the model with the given attributes.*src/Illuminate/Foundation/Console/stubs/maintenance-mode.stub | 2 +-1 file changed, 1 insertion(+), 1 deletion(-)
src/Illuminate/Foundation/Testing/Concerns/InteractsWithDatabase.php+14 −0
@@ -5,6 +5,7 @@use Illuminate\Database\Eloquent\Model;use Illuminate\Database\Eloquent\SoftDeletes;use Illuminate\Support\Arr;+use Illuminate\Support\Facades\DB;use Illuminate\Testing\Constraints\CountInDatabase;use Illuminate\Testing\Constraints\HasInDatabase;use Illuminate\Testing\Constraints\SoftDeletedInDatabase;@@ -118,6 +119,19 @@ protected function isSoftDeletableModel($model)&& in_array(SoftDeletes::class, class_uses_recursive($model));}+/**+* Cast a JSON string to a database compatible type.+*+* @param array|string $value+* @return \Illuminate\Database\Query\Expression+*/+public function castAsJson($value)+{+$value = is_array($value) ? json_encode($value) : $value;++return DB::raw("CAST('$value' AS JSON)");+}+/*** Get the database connection.*composer.json | 2 +-src/Illuminate/Support/composer.json | 2 +-2 files changed, 2 insertions(+), 2 deletions(-)