Blog post

WordPress < 5.8.3 - Object Injection Vulnerability

Simon Scannell photo

Simon Scannell

Vulnerability Researcher

Date

  • Security
We discovered an interesting code vulnerability that could be used to bypass hardening mechanisms in the popular WordPress CMS.

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.

Impact

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.

Technical Details

In this section, we break down the technical details of this Object Injection vulnerability and how it might be exploited.

Background - WordPress options

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:

options page

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.

Object Injection (CVE-2022-21663)

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:

TypeExample of serialized string
Integeri:1337;
Floatd:1337;
Strings:15:"hack the planet";
ObjectO: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:

  1. Inject a PHP serialized string of a special object carrying malicious pop chain gadgets as properties into the database as an option value. 
  2. maybe_serialize() won’t recognize the payload as a serialized string and does not double serialize it.
  3. Modify the database version option to trigger the vulnerable upgrade script
  4. The upgrade script passes the PHP serialized string directly to unserialize(), which recognizes the string and deserializes it, triggering the pop chain.

Patch

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).

Timeline

DateAction
2019-04-17We report the issue to WordPress on Hackerone.
2019-04-25Wordpress acknowledges reception of the vulnerability.
2019-07-26WordPress triages the report.
2022-01-06WordPress fixes the vulnerability with version 5.8.3

Summary

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.