CVE-2021-41268: Session Fixation (CWE-384)
value: Symfony/SecurityBundle is the security system for Symfony, a PHP framework for web and console applications and a set of reusable PHP components. Since the rework of the Remember me cookie in version 5.3.0, the cookie is not invalidated when the user changes their password. Attackers can therefore maintain their access to the account even if the password is changed as long as they have had the chance to login once and get a valid remember me cookie. Starting with version 5.3.12, Symfony makes the password part of the signature by default. In that way, when the password changes, then the cookie is not valid anymore.
CWE description: Authenticating a user, or otherwise establishing a new user session, without invalidating any existing session identifier gives an attacker the opportunity to steal authenticated sessions.
CVSS v3 base score: 8.8
Published: 2021-11-24T19:15Z
Repository: https://github.com/symfony/symfony
Language: PHP
Files changed: src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php, src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php, src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php
Commit date: 2021-10-23 14:13:53 +0200
Commit message: [SecurityBundle] Default signature_properties to the previous behavior

Diff: diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php
index de19f488454f..525726b0bd68 100644
--- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php
+++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Security/Factory/RememberMeFactory.php
@@ -206,10 +206,11 @@ class RememberMeFactory implements SecurityFactoryInterface, AuthenticatorFactor
             ->arrayNode('signature_properties')
                 ->prototype('scalar')->end()
                 ->requiresAtLeastOneElement()
                 ->info('An array of properties on your User that are used to sign the remember-me cookie. If any of these change, all existing cookies will become invalid.')
                 ->example(['email', 'password'])
+                ->defaultValue(['password'])
             ->end()
             ->arrayNode('token_provider')
                 ->beforeNormalization()
                     ->ifString()->then(function ($v) { return ['service' => $v]; })
                 ->end()
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php
index a5306b6bf160..f2eebacf2ca3 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/Bundle/RememberMeBundle/Security/UserChangingUserProvider.php
@@ -19,35 +19,42 @@ use Symfony\Component\Security\Core\User\UserProviderInterface;
 
 class UserChangingUserProvider implements UserProviderInterface
 {
     private $inner;
 
+    public static $changePassword = false;
+
     public function __construct(InMemoryUserProvider $inner)
     {
         $this->inner = $inner;
     }
 
     public function loadUserByUsername($username)
     {
-        return $this->inner->loadUserByUsername($username);
+        return $this->changeUser($this->inner->loadUserByUsername($username));
     }
 
     public function loadUserByIdentifier(string $userIdentifier): UserInterface
     {
-        return $this->inner->loadUserByIdentifier($userIdentifier);
+        return $this->changeUser($this->inner->loadUserByIdentifier($userIdentifier));
     }
 
     public function refreshUser(UserInterface $user)
     {
-        $user = $this->inner->refreshUser($user);
-
-        $alterUser = \Closure::bind(function (InMemoryUser $user) { $user->password = 'foo'; }, null, class_exists(User::class) ? User::class : InMemoryUser::class);
-        $alterUser($user);
-
-        return $user;
+        return $this->changeUser($this->inner->refreshUser($user));
     }
 
     public function supportsClass($class)
     {
         return $this->inner->supportsClass($class);
     }
+
+    private function changeUser(UserInterface $user): UserInterface
+    {
+        if (self::$changePassword) {
+            $alterUser = \Closure::bind(function (InMemoryUser $user) { $user->password = 'changed!'; }, null, class_exists(User::class) ? User::class : InMemoryUser::class);
+            $alterUser($user);
+        }
+
+        return $user;
+    }
 }
diff --git a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php
index 7af43e1154a4..c1958c7dee3f 100644
--- a/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php
+++ b/src/Symfony/Bundle/SecurityBundle/Tests/Functional/RememberMeTest.php
@@ -9,12 +9,19 @@
  * file that was distributed with this source code.
  */
 
 namespace Symfony\Bundle\SecurityBundle\Tests\Functional;
 
+use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Security\UserChangingUserProvider;
+
 class RememberMeTest extends AbstractWebTestCase
 {
+    protected function setUp(): void
+    {
+        UserChangingUserProvider::$changePassword = false;
+    }
+
     /**
      * @dataProvider provideConfigs
      */
     public function testRememberMe(array $options)
     {
@@ -49,15 +56,23 @@ class RememberMeTest extends AbstractWebTestCase
             '_password' => 'test',
         ]);
 
         $this->assertSame(302, $client->getResponse()->getStatusCode());
         $cookieJar = $client->getCookieJar();
