CVE-2026-49740
Description
TYPO3's cache and registry components allow PHP Object Injection via deserialization of untrusted payloads, leading to potential RCE.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
TYPO3's cache and registry components allow PHP Object Injection via deserialization of untrusted payloads, leading to potential RCE.
Vulnerability
TYPO3's cache frontend (VariableFrontend) and persistent key-value store (Registry) deserialize PHP payloads without proper validation. This allows for PHP Object Injection if an attacker has write access to the underlying storage backend, such as the SQL database or file system. This affects TYPO3 CMS versions before 10.4.57, 11.0.0-11.5.51, 12.0.0-12.4.46, 13.0.0-13.4.31, and 14.0.0-14.3.3 [3].
Exploitation
An attacker requires direct local write access to the storage backend, such as the SQL database or file system. With this access, they can inject a crafted serialized PHP payload. This payload, when deserialized by the vulnerable components, can trigger a gadget chain to achieve high-impact effects [3].
Impact
Successful exploitation of this vulnerability can lead to Remote Code Execution or other high-impact effects. The scope of the compromise depends on the privileges associated with the storage backend write access [3].
Mitigation
Update to TYPO3 versions 10.4.57 ELTS, 11.5.51 ELTS, 12.4.46 ELTS, 13.4.31 LTS, or 14.3.3 LTS. These versions contain fixes for the deserialization flaws [3]. The security advisory was released on June 9, 2026 [3].
AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
248bcf24f31f5[SECURITY] Mitigate deserialization flaws
24 files changed · +819 −37
typo3/sysext/core/Classes/Cache/Frontend/VariableFrontend.php+17 −2 modified@@ -18,6 +18,10 @@ namespace TYPO3\CMS\Core\Cache\Frontend; use TYPO3\CMS\Core\Cache\Backend\TransientBackendInterface; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -54,7 +58,9 @@ public function set(string $entryIdentifier, mixed $data, array $tags = [], ?int GeneralUtility::callUserFunction($_funcRef, $params, $this); } if (!$this->backend instanceof TransientBackendInterface) { - $data = serialize($data); + // No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup. + $deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService()); + $data = $deserializer->serialize($data, VariableFrontend::class); } $this->backend->set($entryIdentifier, $data, $tags, $lifetime); } @@ -74,6 +80,15 @@ public function get(string $entryIdentifier): mixed if ($rawResult === false) { return false; } - return $this->backend instanceof TransientBackendInterface ? $rawResult : unserialize($rawResult); + if ($this->backend instanceof TransientBackendInterface) { + return $rawResult; + } + try { + // No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup. + $deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService()); + return $deserializer->deserialize($rawResult, VariableFrontend::class); + } catch (DeserializerException) { + return false; + } } }
typo3/sysext/core/Classes/Registry.php+3 −1 modified@@ -17,6 +17,7 @@ use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -41,6 +42,7 @@ class Registry implements SingletonInterface */ protected $loadedNamespaces = []; + public function __construct(protected readonly DenyListDeserializer $deserializer) {} /** * Returns a persistent entry. * @@ -169,7 +171,7 @@ protected function loadEntriesByNamespace($namespace) ['entry_namespace' => $namespace] ); while ($row = $result->fetchAssociative()) { - $this->entries[$namespace][$row['entry_key']] = unserialize($row['entry_value']); + $this->entries[$namespace][$row['entry_key']] = $this->deserializer->deserialize($row['entry_value']); } $this->loadedNamespaces[$namespace] = true; }
typo3/sysext/core/Classes/Resource/ProcessedFile.php+2 −2 modified@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Core\Resource; +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; use TYPO3\CMS\Core\Resource\Service\ConfigurationService; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Core\Utility\MathUtility; @@ -117,8 +118,7 @@ public function __construct(File $originalFile, string $taskType, array $process protected function reconstituteFromDatabaseRecord(array $databaseRow): void { $this->taskType = $this->taskType ?: $databaseRow['task_type']; - // @todo In case the original configuration contained file objects the reconstitution fails. See ConfigurationService->serialize() - $this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? ''); + $this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? '', ['allowed_classes' => [Area::class]]); $this->originalFileSha1 = $databaseRow['originalfilesha1']; $this->identifier = (string)$databaseRow['identifier'];
typo3/sysext/core/Classes/Serializer/AuthenticatedMessageDeserializer.php+72 −0 added@@ -0,0 +1,72 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Crypto\HashAlgo; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Exception\Crypto\InvalidHashStringException; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +#[Autoconfigure(public: true)] +final readonly class AuthenticatedMessageDeserializer +{ + private const HASH_ALGO = HashAlgo::SHA3_384; + + public function __construct( + private HashService $hashService, + private DeserializationService $deserializationService, + ) {} + + public function serialize(mixed $payload, string $additionalSecret): string + { + return $this->hashService->appendHmac( + serialize($payload), + $additionalSecret, + self::HASH_ALGO + ); + } + + public function deserialize(string $payload, string $additionalSecret): mixed + { + try { + $serialized = $this->hashService->validateAndStripHmac( + $payload, + $additionalSecret, + self::HASH_ALGO + ); + } catch (InvalidHashStringException $e) { + $classNames = $this->deserializationService->parseClassNames($payload); + // in case the payload does not contain any class names, continue with + // a secure deserialization attempt, not allowing any class names + if ($classNames === []) { + return unserialize($payload, ['allowed_classes' => false]); + } + throw new DeserializerException( + 'Authenticated Message Deserialization failed', + 1780317744, + $e + ); + } + // explicitly allowing all classes here after successful HMAC validation + return unserialize($serialized, ['allowed_classes' => true]); + } +}
typo3/sysext/core/Classes/Serializer/DenyListDeserializer.php+167 −0 added@@ -0,0 +1,167 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Security\BlockSerializationTrait; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * Deserializes a PHP-serialized payload while refusing any class that carries + * a user-defined __destruct() or an exploitable __wakeup() (one not provided + * solely by BlockSerializationTrait). + * + * The per-class deny/allow decision is made lazily via ReflectionClass at the + * first encounter of each class name, then cached in cache:core so that + * reflection is never repeated for the same class within a cache lifetime. + * + * Use this instead of a raw unserialize() call when the set of expected classes + * is not known upfront but dangerous gadget classes must still be excluded. + * + * @internal Only to be used by TYPO3 core + */ +#[Autoconfigure(public: true)] +final readonly class DenyListDeserializer +{ + /** + * @var list<string> + */ + private array $allowedClassNames; + private \ReflectionMethod $blockSerializationWakeup; + + public function __construct( + #[Autowire(service: 'cache.core')] + private PhpFrontend $cache, + private HashService $hashService, + private DeserializationService $deserializationService, + ) { + $allowedClassNames = $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'] ?? null; + $this->allowedClassNames = is_array($allowedClassNames) ? $allowedClassNames : []; + $this->blockSerializationWakeup = (new \ReflectionClass(BlockSerializationTrait::class))->getMethod('__wakeup'); + } + + /** + * Deserializes $payload, throwing DeserializerException if any class name + * found in the payload is a deserialization gadget, or if the payload is + * syntactically malformed. + */ + public function deserialize(string $payload): mixed + { + $classNames = $this->deserializationService->parseClassNames($payload); + foreach ($classNames as $className) { + if ($this->shallClassBeDenied($className)) { + throw new DeserializerException( + 'Denied class name "' . $className . '" found in payload', + 1778594101 + ); + } + } + + return $this->deserializationService->deserialize($payload, $classNames ?: false); + } + + private function shallClassBeDenied(string $className): bool + { + if (in_array($className, $this->allowedClassNames, true)) { + return false; + } + + $cacheKey = 'DenyListDeserializer_' . hash('xxh128', $className); + if ($this->cache->has($cacheKey)) { + $entry = $this->cache->require($cacheKey); + if (is_array($entry) + && isset($entry['denied'], $entry['hmac']) + && $this->hashService->validateHmac( + $this->createHmacPayload($className, (bool)$entry['denied']), + DenyListDeserializer::class, + $entry['hmac'] + ) + ) { + return (bool)$entry['denied']; + } + // Tampered or stale entry — fall through to recompute + } + + $denied = $this->resolveClassDenyStatus($className); + $hmac = $this->hashService->hmac($this->createHmacPayload($className, $denied), DenyListDeserializer::class); + $this->cache->set($cacheKey, 'return ' . var_export(['denied' => $denied, 'hmac' => $hmac], true) . ';'); + return $denied; + } + + private function createHmacPayload(string $className, bool $denied): string + { + return $className . ':' . ($denied ? '1' : '0'); + } + + private function resolveClassDenyStatus(string $className): bool + { + try { + $rc = new \ReflectionClass($className); + } catch (\ReflectionException) { + // The class does not exist or cannot be reflected (and not instantiated). + // Thus, the class is allowed, since it cannot be a gadget and would + // result in a `__PHP_Incomplete_Class` during deserialization. + return false; + } + if ($rc->isInterface() || $rc->isTrait()) { + return false; + } + return $this->getUserDefinedMethod($rc, '__destruct') !== null + || $this->hasDeniableWakeupMethod($rc); + } + + /** + * Returns the method when $methodName is declared in user-defined (non-internal) code + * somewhere in the class hierarchy. This excludes methods like Exception::__wakeup() + * that PHP declares internally and that are harmless for deserialization purposes. + */ + private function getUserDefinedMethod(\ReflectionClass $rc, string $methodName): ?\ReflectionMethod + { + if (!$rc->hasMethod($methodName)) { + return null; + } + $method = $rc->getMethod($methodName); + if ($method->getDeclaringClass()->isInternal()) { + return null; + } + return $method; + } + + /** + * Returns true when the class has a user-defined __wakeup() that is NOT + * BlockSerializationTrait::__wakeup(). Classes whose only __wakeup comes + * from BlockSerializationTrait are already protected against deserialization + * (the trait throws unconditionally) and must not be treated as gadgets. + * + * Note: for trait methods getDeclaringClass() returns the using class, not the + * trait — so the origin is identified by comparing the method's source file and line + * against the trait's own __wakeup declaration. + */ + private function hasDeniableWakeupMethod(\ReflectionClass $rc): bool + { + $method = $this->getUserDefinedMethod($rc, '__wakeup'); + if ($method === null) { + return false; + } + return $method->getFileName() !== $this->blockSerializationWakeup->getFileName() + || $method->getStartLine() !== $this->blockSerializationWakeup->getStartLine(); + } +}
typo3/sysext/core/Classes/ServiceProvider.php+10 −1 modified@@ -567,7 +567,16 @@ public static function getPackageDependentCacheIdentifier(ContainerInterface $co public static function getRegistry(ContainerInterface $container): Registry { - return self::new($container, Registry::class); + $denyListDeserializer = $container->has(Serializer\DenyListDeserializer::class) + ? $container->get(Serializer\DenyListDeserializer::class) + : new Serializer\DenyListDeserializer( + $container->get('cache.core'), + $container->get(HashService::class), + new Serializer\DeserializationService(), + ); + return self::new($container, Registry::class, [ + $denyListDeserializer, + ]); } public static function getFileIndexRepository(ContainerInterface $container): Resource\Index\FileIndexRepository
typo3/sysext/core/composer.json+1 −0 modified@@ -47,6 +47,7 @@ "composer-runtime-api": "^2.1", "bacon/bacon-qr-code": "^3.1.1", "christian-riesen/base32": "^1.6", + "composer/class-map-generator": "^1.7.2", "composer/semver": "^3.4", "doctrine/dbal": "~4.4.3", "doctrine/lexer": "^3.0.1",
typo3/sysext/core/Configuration/DefaultConfiguration.php+6 −0 modified@@ -192,6 +192,12 @@ ], ], ], + 'deserialization' => [ + // List of class names that are allowed to be deserialized even if they carry + // __destruct() or __wakeup() and would otherwise be blocked. Use this to + // explicitly permit classes that have been reviewed and are known to be safe. + 'allowedClassNames' => [], + ], 'caching' => [ 'cacheConfigurations' => [ // The core cache is is for core php code only and must
typo3/sysext/core/Documentation/Changelog/13.4.x/Important-108604-MitigateDeserializationFlaws.rst+120 −0 added@@ -0,0 +1,120 @@ +.. include:: /Includes.rst.txt + +.. _important-108604-1780297491: + +=================================================== +Important: #108604 - Mitigate deserialization flaws +=================================================== + +See :issue:`108604` + +Description +=========== + +TYPO3 introduces new serialization infrastructure to protect against PHP object +injection attacks. Two complementary strategies are applied depending on how +long the serialized data lives: + +**Cache frontend (**:php:`VariableFrontend`**) — HMAC-authenticated serialization** + +:php:`\TYPO3\CMS\Core\Cache\Frontend\VariableFrontend` (the default cache +frontend) now uses +:php:`\TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer` for both +writing and reading cache entries. On every :php:`set()` call the payload is +serialized and an HMAC is appended; on every :php:`get()` call the HMAC is +validated before deserialization proceeds. + +Because caches are temporary and written exclusively by the server that reads +them, this approach provides a strong integrity guarantee: an attacker cannot +craft a malicious serialized payload that the server would accept, since they +cannot forge the HMAC without knowing the encryption key. + +Cache entries written by an older TYPO3 version (without an HMAC) are handled +gracefully: if the payload contains no PHP class tokens it is deserialized +safely with :php:`allowed_classes: false`; if it does contain class tokens it +is discarded and treated as a cache miss, causing the entry to be regenerated +transparently. + +**Registry (**:php:`\TYPO3\CMS\Core\Registry`**) — gadget denylist** + +:php:`\TYPO3\CMS\Core\Registry` (the persistent key-value store backed by the +:sql:`sys_registry` table) deserializes stored payloads through +:php:`\TYPO3\CMS\Core\Serializer\DenyListDeserializer`. Before deserialization +the class names embedded in the payload are checked against a gadget deny list. +The deny/allow decision for each class is resolved lazily via +:php:`\ReflectionClass` on first encounter and then cached in :php:`cache:core` +(HMAC-signed for integrity) so that reflection is not repeated for the same +class within a cache lifetime. A class is considered a gadget when it carries a +user-defined :php:`__destruct()` or an exploitable :php:`__wakeup()` — that is, +a :php:`__wakeup()` not provided solely by +:php:`\TYPO3\CMS\Core\Security\BlockSerializationTrait`. If any gadget class is +referenced in a payload, a +:php:`\TYPO3\CMS\Core\Serializer\Exception\DeserializerException` is thrown and +deserialization is aborted. + +A denylist strategy (block known-bad, allow unknown) is used intentionally for +the registry to avoid breaking changes to long-lived persisted data. + +Impact +------ + +**Cache (**:php:`VariableFrontend`**)** + +Existing cache entries that contain serialized PHP objects will be treated as +cache misses on the next read. No exception is thrown; the entry is simply +discarded and the cache is repopulated on the next request. This is transparent +to callers. + +**Registry** + +Extensions or third-party code that stores serialized PHP objects in +:php:`Registry` may encounter a :php:`DeserializerException` at read time if +the serialized object graph contains a class with a user-defined +:php:`__destruct()` or :php:`__wakeup()` method. + +Migration & Insights +-------------------- + +In most cases no migration is required. The sections below aim to provide +some insights into internal details and general suggestions. + +**Cache (**:php:`VariableFrontend`**)** + +No migration is required. Stale or legacy cache entries are automatically +treated as misses and regenerated. If code stores object graphs in a +:php:`VariableFrontend`-backed cache it will continue to work as long as the +server reads the entry it wrote (same encryption key). + +**Registry — preferred approach: avoid object serialization** + +Review what is stored in the registry. Plain PHP arrays and scalar values are +not affected by this protection and should be preferred over serialized object +graphs wherever possible. + +**Registry — alternative: restructure the stored value** + +If an object must be stored, ensure that neither the object itself nor any +object reachable from it through public or serialized properties carries a +user-defined :php:`__destruct()` or :php:`__wakeup()` method. + +**Registry — last resort: explicit class allowlist** + +If neither of the above is feasible in the short term, the affected class can be +added to the site-level allowlist in :file:`config/system/additional.php` (or +the legacy :file:`typo3conf/AdditionalConfiguration.php`): + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'][] = + \Vendor\MyExtension\Domain\Model\MyObject::class; + +.. warning:: + + The allowlist setting bypasses the deserialization gadget protection for + the listed classes. It should only be used as a last resort after carefully + reviewing the class and confirming that its :php:`__destruct()` or + :php:`__wakeup()` implementation cannot be abused in a PHP object injection + attack chain. Remove the entry as soon as the underlying serialization is + refactored. + +.. index:: PHP-API, LocalConfiguration, ext:core
typo3/sysext/core/Tests/Functional/RegistryTest.php+32 −1 modified@@ -21,6 +21,7 @@ use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; final class RegistryTest extends FunctionalTestCase @@ -158,6 +159,36 @@ public function canGetSetEntry(): void self::assertSame('value1', $this->subject->get('ns1', 'key1')); } + #[Test] + public function canGetEntryWithClassInstance(): void + { + $object = new \stdClass(); + $object->foo = 'bar'; + + $connection = (new ConnectionPool())->getConnectionForTable('sys_registry'); + $connection->bulkInsert( + 'sys_registry', + [ + ['ns1', 'key1', serialize($object)], + ['ns2', 'key1', serialize($object)], + ], + ['entry_namespace', 'entry_key', 'entry_value'], + [ + 'entry_value' => Connection::PARAM_LOB, + ] + ); + + // first hit for stdClass (not in DenyListSerializer cache) + $result = $this->subject->get('ns1', 'key1'); + self::assertInstanceOf(\stdClass::class, $result); + self::assertSame($object->foo, $result->foo); + + // second hit for stdClass (should come from DenyListSerializer cache) + $result = $this->subject->get('ns2', 'key1'); + self::assertInstanceOf(\stdClass::class, $result); + self::assertSame($object->foo, $result->foo); + } + #[Test] public function getReturnsNewValueIfValueHasBeenSetMultipleTimes(): void { @@ -184,6 +215,6 @@ public function canNotGetRemovedAllByNamespaceEntry(): void private function deserialize(string $serialized): mixed { - return unserialize($serialized, ['allowed_classes' => false]); + return $this->get(DenyListDeserializer::class)->deserialize($serialized); } }
typo3/sysext/core/Tests/Functional/Serializer/AuthenticatedMessageDeserializerTest.php+107 −0 added@@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class AuthenticatedMessageDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private AuthenticatedMessageDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = $this->get(AuthenticatedMessageDeserializer::class); + } + + public static function dataIsRoundtrippedDataProvider(): array + { + return [ + 'string' => ['hello world', 'test-secret'], + 'integer' => [42, 'test-secret'], + 'float' => [3.14, 'test-secret'], + 'null' => [null, 'test-secret'], + 'array' => [['a' => 1, 'b' => [2, 3]], 'test-secret'], + 'stdClass' => [new \stdClass(), 'test-secret'], + ]; + } + + #[DataProvider('dataIsRoundtrippedDataProvider')] + #[Test] + public function dataIsRoundtripped(mixed $payload, string $secret): void + { + $serialized = $this->subject->serialize($payload, $secret); + self::assertEquals($payload, $this->subject->deserialize($serialized, $secret)); + } + + #[Test] + public function falseValueIsRoundtripped(): void + { + $serialized = $this->subject->serialize(false, 'test-secret'); + self::assertFalse($this->subject->deserialize($serialized, 'test-secret')); + } + + #[Test] + public function tamperedPayloadThrowsException(): void + { + $serialized = $this->subject->serialize(new \stdClass(), 'test-secret'); + // Prepend a byte to invalidate the HMAC while keeping a recognisable class token + $tampered = 'X' . $serialized; + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1780317744); + $this->subject->deserialize($tampered, 'test-secret'); + } + + #[Test] + public function wrongAdditionalSecretThrowsException(): void + { + $serialized = $this->subject->serialize(new \stdClass(), 'secret-a'); + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1780317744); + $this->subject->deserialize($serialized, 'secret-b'); + } + + #[Test] + public function unauthenticatedScalarIsDeserialized(): void + { + // A raw serialized scalar has no HMAC and no class tokens — falls back to + // unserialize($payload, ['allowed_classes' => false]) + self::assertSame('hello', $this->subject->deserialize(serialize('hello'), 'any-secret')); + } + + #[Test] + public function unauthenticatedFalseValueIsDeserialized(): void + { + self::assertFalse($this->subject->deserialize(serialize(false), 'any-secret')); + } + + #[Test] + public function unauthenticatedObjectPayloadThrowsException(): void + { + // A raw serialized object without an HMAC contains class tokens and must be rejected + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1780317744); + $this->subject->deserialize(serialize(new \stdClass()), 'any-secret'); + } +}
typo3/sysext/core/Tests/Functional/Serializer/DenyListDeserializerTest.php+153 −0 added@@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use GuzzleHttp\Cookie\FileCookieJar; +use GuzzleHttp\Psr7\FnStream; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\FormProtection\BackendFormProtection; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class DenyListDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private DenyListDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'] = [Fixtures\ClassWithDestructor::class]; + $this->subject = $this->get(DenyListDeserializer::class); + } + + #[Test] + public function scalarPayloadIsDeserialized(): void + { + self::assertSame('hello', $this->subject->deserialize(serialize('hello'))); + } + + #[Test] + public function falseValueIsDeserialized(): void + { + self::assertFalse($this->subject->deserialize(serialize(false))); + } + + #[Test] + public function allowedClassIsDeserialized(): void + { + self::assertInstanceOf(\stdClass::class, $this->subject->deserialize(serialize(new \stdClass()))); + } + + #[Test] + public function malformedPayloadThrows(): void + { + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1768212616); + $this->subject->deserialize('s:foo:broken'); + } + + #[Test] + public function knownGadgetIsBlocked(): void + { + // Craft the payload without instantiating the class (its __destruct() writes to disk) + $className = FileCookieJar::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1778594101); + $this->subject->deserialize($serialized); + } + + #[Test] + public function classWithWakeupIsBlocked(): void + { + // FnStream has a user-defined __wakeup() and must be blocked before unserialize runs + $className = FnStream::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1778594101); + $this->subject->deserialize($serialized); + } + + #[Test] + public function classWithOnlyBlockSerializationTraitIsAllowed(): void + { + // BackendFormProtection inherits BlockSerializationTrait's __wakeup (throws on deserialization) + // but carries no other gadget methods — it must NOT be blocked by the deny-list. + // The exception must come from BlockSerializationTrait::__wakeup (code 1588784142), + // not from the deserializer deny-list (code 1778594101). + $className = BackendFormProtection::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionCode(1588784142); + $this->subject->deserialize($serialized); + } + + public static function deserializePopulatesCacheWithHmacSignedEntryDataProvider(): iterable + { + yield 'stdClass' => ['stdClass', false]; + yield 'FileCookieJar' => [FileCookieJar::class, true]; + } + + #[Test] + #[DataProvider('deserializePopulatesCacheWithHmacSignedEntryDataProvider')] + public function deserializePopulatesCacheWithHmacSignedEntry(string $className, bool $denied): void + { + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + try { + $this->subject->deserialize($serialized); + } catch (\Throwable) { + // ignore the throwable + } + + $cache = $this->get('cache.core'); + $cacheKey = 'DenyListDeserializer_' . hash('xxh128', $className); + self::assertTrue( + $cache->has($cacheKey), + 'Cache entry must be present after first encounter' + ); + + $entry = $cache->require($cacheKey); + self::assertIsArray($entry); + self::assertArrayHasKey('denied', $entry); + self::assertArrayHasKey('hmac', $entry); + self::assertSame($denied, $entry['denied']); + + // The stored HMAC must be valid and cover both the class name and the deny status + $hashService = $this->get(HashService::class); + $hmacPayload = sprintf('%s:%d', $className, $denied); + self::assertTrue( + $hashService->validateHmac($hmacPayload, DenyListDeserializer::class, $entry['hmac']), + 'HMAC of the cache entry must be valid' + ); + } + + #[Test] + public function allowedClassNamesAreConsidered(): void + { + // the class name was configured to be allowed in the `setUp` method of this test + $className = Fixtures\ClassWithDestructor::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $result = $this->subject->deserialize($serialized); + self::assertInstanceOf($className, $result); + } +}
typo3/sysext/core/Tests/Functional/Serializer/Fixtures/ClassWithDestructor.php+23 −0 added@@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer\Fixtures; + +final readonly class ClassWithDestructor +{ + public function __destruct() {} +}
typo3/sysext/core/Tests/Unit/Cache/Frontend/VariableFrontendTest.php+54 −5 modified@@ -23,10 +23,24 @@ use TYPO3\CMS\Core\Cache\Backend\BackendInterface; use TYPO3\CMS\Core\Cache\Backend\TaggableBackendInterface; use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class VariableFrontendTest extends UnitTestCase { + private AuthenticatedMessageDeserializer $deserializer; + + protected function setUp(): void + { + parent::setUp(); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = 'test-encryption-key'; + $this->deserializer = new AuthenticatedMessageDeserializer( + new HashService(), + new DeserializationService(), + ); + } public static function constructAcceptsValidIdentifiersDataProvider(): array { return [ @@ -240,7 +254,10 @@ public function setPassesSerializedStringToBackend(): void { $theString = 'Just some value'; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('set')->with('VariableCacheTest', serialize($theString)); + $backend->expects($this->once())->method('set')->with( + 'VariableCacheTest', + $this->serialize($theString) + ); $cache = new VariableFrontend('VariableFrontend', $backend); $cache->set('VariableCacheTest', $theString); } @@ -250,7 +267,10 @@ public function setPassesSerializedArrayToBackend(): void { $theArray = ['Just some value', 'and another one.']; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('set')->with('VariableCacheTest', serialize($theArray)); + $backend->expects($this->once())->method('set')->with( + 'VariableCacheTest', + $this->serialize($theArray) + ); $cache = new VariableFrontend('VariableFrontend', $backend); $cache->set('VariableCacheTest', $theArray); } @@ -261,18 +281,24 @@ public function setPassesLifetimeToBackend(): void $theString = 'Just some value'; $theLifetime = 1234; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('set')->with('VariableCacheTest', serialize($theString), [], $theLifetime); + $backend->expects($this->once())->method('set')->with( + 'VariableCacheTest', + $this->serialize($theString), + [], + $theLifetime + ); $cache = new VariableFrontend('VariableFrontend', $backend); $cache->set('VariableCacheTest', $theString, [], $theLifetime); } #[Test] public function getFetchesStringValueFromBackend(): void { + $theString = 'Just some value'; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('get')->willReturn(serialize('Just some value')); + $backend->expects($this->once())->method('get')->willReturn(serialize($theString)); $cache = new VariableFrontend('VariableFrontend', $backend); - self::assertEquals('Just some value', $cache->get('VariableCacheTest')); + self::assertEquals($theString, $cache->get('VariableCacheTest')); } #[Test] @@ -294,6 +320,24 @@ public function getFetchesFalseBooleanValueFromBackend(): void self::assertFalse($cache->get('VariableCacheTest')); } + public static function getHavingUnsignedDataInBackendReturnsValueDataProvider(): iterable + { + yield 'int' => [13, 13]; + yield 'string' => ['Just some value', 'Just some value']; + yield 'array' => [['Just some value', 'and another one.'], ['Just some value', 'and another one.']]; + yield 'stdClass' => [new \stdClass(), false]; + } + + #[Test] + #[DataProvider('getHavingUnsignedDataInBackendReturnsValueDataProvider')] + public function getHavingUnsignedDataInBackendReturnsValue(mixed $payload, mixed $expectation): void + { + $backend = $this->createMock(BackendInterface::class); + $backend->expects($this->once())->method('get')->willReturn(serialize($payload)); + $cache = new VariableFrontend('VariableFrontend', $backend); + self::assertSame($expectation, $cache->get('VariableCacheTest')); + } + #[Test] public function hasReturnsResultFromBackend(): void { @@ -312,4 +356,9 @@ public function removeCallsBackend(): void $cache = new VariableFrontend('VariableFrontend', $backend); self::assertTrue($cache->remove($cacheIdentifier)); } + + private function serialize(mixed $payload): string + { + return $this->deserializer->serialize($payload, VariableFrontend::class); + } }
typo3/sysext/core/Tests/Unit/FormProtection/FormProtectionFactoryTest.php+8 −1 modified@@ -22,7 +22,9 @@ use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Cache\Frontend\NullFrontend; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; +use TYPO3\CMS\Core\Crypto\HashService; use TYPO3\CMS\Core\FormProtection\BackendFormProtection; use TYPO3\CMS\Core\FormProtection\DisabledFormProtection; use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; @@ -32,6 +34,8 @@ use TYPO3\CMS\Core\Localization\LocalizationFactory; use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class FormProtectionFactoryTest extends UnitTestCase @@ -44,14 +48,17 @@ final class FormProtectionFactoryTest extends UnitTestCase protected function setUp(): void { $this->runtimeCacheMock = new VariableFrontend('null', new TransientMemoryBackend()); + $cacheMock = $this->createMock(PhpFrontend::class); + $cacheMock->method('has')->willReturn(false); + $deserializer = new DenyListDeserializer($cacheMock, new HashService(), new DeserializationService()); $this->subject = new FormProtectionFactory( new FlashMessageService(), new LanguageServiceFactory( new Locales(), $this->createMock(LocalizationFactory::class), new NullFrontend('null') ), - new Registry(), + new Registry($deserializer), $this->runtimeCacheMock ); parent::setUp();
typo3/sysext/core/Tests/Unit/RegistryTest.php+20 −6 modified@@ -18,56 +18,70 @@ namespace TYPO3\CMS\Core\Tests\Unit; use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Crypto\HashService; use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class RegistryTest extends UnitTestCase { + private DenyListDeserializer $deserializer; + + protected function setUp(): void + { + parent::setUp(); + $cacheMock = $this->createMock(PhpFrontend::class); + $cacheMock->method('has')->willReturn(false); + $this->deserializer = new DenyListDeserializer($cacheMock, new HashService(), new DeserializationService()); + } + #[Test] public function getThrowsExceptionForInvalidNamespacesUsingNoNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->get('', 'someKey'); + (new Registry($this->deserializer))->get('', 'someKey'); } #[Test] public function getThrowsExceptionForInvalidNamespacesUsingTooShortNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->get('t', 'someKey'); + (new Registry($this->deserializer))->get('t', 'someKey'); } #[Test] public function setThrowsAnExceptionOnEmptyNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->set('', 'someKey', 'someValue'); + (new Registry($this->deserializer))->set('', 'someKey', 'someValue'); } #[Test] public function setThrowsAnExceptionOnWrongNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->set('t', 'someKey', 'someValue'); + (new Registry($this->deserializer))->set('t', 'someKey', 'someValue'); } #[Test] public function removeThrowsAnExceptionOnWrongNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->remove('t', 'someKey'); + (new Registry($this->deserializer))->remove('t', 'someKey'); } #[Test] public function removeAllByNamespaceThrowsAnExceptionOnWrongNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->removeAllByNamespace(''); + (new Registry($this->deserializer))->removeAllByNamespace(''); } }
typo3/sysext/extbase/Classes/Mvc/Controller/ActionController.php+2 −1 modified@@ -613,7 +613,8 @@ protected function forwardToReferringRequest(): ?ResponseInterface $referringRequestArguments['arguments'], HashScope::ReferringArguments->prefix(), HashAlgo::SHA3_256 - )) + )), + ['allowed_classes' => true] ); } $replacedArguments = array_replace_recursive($arguments, $referrerArray);
typo3/sysext/form/Classes/Domain/Runtime/FormRuntime.php+1 −1 modified@@ -247,7 +247,7 @@ protected function initializeFormStateFromRequest() } catch (InvalidHashStringException $e) { throw new BadRequestException('The HMAC of the form state could not be validated.', 1581862823); } - $this->formState = unserialize(base64_decode($serializedFormState)); + $this->formState = unserialize(base64_decode($serializedFormState), ['allowed_classes' => true]); } }
typo3/sysext/frontend/Classes/Http/RequestHandler.php+1 −1 modified@@ -1115,7 +1115,7 @@ protected function processNonCacheableContentPartsAndSubstituteContentMarkers(ar $this->timeTracker->push($label); $contentObjectRendererForNonCacheable = GeneralUtility::makeInstance(ContentObjectRenderer::class); $contentObjectRendererForNonCacheable->setRequest($request); - $contentObjectRendererForNonCacheable->updateState(unserialize($nonCacheableConfig['cObjData'])); + $contentObjectRendererForNonCacheable->updateState(unserialize($nonCacheableConfig['cObjData'], ['allowed_classes' => false])); $nonCacheableContent = match ($nonCacheableConfig['type']) { 'COA' => $contentObjectRendererForNonCacheable->cObjGetSingle('COA', $nonCacheableConfig['conf']), 'FUNC' => $contentObjectRendererForNonCacheable->cObjGetSingle('USER', $nonCacheableConfig['conf']),
typo3/sysext/install/Classes/Controller/UpgradeController.php+6 −8 modified@@ -201,7 +201,8 @@ public function __construct( protected readonly PackageManager $packageManager, private readonly LateBootService $lateBootService, private readonly DatabaseUpgradeWizardsService $databaseUpgradeWizardsService, - private readonly FormProtectionFactory $formProtectionFactory + private readonly FormProtectionFactory $formProtectionFactory, + private readonly Registry $registry, ) {} /** @@ -650,8 +651,7 @@ public function extensionScannerMarkFullyScannedRestFilesAction(ServerRequestInt { $foundRestFileHashes = (array)($request->getParsedBody()['install']['hashes'] ?? []); // First un-mark files marked as scanned-ok - $registry = new Registry(); - $registry->removeAllByNamespace('extensionScannerNotAffected'); + $this->registry->removeAllByNamespace('extensionScannerNotAffected'); // Find all .rst files (except those from v8), see if they are tagged with "FullyScanned" // and if their content is not in incoming "hashes" array, mark as "not affected" $documentationFile = new DocumentationFile(); @@ -680,7 +680,7 @@ public function extensionScannerMarkFullyScannedRestFilesAction(ServerRequestInt } } foreach ($fullyScannedRestFilesNotAffected as $fileHash) { - $registry->set('extensionScannerNotAffected', $fileHash, $fileHash); + $this->registry->set('extensionScannerNotAffected', $fileHash, $fileHash); } return new JsonResponse([ 'success' => true, @@ -885,10 +885,9 @@ public function upgradeDocsGetChangelogForVersionAction(ServerRequestInterface $ */ public function upgradeDocsMarkReadAction(ServerRequestInterface $request): ResponseInterface { - $registry = new Registry(); $filePath = $request->getParsedBody()['install']['ignoreFile']; $fileHash = md5_file($filePath); - $registry->set('upgradeAnalysisIgnoredFiles', $fileHash, $filePath); + $this->registry->set('upgradeAnalysisIgnoredFiles', $fileHash, $filePath); return new JsonResponse([ 'success' => true, ]); @@ -899,10 +898,9 @@ public function upgradeDocsMarkReadAction(ServerRequestInterface $request): Resp */ public function upgradeDocsUnmarkReadAction(ServerRequestInterface $request): ResponseInterface { - $registry = new Registry(); $filePath = $request->getParsedBody()['install']['ignoreFile']; $fileHash = md5_file($filePath); - $registry->remove('upgradeAnalysisIgnoredFiles', $fileHash); + $this->registry->remove('upgradeAnalysisIgnoredFiles', $fileHash); return new JsonResponse([ 'success' => true, ]);
typo3/sysext/install/Classes/ServiceProvider.php+2 −1 modified@@ -370,7 +370,8 @@ public static function getUpgradeController(ContainerInterface $container): Cont $container->get(PackageManager::class), $container->get(Service\LateBootService::class), $container->get(DatabaseUpgradeWizardsService::class), - $container->get(FormProtectionFactory::class) + $container->get(FormProtectionFactory::class), + $container->get(Registry::class), ); }
typo3/sysext/scheduler/Classes/Domain/Repository/SchedulerTaskRepository.php+6 −4 modified@@ -332,7 +332,8 @@ public function getGroupedTasks(): array $taskData['lastExecutionFailure'] = false; if (!empty($row['lastexecution_failure'])) { $taskData['lastExecutionFailure'] = true; - $exceptionArray = @unserialize($row['lastexecution_failure']); + // only scalars are serialized in \TYPO3\CMS\Scheduler\Scheduler::executeTask + $exceptionArray = @unserialize($row['lastexecution_failure'], ['allowed_classes' => false]); $taskData['lastExecutionFailureCode'] = ''; $taskData['lastExecutionFailureMessage'] = ''; if (is_array($exceptionArray)) { @@ -467,7 +468,8 @@ public function addExecutionToTask(AbstractTask $task): int $runningExecutions = $previousExecutions !== null && $previousExecutions !== '' - ? unserialize($previousExecutions) + // serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>` + ? unserialize($previousExecutions, ['allowed_classes' => false]) : []; // Count the number of existing executions and use that number as a key @@ -512,8 +514,8 @@ public function removeExecutionOfTask(AbstractTask $task, int $executionID, arra if ($previousExecutions === '') { break; } - - $runningExecutions = unserialize($previousExecutions); + // serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>` + $runningExecutions = unserialize($previousExecutions, ['allowed_classes' => false]); // Remove the selected execution unset($runningExecutions[$executionID]); if (!empty($runningExecutions)) {
typo3/sysext/scheduler/Classes/Migration/SchedulerDatabaseStorageMigration.php+4 −1 modified@@ -22,6 +22,7 @@ use TYPO3\CMS\Core\Attribute\UpgradeWizard; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Database\Query\QueryBuilder; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; use TYPO3\CMS\Core\Upgrades\DatabaseUpdatedPrerequisite; use TYPO3\CMS\Core\Upgrades\UpgradeWizardInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -39,6 +40,8 @@ class SchedulerDatabaseStorageMigration implements UpgradeWizardInterface { protected const TABLE_NAME = 'tx_scheduler_task'; + public function __construct(private readonly DenyListDeserializer $deserializer) {} + public function getTitle(): string { return 'Migrate the contents of the tx_scheduler_task database table into a more structured form.'; @@ -77,7 +80,7 @@ public function executeUpdate(): bool // unserialize() will only give a E_NOTICE and false result, not throw an error. Silence this // (for tests) and operate on the "false". If future PHP promotes this to an exception, the Throwable // catch will kick in. - $taskObject = @unserialize($record['serialized_task_object']); + $taskObject = $this->deserializer->deserialize($record['serialized_task_object']); } if ($taskObject instanceof AbstractTask) { $fieldsToUpdate = [
typo3/sysext/scheduler/Classes/Scheduler.php+2 −1 modified@@ -90,7 +90,8 @@ protected function cleanExecutionArrays() $maxDuration = $this->extConf['maxLifetime'] * 60; while ($row = $result->fetchAssociative()) { $executions = []; - if ($serialized_executions = unserialize($row['serialized_executions'])) { + // serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>` + if ($serialized_executions = unserialize($row['serialized_executions'], ['allowed_classes' => false])) { foreach ($serialized_executions as $task) { if ($tstamp - $task < $maxDuration) { $executions[] = $task;
87cd7c5b710c[SECURITY] Mitigate deserialization flaws
27 files changed · +867 −50
typo3/sysext/core/Classes/Cache/Backend/FileBackend.php+5 −2 modified@@ -40,7 +40,7 @@ class FileBackend extends SimpleFileBackend implements FreezableBackendInterface protected $cacheEntryFileExtension = ''; /** - * @var array + * @var array<string, true> */ protected $cacheEntryIdentifiers = []; @@ -107,7 +107,10 @@ public function setCache(FrontendInterface $cache) parent::setCache($cache); if (file_exists($this->cacheDirectory . 'FrozenCache.data')) { $this->frozen = true; - $this->cacheEntryIdentifiers = unserialize((string)file_get_contents($this->cacheDirectory . 'FrozenCache.data')); + $this->cacheEntryIdentifiers = unserialize( + (string)file_get_contents($this->cacheDirectory . 'FrozenCache.data'), + ['allowed_classes' => false] + ); } }
typo3/sysext/core/Classes/Cache/Frontend/VariableFrontend.php+17 −2 modified@@ -16,6 +16,10 @@ namespace TYPO3\CMS\Core\Cache\Frontend; use TYPO3\CMS\Core\Cache\Backend\TransientBackendInterface; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -57,7 +61,9 @@ public function set($entryIdentifier, $variable, array $tags = [], $lifetime = n GeneralUtility::callUserFunction($_funcRef, $params, $this); } if (!$this->backend instanceof TransientBackendInterface) { - $variable = serialize($variable); + // No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup. + $deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService()); + $variable = $deserializer->serialize($variable, VariableFrontend::class); } $this->backend->set($entryIdentifier, $variable, $tags, $lifetime); } @@ -82,6 +88,15 @@ public function get($entryIdentifier) if ($rawResult === false) { return false; } - return $this->backend instanceof TransientBackendInterface ? $rawResult : unserialize($rawResult); + if ($this->backend instanceof TransientBackendInterface) { + return $rawResult; + } + try { + // No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup. + $deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService()); + return $deserializer->deserialize($rawResult, VariableFrontend::class); + } catch (DeserializerException) { + return false; + } } }
typo3/sysext/core/Classes/Registry.php+3 −1 modified@@ -17,6 +17,7 @@ use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; use TYPO3\CMS\Core\Utility\GeneralUtility; /** @@ -41,6 +42,7 @@ class Registry implements SingletonInterface */ protected $loadedNamespaces = []; + public function __construct(protected readonly DenyListDeserializer $deserializer) {} /** * Returns a persistent entry. * @@ -169,7 +171,7 @@ protected function loadEntriesByNamespace($namespace) ['entry_namespace' => $namespace] ); while ($row = $result->fetchAssociative()) { - $this->entries[$namespace][$row['entry_key']] = unserialize($row['entry_value']); + $this->entries[$namespace][$row['entry_key']] = $this->deserializer->deserialize($row['entry_value']); } $this->loadedNamespaces[$namespace] = true; }
typo3/sysext/core/Classes/Resource/ProcessedFile.php+2 −2 modified@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Core\Resource; +use TYPO3\CMS\Core\Imaging\ImageManipulation\Area; use TYPO3\CMS\Core\Resource\Processing\TaskTypeRegistry; use TYPO3\CMS\Core\Resource\Service\ConfigurationService; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -120,8 +121,7 @@ public function __construct(File $originalFile, string $taskType, array $process protected function reconstituteFromDatabaseRecord(array $databaseRow): void { $this->taskType = $this->taskType ?: $databaseRow['task_type']; - // @todo In case the original configuration contained file objects the reconstitution fails. See ConfigurationService->serialize() - $this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? ''); + $this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? '', ['allowed_classes' => [Area::class]]); $this->originalFileSha1 = $databaseRow['originalfilesha1']; $this->identifier = $databaseRow['identifier'];
typo3/sysext/core/Classes/Serializer/AuthenticatedMessageDeserializer.php+67 −0 added@@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Exception\Crypto\InvalidHashStringException; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * @internal Only to be used by TYPO3 core + */ +#[Autoconfigure(public: true)] +final readonly class AuthenticatedMessageDeserializer +{ + public function __construct( + private HashService $hashService, + private DeserializationService $deserializationService, + ) {} + + public function serialize(mixed $payload, string $additionalSecret): string + { + return $this->hashService->appendHmac( + serialize($payload), + $additionalSecret + ); + } + + public function deserialize(string $payload, string $additionalSecret): mixed + { + try { + $serialized = $this->hashService->validateAndStripHmac( + $payload, + $additionalSecret + ); + } catch (InvalidHashStringException $e) { + $classNames = $this->deserializationService->parseClassNames($payload); + // in case the payload does not contain any class names, continue with + // a secure deserialization attempt, not allowing any class names + if ($classNames === []) { + return unserialize($payload, ['allowed_classes' => false]); + } + throw new DeserializerException( + 'Authenticated Message Deserialization failed', + 1780317744, + $e + ); + } + // explicitly allowing all classes here after successful HMAC validation + return unserialize($serialized, ['allowed_classes' => true]); + } +}
typo3/sysext/core/Classes/Serializer/DenyListDeserializer.php+167 −0 added@@ -0,0 +1,167 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Serializer; + +use Symfony\Component\DependencyInjection\Attribute\Autoconfigure; +use Symfony\Component\DependencyInjection\Attribute\Autowire; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Security\BlockSerializationTrait; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; + +/** + * Deserializes a PHP-serialized payload while refusing any class that carries + * a user-defined __destruct() or an exploitable __wakeup() (one not provided + * solely by BlockSerializationTrait). + * + * The per-class deny/allow decision is made lazily via ReflectionClass at the + * first encounter of each class name, then cached in cache:core so that + * reflection is never repeated for the same class within a cache lifetime. + * + * Use this instead of a raw unserialize() call when the set of expected classes + * is not known upfront but dangerous gadget classes must still be excluded. + * + * @internal Only to be used by TYPO3 core + */ +#[Autoconfigure(public: true)] +final readonly class DenyListDeserializer +{ + /** + * @var list<string> + */ + private array $allowedClassNames; + private \ReflectionMethod $blockSerializationWakeup; + + public function __construct( + #[Autowire(service: 'cache.core')] + private PhpFrontend $cache, + private HashService $hashService, + private DeserializationService $deserializationService, + ) { + $allowedClassNames = $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'] ?? null; + $this->allowedClassNames = is_array($allowedClassNames) ? $allowedClassNames : []; + $this->blockSerializationWakeup = (new \ReflectionClass(BlockSerializationTrait::class))->getMethod('__wakeup'); + } + + /** + * Deserializes $payload, throwing DeserializerException if any class name + * found in the payload is a deserialization gadget, or if the payload is + * syntactically malformed. + */ + public function deserialize(string $payload): mixed + { + $classNames = $this->deserializationService->parseClassNames($payload); + foreach ($classNames as $className) { + if ($this->shallClassBeDenied($className)) { + throw new DeserializerException( + 'Denied class name "' . $className . '" found in payload', + 1778594101 + ); + } + } + + return $this->deserializationService->deserialize($payload, $classNames ?: false); + } + + private function shallClassBeDenied(string $className): bool + { + if (in_array($className, $this->allowedClassNames, true)) { + return false; + } + + $cacheKey = 'DenyListDeserializer_' . hash('xxh128', $className); + if ($this->cache->has($cacheKey)) { + $entry = $this->cache->require($cacheKey); + if (is_array($entry) + && isset($entry['denied'], $entry['hmac']) + && $this->hashService->validateHmac( + $this->createHmacPayload($className, (bool)$entry['denied']), + DenyListDeserializer::class, + $entry['hmac'] + ) + ) { + return (bool)$entry['denied']; + } + // Tampered or stale entry — fall through to recompute + } + + $denied = $this->resolveClassDenyStatus($className); + $hmac = $this->hashService->hmac($this->createHmacPayload($className, $denied), DenyListDeserializer::class); + $this->cache->set($cacheKey, 'return ' . var_export(['denied' => $denied, 'hmac' => $hmac], true) . ';'); + return $denied; + } + + private function createHmacPayload(string $className, bool $denied): string + { + return $className . ':' . ($denied ? '1' : '0'); + } + + private function resolveClassDenyStatus(string $className): bool + { + try { + $rc = new \ReflectionClass($className); + } catch (\ReflectionException) { + // The class does not exist or cannot be reflected (and not instantiated). + // Thus, the class is allowed, since it cannot be a gadget and would + // result in a `__PHP_Incomplete_Class` during deserialization. + return false; + } + if ($rc->isInterface() || $rc->isTrait()) { + return false; + } + return $this->getUserDefinedMethod($rc, '__destruct') !== null + || $this->hasDeniableWakeupMethod($rc); + } + + /** + * Returns the method when $methodName is declared in user-defined (non-internal) code + * somewhere in the class hierarchy. This excludes methods like Exception::__wakeup() + * that PHP declares internally and that are harmless for deserialization purposes. + */ + private function getUserDefinedMethod(\ReflectionClass $rc, string $methodName): ?\ReflectionMethod + { + if (!$rc->hasMethod($methodName)) { + return null; + } + $method = $rc->getMethod($methodName); + if ($method->getDeclaringClass()->isInternal()) { + return null; + } + return $method; + } + + /** + * Returns true when the class has a user-defined __wakeup() that is NOT + * BlockSerializationTrait::__wakeup(). Classes whose only __wakeup comes + * from BlockSerializationTrait are already protected against deserialization + * (the trait throws unconditionally) and must not be treated as gadgets. + * + * Note: for trait methods getDeclaringClass() returns the using class, not the + * trait — so the origin is identified by comparing the method's source file and line + * against the trait's own __wakeup declaration. + */ + private function hasDeniableWakeupMethod(\ReflectionClass $rc): bool + { + $method = $this->getUserDefinedMethod($rc, '__wakeup'); + if ($method === null) { + return false; + } + return $method->getFileName() !== $this->blockSerializationWakeup->getFileName() + || $method->getStartLine() !== $this->blockSerializationWakeup->getStartLine(); + } +}
typo3/sysext/core/Classes/ServiceProvider.php+10 −1 modified@@ -465,7 +465,16 @@ public static function getPackageDependentCacheIdentifier(ContainerInterface $co public static function getRegistry(ContainerInterface $container): Registry { - return self::new($container, Registry::class); + $denyListDeserializer = $container->has(Serializer\DenyListDeserializer::class) + ? $container->get(Serializer\DenyListDeserializer::class) + : new Serializer\DenyListDeserializer( + $container->get('cache.core'), + $container->get(HashService::class), + new Serializer\DeserializationService(), + ); + return self::new($container, Registry::class, [ + $denyListDeserializer, + ]); } public static function getFileIndexRepository(ContainerInterface $container): Resource\Index\FileIndexRepository
typo3/sysext/core/Configuration/DefaultConfiguration.php+6 −0 modified@@ -174,6 +174,12 @@ ], ], ], + 'deserialization' => [ + // List of class names that are allowed to be deserialized even if they carry + // __destruct() or __wakeup() and would otherwise be blocked. Use this to + // explicitly permit classes that have been reviewed and are known to be safe. + 'allowedClassNames' => [], + ], 'caching' => [ 'cacheConfigurations' => [ // The core cache is is for core php code only and must
typo3/sysext/core/Documentation/Changelog/13.4.x/Important-108604-MitigateDeserializationFlaws.rst+120 −0 added@@ -0,0 +1,120 @@ +.. include:: /Includes.rst.txt + +.. _important-108604-1780297491: + +=================================================== +Important: #108604 - Mitigate deserialization flaws +=================================================== + +See :issue:`108604` + +Description +=========== + +TYPO3 introduces new serialization infrastructure to protect against PHP object +injection attacks. Two complementary strategies are applied depending on how +long the serialized data lives: + +**Cache frontend (**:php:`VariableFrontend`**) — HMAC-authenticated serialization** + +:php:`\TYPO3\CMS\Core\Cache\Frontend\VariableFrontend` (the default cache +frontend) now uses +:php:`\TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer` for both +writing and reading cache entries. On every :php:`set()` call the payload is +serialized and an HMAC is appended; on every :php:`get()` call the HMAC is +validated before deserialization proceeds. + +Because caches are temporary and written exclusively by the server that reads +them, this approach provides a strong integrity guarantee: an attacker cannot +craft a malicious serialized payload that the server would accept, since they +cannot forge the HMAC without knowing the encryption key. + +Cache entries written by an older TYPO3 version (without an HMAC) are handled +gracefully: if the payload contains no PHP class tokens it is deserialized +safely with :php:`allowed_classes: false`; if it does contain class tokens it +is discarded and treated as a cache miss, causing the entry to be regenerated +transparently. + +**Registry (**:php:`\TYPO3\CMS\Core\Registry`**) — gadget denylist** + +:php:`\TYPO3\CMS\Core\Registry` (the persistent key-value store backed by the +:sql:`sys_registry` table) deserializes stored payloads through +:php:`\TYPO3\CMS\Core\Serializer\DenyListDeserializer`. Before deserialization +the class names embedded in the payload are checked against a gadget deny list. +The deny/allow decision for each class is resolved lazily via +:php:`\ReflectionClass` on first encounter and then cached in :php:`cache:core` +(HMAC-signed for integrity) so that reflection is not repeated for the same +class within a cache lifetime. A class is considered a gadget when it carries a +user-defined :php:`__destruct()` or an exploitable :php:`__wakeup()` — that is, +a :php:`__wakeup()` not provided solely by +:php:`\TYPO3\CMS\Core\Security\BlockSerializationTrait`. If any gadget class is +referenced in a payload, a +:php:`\TYPO3\CMS\Core\Serializer\Exception\DeserializerException` is thrown and +deserialization is aborted. + +A denylist strategy (block known-bad, allow unknown) is used intentionally for +the registry to avoid breaking changes to long-lived persisted data. + +Impact +------ + +**Cache (**:php:`VariableFrontend`**)** + +Existing cache entries that contain serialized PHP objects will be treated as +cache misses on the next read. No exception is thrown; the entry is simply +discarded and the cache is repopulated on the next request. This is transparent +to callers. + +**Registry** + +Extensions or third-party code that stores serialized PHP objects in +:php:`Registry` may encounter a :php:`DeserializerException` at read time if +the serialized object graph contains a class with a user-defined +:php:`__destruct()` or :php:`__wakeup()` method. + +Migration & Insights +-------------------- + +In most cases no migration is required. The sections below aim to provide +some insights into internal details and general suggestions. + +**Cache (**:php:`VariableFrontend`**)** + +No migration is required. Stale or legacy cache entries are automatically +treated as misses and regenerated. If code stores object graphs in a +:php:`VariableFrontend`-backed cache it will continue to work as long as the +server reads the entry it wrote (same encryption key). + +**Registry — preferred approach: avoid object serialization** + +Review what is stored in the registry. Plain PHP arrays and scalar values are +not affected by this protection and should be preferred over serialized object +graphs wherever possible. + +**Registry — alternative: restructure the stored value** + +If an object must be stored, ensure that neither the object itself nor any +object reachable from it through public or serialized properties carries a +user-defined :php:`__destruct()` or :php:`__wakeup()` method. + +**Registry — last resort: explicit class allowlist** + +If neither of the above is feasible in the short term, the affected class can be +added to the site-level allowlist in :file:`config/system/additional.php` (or +the legacy :file:`typo3conf/AdditionalConfiguration.php`): + +.. code-block:: php + + $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'][] = + \Vendor\MyExtension\Domain\Model\MyObject::class; + +.. warning:: + + The allowlist setting bypasses the deserialization gadget protection for + the listed classes. It should only be used as a last resort after carefully + reviewing the class and confirming that its :php:`__destruct()` or + :php:`__wakeup()` implementation cannot be abused in a PHP object injection + attack chain. Remove the entry as soon as the underlying serialization is + refactored. + +.. index:: PHP-API, LocalConfiguration, ext:core
typo3/sysext/core/Tests/Functional/RegistryTest.php+32 −1 modified@@ -21,6 +21,7 @@ use TYPO3\CMS\Core\Database\Connection; use TYPO3\CMS\Core\Database\ConnectionPool; use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; final class RegistryTest extends FunctionalTestCase @@ -158,6 +159,36 @@ public function canGetSetEntry(): void self::assertSame('value1', $this->subject->get('ns1', 'key1')); } + #[Test] + public function canGetEntryWithClassInstance(): void + { + $object = new \stdClass(); + $object->foo = 'bar'; + + $connection = (new ConnectionPool())->getConnectionForTable('sys_registry'); + $connection->bulkInsert( + 'sys_registry', + [ + ['ns1', 'key1', serialize($object)], + ['ns2', 'key1', serialize($object)], + ], + ['entry_namespace', 'entry_key', 'entry_value'], + [ + 'entry_value' => Connection::PARAM_LOB, + ] + ); + + // first hit for stdClass (not in DenyListSerializer cache) + $result = $this->subject->get('ns1', 'key1'); + self::assertInstanceOf(\stdClass::class, $result); + self::assertSame($object->foo, $result->foo); + + // second hit for stdClass (should come from DenyListSerializer cache) + $result = $this->subject->get('ns2', 'key1'); + self::assertInstanceOf(\stdClass::class, $result); + self::assertSame($object->foo, $result->foo); + } + #[Test] public function getReturnsNewValueIfValueHasBeenSetMultipleTimes(): void { @@ -184,6 +215,6 @@ public function canNotGetRemovedAllByNamespaceEntry(): void private function deserialize(string $serialized): mixed { - return unserialize($serialized, ['allowed_classes' => false]); + return $this->get(DenyListDeserializer::class)->deserialize($serialized); } }
typo3/sysext/core/Tests/Functional/Serializer/AuthenticatedMessageDeserializerTest.php+107 −0 added@@ -0,0 +1,107 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class AuthenticatedMessageDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private AuthenticatedMessageDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $this->subject = $this->get(AuthenticatedMessageDeserializer::class); + } + + public static function dataIsRoundtrippedDataProvider(): array + { + return [ + 'string' => ['hello world', 'test-secret'], + 'integer' => [42, 'test-secret'], + 'float' => [3.14, 'test-secret'], + 'null' => [null, 'test-secret'], + 'array' => [['a' => 1, 'b' => [2, 3]], 'test-secret'], + 'stdClass' => [new \stdClass(), 'test-secret'], + ]; + } + + #[DataProvider('dataIsRoundtrippedDataProvider')] + #[Test] + public function dataIsRoundtripped(mixed $payload, string $secret): void + { + $serialized = $this->subject->serialize($payload, $secret); + self::assertEquals($payload, $this->subject->deserialize($serialized, $secret)); + } + + #[Test] + public function falseValueIsRoundtripped(): void + { + $serialized = $this->subject->serialize(false, 'test-secret'); + self::assertFalse($this->subject->deserialize($serialized, 'test-secret')); + } + + #[Test] + public function tamperedPayloadThrowsException(): void + { + $serialized = $this->subject->serialize(new \stdClass(), 'test-secret'); + // Prepend a byte to invalidate the HMAC while keeping a recognisable class token + $tampered = 'X' . $serialized; + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1780317744); + $this->subject->deserialize($tampered, 'test-secret'); + } + + #[Test] + public function wrongAdditionalSecretThrowsException(): void + { + $serialized = $this->subject->serialize(new \stdClass(), 'secret-a'); + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1780317744); + $this->subject->deserialize($serialized, 'secret-b'); + } + + #[Test] + public function unauthenticatedScalarIsDeserialized(): void + { + // A raw serialized scalar has no HMAC and no class tokens — falls back to + // unserialize($payload, ['allowed_classes' => false]) + self::assertSame('hello', $this->subject->deserialize(serialize('hello'), 'any-secret')); + } + + #[Test] + public function unauthenticatedFalseValueIsDeserialized(): void + { + self::assertFalse($this->subject->deserialize(serialize(false), 'any-secret')); + } + + #[Test] + public function unauthenticatedObjectPayloadThrowsException(): void + { + // A raw serialized object without an HMAC contains class tokens and must be rejected + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1780317744); + $this->subject->deserialize(serialize(new \stdClass()), 'any-secret'); + } +}
typo3/sysext/core/Tests/Functional/Serializer/DenyListDeserializerTest.php+153 −0 added@@ -0,0 +1,153 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer; + +use GuzzleHttp\Cookie\FileCookieJar; +use GuzzleHttp\Psr7\FnStream; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\FormProtection\BackendFormProtection; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\Exception\DeserializerException; +use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase; + +final class DenyListDeserializerTest extends FunctionalTestCase +{ + protected bool $initializeDatabase = false; + + private DenyListDeserializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'] = [Fixtures\ClassWithDestructor::class]; + $this->subject = $this->get(DenyListDeserializer::class); + } + + #[Test] + public function scalarPayloadIsDeserialized(): void + { + self::assertSame('hello', $this->subject->deserialize(serialize('hello'))); + } + + #[Test] + public function falseValueIsDeserialized(): void + { + self::assertFalse($this->subject->deserialize(serialize(false))); + } + + #[Test] + public function allowedClassIsDeserialized(): void + { + self::assertInstanceOf(\stdClass::class, $this->subject->deserialize(serialize(new \stdClass()))); + } + + #[Test] + public function malformedPayloadThrows(): void + { + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1768212616); + $this->subject->deserialize('s:foo:broken'); + } + + #[Test] + public function knownGadgetIsBlocked(): void + { + // Craft the payload without instantiating the class (its __destruct() writes to disk) + $className = FileCookieJar::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1778594101); + $this->subject->deserialize($serialized); + } + + #[Test] + public function classWithWakeupIsBlocked(): void + { + // FnStream has a user-defined __wakeup() and must be blocked before unserialize runs + $className = FnStream::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $this->expectException(DeserializerException::class); + $this->expectExceptionCode(1778594101); + $this->subject->deserialize($serialized); + } + + #[Test] + public function classWithOnlyBlockSerializationTraitIsAllowed(): void + { + // BackendFormProtection inherits BlockSerializationTrait's __wakeup (throws on deserialization) + // but carries no other gadget methods — it must NOT be blocked by the deny-list. + // The exception must come from BlockSerializationTrait::__wakeup (code 1588784142), + // not from the deserializer deny-list (code 1778594101). + $className = BackendFormProtection::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $this->expectException(\BadMethodCallException::class); + $this->expectExceptionCode(1588784142); + $this->subject->deserialize($serialized); + } + + public static function deserializePopulatesCacheWithHmacSignedEntryDataProvider(): iterable + { + yield 'stdClass' => ['stdClass', false]; + yield 'FileCookieJar' => [FileCookieJar::class, true]; + } + + #[Test] + #[DataProvider('deserializePopulatesCacheWithHmacSignedEntryDataProvider')] + public function deserializePopulatesCacheWithHmacSignedEntry(string $className, bool $denied): void + { + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + try { + $this->subject->deserialize($serialized); + } catch (\Throwable) { + // ignore the throwable + } + + $cache = $this->get('cache.core'); + $cacheKey = 'DenyListDeserializer_' . hash('xxh128', $className); + self::assertTrue( + $cache->has($cacheKey), + 'Cache entry must be present after first encounter' + ); + + $entry = $cache->require($cacheKey); + self::assertIsArray($entry); + self::assertArrayHasKey('denied', $entry); + self::assertArrayHasKey('hmac', $entry); + self::assertSame($denied, $entry['denied']); + + // The stored HMAC must be valid and cover both the class name and the deny status + $hashService = $this->get(HashService::class); + $hmacPayload = sprintf('%s:%d', $className, $denied); + self::assertTrue( + $hashService->validateHmac($hmacPayload, DenyListDeserializer::class, $entry['hmac']), + 'HMAC of the cache entry must be valid' + ); + } + + #[Test] + public function allowedClassNamesAreConsidered(): void + { + // the class name was configured to be allowed in the `setUp` method of this test + $className = Fixtures\ClassWithDestructor::class; + $serialized = 'O:' . strlen($className) . ':"' . $className . '":0:{}'; + $result = $this->subject->deserialize($serialized); + self::assertInstanceOf($className, $result); + } +}
typo3/sysext/core/Tests/Functional/Serializer/Fixtures/ClassWithDestructor.php+23 −0 added@@ -0,0 +1,23 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Tests\Functional\Serializer\Fixtures; + +final readonly class ClassWithDestructor +{ + public function __destruct() {} +}
typo3/sysext/core/Tests/Unit/Cache/Frontend/VariableFrontendTest.php+54 −5 modified@@ -23,10 +23,24 @@ use TYPO3\CMS\Core\Cache\Backend\BackendInterface; use TYPO3\CMS\Core\Cache\Backend\TaggableBackendInterface; use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; +use TYPO3\CMS\Core\Crypto\HashService; +use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class VariableFrontendTest extends UnitTestCase { + private AuthenticatedMessageDeserializer $deserializer; + + protected function setUp(): void + { + parent::setUp(); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = 'test-encryption-key'; + $this->deserializer = new AuthenticatedMessageDeserializer( + new HashService(), + new DeserializationService(), + ); + } public static function constructAcceptsValidIdentifiersDataProvider(): array { return [ @@ -240,7 +254,10 @@ public function setPassesSerializedStringToBackend(): void { $theString = 'Just some value'; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('set')->with('VariableCacheTest', serialize($theString)); + $backend->expects($this->once())->method('set')->with( + 'VariableCacheTest', + $this->serialize($theString) + ); $cache = new VariableFrontend('VariableFrontend', $backend); $cache->set('VariableCacheTest', $theString); } @@ -250,7 +267,10 @@ public function setPassesSerializedArrayToBackend(): void { $theArray = ['Just some value', 'and another one.']; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('set')->with('VariableCacheTest', serialize($theArray)); + $backend->expects($this->once())->method('set')->with( + 'VariableCacheTest', + $this->serialize($theArray) + ); $cache = new VariableFrontend('VariableFrontend', $backend); $cache->set('VariableCacheTest', $theArray); } @@ -261,18 +281,24 @@ public function setPassesLifetimeToBackend(): void $theString = 'Just some value'; $theLifetime = 1234; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('set')->with('VariableCacheTest', serialize($theString), [], $theLifetime); + $backend->expects($this->once())->method('set')->with( + 'VariableCacheTest', + $this->serialize($theString), + [], + $theLifetime + ); $cache = new VariableFrontend('VariableFrontend', $backend); $cache->set('VariableCacheTest', $theString, [], $theLifetime); } #[Test] public function getFetchesStringValueFromBackend(): void { + $theString = 'Just some value'; $backend = $this->createMock(BackendInterface::class); - $backend->expects($this->once())->method('get')->willReturn(serialize('Just some value')); + $backend->expects($this->once())->method('get')->willReturn(serialize($theString)); $cache = new VariableFrontend('VariableFrontend', $backend); - self::assertEquals('Just some value', $cache->get('VariableCacheTest')); + self::assertEquals($theString, $cache->get('VariableCacheTest')); } #[Test] @@ -294,6 +320,24 @@ public function getFetchesFalseBooleanValueFromBackend(): void self::assertFalse($cache->get('VariableCacheTest')); } + public static function getHavingUnsignedDataInBackendReturnsValueDataProvider(): iterable + { + yield 'int' => [13, 13]; + yield 'string' => ['Just some value', 'Just some value']; + yield 'array' => [['Just some value', 'and another one.'], ['Just some value', 'and another one.']]; + yield 'stdClass' => [new \stdClass(), false]; + } + + #[Test] + #[DataProvider('getHavingUnsignedDataInBackendReturnsValueDataProvider')] + public function getHavingUnsignedDataInBackendReturnsValue(mixed $payload, mixed $expectation): void + { + $backend = $this->createMock(BackendInterface::class); + $backend->expects($this->once())->method('get')->willReturn(serialize($payload)); + $cache = new VariableFrontend('VariableFrontend', $backend); + self::assertSame($expectation, $cache->get('VariableCacheTest')); + } + #[Test] public function hasReturnsResultFromBackend(): void { @@ -312,4 +356,9 @@ public function removeCallsBackend(): void $cache = new VariableFrontend('VariableFrontend', $backend); self::assertTrue($cache->remove($cacheIdentifier)); } + + private function serialize(mixed $payload): string + { + return $this->deserializer->serialize($payload, VariableFrontend::class); + } }
typo3/sysext/core/Tests/Unit/FormProtection/FormProtectionFactoryTest.php+8 −1 modified@@ -23,7 +23,9 @@ use TYPO3\CMS\Core\Cache\Backend\TransientMemoryBackend; use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface; use TYPO3\CMS\Core\Cache\Frontend\NullFrontend; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; use TYPO3\CMS\Core\Cache\Frontend\VariableFrontend; +use TYPO3\CMS\Core\Crypto\HashService; use TYPO3\CMS\Core\FormProtection\BackendFormProtection; use TYPO3\CMS\Core\FormProtection\DisabledFormProtection; use TYPO3\CMS\Core\FormProtection\FormProtectionFactory; @@ -33,6 +35,8 @@ use TYPO3\CMS\Core\Localization\LocalizationFactory; use TYPO3\CMS\Core\Messaging\FlashMessageService; use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class FormProtectionFactoryTest extends UnitTestCase @@ -43,14 +47,17 @@ final class FormProtectionFactoryTest extends UnitTestCase protected function setUp(): void { $this->runtimeCacheMock = new VariableFrontend('null', new TransientMemoryBackend('null', ['logger' => new NullLogger()])); + $cacheMock = $this->createMock(PhpFrontend::class); + $cacheMock->method('has')->willReturn(false); + $deserializer = new DenyListDeserializer($cacheMock, new HashService(), new DeserializationService()); $this->subject = new FormProtectionFactory( new FlashMessageService(), new LanguageServiceFactory( new Locales(), $this->createMock(LocalizationFactory::class), new NullFrontend('null') ), - new Registry(), + new Registry($deserializer), $this->runtimeCacheMock ); parent::setUp();
typo3/sysext/core/Tests/Unit/RegistryTest.php+20 −6 modified@@ -18,56 +18,70 @@ namespace TYPO3\CMS\Core\Tests\Unit; use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Crypto\HashService; use TYPO3\CMS\Core\Registry; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class RegistryTest extends UnitTestCase { + private DenyListDeserializer $deserializer; + + protected function setUp(): void + { + parent::setUp(); + $cacheMock = $this->createMock(PhpFrontend::class); + $cacheMock->method('has')->willReturn(false); + $this->deserializer = new DenyListDeserializer($cacheMock, new HashService(), new DeserializationService()); + } + #[Test] public function getThrowsExceptionForInvalidNamespacesUsingNoNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->get('', 'someKey'); + (new Registry($this->deserializer))->get('', 'someKey'); } #[Test] public function getThrowsExceptionForInvalidNamespacesUsingTooShortNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->get('t', 'someKey'); + (new Registry($this->deserializer))->get('t', 'someKey'); } #[Test] public function setThrowsAnExceptionOnEmptyNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->set('', 'someKey', 'someValue'); + (new Registry($this->deserializer))->set('', 'someKey', 'someValue'); } #[Test] public function setThrowsAnExceptionOnWrongNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->set('t', 'someKey', 'someValue'); + (new Registry($this->deserializer))->set('t', 'someKey', 'someValue'); } #[Test] public function removeThrowsAnExceptionOnWrongNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->remove('t', 'someKey'); + (new Registry($this->deserializer))->remove('t', 'someKey'); } #[Test] public function removeAllByNamespaceThrowsAnExceptionOnWrongNamespace(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionCode(1249755131); - (new Registry())->removeAllByNamespace(''); + (new Registry($this->deserializer))->removeAllByNamespace(''); } }
typo3/sysext/extbase/Classes/Mvc/Controller/ActionController.php+2 −1 modified@@ -657,7 +657,8 @@ protected function forwardToReferringRequest(): ?ResponseInterface $arguments = []; if (is_string($referringRequestArguments['arguments'] ?? null)) { $arguments = unserialize( - base64_decode($this->hashService->validateAndStripHmac($referringRequestArguments['arguments'], HashScope::ReferringArguments->prefix())) + base64_decode($this->hashService->validateAndStripHmac($referringRequestArguments['arguments'], HashScope::ReferringArguments->prefix())), + ['allowed_classes' => true] ); } $replacedArguments = array_replace_recursive($arguments, $referrerArray);
typo3/sysext/extbase/Tests/Unit/Reflection/ReflectionServiceTest.php+1 −1 modified@@ -91,7 +91,7 @@ public function reflectionServiceIsResetDuringWakeUp(): void $insecureString = file_get_contents(__DIR__ . '/Fixture/InsecureSerializedReflectionService.txt'); // Note: We need to use the silence operator here for `unserialize()`, otherwise PHP8.3 would emit a warning // because of unneeded bytes in the content which needs to be unserialized. - $reflectionService = @unserialize($insecureString); + $reflectionService = @unserialize($insecureString, ['allowed_classes' => [ReflectionService::class]]); $reflectionClass = new \ReflectionClass($reflectionService); $classSchemaProperty = $reflectionClass->getProperty('classSchemata');
typo3/sysext/form/Classes/Domain/Runtime/FormRuntime.php+1 −1 modified@@ -242,7 +242,7 @@ protected function initializeFormStateFromRequest() } catch (InvalidHashStringException $e) { throw new BadRequestException('The HMAC of the form state could not be validated.', 1581862823); } - $this->formState = unserialize(base64_decode($serializedFormState)); + $this->formState = unserialize(base64_decode($serializedFormState), ['allowed_classes' => true]); } }
typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php+19 −1 modified@@ -683,7 +683,25 @@ protected function processNonCacheableContentPartsAndSubstituteContentMarkers(ar $label = 'Include ' . $nonCacheableData[$nonCacheableKey]['type']; $timeTracker->push($label); $nonCacheableContent = ''; - $contentObjectRendererForNonCacheable = unserialize($nonCacheableData[$nonCacheableKey]['cObj']); + // The cObj payload originates from one of two trusted sources and in + // neither case does it require gadget-list filtering: + // + // 1. Served from page cache: the entire page-cache entry (including + // INTincScript) is HMAC-verified by VariableFrontend before it + // reaches this point, so the payload is cryptographically guaranteed + // to be unmodified server-produced data. + // + // 2. Produced in the same request: when a page is built without a + // cache hit, ContentObjectRenderer serializes the cObj via PHP's own + // serialize() within the same process. The data never leaves the + // process before it is deserialized here, so no external actor can + // have influenced it. + // + // In both cases the serialized data is trusted server-side output; + // allowing all classes is safe and required because ContentObjectRenderer + // itself carries __wakeup() (to restore TSFE and currentFile references) + // and would therefore be blocked by DenyListDeserializer. + $contentObjectRendererForNonCacheable = unserialize($nonCacheableData[$nonCacheableKey]['cObj'], ['allowed_classes' => true]); if ($contentObjectRendererForNonCacheable instanceof ContentObjectRenderer) { $contentObjectRendererForNonCacheable->setRequest($request); $nonCacheableContent = match ($nonCacheableData[$nonCacheableKey]['type']) {
typo3/sysext/install/Classes/Controller/UpgradeController.php+6 −8 modified@@ -202,7 +202,8 @@ public function __construct( private readonly LateBootService $lateBootService, private readonly DatabaseUpgradeWizardsService $databaseUpgradeWizardsService, private readonly FormProtectionFactory $formProtectionFactory, - private readonly LoadTcaService $loadTcaService + private readonly LoadTcaService $loadTcaService, + private readonly Registry $registry, ) {} /** @@ -685,8 +686,7 @@ public function extensionScannerMarkFullyScannedRestFilesAction(ServerRequestInt { $foundRestFileHashes = (array)($request->getParsedBody()['install']['hashes'] ?? []); // First un-mark files marked as scanned-ok - $registry = new Registry(); - $registry->removeAllByNamespace('extensionScannerNotAffected'); + $this->registry->removeAllByNamespace('extensionScannerNotAffected'); // Find all .rst files (except those from v8), see if they are tagged with "FullyScanned" // and if their content is not in incoming "hashes" array, mark as "not affected" $documentationFile = new DocumentationFile(); @@ -715,7 +715,7 @@ public function extensionScannerMarkFullyScannedRestFilesAction(ServerRequestInt } } foreach ($fullyScannedRestFilesNotAffected as $fileHash) { - $registry->set('extensionScannerNotAffected', $fileHash, $fileHash); + $this->registry->set('extensionScannerNotAffected', $fileHash, $fileHash); } return new JsonResponse([ 'success' => true, @@ -964,10 +964,9 @@ public function upgradeDocsGetChangelogForVersionAction(ServerRequestInterface $ */ public function upgradeDocsMarkReadAction(ServerRequestInterface $request): ResponseInterface { - $registry = new Registry(); $filePath = $request->getParsedBody()['install']['ignoreFile']; $fileHash = md5_file($filePath); - $registry->set('upgradeAnalysisIgnoredFiles', $fileHash, $filePath); + $this->registry->set('upgradeAnalysisIgnoredFiles', $fileHash, $filePath); return new JsonResponse([ 'success' => true, ]); @@ -978,10 +977,9 @@ public function upgradeDocsMarkReadAction(ServerRequestInterface $request): Resp */ public function upgradeDocsUnmarkReadAction(ServerRequestInterface $request): ResponseInterface { - $registry = new Registry(); $filePath = $request->getParsedBody()['install']['ignoreFile']; $fileHash = md5_file($filePath); - $registry->remove('upgradeAnalysisIgnoredFiles', $fileHash); + $this->registry->remove('upgradeAnalysisIgnoredFiles', $fileHash); return new JsonResponse([ 'success' => true, ]);
typo3/sysext/install/Classes/ServiceProvider.php+2 −1 modified@@ -371,7 +371,8 @@ public static function getUpgradeController(ContainerInterface $container): Cont $container->get(Service\LateBootService::class), $container->get(Service\DatabaseUpgradeWizardsService::class), $container->get(FormProtectionFactory::class), - $container->get(LoadTcaService::class) + $container->get(LoadTcaService::class), + $container->get(Registry::class) ); }
typo3/sysext/install/Classes/Service/SetupService.php+3 −0 modified@@ -171,6 +171,9 @@ public function prepareSystemSettings(bool $forceOverwrite = false): void $this->configurationManager->createLocalConfigurationFromFactoryConfiguration(); $randomKey = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(96); $this->configurationManager->setLocalConfigurationValueByPath('SYS/encryptionKey', $randomKey); + // ensure there is an encryption key for the current process (see https://forge.typo3.org/issues/109789) + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = $randomKey; + $extensionConfiguration = new ExtensionConfiguration(); $extensionConfiguration->synchronizeExtConfTemplateWithLocalConfigurationOfAllExtensions();
typo3/sysext/scheduler/Classes/Domain/Repository/SchedulerTaskRepository.php+6 −4 modified@@ -332,7 +332,8 @@ public function getGroupedTasks(): array $taskData['lastExecutionFailure'] = false; if (!empty($row['lastexecution_failure'])) { $taskData['lastExecutionFailure'] = true; - $exceptionArray = @unserialize($row['lastexecution_failure']); + // only scalars are serialized in \TYPO3\CMS\Scheduler\Scheduler::executeTask + $exceptionArray = @unserialize($row['lastexecution_failure'], ['allowed_classes' => false]); $taskData['lastExecutionFailureCode'] = ''; $taskData['lastExecutionFailureMessage'] = ''; if (is_array($exceptionArray)) { @@ -475,7 +476,8 @@ public function addExecutionToTask(AbstractTask $task): int $runningExecutions = $previousExecutions !== null && $previousExecutions !== '' - ? unserialize($previousExecutions) + // serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>` + ? unserialize($previousExecutions, ['allowed_classes' => false]) : []; // Count the number of existing executions and use that number as a key @@ -520,8 +522,8 @@ public function removeExecutionOfTask(AbstractTask $task, int $executionID, arra if ($previousExecutions === '') { break; } - - $runningExecutions = unserialize($previousExecutions); + // serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>` + $runningExecutions = unserialize($previousExecutions, ['allowed_classes' => false]); // Remove the selected execution unset($runningExecutions[$executionID]); if (!empty($runningExecutions)) {
typo3/sysext/scheduler/Classes/Scheduler.php+2 −1 modified@@ -84,7 +84,8 @@ protected function cleanExecutionArrays() $maxDuration = $this->extConf['maxLifetime'] * 60; while ($row = $result->fetchAssociative()) { $executions = []; - if ($serialized_executions = unserialize($row['serialized_executions'])) { + // serialized in \TYPO3\CMS\Scheduler\Domain\Repository\SchedulerTaskRepository::addExecutionToTask as `array<int, int>` + if ($serialized_executions = unserialize($row['serialized_executions'], ['allowed_classes' => false])) { foreach ($serialized_executions as $task) { if ($tstamp - $task < $maxDuration) { $executions[] = $task;
typo3/sysext/scheduler/Classes/Task/TaskSerializer.php+4 −1 modified@@ -17,6 +17,7 @@ namespace TYPO3\CMS\Scheduler\Task; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; use TYPO3\CMS\Scheduler\Exception\InvalidTaskException; /** @@ -26,6 +27,8 @@ */ class TaskSerializer { + public function __construct(private readonly DenyListDeserializer $denyListDeserializer) {} + /** * This method takes care of safely deserializing tasks from the database * and either returns a valid Task or throws an InvalidTaskException, which @@ -36,7 +39,7 @@ class TaskSerializer public function deserialize(string $serializedTask): AbstractTask { try { - $task = @unserialize($serializedTask); + $task = $this->denyListDeserializer->deserialize($serializedTask); if ($task === false) { throw new InvalidTaskException('The serialized task is corrupted', 1642956282); }
typo3/sysext/scheduler/Tests/Unit/Task/TaskSerializerTest.php+27 −10 modified@@ -19,14 +19,31 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend; +use TYPO3\CMS\Core\Crypto\HashService; use TYPO3\CMS\Core\Log\Writer\NullWriter; +use TYPO3\CMS\Core\Serializer\DenyListDeserializer; +use TYPO3\CMS\Core\Serializer\DeserializationService; use TYPO3\CMS\Scheduler\Exception\InvalidTaskException; use TYPO3\CMS\Scheduler\Task\TaskSerializer; use TYPO3\CMS\Scheduler\Tests\Unit\Task\Fixtures\TestTask; use TYPO3\TestingFramework\Core\Unit\UnitTestCase; final class TaskSerializerTest extends UnitTestCase { + private TaskSerializer $subject; + + protected function setUp(): void + { + parent::setUp(); + $GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] = 'test-encryption-key'; + $cacheMock = $this->createMock(PhpFrontend::class); + $cacheMock->method('has')->willReturn(false); + $this->subject = new TaskSerializer( + new DenyListDeserializer($cacheMock, new HashService(), new DeserializationService()) + ); + } + public static function dataIsDeserializedDataProvider(): array { $testTaskWithString = new TestTask(); @@ -51,8 +68,7 @@ public static function dataIsDeserializedDataProvider(): array #[Test] public function dataIsDeserialized(string $data, $expectation): void { - $taskSerializer = new TaskSerializer(); - self::assertEquals($expectation, $taskSerializer->deserialize($data)); + self::assertEquals($expectation, $this->subject->deserialize($data)); } public static function deserializationThrowsExceptionDataProvider(): array @@ -75,13 +91,17 @@ public static function deserializationThrowsExceptionDataProvider(): array ); return [ + 'serialized false' => [ + serialize(false), + 1642956282, + ], 'blank' => [ '', - 1642956282, + 1740514197, ], 'invalid' => [ '{}', - 1642956282, + 1740514197, ], 'invalid task' => [ 'O:29:"TYPO3\CMS\Testing\InvalidTask":1:{s:5:"value";s:5:"value";}', @@ -108,8 +128,7 @@ public function deserializationThrowsException(string $data, int $exceptionCode) { $this->expectException(InvalidTaskException::class); $this->expectExceptionCode($exceptionCode); - $taskSerializer = new TaskSerializer(); - $taskSerializer->deserialize($data); + $this->subject->deserialize($data); } public static function classNameIsResolvedDataProvider(): array @@ -133,8 +152,7 @@ public static function classNameIsResolvedDataProvider(): array #[Test] public function classNameIsResolved(?object $task, ?string $expectation): void { - $taskSerializer = new TaskSerializer(); - self::assertSame($expectation, $taskSerializer->resolveClassName($task)); + self::assertSame($expectation, $this->subject->resolveClassName($task)); } public static function classNameIsExtractedDataProvider(): array @@ -159,7 +177,6 @@ public static function classNameIsExtractedDataProvider(): array #[Test] public function classNameIsExtracted(string $serializedTask, ?string $expectation): void { - $taskSerializer = new TaskSerializer(); - self::assertSame($expectation, $taskSerializer->extractClassName($serializedTask)); + self::assertSame($expectation, $this->subject->extractClassName($serializedTask)); } }
Vulnerability mechanics
No source-code context for this CVE — mechanics is only generated when we can read the actual fix diff. Without that, the four sections (root cause, attack vector, affected code, fix) would be speculation rather than analysis.
References
3News mentions
1- TYPO3 CMS: Thirteen Backend Vulnerabilities Disclosed on June 9, 2026Vypr Intelligence · Jun 9, 2026