CVE-2022-31050: Insufficient Session Expiration (CWE-613)
value: TYPO3 is an open source web content management system. Prior to versions 9.5.34 ELTS, 10.4.29, and 11.5.11, Admin Tool sessions initiated via the TYPO3 backend user interface had not been revoked even if the corresponding user account was degraded to lower permissions or disabled completely. This way, sessions in the admin tool theoretically could have been prolonged without any limit. TYPO3 versions 9.5.34 ELTS, 10.4.29, and 11.5.11 contain a fix for the problem.
CWE description: According to WASC, "Insufficient Session Expiration is when a web site permits an attacker to reuse old session credentials or session IDs for authorization."
CVSS v3 base score: 7.2
Published: 2022-06-14T21:15Z
Repository: https://github.com/TYPO3/typo3
Language: PHP
Files changed: typo3/sysext/install/Classes/Controller/BackendModuleController.php, typo3/sysext/install/Classes/Middleware/Maintenance.php, typo3/sysext/install/Classes/Service/SessionService.php, typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php
Commit date: 2022-06-14 09:19:06 +0200
Commit message: [SECURITY] Synchronize admin tools session with backend user session

Admin tools sessions are revoked in case the initiatin backend user
does not have admin or system maintainer privileges anymore. Besides
that, revoking backend user interface sessions now also revokes access
to admin tools. Standalone install tool is not affected.

Resolves: #92019
Releases: main, 11.5, 10.4
Change-Id: I367098abd632fa34caa59e4e165f5ab1916894c5
Security-Bulletin: TYPO3-CORE-SA-2022-005
Security-References: CVE-2022-31050
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/74905
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>

Diff: diff --git a/typo3/sysext/install/Classes/Controller/BackendModuleController.php b/typo3/sysext/install/Classes/Controller/BackendModuleController.php
index 825190544bf0..d592d9838536 100644
--- a/typo3/sysext/install/Classes/Controller/BackendModuleController.php
+++ b/typo3/sysext/install/Classes/Controller/BackendModuleController.php
@@ -173,11 +173,12 @@ class BackendModuleController
      * Starts / updates the session and redirects to the Install Tool
      * with given action.
      */
     protected function setAuthorizedAndRedirect(string $controller): ResponseInterface
     {
-        $this->getSessionService()->setAuthorizedBackendSession();
+        $userSession = $this->getBackendUser()->getSession();
+        $this->getSessionService()->setAuthorizedBackendSession($userSession);
         $redirectLocation = PathUtility::getAbsoluteWebPath('install.php?install[controller]=' . $controller . '&install[context]=backend');
         return new RedirectResponse($redirectLocation, 303);
     }
 
     /**
diff --git a/typo3/sysext/install/Classes/Middleware/Maintenance.php b/typo3/sysext/install/Classes/Middleware/Maintenance.php
index 09ead1cc6c61..e77cd9f41125 100644
--- a/typo3/sysext/install/Classes/Middleware/Maintenance.php
+++ b/typo3/sysext/install/Classes/Middleware/Maintenance.php
@@ -141,10 +141,25 @@ class Maintenance implements MiddlewareInterface
             return $controller->showLoginAction($request);
         }
 
         // session related actions
         $session = new SessionService();
+
+        // the backend user has an active session but the admin / maintainer
+        // rights have been revoked or the user was disabled or deleted in the meantime
+        if ($session->isAuthorizedBackendUserSession() && !$session->hasActiveBackendUserRoleAndSession()) {
+            // log out the user and destroy the session
+            $session->resetSession();
+            $session->destroySession();
+            $formProtection = FormProtectionFactory::get(
+                InstallToolFormProtection::class
+            );
+            $formProtection->clean();
+
+            return new HtmlResponse('', 403);
+        }
+
         if ($actionName === 'preAccessCheck') {
             $response = new JsonResponse([
                 'installToolLocked' => !$this->checkEnableInstallToolFile(),
                 'isAuthorized' => $session->isAuthorized(),
             ]);
diff --git a/typo3/sysext/install/Classes/Service/SessionService.php b/typo3/sysext/install/Classes/Service/SessionService.php
index 1d12c094aef0..6fc93c3564f0 100644
--- a/typo3/sysext/install/Classes/Service/SessionService.php
+++ b/typo3/sysext/install/Classes/Service/SessionService.php
@@ -15,12 +15,19 @@
 
 namespace TYPO3\CMS\Install\Service;
 
 use Symfony\Component\HttpFoundation\Cookie;
 use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Database\ConnectionPool;
+use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer;
+use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
 use TYPO3\CMS\Core\Messaging\FlashMessage;
 use TYPO3\CMS\Core\Security\BlockSerializationTrait;
+use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface;
+use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
+use TYPO3\CMS\Core\Session\SessionManager;
+use TYPO3\CMS\Core\Session\UserSession;
 use TYPO3\CMS\Core\SingletonInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 use TYPO3\CMS\Install\Exception;
 use TYPO3\CMS\Install\Service\Session\FileSessionHandler;
 
@@ -186,18 +193,32 @@ class SessionService implements SingletonInterface
     }
 
     /**
      * Marks this session as an "authorized by backend user" one.
      * This is called by BackendModuleController from backend context.
+     *
+     * @param UserSession $userSession session of the current backend user
      */
