VYPR
Moderate severityOSV Advisory· Published Jan 6, 2026· Updated Jan 6, 2026

Pterodactyl TOTPs can be reused during validity window

CVE-2025-69197

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.

PackageAffected versionsPatched versions
pterodactyl/panelPackagist
< 1.12.01.12.0

Affected products

1

Patches

1
032bf076d92b

Ensure that TOTP tokens cannot be reused (#5481)

https://github.com/pterodactyl/panelDane EverittDec 30, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.