Unaddressed WordPress Security Issue Behind Recent “Critical” Vulnerability in 100,000+ Install Plugin
Earlier this week, the WordPress security provider Wordfence released a post about a claimed “critical” vulnerability found in a WordPress plugin with 100,000+ installs. In that post they made this claim:
Our mission is to Secure the Web, which is why we are investing in quality vulnerability research and collaborating with researchers of this caliber through our Bug Bounty Program.
What they are actually doing is directing vulnerability reports away from developers and buying vulnerabilities, which raises some serious ethical, if not legal, questions (especially considering how they are willing to sell information about those vulnerabilities to hackers). But more concerning was that while they were making that claim about securing the web in their post, they were actually ignoring that the vulnerability only existed because of an unfixed security issue in WordPress. If that issue in WordPress were addressed, it would shut down the possibility of many vulnerabilities. This isn’t a newly identified issue. We found, while doing some research for this post, someone bringing it up in the same context as this particular vulnerability back in October 2021. Separate from this situation, we were already looking in to an element of this based on a security review of a plugin we have been doing.
Wordfence’s post provides what seems like an unnecessary level of detail on weaponizing the vulnerability, which would make it easier for hackers to exploit the vulnerability, without any justification for doing that. While including too much detail on weaponizing this, there was a significant piece of information missing for those looking to more systematically prevent vulnerabilities like this instead of exploiting them.
The vulnerability in question is of a type referred to as PHP object injection. That involves passage of a malicious payload with the PHP function unserialize() that includes a PHP object. PHP introduced a feature to prevent PHP object injection in PHP 7.0, which was released in December 2015.
Here is an example of usage of unserialize() without that feature:
$data = unserialize($foo); |
And here is an example of usage of unserialize() with the feature included:
$data = unserialize($foo, ["allowed_classes" => false]); |
For there to be a vulnerability, a plugin has to not utilize that feature or has to be relying on WordPress code that isn’t using the feature.
If you look through Wordfence’s long post, the usage of unserialize() is never shown, which is odd. The closest they get to that is to write this:
Even in this payment processing, the
_give_donor_title_prefix
meta is called with theget_meta()
function in thesetup_user_info()
function inGive_Payment
class, which unserializes the previously saved serialized object:$user_info[ $key ] = Give()->donor_meta->get_meta( $donor->id, ‘_give_donor_title_prefix’, true );
The function they refer to there, get_meta(), which is in the file /includes/database/class-give-db-meta.php, doesn’t contain the unserialize() function:
139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 | public function get_meta( $id = 0, $meta_key = '', $single = false ) { if ( ! $this->is_filter_callback ) { return get_metadata( $this->meta_type, $id, $meta_key, $single ); } $id = $this->sanitize_id( $id ); // Bailout. if ( ! $this->is_valid_post_type( $id ) ) { return $this->check; } if ( $this->raw_result ) { if ( ! ( $value = get_metadata( $this->meta_type, $id, $meta_key, false ) ) ) { $value = $single ? '' : array(); } // Reset flag. $this->raw_result = false; } else { $value = get_metadata( $this->meta_type, $id, $meta_key, $single ); } $this->is_filter_callback = false; return $value; } |
Either they got things wrong or the function has to be called through another function used in get_meta(). It turns out to be the latter option.
That function calls the WordPress function get_metadata(), which in turn calls the WordPress function get_metadata_raw():
function get_metadata( $meta_type, $object_id, $meta_key = '', $single = false ) { $value = get_metadata_raw( $meta_type, $object_id, $meta_key, $single ); |
In that function, the WordPress function maybe_unserialize() is called:
if ( isset( $meta_cache[ $meta_key ] ) ) { if ( $single ) { return maybe_unserialize( $meta_cache[ $meta_key ][0] ); } else { return array_map( 'maybe_unserialize', $meta_cache[ $meta_key ] ); |
As we noted in January, that function won’t prevent PHP object injection. That is because it doesn’t use the feature of unserialize() to prevent that:
function maybe_unserialize( $data ) { if ( is_serialized( $data ) ) { // Don't attempt to unserialize data that wasn't serialized going in. return @unserialize( trim( $data ) ); } return $data; } |
(Here is the post describing that chain of insecurity from October 2021)
There are reasons why WordPress might not use that feature by default, but there isn’t even an option to use it when calling the function. WordPress’ security team is largely a black hole, so we can’t see if they ever discussed addressing this. Looking at the Trac system for WordPress, we found a couple of troubling discussions related to maybe_unserialize().
In one, a Lead Developer of WordPress wrote this:
Unfortunately we can’t change how this function is written. is_serialized() operates as a guard to make sure that only things that had been serialized by maybe_serialize() are unserialized. If you can craft a string that maybe_serialize() won’t re-serialize, but unserialize() will unserialize, then you open yourself up to object injection.
That Lead Developer seems a bit confused there, as PHP object injection requires that something be serialized, so checking the malicious payload with the function is_serialized() doesn’t prevent object injection generally. What they seem to be referring to is the class variant of that based on the other Trac discussion.
In that other discussion, that same Lead Developer really didn’t seem to be acting appropriately, including this response:
Okay hakre, that’s enough. At this point, you’ve emailed security@…. If you want to curse and rant, you can do so via email.
Fixing This
What seems like the best option from a security perspective would be to enable the feature to prevent PHP object injection and provide the option to disable it. That way if code in WordPress or a plugin needs that capability they can access it, but by default code is protected.
Depending on what, if any, intended usage there is to allow object injection, that might cause problems. So the alternate would be to provide the option to enable the feature.
For whatever reason, in over nine years in WordPress hasn’t implemented either of those.
No Place To Report This
WordPress’ information on reporting security issues in WordPress states this:
- For security issues with the self-hosted version of WordPress, submit a report at the WordPress HackerOne page. Include as much detail as you can. Please always use HackerOne instead of Core Trac, even if the vulnerability is only in
trunk
, or a beta/RC release, because there are some sites that run those in production.
As we noted earlier this month, after running in to an issue with reporting security issues in a plugin coming from WordPress, the bug bounty program they are mentioning doesn’t accept reports of security issues, only limited types of vulnerabilities. We raised that problem with their reporting system, but so far we have received no response. So as far as we can tell, there isn’t an appropriate place to report this.
Wide Usage of maybe_unserialize() in Plugins
With this vulnerability, maybe_unserialize() is called indirectly. It is also widely called directly in WordPress plugins. Including many plugins with millions of installs. That includes Wordfence’s own Wordefence Security plugin. (Another plugin that uses it the plugin with the “critical” vulnerability.) For those plugins’ usage of it to allow PHP object injection, an attacker would need to be able to get a malicious payload to the function. They often can’t do that directly. But in some cases they can. It also might be possible to utilize a vulnerability that does exist somewhere and chain it to that.
As an example of how another security issue could be combined with this, take a look at this code from the plugin we were doing the review of. The code makes a request to a website controlled by the developer and then passes a value from that to maybe_unserialize():
379 380 381 382 383 384 385 386 | $request = wp_remote_post($this->api_url, array('timeout' => 15, 'sslverify' => $verify_ssl, 'body' => $api_params)); if (!is_wp_error($request)) { $request = json_decode(wp_remote_retrieve_body($request)); } if ($request && isset($request->sections)) { $request->sections = maybe_unserialize($request->sections); |
So if a hacker could hack the developer’s website, they could cause PHP object injection to occur. More problematic is that a firewall plugin that can protect against PHP object injection, like our own, wouldn’t protect against this because the malicious payload is being requested by the website instead of sent to it by the attacker.
Update (8/26/24): A Core Committer of WordPress seemed to be unaware that plugins use maybe_unserialize() directly and said that “I don’t think we want to encourage” that.
Plugin Developers Can Take Action Now
Instead of waiting for WordPress to secure maybe_unserialize() with a more secure solution, developers can replace it with a secure solution. The function rewritten with the feature enabled would look like this:
function maybe_unserialize( $data ) { if ( is_serialized( $data ) ) { // Don't attempt to unserialize data that wasn't serialized going in. return @unserialize( trim( $data ), ["allowed_classes" => false] ); } return $data; } |
Checking For Usage of maybe_unserialize() With Our Plugin Security Scorecard
Our new Plugin Security Scorecard is intended to help incentivize WordPress plugin developers to implement better security practices. In line with that, we have decided to add to the grading a check to see if the plugin is using maybe_unserialize() until WordPress implements security to it to avoid PHP object injection. WordPress really should fix this, but that it hasn’t been fixed so far, doesn’t suggest a fix is coming any time soon. Therefore, plugin developers should take action to make sure there isn’t a chance of a vulnerability caused by this (they also could put pressure on WordPress to fix this).
There is an existing ticket for this: https://core.trac.wordpress.org/ticket/37757
Thanks for noting that. One of the comments on that raises even more concern about WordPress’ handling of this, which we have covered in a follow up post: https://www.pluginvulnerabilities.com/2024/08/26/wordpress-documentation-doesnt-warn-about-security-risk-of-maybe_unserialize/