-    public function setAuthorizedBackendSession()
+    public function setAuthorizedBackendSession(UserSession $userSession)
     {
+        $nonce = bin2hex(random_bytes(20));
+        $sessionBackend = $this->getBackendUserSessionBackend();
+        // use hash mechanism of session backend, or pass plain value through generic hmac
+        $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
+            ? $sessionBackend->hash($userSession->getIdentifier())
+            : hash_hmac('sha256', $userSession->getIdentifier(), $nonce);
+
         $_SESSION['authorized'] = true;
         $_SESSION['lastSessionId'] = time();
         $_SESSION['tstamp'] = time();
         $_SESSION['expires'] = time() + $this->expireTimeInMinutes * 60;
         $_SESSION['isBackendSession'] = true;
+        $_SESSION['backendUserSession'] = [
+            'nonce' => $nonce,
+            'userId' => $userSession->getUserId(),
+            'hmac' => $sessionHmac,
+        ];
         // Renew the session id to avoid session fixation
         $this->renewSession();
     }
 
     /**
@@ -220,11 +241,11 @@ class SessionService implements SingletonInterface
     /**
      * Check if we have an authorized session from a system maintainer
      *
      * @return bool TRUE if this session has been authorized before and initialized by a backend system maintainer
      */
-    public function isAuthorizedBackendUserSession()
+    public function isAuthorizedBackendUserSession(): bool
     {
         if (!$this->hasSessionCookie()) {
             return false;
         }
         $this->initializeSession();
@@ -232,10 +253,53 @@ class SessionService implements SingletonInterface
             return false;
         }
         return !$this->isExpired();
     }
 
