Pterodactyl has a database resource limit bypass via race condition in Client API
Description
Summary
The Pterodactyl Client API has a logic flaw that lets users bypass their assigned limits for database allocations. This happens because the database locking mechanism used in the controllers is totally broken and doesn't actually lock anything.
Details
Inside DatabaseController.php, the code tries to prevent multiple databases from being created at once by calling $server->databases()->lockForUpdate(). In Laravel, this just configures a query builder but never actually sends a command to the database because it’s missing a terminal method like count() or get(). It’s basically a no-op that does nothing.
Since there’s no real lock, multiple requests hitting the endpoint at the exact same time will all see that the database count is under the limit. They all move forward to the DeployServerDatabaseService and successfully create extra resources on the physical host.
Impact
Users are able to create more databases than they are supposed to, potentially also breaking the web interface.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Pterodactyl Client API has a race condition due to a missing terminal method on a query builder, allowing users to bypass database allocation limits.
Vulnerability
In DatabaseController.php, the code attempts to prevent concurrent database creation by calling $server->databases()->lockForUpdate(). However, in Laravel, this only configures the query builder and never actually executes a database query because a terminal method like get() or count() is missing. As a result, the lock is never applied, creating a race condition. The vulnerability affects the Pterodactyl Panel (specific versions not disclosed in available references [1], [2]).
Exploitation
An attacker needs only to be an authenticated user with permission to create databases on a server. By sending multiple concurrent requests to the database creation endpoint, each request sees the current database count as below the limit and proceeds to create a database, bypassing the intended limit.
Impact
Successful exploitation allows a user to create more databases than their assigned limit, potentially exhausting resources on the physical host and breaking the web interface due to unexpected database counts.
Mitigation
No fix has been disclosed in the available references [1], [2]. Users should monitor the Pterodactyl project for a patched version. Until a fix is released, no workaround is provided.
AI Insight generated on May 26, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2- Range: < 1.12.3
Patches
2a94b8bdb4e91Fix API Key Limit Race Condition Bypass (#5620)
1 file changed · +10 −7
app/Http/Controllers/Api/Client/ApiKeyController.php+10 −7 modified@@ -5,6 +5,7 @@ use Pterodactyl\Models\ApiKey; use Illuminate\Http\JsonResponse; use Pterodactyl\Facades\Activity; +use Illuminate\Support\Facades\DB; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest; use Pterodactyl\Transformers\Api\Client\ApiKeyTransformer; @@ -29,14 +30,16 @@ public function index(ClientApiRequest $request): array */ public function store(StoreApiKeyRequest $request): array { - if ($request->user()->apiKeys->count() >= 25) { - throw new DisplayException('You have reached the account limit for number of API keys.'); - } + $token = DB::transaction(function () use ($request) { + if ($request->user()->apiKeys()->lockForUpdate()->count() >= 25) { + throw new DisplayException('You have reached the account limit for number of API keys.'); + } - $token = $request->user()->createToken( - $request->input('description'), - $request->input('allowed_ips') - ); + return $request->user()->createToken( + $request->input('description'), + $request->input('allowed_ips') + ); + }); Activity::event('user:api-key.create') ->subject($token->accessToken)
ec7231bd4aa2Lock resources more explicitly when creating databases or backups (#5613)
4 files changed · +10 −10
app/Http/Controllers/Api/Client/Servers/BackupController.php+1 −1 modified@@ -78,7 +78,7 @@ public function store(StoreBackupRequest $request, Server $server): array } $backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) { - $server->backups()->lockForUpdate(); + $server->backups()->lockForUpdate()->count(); $backup = $action->handle($server, $request->input('name'));
app/Http/Controllers/Api/Client/Servers/DatabaseController.php+5 −6 modified@@ -6,6 +6,7 @@ use Pterodactyl\Models\Server; use Pterodactyl\Models\Database; use Pterodactyl\Facades\Activity; +use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Services\Databases\DatabasePasswordService; use Pterodactyl\Transformers\Api\Client\DatabaseTransformer; use Pterodactyl\Services\Databases\DatabaseManagementService; @@ -49,7 +50,9 @@ public function index(GetDatabasesRequest $request, Server $server): array public function store(StoreDatabaseRequest $request, Server $server): array { $database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) { - $server->databases()->lockForUpdate(); + if ($server->databases()->lockForUpdate()->count() >= $server->database_limit) { + throw new DisplayException('Cannot create additional databases on this server: limit has been reached.'); + } $database = $this->deployDatabaseService->handle($server, $request->validated()); @@ -75,11 +78,7 @@ public function rotatePassword(RotatePasswordRequest $request, Server $server, D Activity::event('server:database.rotate-password') ->subject($database) ->property('name', $database->database) - ->transaction(function () use ($database) { - $database->lockForUpdate(); - - $this->passwordService->handle($database); - }); + ->transaction(fn () => $this->passwordService->handle($database)); return $this->fractal->item($database->refresh()) ->parseIncludes(['password'])
app/Services/Allocations/FindAssignableAllocationService.php+2 −0 modified@@ -39,6 +39,7 @@ public function handle(Server $server): Allocation // server. /** @var Allocation|null $allocation */ $allocation = $server->node->allocations() + ->lockForUpdate() ->where('ip', $server->allocation->ip) ->whereNull('server_id') ->inRandomOrder() @@ -102,6 +103,7 @@ protected function createNewAllocation(Server $server): Allocation /** @var Allocation $allocation */ $allocation = $server->node->allocations() + ->lockForUpdate() ->where('ip', $server->allocation->ip) ->where('port', $port) ->firstOrFail();
app/Services/Databases/DatabasePasswordService.php+2 −3 modified@@ -32,12 +32,11 @@ public function handle(Database|int $database): string $password = Utilities::randomStringWithSpecialCharacters(24); $this->connection->transaction(function () use ($database, $password) { - $this->dynamic->set('dynamic', $database->database_host_id); - - $this->repository->withoutFreshModel()->update($database->id, [ + $database->sharedLock()->update([ 'password' => $this->encrypter->encrypt($password), ]); + $this->dynamic->set('dynamic', $database->database_host_id); $this->repository->dropUser($database->username, $database->remote); $this->repository->createUser($database->username, $database->remote, $password, $database->max_connections); $this->repository->assignUserToDatabase($database->database, $database->username, $database->remote);
Vulnerability mechanics
Root cause
"Missing terminal method on a query builder call causes `lockForUpdate()` to be a no-op, so no database lock is ever acquired."
Attack vector
An attacker sends multiple concurrent HTTP requests to the database creation endpoint (`store()`) for the same server. Because `$server->databases()->lockForUpdate()` is a no-op (it only configures the query builder without executing it), no row-level lock is held [patch_id=2568918]. Each concurrent request independently reads the current database count, finds it under the limit, and proceeds to call `DeployServerDatabaseService`, resulting in more databases being created than the server's `database_limit` allows.
Affected code
The vulnerability is in `app/Http/Controllers/Api/Client/Servers/DatabaseController.php`, specifically in the `store()` method. The code calls `$server->databases()->lockForUpdate()` without a terminal method like `count()` or `get()`, so no actual database lock is acquired [patch_id=2568918]. The same broken locking pattern also existed in `BackupController.php` and `DatabasePasswordService.php` [patch_id=2568918].
What the fix does
The patch changes `$server->databases()->lockForUpdate()` to `$server->databases()->lockForUpdate()->count()` in `DatabaseController.php`, which actually executes the query and acquires a shared lock on the matching rows [patch_id=2568918]. It then checks whether the count meets or exceeds `$server->database_limit` and throws a `DisplayException` if so, preventing over-allocation. The same fix is applied to `BackupController.php` (`->count()` added) and `FindAssignableAllocationService.php` (proper `lockForUpdate()` calls added) [patch_id=2568918]. The `DatabasePasswordService.php` diff also moves the `sharedLock()` call earlier to ensure the password update is properly locked [patch_id=2568918].
Preconditions
- authThe attacker must be an authenticated user with permission to create databases on a server they control.
- configThe server must have a non-zero database_limit configured.
- inputThe attacker must send multiple concurrent requests to the store endpoint.
Generated on May 26, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.