A recent exploit was discovered for all versions of WordPress prior to and including 2.8.3. The vulnerability is in how variables are checked while processing a password reset request. The offending code is quite simple and by itself looks very innocent:
if ( empty( $key ) )
If the $key variable is empty, an error is generated. If $key is not empty it’s passed to a SQL query that looks up the user whose key is being reset. Seems innocent enough, right?
The variable $key is a sanitized copy of $_GET[‘key’]. And what happens if the value passed on the url for key is an array?
Well the sanitization step is a simple preg_replace, like so:
$key = preg_replace('/[^a-z0-9]/i', '', $key);
The PHP documentation states that if the subject ($key) is an array, the return value will be an array as well. It also states that if no matches are found, the original subject is returned. Since $key is empty to begin with, thus no matches will be found, a copy of $key is returned. Essentially $key remains unchanged. So what happens when you pass an empty array to PHP’s empty() function? Well the documentation says it should return FALSE. So what’s the problem?
The problem is the array isn’t really empty. It contains a single node which contains an empty string. So the empty() call returns FALSE, thus no error is generated for an empty $key.
Surely the SQL check will return no entries since there is no key to search for, right?
Wrong. Here’s the code:
$user = $wpdb->get_row($wpdb->prepare("SELECT * FROM $wpdb->users WHERE user_activation_key = %s", $key));
The $wpdb->prepare() is used to insert values into SQL statements. $key is treated like an empty string. Therefore the SQL that is generated looks like:
SELECT * FROM wp_users WHERE user_activation_key = ''
And that query will return every user who has an empty activation key — that’s everyone not currently trying to reset their password. Since the first row returned is the record that gets reset, you will almost always be resetting the admin account. If you keep making the same request over and over you will eventually wind up resetting the password of every user in the system!
Reset passwords are auto-generated and then e-mailed out to the users. This sort of attack could be used as a weak style of denial-of-service (or pain-in-the-ass attack as I prefer to call it) or an attacker who has access to a victim’s e-mail account (or can packet sniff their e-mail downloading) could take control of the blog.
A fix is already on the way as you can see by viewing wp-login.php in WordPress’ CVS. The fix seems simple enough:
if ( empty( $key ) || !is_string( $key ) )
So $key must be a string and it must be empty. Seems very straightforward. Although I would prefer they went the added step of simply modifying the SQL query to check against only those user_activation_code fields that are not empty strings. In other words add something like
$user = $wpdb->get_row($wpdb->prepare("SELECT * FROM $wpdb->users WHERE user_activation_key = %s AND user_activaction_key <> ''", $key));
That would more correctly fix the issue, as it would protect against even those attacks that are somehow able to bypass the initial empty() check.
WordPress appears to also be adding a username to the password reset request so that simply passing an empty key on the URL and hitting reload a bunch of times won’t result in everyone having their password reset — probably something they should have done in the first place.
So there it is. Patch your WordPresses, either by manually editing wp-login.php or waiting for the official patch to come out sometime later this week.