CVE-2026-27206
Description
Zumba Json Serializer is a library to serialize PHP variables in JSON format. In versions 3.2.2 and below, the library allows deserialization of PHP objects from JSON using a special @type field. The deserializer instantiates any class specified in the @type field without restriction. When processing untrusted JSON input, this behavior may allow an attacker to instantiate arbitrary classes available in the application. If a vulnerable application passes attacker-controlled JSON into JsonSerializer::unserialize() and contains classes with dangerous magic methods (such as __wakeup() or __destruct()), this may lead to PHP Object Injection and potentially Remote Code Execution (RCE), depending on available gadget chains in the application or its dependencies. This behavior is similar in risk profile to PHP's native unserialize() when used without the allowed_classes restriction. Applications are impacted only if untrusted or attacker-controlled JSON is passed into JsonSerializer::unserialize() and the application or its dependencies contain classes that can be leveraged as a gadget chain. This issue has been fixed in version 3.2.3. If an immediate upgrade isn't feasible, mitigate the vulnerability by never deserializing untrusted JSON with JsonSerializer::unserialize(), validating and sanitizing all JSON input before deserialization, and disabling @type-based object instantiation wherever possible.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
zumba/json-serializerPackagist | < 3.2.3 | 3.2.3 |
Affected products
1Patches
1bf26227879adMerge commit from fork
4 files changed · +227 −0
README.md+42 −0 modified@@ -43,6 +43,47 @@ This project should not be confused with `JsonSerializable` interface added on P *Json Serializer requires PHP >= 7.2 and tested until PHP 8.4* +## Security: do not unserialize untrusted input + +> **Warning** +> Never pass untrusted data (user input, third-party API responses, cookies, etc.) +> to `unserialize()` without first restricting which classes may be instantiated. + +The JSON format used by this library embeds a `@type` key that names the PHP class +to restore. Without restrictions an attacker who controls the JSON payload can +cause **any class available in the autoloader to be instantiated**, including +classes whose `__wakeup()` or `__destruct()` methods execute dangerous operations +(remote code execution, file deletion, etc.). + +Use `setAllowedClasses()` to declare the exact set of classes your application +expects to deserialize: + +```php +$serializer = new Zumba\JsonSerializer\JsonSerializer(); +$serializer->setAllowedClasses([ + MyApp\Model\User::class, + MyApp\Model\Order::class, +]); + +// Safe: User and Order are allowed. +$obj = $serializer->unserialize($jsonFromDatabase); + +// Throws JsonSerializerException: SomeOtherClass is not in the allowlist. +$obj = $serializer->unserialize($attackerControlledJson); +``` + +`setAllowedClasses()` accepts: + +| Value | Behaviour | +|---|---| +| `null` *(default)* | No restriction — any known class can be instantiated. Keep this only for fully trusted, internally-generated JSON. | +| `[]` (empty array) | All class instantiation is blocked. | +| `['Foo', 'Bar']` | Only `Foo` and `Bar` (exact class names) may be instantiated. | + +Classes registered via the custom object serializer map are always allowed +regardless of this setting, because they are explicitly configured by the +developer. + ## Example ```php @@ -58,6 +99,7 @@ $serializer = new Zumba\JsonSerializer\JsonSerializer(); $json = $serializer->serialize($instance); // $json will contain the content {"@type":"MyCustomClass","isItAwesome":true,"nice":"very!"} +$serializer->setAllowedClasses([MyCustomClass::class]); $restoredInstance = $serializer->unserialize($json); // $restoredInstance will be an instance of MyCustomClass ```
src/JsonSerializer/JsonSerializer.php+35 −0 modified@@ -69,6 +69,16 @@ class JsonSerializer */ protected $undefinedAttributeMode = self::UNDECLARED_PROPERTY_MODE_SET; + /** + * Allowed classes for deserialization. + * Null means all classes are allowed (default, backward-compatible). + * An empty array means no classes are allowed. + * A non-empty array restricts deserialization to only the listed classes. + * + * @var array|null + */ + protected $allowedClasses = null; + /** * Constructor. * @@ -239,6 +249,24 @@ public function setUnserializeUndeclaredPropertyMode($value) return $this; } + /** + * Set the list of classes allowed during deserialization. + * + * When set to an array, only those classes can be instantiated via the + * "@type" key in a JSON payload. Classes registered in the custom object + * serializer map are always allowed regardless of this setting. + * Pass null (the default) to restore the unrestricted, backward-compatible + * behaviour. Pass an empty array to forbid all class instantiation. + * + * @param array|null $allowedClasses + * @return self + */ + public function setAllowedClasses(?array $allowedClasses): self + { + $this->allowedClasses = $allowedClasses; + return $this; + } + /** * Parse the data to be json encoded * @@ -449,6 +477,13 @@ protected function unserializeObject($value) throw new JsonSerializerException('Unable to find class ' . $className); } + if ($this->allowedClasses !== null && !in_array($className, $this->allowedClasses, true)) { + throw new JsonSerializerException( + 'Class ' . $className . ' is not allowed for deserialization. ' . + 'Use setAllowedClasses() to configure the list of allowed classes.' + ); + } + if ($className === 'DateTime' || $className === 'DateTimeImmutable') { $obj = $this->restoreUsingUnserialize($className, $value); $this->objectMapping[$this->objectMappingIndex++] = $obj;
tests/JsonSerializerTest.php+124 −0 modified@@ -850,4 +850,128 @@ public function testSerializationOfSplDoublyLinkedList() $unserialized = $this->serializer->unserialize($this->serializer->serialize($list)); $this->assertTrue($list->serialize() === $unserialized->serialize()); } + + // ------------------------------------------------------------------------- + // Security: class allowlist (CVE fix for insecure deserialization / CWE-502) + // ------------------------------------------------------------------------- + + /** + * setAllowedClasses() must return $this for fluent chaining. + */ + public function testSetAllowedClassesIsChainable(): void + { + $result = $this->serializer->setAllowedClasses(['stdClass']); + $this->assertSame($this->serializer, $result); + } + + /** + * Default behaviour (allowedClasses = null) remains unchanged so that + * existing applications are not broken. + */ + public function testDefaultBehaviourAllowsAllClasses(): void + { + // No setAllowedClasses() call → backward-compatible: any known class works. + $serialized = '{"@type":"stdClass","x":1}'; + $obj = $this->serializer->unserialize($serialized); + $this->assertInstanceOf('stdClass', $obj); + } + + /** + * A class that IS in the allowlist must deserialize successfully. + */ + public function testAllowedClassIsDeserialized(): void + { + $this->serializer->setAllowedClasses([ + 'stdClass', + 'Zumba\\JsonSerializer\\Test\\SupportClasses\\EmptyClass', + ]); + + $serialized = '{"@type":"Zumba\\\\JsonSerializer\\\\Test\\\\SupportClasses\\\\EmptyClass"}'; + $obj = $this->serializer->unserialize($serialized); + $this->assertInstanceOf('Zumba\JsonSerializer\Test\SupportClasses\EmptyClass', $obj); + } + + /** + * A class NOT in the allowlist must throw JsonSerializerException and must + * never have its __wakeup() or __destruct() triggered (gadget chain blocked). + */ + public function testUnlistedClassIsRejectedAndMagicMethodsNotCalled(): void + { + SupportClasses\GadgetClass::$wakeupCalled = false; + SupportClasses\GadgetClass::$destructCalled = false; + + $this->serializer->setAllowedClasses(['stdClass']); // GadgetClass is NOT listed + + $payload = '{"@type":"Zumba\\\\JsonSerializer\\\\Test\\\\SupportClasses\\\\GadgetClass","command":"id"}'; + + try { + $this->serializer->unserialize($payload); + $this->fail('Expected JsonSerializerException was not thrown.'); + } catch (JsonSerializerException $e) { + $this->assertStringContainsString('not allowed', $e->getMessage()); + } + + // Ensure neither magic method was executed. + $this->assertFalse( + SupportClasses\GadgetClass::$wakeupCalled, + '__wakeup() must not be called on a blocked class.' + ); + } + + /** + * An empty allowlist must block every class, including stdClass. + */ + public function testEmptyAllowedClassesBlocksAll(): void + { + $this->serializer->setAllowedClasses([]); + + $this->expectException(JsonSerializerException::class); + $this->serializer->unserialize('{"@type":"stdClass"}'); + } + + /** + * Classes registered in the custom object serializer map must bypass the + * allowlist because they are explicitly trusted by the developer. + */ + public function testCustomSerializerClassBypassesAllowlist(): void + { + // setUpSerializer() registered MyType with a custom serializer. + // Even with a restrictive allowlist, MyType must still deserialize. + $this->serializer->setAllowedClasses([]); // everything blocked + + $serialized = '{"@type":"Zumba\\\\JsonSerializer\\\\Test\\\\SupportClasses\\\\MyType","fields":"x y"}'; + $obj = $this->serializer->unserialize($serialized); + $this->assertInstanceOf('Zumba\JsonSerializer\Test\SupportClasses\MyType', $obj); + } + + /** + * Passing null to setAllowedClasses() restores the unrestricted default. + */ + public function testSettingAllowedClassesToNullRestoresDefaultBehaviour(): void + { + $this->serializer->setAllowedClasses([]); // block everything + $this->serializer->setAllowedClasses(null); // restore default + + $obj = $this->serializer->unserialize('{"@type":"stdClass"}'); + $this->assertInstanceOf('stdClass', $obj); + } + + /** + * Simulates the PoC from the security report: an attacker-supplied @type + * pointing to a gadget class must be rejected when an allowlist is active. + */ + public function testSecurityReportPoCIsBlockedByAllowlist(): void + { + $this->serializer->setAllowedClasses(['stdClass']); + + // Payload from the security report (adapted to a class defined in this + // test suite so that class_exists() returns true). + $payload = json_encode([ + '@type' => 'Zumba\\JsonSerializer\\Test\\SupportClasses\\GadgetClass', + 'command' => 'id', + ]); + + $this->expectException(JsonSerializerException::class); + $this->serializer->unserialize($payload); + } }
tests/SupportClasses/GadgetClass.php+26 −0 added@@ -0,0 +1,26 @@ +<?php + +namespace Zumba\JsonSerializer\Test\SupportClasses; + +/** + * Simulates a "gadget" class that an attacker could abuse via insecure + * deserialization. The static flags let tests assert that the dangerous + * magic methods were never triggered when the class is not in the allowlist. + */ +class GadgetClass +{ + public static bool $wakeupCalled = false; + public static bool $destructCalled = false; + + public string $command = 'id'; + + public function __wakeup(): void + { + self::$wakeupCalled = true; + } + + public function __destruct() + { + self::$destructCalled = true; + } +}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-v7m3-fpcr-h7m2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27206ghsaADVISORY
- github.com/zumba/json-serializer/commit/bf26227879adefce75eb9651040d8982be97b881nvdWEB
- github.com/zumba/json-serializer/releases/tag/3.2.3nvdWEB
- github.com/zumba/json-serializer/security/advisories/GHSA-v7m3-fpcr-h7m2nvdWEB
News mentions
0No linked articles in our index yet.