+    /**
+     * Evaluates whether the backend user that initiated this admin tool session,
+     * has an active role (is still admin & system maintainer) and has an active backend user interface session.
+     *
+     * @return bool whether the backend user has an active role and backend user interface session
+     */
+    public function hasActiveBackendUserRoleAndSession(): bool
+    {
+        // @see \TYPO3\CMS\Install\Controller\BackendModuleController::setAuthorizedAndRedirect()
+        $backendUserSession = $this->getBackendUserSession();
+        $backendUserRecord = $this->getBackendUserRecord($backendUserSession['userId']);
+        if ($backendUserRecord === null || empty($backendUserRecord['uid'])) {
+            return false;
+        }
+        $isAdmin = (($backendUserRecord['admin'] ?? 0) & 1) === 1;
+        $systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
+        // stop here, in case the current admin tool session does not belong to a backend user having admin & maintainer privileges
+        if (!$isAdmin || !in_array((int)$backendUserRecord['uid'], $systemMaintainers, true)) {
+            return false;
+        }
+
+        $sessionBackend = $this->getBackendUserSessionBackend();
+        foreach ($sessionBackend->getAll() as $sessionRecord) {
+            $sessionUserId = (int)($sessionRecord['ses_userid'] ?? 0);
+            // skip, in case backend user id does not match
+            if ($backendUserSession['userId'] !== $sessionUserId) {
+                continue;
+            }
+            $sessionId = (string)($sessionRecord['ses_id'] ?? '');
+            // use persisted hashed `ses_id` directly, or pass through hmac for plain values
+            $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
+                ? $sessionId
+                : hash_hmac('sha256', $sessionId, $backendUserSession['nonce']);
+            // skip, in case backend user session id does not match
+            if ($backendUserSession['hmac'] !== $sessionHmac) {
+                continue;
+            }
+            // backend user id and session id matched correctly
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Check if our session is expired.
      * Useful only right after a FALSE "isAuthorized" to see if this is the
      * reason for not being authorized anymore.
      *
@@ -297,10 +361,24 @@ class SessionService implements SingletonInterface
         }
         $_SESSION['messages'] = [];
         return $messages;
     }
 
+    /**
+     * @return array{userId: int, nonce: string, hmac: string} backend user session references
+     */
+    public function getBackendUserSession(): array
+    {
+        if (empty($_SESSION['backendUserSession'])) {
+            throw new Exception(
+                'The backend user session is only available if invoked via the backend user interface.',
+                1624879295
+            );
+        }
+        return $_SESSION['backendUserSession'];
+    }
+
     /**
      * Check if php session.auto_start is enabled
      *
      * @return bool TRUE if session.auto_start is enabled, FALSE if disabled
      */
@@ -321,6 +399,54 @@ class SessionService implements SingletonInterface
             ini_get($configOption),
             FILTER_VALIDATE_BOOLEAN,
             [FILTER_REQUIRE_SCALAR, FILTER_NULL_ON_FAILURE]
         );
     }
+
+    /**
+     * Fetching a user record with uid=$uid.
+     * Functionally similar to TYPO3\CMS\Core\Authentication\BackendUserAuthentication::setBeUserByUid().
+     *
+     * @param int $uid The UID of the backend user
+     * @return array<string, int>|null The backend user record or NULL
+     */
+    protected function getBackendUserRecord(int $uid): ?array
+    {
+        $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class);
+        $restrictionContainer->add(GeneralUtility::makeInstance(RootLevelRestriction::class, ['be_users']));
+
+        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
+        $queryBuilder->setRestrictions($restrictionContainer);
+        $queryBuilder->select('uid', 'admin')
+            ->from('be_users')
+            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)));
+
+        $resetBeUsersTca = false;
+        if (!isset($GLOBALS['TCA']['be_users'])) {
+            // The admin tool intentionally does not load any TCA information at this time.
+            // The database restictions, needs the enablecolumns TCA information
+            // for 'be_users' to load the user correctly.
+            // That is why this part of the TCA ($GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'])
+            // is simulated.
+            // The simulation state will be removed later to avoid unexpected side effects.
+            $GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'] = [
+                'rootLevel' => 1,
+                'deleted' => 'deleted',
+                'disabled' => 'disable',
+                'starttime' => 'starttime',
+                'endtime' => 'endtime',
+            ];
+            $resetBeUsersTca = true;
+        }
+        $result = $queryBuilder->executeQuery()->fetchAssociative();
+        if ($resetBeUsersTca) {
+            unset($GLOBALS['TCA']['be_users']);
+        }
+
+        return is_array($result) ? $result : null;
+    }
+
+    protected function getBackendUserSessionBackend(): SessionBackendInterface
+    {
+        return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend('BE');
+    }
 }
diff --git a/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php b/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php
index 48de7619ca89..3eb4f9d43912 100644
--- a/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php
+++ b/typo3/sysext/install/Tests/Functional/Controller/BackendModuleControllerTest.php
@@ -17,10 +17,11 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Install\Tests\Functional\Controller;
 
 use TYPO3\CMS\Backend\Routing\UriBuilder;
 use TYPO3\CMS\Backend\Template\ModuleTemplateFactory;
