The Exploit
An unauthenticated attacker can inject arbitrary PHP objects into the Better Search Replace plugin by supplying a malicious serialized payload to the deserialization sink. No authentication or CSRF token is required.
POST /wp-admin/admin-ajax.php HTTP/1.1
Host: target.wordpress.local
Content-Type: application/x-www-form-urlencoded
action=bsr_db_query&serialized_string=O:8:"stdClass":1:{s:4:"test";O:8:"DateTime":0:{}}
The server deserializes the serialized_string parameter without restricting object instantiation. An attacker observes HTTP 200 with the plugin processing the request, confirming the object was unserialized. If a gadget chain (POP chain) exists in another installed theme or plugin on the target system, the attacker can chain object instantiation to arbitrary code execution, file deletion, or data exfiltration.
What the Patch Did
Before
$unserialized_string = @unserialize( $serialized_string );
After
if ( PHP_VERSION_ID >= 70000 ) {
$unserialized_string = @unserialize( $serialized_string, array('allowed_classes' => false ) );
} else {
$unserialized_string = @BSR\Brumann\Polyfill\Unserialize::unserialize( $serialized_string, array( 'allowed_classes' => false ) );
}
The patch adds the allowed_classes parameter set to false to the native PHP unserialize() function. This parameter, introduced in PHP 7.0.0, restricts deserialization to scalar types only (string, int, float, bool, null, array) and blocks instantiation of any class. For PHP versions prior to 7.0, the patch delegates to a polyfill implementation (BSR\Brumann\Polyfill\Unserialize) that replicates the same restriction. The security control is object instantiation filtering at the deserialization boundary.
Root Cause
CWE-502: Deserialization of Untrusted Data
The plugin accepts user-supplied serialized PHP data via the serialized_string POST parameter without validation or filtering. This data flows directly into unserialize() in includes/class-bsr-db.php at line 448–452, crossing a critical trust boundary: from untrusted external input to execution context where object instantiation occurs. The default behavior of unserialize() without the allowed_classes parameter instantiates any class referenced in the serialized string. An attacker constructs a serialized object such as O:8:"stdClass":1:{...} that references arbitrary classes; the PHP engine executes the class constructor and magic methods (__wakeup, __destruct, __toString, etc.), which may trigger gadget chains if matching classes exist elsewhere in the application stack.
Why It Works
The load-bearing line is array('allowed_classes' => false). Remove it and the vulnerability remains fully exploitable. Without this parameter, unserialize() still instantiates objects; the conditional logic (if ( PHP_VERSION_ID >= 70000 )) and the polyfill fallback are support infrastructure. The allowed_classes => false setting is the only line that actually prevents object instantiation. The engineers added version detection and a polyfill because the parameter was not available in PHP 5.x; they needed defence-in-depth across the supported version matrix. The @ error suppression operator remains unchanged and is acceptable here because it merely mutes "incomplete class" warnings that occur when classes are not available at unserialize time — a symptom, not a root cause.
Hardening Checklist
- Use typed deserialization or schema validation: avoid
unserialize()entirely; preferjson_decode()with strict type checking or explicit whitelist-based object construction. Ifunserialize()is unavoidable, always passarray('allowed_classes' => false)on PHP 7.0+, or use a runtime polyfill for older versions. - Audit all entry points for serialized data: grep the codebase for
$_GET,$_POST,$_REQUEST, and AJAX action handlers, then trace to anyunserialize()or object access. Document which parameters accept serialized input and why. - Implement a Content Security Policy (CSP) and disable dynamic object instantiation: use PHP's
disable_classesanddisable_functionsdirectives inphp.inito prevent instantiation of dangerous classes (SimpleXMLElement,DOMDocument, etc.) that commonly serve as gadget chain entry points. - Monitor for object injection attempts at runtime: log calls to
unserialize()and flag payloads containingO:(object notation); integrate with a SIEM or WAF to alert on repeated attempts. - Vendor dependencies and POP chain audits: if your plugin depends on third-party libraries, use
composer auditand tools likephpstanwith plugins for gadget chain detection. Advise users to keep all plugins and themes updated.
References
- https://nvd.nist.gov/vuln/detail/CVE-2023-6933