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.orgwebsite itself was affected and could have been exploited by any forum user to launch a supply chain attack for WordPress plugins.
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.
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):
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:
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.
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
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
This leads to
_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
_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.
- Create two posts
- Set the slug of
- Set the slug of
Bto the same as
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
_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).
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
As a result, any forum user could exploit the vulnerability by applying the technique discussed in the previous section to forum topics.
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_characterswhich, when set to
true, leads to non-alphanumeric characters required for a payload to be encoded with the PHP function
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.
|2018-10-18||We report the issue to WordPress on Hackerone.|
|2018-11-26||Report gets triaged and confirmed by WordPress.|
|2018-12-13||We remind WordPress that, since bbPress is used, the issue can be exploited without privileges on wordpress.org.|
|2018-12-13||WordPress tells us that they added a hotfix to wordpress.org to avoid unprivileged exploitation and that they contacted bbPress.|
|2019-11-12||bbPress 2.6.0 gets released with title length limitation.|
|2020-10-29||According to the 5.5.2 changelog, the core issue is supposedly fixed.|
|2020-12-28||We inform WordPress that the issue was not fixed and that it is still exploitable with the same payload.|
|2021-02-24||WordPress tells us that they hope to include a fix in a 5.7.x release.|
|2021-05-25||We make WordPress aware of a 90 days disclosure deadline starting that day.|
|2021-12-03||We inform WordPress that the vulnerability will be disclosed on the 11th of January 2022.|
|2022-01-06||Fix released with WordPress version 5.8.3.|
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.