Skip to content

Commit 87cd7c5

Browse files
committed
[SECURITY] Mitigate deserialization flaws
Introduce serializer infrastructure to block PHP object injection gadgets during deserialization: * DenyListDeserializer: runtime guard that rejects any payload referencing a gadget class before deserialization proceeds. The per-class deny/allow decision is resolved lazily via ReflectionClass at first encounter, then cached in cache:core signed with HMAC so that reflection is never repeated for the same class within a cache lifetime. A gadget class is one with a user-defined __destruct() or an exploitable __wakeup() (i.e. one not provided solely by BlockSerializationTrait). * AuthenticatedMessageDeserializer: signs payloads with HMAC on write and validates the HMAC on read, preventing adversarially crafted deserialization payloads. Two complementary strategies are applied: VariableFrontend (cache frontend) uses AuthenticatedMessageDeserializer: caches are temporary and written exclusively by the server, so HMAC authentication provides a strong integrity guarantee. All classes are allowed after a successful HMAC check. Legacy cache entries without an HMAC that contain no class tokens are still deserialized safely with allowed_classes=false; those with class tokens are discarded and treated as a transparent cache miss. Registry (sys_registry) uses DenyListDeserializer: long-lived persisted data requires a denylist strategy (block known-bad, allow unknown) to avoid breaking changes to existing stored values. Where only scalar values are serialized (Scheduler task executions, last-failure data, non-cacheable cObj data in RequestHandler), unserialize() is hardened directly with allowed_classes=false. Resolves: #108604 Releases: main, 14.3, 13.4 Change-Id: Ie3284eba4937b28be98c4d0f83d1d2f85ce963b5 Security-Bulletin: TYPO3-CORE-SA-2026-018 Security-References: CVE-2026-49740 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/94428 Tested-by: Oliver Hader <oliver.hader@typo3.org> Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
1 parent 150a983 commit 87cd7c5

27 files changed

Lines changed: 867 additions & 50 deletions

File tree

typo3/sysext/core/Classes/Cache/Backend/FileBackend.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class FileBackend extends SimpleFileBackend implements FreezableBackendInterface
4040
protected $cacheEntryFileExtension = '';
4141

4242
/**
43-
* @var array
43+
* @var array<string, true>
4444
*/
4545
protected $cacheEntryIdentifiers = [];
4646

@@ -107,7 +107,10 @@ public function setCache(FrontendInterface $cache)
107107
parent::setCache($cache);
108108
if (file_exists($this->cacheDirectory . 'FrozenCache.data')) {
109109
$this->frozen = true;
110-
$this->cacheEntryIdentifiers = unserialize((string)file_get_contents($this->cacheDirectory . 'FrozenCache.data'));
110+
$this->cacheEntryIdentifiers = unserialize(
111+
(string)file_get_contents($this->cacheDirectory . 'FrozenCache.data'),
112+
['allowed_classes' => false]
113+
);
111114
}
112115
}
113116

typo3/sysext/core/Classes/Cache/Frontend/VariableFrontend.php

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
namespace TYPO3\CMS\Core\Cache\Frontend;
1717

1818
use TYPO3\CMS\Core\Cache\Backend\TransientBackendInterface;
19+
use TYPO3\CMS\Core\Crypto\HashService;
20+
use TYPO3\CMS\Core\Serializer\AuthenticatedMessageDeserializer;
21+
use TYPO3\CMS\Core\Serializer\DeserializationService;
22+
use TYPO3\CMS\Core\Serializer\Exception\DeserializerException;
1923
use TYPO3\CMS\Core\Utility\GeneralUtility;
2024

