WordPress 5.8.2 Stored XSS Vulnerability

by karim el ouerghemmi|

A Cross-Site Scripting vulnerability is exploited against a WordPress website.

WordPress is the world’s most popular content management system that, according to w3techs, is used by over 40% of all websites. This wide adoption makes it a top target for cyber criminals who seek to compromise high-traffic websites or infect as many web servers as possible. Its code is heavily reviewed by the security community and by bug bounty hunters that get paid for reporting security issues.

In this blog post, we investigate a WordPress vulnerability we reported back in 2018, and that remained unpatched for around 3 years afterwards. It can for example be used for privilege escalation and to hijack an admin account from an author account. However, as we'll see, exploitation can also be achieved without special privileges when certain WordPress plugins are installed. When we reported the vulnerability, the wordpress.org website itself was affected and could have been exploited by any forum user to launch a supply chain attack for WordPress plugins.

Impact

The discussed vulnerability (CVE-2022-21662) is a Stored Cross-Site Scripting vulnerability which affects WordPress versions up to and including 5.8.2. It allows an attacker to inject a JavaScript payload. This payload would be saved to the database and later infect various user interfaces, such as the administration dashboard, allowing the attacker to hijack admin user sessions.

Normal exploitation requires author role privileges. The author user role in WordPress, by default, cannot do anything except managing posts. Exploiting this vulnerability would allow an author to escalate their privileges to those of a more powerful role and eventually execute arbitrary code on the server.

Technical Details

In the following section, we’ll discuss the root cause of the identified Stored XSS vulnerability, and explain how it can be used to hijack an admin user as an author. Furthermore, we’ll investigate why and how the issue can be exploited without any privileged account when vulnerable versions of the bbPress plugin are installed.

Stored XSS in Post Slugs

A WordPress post “slug” is best explained with an example: given the link to a post in a WordPress blog www.example.com/blog/the-post-title, the post slug is the the-post-title part of the URL. 

Although they can also be set explicitly, post slugs are usually derived from the post title. In the example given above, the title could have been “The Post Title”. When saving the post, WordPress transforms the title to a representation suitable to be part of a URL. This logic starts in the wp_insert_post() WordPress function, and has mainly the following flow (note that $post_name is the variable holding the slug):

wp-includes/post.php