+use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
 use TYPO3\CMS\Core\Core\ApplicationContext;
 use TYPO3\CMS\Core\Core\Environment;
 use TYPO3\CMS\Install\Controller\BackendModuleController;
 use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
 
@@ -82,11 +83,17 @@ class BackendModuleControllerTest extends FunctionalTestCase
             Environment::getConfigPath(),
             Environment::getBackendPath() . '/index.php',
             Environment::isWindows() ? 'WINDOWS' : 'UNIX'
         );
 
-        // Authorized redirect to the install tool is performed, sudo mode is not required
+        // Authorized redirect to the admin tool is performed
+        // sudo mode is not required (due to development context)
+        $GLOBALS['BE_USER'] = new BackendUserAuthentication();
+        // using anonymous user session, which is fine for this test case
+        $GLOBALS['BE_USER']->initializeUserSessionManager();
+        $GLOBALS['BE_USER']->user = ['uid' => 1];
+
         $response = $subject->{$action}();
         self::assertEquals(303, $response->getStatusCode());
         self::assertNotEmpty($response->getHeader('location'));
         self::assertStringContainsString(
             'typo3/install.php?install[controller]=' . $module . '&install[context]=backend',

Vulnerable code:         $this->getSessionService()->setAuthorizedBackendSession();
    public function setAuthorizedBackendSession()
    public function isAuthorizedBackendUserSession()
        // Authorized redirect to the install tool is performed, sudo mode is not required
Fixed code:         $userSession = $this->getBackendUser()->getSession();
        $this->getSessionService()->setAuthorizedBackendSession($userSession);

        // the backend user has an active session but the admin / maintainer
        // rights have been revoked or the user was disabled or deleted in the meantime
        if ($session->isAuthorizedBackendUserSession() && !$session->hasActiveBackendUserRoleAndSession()) {
            // log out the user and destroy the session
            $session->resetSession();
            $session->destroySession();
            $formProtection = FormProtectionFactory::get(
                InstallToolFormProtection::class
            );
            $formProtection->clean();

            return new HtmlResponse('', 403);
        }

use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\DefaultRestrictionContainer;
use TYPO3\CMS\Core\Database\Query\Restriction\RootLevelRestriction;
use TYPO3\CMS\Core\Session\Backend\HashableSessionBackendInterface;
use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
use TYPO3\CMS\Core\Session\SessionManager;
use TYPO3\CMS\Core\Session\UserSession;
     *
     * @param UserSession $userSession session of the current backend user
    public function setAuthorizedBackendSession(UserSession $userSession)
        $nonce = bin2hex(random_bytes(20));
        $sessionBackend = $this->getBackendUserSessionBackend();
        // use hash mechanism of session backend, or pass plain value through generic hmac
        $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
            ? $sessionBackend->hash($userSession->getIdentifier())
            : hash_hmac('sha256', $userSession->getIdentifier(), $nonce);

        $_SESSION['backendUserSession'] = [
            'nonce' => $nonce,
            'userId' => $userSession->getUserId(),
            'hmac' => $sessionHmac,
        ];
    public function isAuthorizedBackendUserSession(): bool
    /**
     * Evaluates whether the backend user that initiated this admin tool session,
     * has an active role (is still admin & system maintainer) and has an active backend user interface session.
     *
     * @return bool whether the backend user has an active role and backend user interface session
     */
    public function hasActiveBackendUserRoleAndSession(): bool
    {
        // @see \TYPO3\CMS\Install\Controller\BackendModuleController::setAuthorizedAndRedirect()
        $backendUserSession = $this->getBackendUserSession();
        $backendUserRecord = $this->getBackendUserRecord($backendUserSession['userId']);
        if ($backendUserRecord === null || empty($backendUserRecord['uid'])) {
            return false;
        }
        $isAdmin = (($backendUserRecord['admin'] ?? 0) & 1) === 1;
        $systemMaintainers = array_map('intval', $GLOBALS['TYPO3_CONF_VARS']['SYS']['systemMaintainers'] ?? []);
        // stop here, in case the current admin tool session does not belong to a backend user having admin & maintainer privileges
        if (!$isAdmin || !in_array((int)$backendUserRecord['uid'], $systemMaintainers, true)) {
            return false;
        }

        $sessionBackend = $this->getBackendUserSessionBackend();
        foreach ($sessionBackend->getAll() as $sessionRecord) {
            $sessionUserId = (int)($sessionRecord['ses_userid'] ?? 0);
            // skip, in case backend user id does not match
            if ($backendUserSession['userId'] !== $sessionUserId) {
                continue;
            }
            $sessionId = (string)($sessionRecord['ses_id'] ?? '');
            // use persisted hashed `ses_id` directly, or pass through hmac for plain values
            $sessionHmac = $sessionBackend instanceof HashableSessionBackendInterface
                ? $sessionId
                : hash_hmac('sha256', $sessionId, $backendUserSession['nonce']);
            // skip, in case backend user session id does not match
            if ($backendUserSession['hmac'] !== $sessionHmac) {
                continue;
            }
            // backend user id and session id matched correctly
            return true;
        }
        return false;
    }

    /**
     * @return array{userId: int, nonce: string, hmac: string} backend user session references
     */
    public function getBackendUserSession(): array
    {
        if (empty($_SESSION['backendUserSession'])) {
            throw new Exception(
                'The backend user session is only available if invoked via the backend user interface.',
                1624879295
            );
        }
        return $_SESSION['backendUserSession'];
    }


    /**
     * Fetching a user record with uid=$uid.
     * Functionally similar to TYPO3\CMS\Core\Authentication\BackendUserAuthentication::setBeUserByUid().
     *
     * @param int $uid The UID of the backend user
     * @return array<string, int>|null The backend user record or NULL
     */
    protected function getBackendUserRecord(int $uid): ?array
    {
        $restrictionContainer = GeneralUtility::makeInstance(DefaultRestrictionContainer::class);
        $restrictionContainer->add(GeneralUtility::makeInstance(RootLevelRestriction::class, ['be_users']));

        $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable('be_users');
        $queryBuilder->setRestrictions($restrictionContainer);
        $queryBuilder->select('uid', 'admin')
            ->from('be_users')
            ->where($queryBuilder->expr()->eq('uid', $queryBuilder->createNamedParameter($uid, \PDO::PARAM_INT)));

        $resetBeUsersTca = false;
        if (!isset($GLOBALS['TCA']['be_users'])) {
            // The admin tool intentionally does not load any TCA information at this time.
            // The database restictions, needs the enablecolumns TCA information
            // for 'be_users' to load the user correctly.
            // That is why this part of the TCA ($GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'])
            // is simulated.
            // The simulation state will be removed later to avoid unexpected side effects.
            $GLOBALS['TCA']['be_users']['ctrl']['enablecolumns'] = [
                'rootLevel' => 1,
                'deleted' => 'deleted',
                'disabled' => 'disable',
                'starttime' => 'starttime',
                'endtime' => 'endtime',
            ];
            $resetBeUsersTca = true;
        }
        $result = $queryBuilder->executeQuery()->fetchAssociative();
        if ($resetBeUsersTca) {
            unset($GLOBALS['TCA']['be_users']);
        }

        return is_array($result) ? $result : null;
    }

    protected function getBackendUserSessionBackend(): SessionBackendInterface
    {
        return GeneralUtility::makeInstance(SessionManager::class)->getSessionBackend('BE');
    }
use TYPO3\CMS\Core\Authentication\BackendUserAuthentication;
        // Authorized redirect to the admin tool is performed
        // sudo mode is not required (due to development context)
        $GLOBALS['BE_USER'] = new BackendUserAuthentication();
        // using anonymous user session, which is fine for this test case
        $GLOBALS['BE_USER']->initializeUserSessionManager();
        $GLOBALS['BE_USER']->user = ['uid' => 1];