At the time of writing, WordPress powers 43% of websites on the Internet. Its simplicity and robustness enable millions of users to host their blog, eCommerce site, forum, or static website. To protect its users, several security hardening mechanisms were introduced to the code base in the past.
We discovered an interesting Object Injection vulnerability (CVE-2022-21663) in the WordPress core that was recently fixed with version 5.8.3. Object Injection is a code vulnerability that enables attackers to inject PHP objects of arbitrary types into the application which can then tamper with the application’s logic at runtime. If you are new to the subject, we recommend reading our PHP Object Injection blog post.
Although this particular vulnerability is hard to exploit, it demonstrates that these types of severe vulnerabilities are still found in complex and hardened code-bases. In this blog post, we examine the vulnerable code lines and uncover an interesting attack surface in the WordPress core.
The Object injection vulnerability can be triggered on multi-site WordPress installations by a malicious super-admin. Such privileges could be gained by exploiting a Cross-Site-Scripting vulnerability in the core or in any of the plugins installed on a targeted WordPress instance.
A WordPress instance usually ships with multiple plugins out of the 60.000 plugins that are freely available. It is common for a business website to have 20-30 active plugins. We have demonstrated in the past how all an attacker needs is a simple Cross-Site Scripting vulnerability in just one of the plugins installed to take over the targeted WordPress instance. This is due to the fact that on instances with default configurations, an admin can install malicious plugins and even edit their PHP code from within the admin panel.
To prevent attackers from abusing these features, WordPress released an official hardening guide, which enables administrators to disable the aforementioned, dangerous features. When they are disabled and an attacker manages to hijack an administrative session, for example with a Stored XSS vulnerability in the core (see our last blog post), the attacker finds themselves in a “sandbox”. This means they are an administrator on the targeted instance, but they can’t execute PHP code on the underlying server. When a plugin is installed that contains appropriate pop-chain gadgets, this Object Injection vulnerability in WordPress could lead to Remote Code Execution.
In this section, we break down the technical details of this Object Injection vulnerability and how it might be exploited.
A WordPress site is controlled by hundreds of different options. These options are used to configure a WordPress site. In the underlying code, options are fetched from the database with help of the get_option($key) function and updated with help of the update_option($key, $value) function. Over time, the list of options stored on a WordPress site usually grows as WordPress plugin developers and even core developers tend to store internal data, which is not meant to be modified by a user or even administrator, as option pairs.
However, as an administrator of a WordPress site, it is possible to list and modify almost all option key/value pairs stored in the database. The following screenshot shows a list of options obtained by visiting the page at /wp-admin/options.php on a test instance:
Some of the option names in the screenshot above suggest that the data associated with them is meant for internal processes and should not be modified by an administrator. For instance, the value of the active_plugins option: in the screenshot, it is displayed as a grayed-out field with the value SERIALIZED_DATA.
As documented in the WordPress developer reference, the update_option($key, $value) function can take objects, arrays, integers, strings, and other types as a value as long as they can be serialized. In such a case, a PHP serialized string is stored in the database.
The WordPress core ensures that no deserialization attacks can be performed by checking if a string has previously been serialized and if so, double-serializing it. This is done by the maybe_serialize($data) function:
wordpress/wp-includes/functions.php
597 function maybe_serialize( $data ) {
598 if ( is_array( $data ) || is_object( $data ) ) {
599 return serialize( $data );
600 }
601
602 /*
603 * Double serialization is required for backward compatibility.
604 * See https://core.trac.wordpress.org/ticket/12930
605 * Also the world will end. See WP 3.6.1.
606 */
607 if ( is_serialized( $data, false ) ) {
608 return serialize( $data );
609 }
610
611 return $data;
The symmetrical twin of the maybe_serialize($data) function is the maybe_unserialize($data) function:
wordpress/wp-includes/functions.php
wordpress/wp-includes/functions.php
622 function maybe_unserialize( $data ) {
623 if ( is_serialized( $data ) ) {
624 return @unserialize( trim( $data ) );
625 }
626
627 return $data;
628 }
Notice how both functions utilize is_serialized($data) to detect whether a string looks like a PHP serialized string. The next section goes into detail about an Object Injection vulnerability that occured because this function was used incorrectly in the WordPress core.
Every time WordPress handles an incoming request, it executes a list of validation steps. One of these steps is to ensure that the version of the database associated with the WordPress installation matches the version of the current code files.
For each new WordPress version that is released, the latest database version is updated in a global variable:
wordpress/wp-includes/version.php
18 /**
19 * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.
20 *
21 * @global int $wp_db_version
22 */
23 $wp_db_version = 49752;
The version shown in the snippet above is the version that the database should be in when it has been fully upgraded. The version of the database as it is at the time of the request is stored as a WordPress option. The following snippet shows how this option is fetched from the database. When it is equal to the version that it is supposed to be, no action is performed and the request is handled. If the version is out of sync, a set of upgrade scripts are run, as shown below:
wordpress/wp-admin/includes/upgrade.php
636 function wp_upgrade() {
637 global $wp_current_db_version, $wp_db_version, $wpdb;
638
639 $wp_current_db_version = __get_option( 'db_version' );
640
641 // We are up to date. Nothing to do.
642 if ( $wp_db_version == $wp_current_db_version ) {
643 return;
644 }
645
646 if ( ! is_blog_installed() ) {
647 return;
648 }
649
// …
654 upgrade_all();
754 // …
755 if ( $wp_current_db_version < 8989 ) {
756 upgrade_270();
757 }
758
759 if ( $wp_current_db_version < 10360 ) {
760 upgrade_280();
761 }
762 // …
This behavior is interesting as a malicious admin can set $wp_current_db_version to an arbitrary value, as it is a controllable option. Thus, an attacker can run any database upgrade scripts, including those that operate on controllable data, such as option values and meta-data associated with users and posts. This ability gives an attacker access to an interesting attack surface in the WordPress core.
The executed upgrade script upgrade_280() is of particular interest:
wordpress/wp-admin/includes/upgrade.php
wordpress/wp-admin/includes/upgrade.php
1605 function upgrade_280() {
1606 global $wp_current_db_version, $wpdb;
1607
1608 if($wp_current_db_version < 10360 ) {
1609 populate_roles_280();
1610 }
1611 if(is_multisite() ) {
1612 $start = 0;
1613 while($rows = $wpdb->get_results( "SELECT option_name, option_value FROM $wpdb->options ORDER BY option_id LIMIT $start, 20")){
1614 foreach ( $rows as $row ) {
1615 $value = $row->option_value;
1616 if ( ! @unserialize( $value ) ) {
1617 $value = stripslashes( $value );
1618 }
This upgrade script fetches options from the database in line 1613 and attempts to deserialize them on line 1616. The important detail to look out for is that PHP’s built-in unserialize() function is used directly, and not the usual maybe_unserialize(). The following paragraphs will break down why this behavior is interesting and how it leads to an Object Injection vulnerability.
As discussed previously, a malicious admin can almost arbitrarily control the values of options and could thus attempt to inject a serialized PHP string into the database. One restriction is that when a serialized PHP string is detected, it is serialized again and thus becomes harmless.
As an example, if an attacker tried to set the value of an option to the following serialized string:
O:20:"SuperDangerousGadget":1:{s:18:"dangerous_property";s:8:"bash ...";}
it would be double serialized into:
s:73:"O:20:"SuperDangerousGadget":1:{s:18:"dangerous_property";s:8:"bash ...";}";
The result of this double-serialization is that the payload becomes harmless when unserialized, as it will result in a string.
As a consequence, we looked at the code that actually detects if a string is serialized in the WordPress core in hope to find a differential in the logic between the code of WordPress and the unserialize() code in the PHP core.
As a reminder: here are some of the types supported by PHP’s unserialize() function:
Type | Example of serialized string |
---|---|
Integer | i:1337; |
Float | d:1337; |
String | s:15:"hack the planet"; |
Object | O:8:"stdClass":0:{} |
Object with custom deserialization function (available in PHP < 7.4) | C:11:"ArrayObject":21:{x:i:0;a:0:{};m:a:0:{}} |
What follows is a code excerpt from the is_serialized($data) function from the WordPress core. This function compares the first character of the supplied input against a list of characters that indicate this string could be a serialized PHP string and then further makes comparisons. Note how the C character for special objects is not taken into account in the switch cases:
wordpress/wp-includes/functions.php
677 $token = $data[0];
678 switch ( $token ) {
679 case 's':
680 if ( $strict ) {
681 if ( '"' !== substr( $data, -2, 1 ) ) {
682 return false;
683 }
684 } elseif ( false === strpos( $data, '"' ) ) {
685 return false;
686 }
687 // Or else fall through.
688 case 'a':
689 case 'O':
690 return (bool) preg_match( "/^{$token}:[0-9]+:/s", $data );
691 case 'b':
692 case 'i':
693 case 'd':
694 $end = $strict ? '$' : '';
695 return (bool) preg_match( "/^{$token}:[0-9.E+-]+;$end/", $data );
696 }
697 return false;
Usually, this would not be a problem. As this function misses special objects where the serialized string starts with a C, an attacker can inject such a serialized PHP string into the database. However, because the maybe_unserialize() function only passes the string to PHP’s unserialize() when it is recognized as a serialized string with maybe_serialize(), it will never be unserialized.
This symmetry between maybe_unserialize() and maybe_serialize() is broken in the previously described upgrade script. It passes the string directly to PHP’s unserialize() function.
As a result, an attacker can perform the following steps to exploit this vulnerability:
WordPress fixed this code vulnerability with a patch commit which is included in WordPress version 5.8.3. The vulnerability was fixed by using maybe_unserialize($data) in the vulnerable upgrade_280() function to fix the asymmetry between maybe_serialize($data) and unserialize($data).
Date | Action |
---|---|
2019-04-17 | We report the issue to WordPress on Hackerone. |
2019-04-25 | Wordpress acknowledges reception of the vulnerability. |
2019-07-26 | WordPress triages the report. |
2022-01-06 | WordPress fixes the vulnerability with version 5.8.3 |
In this blog post we analyzed an Object Injection vulnerability (CVE-2022-21663) in the WordPress core. This vulnerability was caused by an asymmetry between parsers of two functions. Differences in the way two different components of an application handle and interpret data is a common issue that often has security consequences. in this case, it lead to an Object Injection vulnerability. Other research has shown how this can lead to SSRF issues and / or Path Traversal issues.
We are happy to see the vulnerability patched after almost 3 years of it being reported, and, if not already done so, strongly recommend updating your WordPress installation to the latest version 5.8.3.