Pterodactyl TOTPs can be reused during validity window
Description
Pterodactyl is a free, open-source game server management panel. Versions 1.11.11 and below allow TOTP to be used multiple times during its validity window. Users with 2FA enabled are prompted to enter a token during sign-in, and afterward it is not sufficiently marked as used in the system. This allows an attacker who intercepts that token to use it in addition to a known username/password during the 60-second token validity window. The attacker must have intercepted a valid 2FA token (for example, during a screen share). This issue is fixed in version 1.12.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
pterodactyl/panelPackagist | < 1.12.0 | 1.12.0 |
Affected products
1- Range: v0.1.0-beta, v0.1.1-beta, v0.1.2-beta, …
Patches
1032bf076d92bEnsure that TOTP tokens cannot be reused (#5481)
5 files changed · +231 −14
app/Http/Controllers/Auth/LoginCheckpointController.php+16 −3 modified@@ -2,6 +2,7 @@ namespace Pterodactyl\Http\Controllers\Auth; +use Carbon\Carbon; use Carbon\CarbonImmutable; use Carbon\CarbonInterface; use Pterodactyl\Models\User; @@ -70,8 +71,20 @@ public function __invoke(LoginCheckpointRequest $request): JsonResponse } } else { $decrypted = $this->encrypter->decrypt($user->totp_secret); + $oldTimestamp = $user->totp_authenticated_at + ? (int) floor($user->totp_authenticated_at->unix() / $this->google2FA->getKeyRegeneration()) + : null; + + $verified = $this->google2FA->verifyKeyNewer( + $decrypted, + $request->input('authentication_code') ?? '', + $oldTimestamp, + config('pterodactyl.auth.2fa.window') ?? 1, + ); + + if ($verified !== false) { + $user->update(['totp_authenticated_at' => Carbon::now()]); - if ($this->google2FA->verifyKey($decrypted, (string) ($request->input('authentication_code') ?? ''), config('pterodactyl.auth.2fa.window'))) { Event::dispatch(new ProvidedAuthenticationToken($user)); return $this->sendLoginResponse($user, $request); @@ -105,9 +118,9 @@ protected function isValidRecoveryToken(User $user, string $value): bool * will return false if the data is invalid, or if more time has passed than * was configured when the session was written. */ - protected function hasValidSessionData(array $data): bool + protected function hasValidSessionData(?array $data): bool { - $validator = $this->validation->make($data, [ + $validator = $this->validation->make($data ?? [], [ 'user_id' => 'required|integer|min:1', 'token_value' => 'required|string', 'expires_at' => 'required',
app/Services/Users/ToggleTwoFactorService.php+1 −1 modified@@ -79,7 +79,7 @@ public function handle(User $user, string $token, ?bool $toggleState = null): ar } $this->repository->withoutFreshModel()->update($user->id, [ - 'totp_authenticated_at' => Carbon::now(), + 'totp_authenticated_at' => null, 'use_totp' => (is_null($toggleState) ? !$user->use_totp : $toggleState), ]);
database/Factories/UserFactory.php+3 −8 modified@@ -5,18 +5,13 @@ use Carbon\Carbon; use Ramsey\Uuid\Uuid; use Illuminate\Support\Str; -use Pterodactyl\Models\User; use Illuminate\Database\Eloquent\Factories\Factory; +/** + * @extends \Illuminate\Database\Eloquent\Factories\Factory<\Pterodactyl\Models\User> + */ class UserFactory extends Factory { - /** - * The name of the factory's corresponding model. - * - * @var string - */ - protected $model = User::class; - /** * Define the model's default state. */
tests/Integration/Http/Controllers/Auth/LoginCheckpointControllerTest.php+207 −0 added@@ -0,0 +1,207 @@ +<?php + +namespace Pterodactyl\Tests\Integration\Http\Controllers\Auth; + +use Carbon\Carbon; +use Pterodactyl\Models\User; +use PragmaRX\Google2FA\Google2FA; +use Illuminate\Auth\Events\Failed; +use Illuminate\Support\Facades\Event; +use Illuminate\Support\Facades\Session; +use Pterodactyl\Events\Auth\DirectLogin; +use PHPUnit\Framework\Attributes\TestWith; +use Pterodactyl\Tests\Integration\Http\HttpTestCase; +use Pterodactyl\Events\Auth\ProvidedAuthenticationToken; + +class LoginCheckpointControllerTest extends HttpTestCase +{ + public function setUp(): void + { + parent::setUp(); + + Event::fake([Failed::class, DirectLogin::class, ProvidedAuthenticationToken::class]); + } + + /** + * Basic test that a user can be signed in using their TOTP token and that + * the `totp_authenticated_at` field in the database is updated to the login + * verification time. + */ + #[TestWith([null])] + #[TestWith([-31])] + #[TestWith([-60])] + public function testUserCanSignInUsingTotpToken(?int $ts): void + { + $user = User::factory()->create([ + 'use_totp' => true, + 'totp_secret' => encrypt(str_repeat('a', 16)), + 'totp_authenticated_at' => is_null($ts) ? null : Carbon::now()->addSeconds($ts), + ]); + + Session::put('auth_confirmation_token', [ + 'user_id' => $user->id, + 'token_value' => 'token', + 'expires_at' => now()->addMinutes(5), + ]); + + $totp = $this->app->make(Google2FA::class)->getCurrentOtp(str_repeat('a', 16)); + + $this->withoutExceptionHandling()->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'token', + 'authentication_code' => $totp, + ])) + ->assertOk() + ->assertSessionMissing('auth_confirmation_token') + ->assertJsonPath('data.complete', true) + ->assertJsonPath('data.intended', '/') + ->assertJsonPath('data.user.uuid', $user->uuid); + + $this->assertEquals(now(), $user->refresh()->totp_authenticated_at); + + $this->assertAuthenticatedAs($user); + + Event::assertDispatched(fn (DirectLogin $event) => $event->user->is($user) && $event->remember); + Event::assertDispatched(fn (ProvidedAuthenticationToken $event) => $event->user->is($user)); + } + + /** + * Test that a TOTP token cannot be reused by verifying that the OTP verification + * logic fails if the token's timestamp is before the `totp_authenticated_at` + * column value. + * + * @see https://github.com/pterodactyl/panel/security/advisories/GHSA-rgmp-4873-r683 + */ + #[TestWith([1])] + #[TestWith([30])] + #[TestWith([80])] + public function testTotpTokenCannotBeReused(int $seconds): void + { + $user = User::factory()->create([ + 'use_totp' => true, + 'totp_secret' => encrypt(str_repeat('a', 16)), + 'totp_authenticated_at' => now()->addSeconds($seconds), + ]); + + Session::put('auth_confirmation_token', [ + 'user_id' => $user->id, + 'token_value' => 'token', + 'expires_at' => now()->addMinutes(5), + ]); + + $totp = $this->app->make(Google2FA::class)->getCurrentOtp(str_repeat('a', 16)); + + $this->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'token', + 'authentication_code' => $totp, + ])) + ->assertBadRequest() + ->assertJsonPath('errors.0.detail', 'The two-factor authentication token was invalid.'); + + $this->assertGuest(); + $this->assertEquals(now()->addSeconds($seconds), $user->refresh()->totp_authenticated_at); + + Event::assertDispatched(fn (Failed $event) => $event->guard === 'auth' && $event->user->is($user)); + } + + public function testEndpointReturnsErrorIfSessionMissing(): void + { + $this->postJson(route('auth.login-checkpoint')) + ->assertUnprocessable() + ->assertJsonPath('errors.0.meta.source_field', 'confirmation_token') + ->assertJsonPath('errors.1.meta.source_field', 'authentication_code') + ->assertJsonPath('errors.2.meta.source_field', 'recovery_token'); + + $this->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'token', + 'authentication_code' => '123456', + ])) + ->assertBadRequest() + ->assertJsonPath('errors.0.detail', 'The authentication token provided has expired, please refresh the page and try again.'); + + $this->assertGuest(); + + Event::assertDispatched(fn (Failed $event) => $event->guard === 'auth'); + } + + public function testEndpointAppliesThrottling(): void + { + for ($i = 0; $i < 5; ++$i) { + $this->postJson(route('auth.login-checkpoint', ['confirmation_token' => 'token', 'authentication_code' => '123456'])) + ->assertBadRequest(); + } + + $this->postJson(route('auth.login-checkpoint', ['confirmation_token' => 'token', 'authentication_code' => '123456'])) + ->assertTooManyRequests(); + } + + public function testEndpointBlocksSessionDataMismatch(): void + { + $user = User::factory()->create([ + 'use_totp' => true, + 'totp_secret' => str_repeat('a', 16), + ]); + + Session::put('auth_confirmation_token', [ + 'user_id' => $user->id, + 'token_value' => 'token', + 'expires_at' => now()->addMinutes(5), + ]); + + $this->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'wrong-token', + 'authentication_code' => $this->app->make(Google2FA::class)->getCurrentOtp(str_repeat('a', 16)), + ])) + ->assertBadRequest(); + + $this->assertGuest(); + + Event::assertDispatched(Failed::class); + } + + public function testEndpointReturnsErrorIfUserDoesNotExist(): void + { + Session::put('auth_confirmation_token', [ + 'user_id' => 0, + 'token_value' => 'token', + 'expires_at' => now()->addMinutes(5), + ]); + + $this->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'token', + 'authentication_code' => '123456', + ])) + ->assertBadRequest() + ->assertJsonPath('errors.0.detail', 'The authentication token provided has expired, please refresh the page and try again.'); + } + + public function testEndpointAllowsRecoveryToken(): void + { + $user = User::factory()->create(); + $token = $user->recoveryTokens()->forceCreate(['token' => password_hash('recovery', PASSWORD_DEFAULT)]); + + Session::put('auth_confirmation_token', [ + 'user_id' => $user->id, + 'token_value' => 'token', + 'expires_at' => now()->addMinutes(5), + ]); + + $this->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'token', + 'recovery_token' => 'invalid', + ])) + ->assertBadRequest() + ->assertJsonPath('errors.0.detail', 'The recovery token provided is not valid.'); + + $this->assertGuest(); + + $this->postJson(route('auth.login-checkpoint', [ + 'confirmation_token' => 'token', + 'recovery_token' => 'recovery', + ])) + ->assertOk() + ->assertSessionMissing('auth_confirmation_token'); + + Event::assertDispatched(ProvidedAuthenticationToken::class); + Event::assertDispatched(DirectLogin::class); + } +}
tests/TestCase.php+4 −2 modified@@ -15,8 +15,10 @@ public function setUp(): void { parent::setUp(); - Carbon::setTestNow(Carbon::now()); - CarbonImmutable::setTestNow(Carbon::now()); + $now = Carbon::now()->startOfSecond(); + + Carbon::setTestNow($now); + CarbonImmutable::setTestNow($now); // Why, you ask? If we don't force this to false it is possible for certain exceptions // to show their error message properly in the integration test output, but not actually
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-rgmp-4873-r683ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-69197ghsaADVISORY
- github.com/pterodactyl/panel/commit/032bf076d92bb2f929fa69c1bac1b89f26b8badfghsax_refsource_MISCWEB
- github.com/pterodactyl/panel/releases/tag/v1.12.0ghsax_refsource_MISCWEB
- github.com/pterodactyl/panel/security/advisories/GHSA-rgmp-4873-r683ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.