-        $this->assertNotNull($cookieJar->get('REMEMBERME'));
+        $this->assertNotNull($cookie = $cookieJar->get('REMEMBERME'));
+
+        UserChangingUserProvider::$changePassword = true;
 
+        // change password (through user provider), this deauthenticates the session
         $client->request('GET', '/profile');
         $this->assertRedirect($client->getResponse(), '/login');
         $this->assertNull($cookieJar->get('REMEMBERME'));
+
+        // restore the old remember me cookie, it should no longer be valid
+        $cookieJar->set($cookie);
+        $client->request('GET', '/profile');
+        $this->assertRedirect($client->getResponse(), '/login');
     }
 
     public function testSessionLessRememberMeLogout()
     {
         $client = $this->createClient(['test_case' => 'RememberMe', 'root_config' => 'stateless_config.yml']);
@@ -119,15 +134,23 @@ class RememberMeTest extends AbstractWebTestCase
             '_password' => 'test',
         ]);
 
         $this->assertSame(302, $client->getResponse()->getStatusCode());
         $cookieJar = $client->getCookieJar();
-        $this->assertNotNull($cookieJar->get('REMEMBERME'));
+        $this->assertNotNull($cookie = $cookieJar->get('REMEMBERME'));
+
+        UserChangingUserProvider::$changePassword = true;
 
+        // change password (through user provider), this deauthenticates the session
         $client->request('GET', '/profile');
         $this->assertRedirect($client->getResponse(), '/login');
         $this->assertNull($cookieJar->get('REMEMBERME'));
+
+        // restore the old remember me cookie, it should no longer be valid
+        $cookieJar->set($cookie);
+        $client->request('GET', '/profile');
+        $this->assertRedirect($client->getResponse(), '/login');
     }
 
     /**
      * @group legacy
      */

Vulnerable code:         return $this->inner->loadUserByUsername($username);
        return $this->inner->loadUserByIdentifier($userIdentifier);
        $user = $this->inner->refreshUser($user);

        $alterUser = \Closure::bind(function (InMemoryUser $user) { $user->password = 'foo'; }, null, class_exists(User::class) ? User::class : InMemoryUser::class);
        $alterUser($user);

        return $user;
        $this->assertNotNull($cookieJar->get('REMEMBERME'));
        $this->assertNotNull($cookieJar->get('REMEMBERME'));
Fixed code:                 ->defaultValue(['password'])
    public static $changePassword = false;

        return $this->changeUser($this->inner->loadUserByUsername($username));
        return $this->changeUser($this->inner->loadUserByIdentifier($userIdentifier));
        return $this->changeUser($this->inner->refreshUser($user));

    private function changeUser(UserInterface $user): UserInterface
    {
        if (self::$changePassword) {
            $alterUser = \Closure::bind(function (InMemoryUser $user) { $user->password = 'changed!'; }, null, class_exists(User::class) ? User::class : InMemoryUser::class);
            $alterUser($user);
        }

        return $user;
    }
use Symfony\Bundle\SecurityBundle\Tests\Functional\Bundle\RememberMeBundle\Security\UserChangingUserProvider;

    protected function setUp(): void
    {
        UserChangingUserProvider::$changePassword = false;
    }

        $this->assertNotNull($cookie = $cookieJar->get('REMEMBERME'));

        UserChangingUserProvider::$changePassword = true;
        // change password (through user provider), this deauthenticates the session

        // restore the old remember me cookie, it should no longer be valid
        $cookieJar->set($cookie);
        $client->request('GET', '/profile');
        $this->assertRedirect($client->getResponse(), '/login');
        $this->assertNotNull($cookie = $cookieJar->get('REMEMBERME'));

        UserChangingUserProvider::$changePassword = true;
        // change password (through user provider), this deauthenticates the session

        // restore the old remember me cookie, it should no longer be valid
        $cookieJar->set($cookie);
        $client->request('GET', '/profile');
        $this->assertRedirect($client->getResponse(), '/login');