In this series of blog posts, Check Point vulnerability researcher Netanel Rubin tells a story in three acts – describing his long path of discovered flaws and vulnerabilities in core WordPress, leading him from a read-only ‘Subscriber’ user, through creating, editing and deleting posts, and all the way to performing SQL injection and persistent XSS attacks on 20% of the popular web.
A number of critical vulnerabilities exist in default WordPress installations, allowing potential compromise of millions of live web sites.
MITRE has assigned CVE-2015-5623, CVE-2015-2213, CVE-2015-5714, CVE-2015-5715, CVE-2015-5716 as identifiers for these vulnerabilities. * CVE-2015-2212 was marked as a duplicate of CVE-2015-5623.
The first vulnerability in this sequence (CVE-2015-5623) was patched in a recent WordPress security release (4.2.3).
Site administrators are urged to apply security updates as they are released (in case auto-update was disabled).
For further updates please follow our blog as well as upcoming WordPress security advisories.
Check Point customers are protected against the vulnerability sequence via IPS signatures.
WordPress is a PHP-based CMS (Content Management System), in effect the most prominent web platform on the Internet today. It is estimated to run over 20% of the top 1 million websites worldwide.
WordPress instances are frequently targeted by cybercriminals and malicious hackers, as they can lead to private databases and internal network access. A typical WordPress compromise is based on exploits of vulnerabilities in an outdated plug-in component.
Being such a popular platform, WordPress is also one of the world’s most scrutinized pieces of software, in terms of security assessments and vulnerability research, with thousands of eyes reviewing new features and bug fixes, as well as 3rd party plug-ins.
Occasionally, researchers discover vulnerabilities in certain plug-ins, written by third parties and separately installed by site administrators. Such vulnerabilities get published on a weekly basis in various channels including the “Full Disclosure” mailing list.
In contrast to these frequent findings in 3rd party plug-ins’ code, barebones WordPress issues are rare, as WordPress core developers are well-trained to hold high security awareness for all released code. We can confirm that during our audit of the source code, we witnessed the developers ‘leaving nothing to chance’, and implementing multiple layers of security protecting most attack vectors we could think of.
WordPress developers deserve praise for their efforts to maintain such complex software in this level of security, specifically considering the presence of the notoriously trigger-happy foot-gun called PHP. Check Point’s vulnerability researchers often target high-profile platforms, striving to improve the security of common software. Findings are reported to software vendors prior to public disclosure, in order to provide a fix as soon as possible, contributing to the security of users everywhere. Check Point customers also receive preemptive protections via our product lines (occasionally before a vendor fix is released). It is, therefore, no surprise that we decided to audit WordPress, resulting in our discovery of these critical issues.
We intend to release the technical description of the vulnerabilities only after WordPress have released fixes for each issue. The WordPress team has been responsive and alert; they are working on providing reliable fixes to all reported issues. Since CVE-2015-5623 was patched in 4.2.3, may we present our first part of the vulnerability hunt trilogy.
Part 1 – “Identity”
WordPress utilizes a rich, extensible Roles and Capabilities model, where each user is assigned a Role, ranging from the lowest privileged Subscriber to the omnipotent Super Admin.
Our attack surface begins with the understanding that even subscribers can access the WordPress administrator control panel, located in the ‘/admin’ directory. They have extremely limited options to control in comparison with Administrators, for example, as determined by their respective privileges.
By default, subscribers are only assigned the limited ‘read_page’ and ‘read_post’ privileges, allowing them to read pages and posts. Administrators, on the other hand, have every privilege available and can create / edit posts, upload files and manage configuration.
WordPress checks the privileges of the logged in user whenever they try to perform an action using the ‘current_user_can()’ function.
‘current_user_can()’ receives the capability requested and maps it to the actual privilege the user needs in order to access the action. It performs that by calling the ‘map_meta_cap()’ function with the capability as an argument, which returns an array with the actual privileges needed for that capability.
Then, ‘current_user_can()’ loops through that array and checks if the user has those privileges.
As attackers, let us begin with the assumption we have a Subscriber user. It is common for WordPress web sites to enable free user registration (‘anyone can register’), defaulting the new user role to Subscriber. As subscribers, we can only read pages and posts, but as attackers, we want to do something a bit more interesting than that.
Let’s observe an excerpt from the ‘map_meta_cap()’ code to examine how capabilities are mapped into actual privileges.
As we can see, the ‘edit_post/edit_page’ capability check validates the post by checking if the post ID we’ve entered exist (highlighted). The interesting part is what happens when it’s not – the check does not set any capabilities into $caps and the array is returned empty. Since there are no capabilities set, the function acts as if we don’t need any and returns true.
That means that in theory, we can bypass capability checks with an invalid post ID. To exploit that, we would need code that is calling this check and does not validate the post ID prior to the call. Unfortunately there aren’t many cases where this is true. One important location, though, is the function ‘edit_post()’.
This function is responsible for updating a post. It parses the input for the update, adds the requested metadata, inserts the format, and generally does all the heavy lifting before actually updating the post entry in the DB. Once the function finished parsing the input, it calls ‘wp_update_post()’ which is responsible for updating the requested post in the DB, among other minor things such as setting default values for properties.
Although ‘edit_post()’ does not validate our post ID, ‘wp_update_post()’ does. That means that even if we bypassed the privilege check the first time, the second time will fail because our ID does not exist in the DB. Although we can’t edit actual posts in the DB, we can still get processed through almost all of the code in ‘edit_post’.
Let’s have a look at the interesting bits from that code:
As can be seen, apart from the post type that is set by the exiting post, we can control any value inside ‘$post_data’. That means that (although not visible in the pasted code) we can attempt to update posts’ metadata or change taxonomies, which would significantly expand our attack surface.
In order for that to happen we first need to reach that function. There are several places that call it, but those places either check other capabilities or check that the post we’re trying to edit exists. That leaves us with only one option to use – the ‘post.php’ admin page which uses the following code:
Initially, this code seems perfect for us – no capabilities check, no post ID validation, just what we need. However, another problem surfaces with the ‘check_admin_referer()’ function call. This function checks for a specific CSRF token that has been given to us by the system. This token uses the server’s randomly generated hash and current time, which apparently cannot be guessed or altered by an attacker.
In order for us to access ‘edit_post()’ we first need to get this token. We can see in the code that the token the systems expects is formatted as ‘add-[POST_TYPE]’, where POST_TYPE is the type of the post we are trying to edit.
We are trying to bypass the capabilities check, meaning we are using a post ID that does not exist, which creates a token action of ‘add-‘, as a non-existing post does not have a post type. Luckily for us, the code first tries to retrieve the post ID from the ‘GET’ HTTP parameter ‘post’, and only if it’s empty it then tries to get it from the ‘POST’ field. That creates the interesting opportunity to send 2 post IDs – one in the ‘GET’ parameter that is a valid post ID, and one in the ‘POST’ parameter that is invalid, allowing us to use both the right token string and bypass the capabilities check.
Now that we can use the ‘add-post’ token string, we just need to find accessible code that will generate that token. Although this task seems simple, our lack of capabilities significantly limits access to other actions. Again, all we need was one such location, which was indeed found in the function ‘wp_dashboard_quick_press()’. This code, among other actions, generates a valid admin token.
As evident above, if a user does not supply a correct token, or is lacking the ‘edit_posts’ capability, the function is triggered and we receive a valid ‘add-post’ admin token.
With this token we can now freely get processed in the ‘edit_post’ function, as long as we supply an invalid post ID.
At this point we can create and add non-protected metadata to non-existing posts, create and select taxonomies and several other minor things. Although this sounds great, it is still far from our attacker’s objective – we want to actually be able to edit a real post so we can access code previously protected by the ‘edit_post’ capability check.
Note the highlighted code – if the user is the post’s author, and the post is currently marked as trash, no capabilities are needed. This marks a clear path towards editing privileges. But how can we become both a post author and set its status to ‘trash’?
The first problem is easy – in order to become a post’s author all we have to do is create one. For this to happen all we need to do is call ‘wp_dashboard_quick_press()’, just like we did when we created our token. Besides creating the admin token, the function also checks if an auto-draft was previously created for the current user. If no draft is detected, the function calls the ‘get_default_post_to_edit()’, which contains the following code:
As can be seen, the code retrieves the post title, content, and excerpt from the request parameters, and then, if instructed, creates the post in the DB. The call for ‘wp_insert_post()’ does not include a post author, so the system assumes the author should be the current user. Although the ‘$create_in_db’ variable is set to be false as default, in our case ‘wp_dashboard_quick_press()’ assigns it the value ‘true’ and thus creates the auto-draft in the DB.
Now that we have a post in the DB with us as the post authors, we need to change that post status to ‘trash’. Now we need to use some black magic.
When we enter ‘edit_post()’ the post ID we are editing must not exist in the DB otherwise the capabilities check will fail and the script execution will end, but when we enter ‘wp_update_post()’ (in charge of changing the post in the DB) the post ID must be valid and exist in the DB otherwise the function will return with a ‘Post does not exist’ error. How can we fulfil these 2 contradicting conditions at the same time?
Easy. We’ll race condition.
When we enter ‘edit_post()’ we will use the ID of the next post to be created. As the IDs are auto incremented, we can easily “guess” it, given the ID of the last existing post can be easily figured. At that point our ID will not exist in the DB and we will pass the capability check without any problem. Then, while this function executes, we will immediately send another request to create a new quick draft, as showed earlier, in order to create that ID in the DB. Once ‘edit_post()’ will call ‘wp_update_post()’, fetching the post from the DB, it will exist and the code will continue, not suspecting a thing. J
The question remains, how can we send 2 different requests (edit post followed by post creation), in such a careful timing so that this tactic will work. Obviously, we must delay the script execution.
We can see that the variable ‘$terms’, controlled by us, is converted into an array by splitting it using a comma (‘,’) in case it is a string. Afterwards, the code loops through that array, treating every element as a taxonomy name, and tries to fetch it from the DB using a ‘SELECT’ statement.
Although every ‘SELECT’ statement takes a couple of microseconds on a well-managed, powerful server, because we can actually send 16MB (PHP hard limit for a ‘POST’ parameter) of terms to fetch, we can actually execute about 16 million such queries that will take quite a lot of time to execute. On my rather empty DB located at my localhost, powered by 1GB of RAM and an i7 processor, it only took about 100 thousand queries to cause a 30 second delay.
This now grants us a deterministic, extremely powerful leverage to exploit our race condition with, yet, our job is not over and we still have a couple of things to do before fully exploiting this vulnerability in a deterministic manor on every WordPress install in the world.
Because we are calling ‘wp_dashboard_quick_press()’ in order to retrieve the admin token that is needed in order for the code to call ‘edit_post()’, we actually create a quick draft in the process. As I mentioned earlier, ‘wp_dashboard_quick_press()’ only creates a draft if there isn’t one already, and as we need to call that same function again in order to create our post while ‘edit_post()’ is delayed, that process will not occur.
How can we create a draft if another one already exists? Well, the draft created by ‘wp_dashboard_quick_press()’ is no ordinary draft, it is in fact a quick draft (also called an auto-save). As such, the draft is supposed to be just a temporary manner of storing the post text in case of a sudden data-loss. Because these drafts are treated as temporary items, they get auto deleted within a week.
This means that we need to wait a full week in order to exploit our vulnerability, as this is the time frame that takes that draft to be deleted. Fortunately for us, tokens usually last 24 hours, allowing us to retrieve the token a day before the expected deletion date, and using it in order to get into ‘edit_post()’ after the draft gets deleted.
Finally, after passing the token validation, the privileges validation, the basic admin validation, and the post ID validation, changing the post status to ‘trash’ is as simple as sending an HTTP parameter. Thank God.
Combining all of these bypasses together, we use a chain of around a dozen different bugs, a faulty privilege system, and about every false assumption in the system to achieve partial editor privileges. The road to a critical vulnerability is still long, but at its end we found both a SQLi and an XSS, to be described in the next posts.
Admin token retrieval / post creation