3834    function wp_insert_post( /*...*/ ) {
3835        // …
3977        if ( empty( $post_name ) ) {
3978            // …
3979            $post_name = sanitize_title( $post_title );
3980            //…
3983        else {
3984            // …
3990            $post_name = sanitize_title( $post_name );

As can be seen from the code, the function sanitize_title() governs how the transformation from title to slug is done, and which characters are allowed. The documentation currently states:
“Sanitizes a string into a slug, which can be used in URLs or HTML attributes. … By default, converts accent characters to ASCII characters and further limits the output to alphanumeric characters, underscore (_) and dash (-) through the ‘sanitize_title’ filter.”
This sounds pretty restricting, and does not give the impression that any interesting injection can get past this sanitization. However, looking at the sanitize_title_with_dashes() function, which is the default function hooked to the sanitize_title filter, we can see that one detail was left out in the documentation.

wp-includes/formatting.php

2226    function sanitize_title_with_dashes( $title, /*...*/ ) {
2227        $title = strip_tags( $title );
2228        // Preserve escaped octets.
2229        $title = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '---$1---', $title );
2230        // Remove percent signs that are not part of an octet.
2231        $title = str_replace( '%', '', $title );
2232        // Restore octets.
2233        $title = preg_replace( '|---([a-fA-F0-9][a-fA-F0-9])---|', '%$1', $title );
2234        // … some other replacements
2304        $title = preg_replace( '/[^%a-z0-9 _-]/', '', $title );
2305         // …
2309        return $title;

As can be seen from the regular expressions, the sanitization preserves URL-encoded octets, and, indeed, slugs can contain URL-encoded characters. Although this is not explicit in the documentation, the "... which can be used in URLs or HTML attributes" part still holds. Usually, a URL-encoded string cannot be used to inject anything interesting unless it is decoded again. After some investigation, we encountered the _truncate_post_slug() function:

wp-includes/post.php

4921    function _truncate_post_slug( $slug, $length = 200 ) {
4922        if ( strlen( $slug ) > $length ) {
4923            $decoded_slug = urldecode( $slug );
4924            if ( $decoded_slug === $slug ) {
4925                $slug = substr( $slug, 0, $length );
4926            } else {
4927                $slug = utf8_uri_encode( $decoded_slug, $length );
4928            }
4929        }
4931        return rtrim( $slug, '-' );

In line 4923 of the code above, a slug gets URL decoded with the PHP function urldecode(). In case the slug does contain URL-encoded characters, it gets encoded again limiting its length. The subtlety here is that the function used for encoding is not the counterpart of the one used for decoding.

The WordPress function utf8_uri_encode() only encodes Unicode characters. As an example, the result of utf8_uri_encode('<script>alert(1)</script>', 200) remains <script>alert(1)</script>.

This leads to  _truncate_post_slug() having a discrepancy between what gets decoded and what gets encoded. Calling this function with a slug containing a URL-encoded JavaScript payload returns a slug containing the decoded payload. The next step in our investigation was to find out when _truncate_post_slug() gets called, and if there is any sanitization of the resulting slug afterwards. 

The main location where _truncate_post_slug() is called is in the WordPress function wp_unique_post_slug(). During the post saving process, this function ensures that slugs stay unique by adding a numerical suffix on duplicates. When trying to set the slug of one post to, for example, the-post-slug, and there is already another post with that slug, the function will calculate an alternative slug the-post-slug-2 calling _truncate_post_slug() on it to ensure that alternatives do not get too long with the suffix. This whole process is executed in wp_insert_post() after all sanitization is done. 

Code flow of the vulnerable feature.

Using what we have discovered so far, we were able to save a post whose slug contains a JavaScript payload in the WordPress database following these steps:

  • Create two posts A and B
  • Set the slug of A to URL_ENCODED_JS_PAYLOAD+FILLING_CHARACTERS
  • Set the slug of B to the same as A

When setting the slug for the second post B, the payload ends up decoded in the database because there is already a post with the same slug, and an alternative is calculated by going through the wp_unique_post_slug() -> _truncate_post_slug() process. Note that some filling characters might be needed in the slug because the decoding in  _truncate_post_slug() only happens over a certain length (200 by default).

Because post slugs were supposed to only contain safe characters, it didn’t take long to find a location in which they are printed without any escaping:one such location is the main post listing in the administration panel. As a result, the JavaScript payload is injected into the HTML response page and executed in the browser of any administrator visiting that page. From here, the JavaScript payload can control further administrator actions, such as uploading malicious WordPress plugins and executing arbitrary PHP code.

Unprivileged exploitation with bbPress < 2.6.0

The vulnerability as discussed so far can only be exploited by attackers that have author privileges. The reason for this is that control over the slug of a post has to be given either directly or indirectly by having control over the title of a post and having the slug be calculated from the title.

Because many things in WordPress are built around the concept of posts with custom post types, we did investigate further to find possible attack vectors requiring no special privileges. Such a case turned out to be possible when the WordPress forum plugin bbPress is installed (versions < 2.6.0). This plugin is, for example, used to run the support forums on wordpress.org.

Internally in bbPress, a forum topic is represented by a WordPress post with a custom post type. Understandably, when creating a topic, a forum user can also set its title, and a first investigation showed that the slug is calculated from the title. As an example, when creating a topic with the title my-topic it will be accessible from www.example.com/forum/topic/my-topic.

As a result, any forum user could exploit the vulnerability by applying the technique discussed in the previous section to forum topics. 

Patch

The core issue leading to a Stored XSS vulnerability in post slugs was fixed in the release 5.8.3 of WordPress. The implemented solution was to modify the function utf8_uri_encode() by adding an optional parameter $encode_ascii_characters which, when set to true, leads to non-alphanumeric characters required for a payload to be encoded with the PHP function rawurlencode().

The main learning here is to always be extra careful when modifying a value after it has been sanitized. This is a common root cause for vulnerabilities that we find in various applications, as presented in our talk at the Hacktivity conference last year.

Possible unprivileged exploitation in case the bbPress plugin is installed was fixed with the release of bbPress 2.6.0. The new version is shipped with a server-side validation of the maximum topic title length making exploitation with the discussed technique not possible. 

Timeline

DateAction
2018-10-18We report the issue to WordPress on Hackerone.
2018-11-26Report gets triaged and confirmed by WordPress.
2018-12-13We remind WordPress that, since bbPress is used, the issue can be exploited without privileges on wordpress.org.
2018-12-13WordPress tells us that they added a hotfix to wordpress.org to avoid unprivileged exploitation and that they contacted bbPress.
2019-11-12bbPress 2.6.0 gets released with title length limitation.
2020-10-29According to the 5.5.2 changelog, the core issue is supposedly fixed.
2020-12-28We inform WordPress that the issue was not fixed and that it is still exploitable with the same payload.
2021-02-24WordPress tells us that they hope to include a fix in a 5.7.x release.
2021-05-25We make WordPress aware of a 90 days disclosure deadline starting that day.
2021-12-03We inform WordPress that the vulnerability will be disclosed on the 11th of January 2022.
2022-01-06Fix released with WordPress version 5.8.3.

Summary

In this article, we described a Stored Cross-Site Scripting vulnerability affecting WordPress versions up to 5.8.3. We analyzed the root cause of the vulnerability, how it could be exploited by attackers in both privileged and unprivileged scenarios, and what the implemented patch was.

We are happy to see the vulnerability patched after more than 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.

Related Blog Posts