2125
/**
@@ -57,7 +61,9 @@ public function set($entryIdentifier, $variable, array $tags = [], $lifetime = n
5761
GeneralUtility::callUserFunction($_funcRef, $params, $this);
5862
}
5963
if (!$this->backend instanceof TransientBackendInterface) {
60-
$variable = serialize($variable);
64+
// No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup.
65+
$deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService());
66+
$variable = $deserializer->serialize($variable, VariableFrontend::class);
6167
}
6268
$this->backend->set($entryIdentifier, $variable, $tags, $lifetime);
6369
}
@@ -82,6 +88,15 @@ public function get($entryIdentifier)
8288
if ($rawResult === false) {
8389
return false;
8490
}
85-
return $this->backend instanceof TransientBackendInterface ? $rawResult : unserialize($rawResult);
91+
if ($this->backend instanceof TransientBackendInterface) {
92+
return $rawResult;
93+
}
94+
try {
95+
// No DI/GeneralUtility::makeInstance usage, since caching needs to operate prior to DI container setup.
96+
$deserializer = new AuthenticatedMessageDeserializer(new HashService(), new DeserializationService());
97+
return $deserializer->deserialize($rawResult, VariableFrontend::class);
98+
} catch (DeserializerException) {
99+
return false;
100+
}
86101
}
87102
}

typo3/sysext/core/Classes/Registry.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
use TYPO3\CMS\Core\Database\Connection;
1919
use TYPO3\CMS\Core\Database\ConnectionPool;
20+
use TYPO3\CMS\Core\Serializer\DenyListDeserializer;
2021
use TYPO3\CMS\Core\Utility\GeneralUtility;
2122

2223
/**
@@ -41,6 +42,7 @@ class Registry implements SingletonInterface
4142
*/
4243
protected $loadedNamespaces = [];
4344

45+
public function __construct(protected readonly DenyListDeserializer $deserializer) {}
4446
/**
4547
* Returns a persistent entry.
4648
*
@@ -169,7 +171,7 @@ protected function loadEntriesByNamespace($namespace)
169171
['entry_namespace' => $namespace]
170172
);
171173
while ($row = $result->fetchAssociative()) {
172-
$this->entries[$namespace][$row['entry_key']] = unserialize($row['entry_value']);
174+
$this->entries[$namespace][$row['entry_key']] = $this->deserializer->deserialize($row['entry_value']);
173175
}
174176
$this->loadedNamespaces[$namespace] = true;
175177
}

typo3/sysext/core/Classes/Resource/ProcessedFile.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
namespace TYPO3\CMS\Core\Resource;
1919

20+
use TYPO3\CMS\Core\Imaging\ImageManipulation\Area;
2021
use TYPO3\CMS\Core\Resource\Processing\TaskTypeRegistry;
2122
use TYPO3\CMS\Core\Resource\Service\ConfigurationService;
2223
use TYPO3\CMS\Core\Utility\GeneralUtility;
@@ -120,8 +121,7 @@ public function __construct(File $originalFile, string $taskType, array $process
120121
protected function reconstituteFromDatabaseRecord(array $databaseRow): void
121122
{
122123
$this->taskType = $this->taskType ?: $databaseRow['task_type'];
123-
// @todo In case the original configuration contained file objects the reconstitution fails. See ConfigurationService->serialize()
124-
$this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? '');
124+
$this->processingConfiguration = $this->processingConfiguration ?: (array)unserialize($databaseRow['configuration'] ?? '', ['allowed_classes' => [Area::class]]);
125125

126126
$this->originalFileSha1 = $databaseRow['originalfilesha1'];
127127
$this->identifier = $databaseRow['identifier'];
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Serializer;
19+
20+
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
21+
use TYPO3\CMS\Core\Crypto\HashService;
22+
use TYPO3\CMS\Core\Exception\Crypto\InvalidHashStringException;
23+
use TYPO3\CMS\Core\Serializer\Exception\DeserializerException;
24+
25+
/**
26+
* @internal Only to be used by TYPO3 core
27+
*/
28+
#[Autoconfigure(public: true)]
29+
final readonly class AuthenticatedMessageDeserializer
30+
{
31+
public function __construct(
32+
private HashService $hashService,
33+
private DeserializationService $deserializationService,
34+
) {}
35+
36+
public function serialize(mixed $payload, string $additionalSecret): string
37+
{
38+
return $this->hashService->appendHmac(
39+
serialize($payload),
40+
$additionalSecret
41+
);
42+
}
43+
44+
public function deserialize(string $payload, string $additionalSecret): mixed
45+
{
46+
try {
47+
$serialized = $this->hashService->validateAndStripHmac(
48+
$payload,
49+
$additionalSecret
50+
);
51+
} catch (InvalidHashStringException $e) {
52+
$classNames = $this->deserializationService->parseClassNames($payload);
53+
// in case the payload does not contain any class names, continue with
54+
// a secure deserialization attempt, not allowing any class names
55+
if ($classNames === []) {
56+
return unserialize($payload, ['allowed_classes' => false]);
57+
}
58+
throw new DeserializerException(
59+
'Authenticated Message Deserialization failed',
60+
1780317744,
61+
$e
62+
);
63+
}
64+
// explicitly allowing all classes here after successful HMAC validation
65+
return unserialize($serialized, ['allowed_classes' => true]);
66+
}
67+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of the TYPO3 CMS project.
7+
*
8+
* It is free software; you can redistribute it and/or modify it under
9+
* the terms of the GNU General Public License, either version 2
10+
* of the License, or any later version.
11+
*
12+
* For the full copyright and license information, please read the
13+
* LICENSE.txt file that was distributed with this source code.
14+
*
15+
* The TYPO3 project - inspiring people to share!
16+
*/
17+
18+
namespace TYPO3\CMS\Core\Serializer;
19+
20+
use Symfony\Component\DependencyInjection\Attribute\Autoconfigure;
21+
use Symfony\Component\DependencyInjection\Attribute\Autowire;
22+
use TYPO3\CMS\Core\Cache\Frontend\PhpFrontend;
23+
use TYPO3\CMS\Core\Crypto\HashService;
24+
use TYPO3\CMS\Core\Security\BlockSerializationTrait;
25+
use TYPO3\CMS\Core\Serializer\Exception\DeserializerException;
26+
27+
/**
28+
* Deserializes a PHP-serialized payload while refusing any class that carries
29+
* a user-defined __destruct() or an exploitable __wakeup() (one not provided
30+
* solely by BlockSerializationTrait).
31+
*
32+
* The per-class deny/allow decision is made lazily via ReflectionClass at the
33+
* first encounter of each class name, then cached in cache:core so that
34+
* reflection is never repeated for the same class within a cache lifetime.
35+
*
36+
* Use this instead of a raw unserialize() call when the set of expected classes
37+
* is not known upfront but dangerous gadget classes must still be excluded.
38+
*
39+
* @internal Only to be used by TYPO3 core
40+
*/
41+
#[Autoconfigure(public: true)]
42+
final readonly class DenyListDeserializer
43+
{
44+
/**
45+
* @var list<string>
46+
*/
47+
private array $allowedClassNames;
48+
private \ReflectionMethod $blockSerializationWakeup;
49+
50+
public function __construct(
51+
#[Autowire(service: 'cache.core')]
52+
private PhpFrontend $cache,
53+
private HashService $hashService,
54+
private DeserializationService $deserializationService,
55+
) {
56+
$allowedClassNames = $GLOBALS['TYPO3_CONF_VARS']['SYS']['deserialization']['allowedClassNames'] ?? null;
57+
$this->allowedClassNames = is_array($allowedClassNames) ? $allowedClassNames : [];
58+
$this->blockSerializationWakeup = (new \ReflectionClass(BlockSerializationTrait::class))->getMethod('__wakeup');
59+
}
60+
61+
/**
62+
* Deserializes $payload, throwing DeserializerException if any class name
63+
* found in the payload is a deserialization gadget, or if the payload is
64+
* syntactically malformed.
65+
*/
66+
public function deserialize(string $payload): mixed
67+
{
68+
$classNames = $this->deserializationService->parseClassNames($payload);
69+
foreach ($classNames as $className) {
70+
if ($this->shallClassBeDenied($className)) {
71+
throw new DeserializerException(
72+
'Denied class name "' . $className . '" found in payload',
73+
1778594101
74+
);
75+
}
76+
}
77+
78+
return $this->deserializationService->deserialize($payload, $classNames ?: false);
79+
}
80+
81+
private function shallClassBeDenied(string $className): bool
82+
{
83+
if (in_array($className, $this->allowedClassNames, true)) {
84+
return false;
85+
}
86+
87+
$cacheKey = 'DenyListDeserializer_' . hash('xxh128', $className);
88+
if ($this->cache->has($cacheKey)) {
89+
$entry = $this->cache->require($cacheKey);
90+
if (is_array($entry)
91+
&& isset($entry['denied'], $entry['hmac'])
92+
&& $this->hashService->validateHmac(
93+
$this->createHmacPayload($className, (bool)$entry['denied']),
94+
DenyListDeserializer::class,
95+
$entry['hmac']
96+
)
97+
) {
98+
return (bool)$entry['denied'];
99+
}
100+
// Tampered or stale entry — fall through to recompute
101+
}
102+
103+
$denied = $this->resolveClassDenyStatus($className);
104+
$hmac = $this->hashService->hmac($this->createHmacPayload($className, $denied), DenyListDeserializer::class);
105+
$this->cache->set($cacheKey, 'return ' . var_export(['denied' => $denied, 'hmac' => $hmac], true) . ';');
106+
return $denied;
107+
}
108+
109+
private function createHmacPayload(string $className, bool $denied): string
110+
{
111+
return $className . ':' . ($denied ? '1' : '0');
112+
}
113+
114+
private function resolveClassDenyStatus(string $className): bool
115+
{
116+
try {
117+
$rc = new \ReflectionClass($className);
118+
} catch (\ReflectionException) {
119+
// The class does not exist or cannot be reflected (and not instantiated).
120+
// Thus, the class is allowed, since it cannot be a gadget and would
121+
// result in a `__PHP_Incomplete_Class` during deserialization.
122+
return false;
123+
}
124+
if ($rc->isInterface() || $rc->isTrait()) {
125+
return false;
126+
}
127+
return $this->getUserDefinedMethod($rc, '__destruct') !== null
128+
|| $this->hasDeniableWakeupMethod($rc);
129+
}
130+
131+
/**
132+
* Returns the method when $methodName is declared in user-defined (non-internal) code
133+
* somewhere in the class hierarchy. This excludes methods like Exception::__wakeup()
134+
* that PHP declares internally and that are harmless for deserialization purposes.
135+
*/
136+
private function getUserDefinedMethod(\ReflectionClass $rc, string $methodName): ?\ReflectionMethod
137+
{
138+
if (!$rc->hasMethod($methodName)) {
139+
return null;
140+
}
141+
$method = $rc->getMethod($methodName);
142+
if ($method->getDeclaringClass()->isInternal()) {
143+
return null;
144+
}
145+
return $method;
146+
}
147+
148+
/**
149+
* Returns true when the class has a user-defined __wakeup() that is NOT
150+
* BlockSerializationTrait::__wakeup(). Classes whose only __wakeup comes
151+
* from BlockSerializationTrait are already protected against deserialization
152+
* (the trait throws unconditionally) and must not be treated as gadgets.
153+
*
154+
* Note: for trait methods getDeclaringClass() returns the using class, not the
155+
* trait — so the origin is identified by comparing the method's source file and line
156+
* against the trait's own __wakeup declaration.
157+
*/
158+
private function hasDeniableWakeupMethod(\ReflectionClass $rc): bool
159+
{
160+
$method = $this->getUserDefinedMethod($rc, '__wakeup');
161+
if ($method === null) {
162+
return false;
163+
}
164+
return $method->getFileName() !== $this->blockSerializationWakeup->getFileName()
165+
|| $method->getStartLine() !== $this->blockSerializationWakeup->getStartLine();
166+
}
167+
}

typo3/sysext/core/Classes/ServiceProvider.php

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,16 @@ public static function getPackageDependentCacheIdentifier(ContainerInterface $co
465465

466466
public static function getRegistry(ContainerInterface $container): Registry
467467
{
468-
return self::new($container, Registry::class);
468+
$denyListDeserializer = $container->has(Serializer\DenyListDeserializer::class)
469+
? $container->get(Serializer\DenyListDeserializer::class)
470+
: new Serializer\DenyListDeserializer(
471+
$container->get('cache.core'),
472+
$container->get(HashService::class),
473+
new Serializer\DeserializationService(),
474+
);
475+
return self::new($container, Registry::class, [
476+
$denyListDeserializer,
477+
]);
469478
}
470479

471480
public static function getFileIndexRepository(ContainerInterface $container): Resource\Index\FileIndexRepository

typo3/sysext/core/Configuration/DefaultConfiguration.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@
174174
],
175175
],
176176
],
177+
'deserialization' => [
178+
// List of class names that are allowed to be deserialized even if they carry
179+
// __destruct() or __wakeup() and would otherwise be blocked. Use this to
180+
// explicitly permit classes that have been reviewed and are known to be safe.
181+
'allowedClassNames' => [],
182+
],
177183
'caching' => [
178184
'cacheConfigurations' => [
179185
// The core cache is is for core php code only and must

0 commit comments

Comments
 (0)