Critical Privilege Escalation in LiteSpeed Cache Plugin Affecting 5+ Million Sites

Published 21 August 2024
Updated 13 December 2024
Table of Contents

The vulnerability in the LiteSpeed Cache plugin was originally reported by Patchstack Alliance community member John Blackbourn to the Patchstack Zero Day bug bounty program for WordPress. We are collaborating with the researcher to release the content of this security advisory article.

This vulnerability has been rewarded the highest bounty in the history of WordPress bug bounty hunting. Patchstack Zero Day program has awarded the researcher $14,400 USD in cash. If you wish to also participate in the program then join the community here.

This blog post is about the LiteSpeed plugin vulnerability. If you’re a LiteSpeed user, please update the plugin to at least version 6.4.

If you are a Patchstack customer, you are protected from this vulnerability already, and no further action is required from you.

For plugin developers, we have security audit services and Enterprise API for hosting companies.

About the LiteSpeed Cache Plugin

The plugin LiteSpeed Cache (free version), which has over 5 million active installations, is known as the most popular caching plugin in WordPress.

This WordPress plugin is an all-in-one site acceleration plugin, featuring an exclusive server-level cache and a collection of optimization features. The plugin supports WordPress Multisite and is compatible with the most popular plugins, including WooCommerce, bbPress, and Yoast SEO.

The security vulnerability

The plugin suffers from an unauthenticated privilege escalation vulnerability which allows any unauthenticated visitor to gain Administrator level access after which malicious plugins could be uploaded and installed.

The vulnerability exploits a user simulation feature in the plugin which is protected by a weak security hash that uses known values. The vulnerability has been assigned CVE-2024-28000 and was fixed in version 6.4 of the plugin.

Insecure random number generation

LiteSpeed Cache includes a crawler feature that crawls your site on a schedule to pre-populate the caches for pages on your site. A crawler can simulate a logged-in user of a given ID, therefore the plugin includes a user simulation feature that checks the litespeed_role and litespeed_hash cookie values for a user ID and security hash and sets the current user ID using wp_set_current_user function if a valid security hash is used.

The security hash intends to protect the user simulation feature from being used by anything other than a validated crawler request. The code snippet below is an illustration of how the security hash is generated in the Str::rrand() method before being used by the crawler functionality:

public static function rrand($len, $type = 7)
{
	mt_srand((int) ((float) microtime() * 1000000));

	switch ($type) {
		case 0:
			$charlist = '012';
			break;
		case 1:
			$charlist = '0123456789';
			break;
		case 2:
			$charlist = 'abcdefghijklmnopqrstuvwxyz';
			break;
		case 3:
			$charlist = '0123456789abcdefghijklmnopqrstuvwxyz';
			break;
		case 4:
			$charlist = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
			break;
		case 5:
			$charlist = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
			break;
		case 6:
			$charlist = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
			break;
		case 7:
			$charlist = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
			break;
	}

	$str = '';

	$max = strlen($charlist) - 1;
	for ($i = 0; $i < $len; $i++) {
		$str .= $charlist[mt_rand(0, $max)];
	}

	return $str;
}

Unfortunately, this security hash generation suffers from several problems that make its possible values known:

  • The random number generator that generates the security hash is seeded with the microsecond portion of the current time. This means the only possible values for the seed are integers 0 through 999,999.
  • The random number generator is not cryptographically secure, which means the “random” values that it generates are fully determinate if the seed is known.
  • The security hash is generated once and saved in the litespeed.router.hash option in the database. It is not salted with a secret, nor is it connected to a particular request or a particular user. Once generated, the value never changes.
  • Due to all of the above, there are only 1 million possible values for the security hash. The values are known and identical across all environments and websites.

Validating the hash

The code snippet below illustrates how the value from the litespeed_hash cookie is validated as a guard condition for the call to wp_set_current_user function which uses the litespeed_role cookie value. This code is called on the init hook in WordPress and fires for all requests except for those to the admin area.

public function is_role_simulation()
{
	if (is_admin()) {
		return;
	}

	if (empty($_COOKIE['litespeed_role']) || empty($_COOKIE['litespeed_hash'])) {
		return;
	}

	Debug2::debug('[Router] starting role validation');

	// Check if is from crawler
	// if ( empty( $_SERVER[ 'HTTP_USER_AGENT' ] ) || strpos( $_SERVER[ 'HTTP_USER_AGENT' ], Crawler::FAST_USER_AGENT ) !== 0 ) {
	// 	Debug2::debug( '[Router] user agent not match' );
	// 	return;
	// }

	// Hash validation
	$hash = self::get_option(self::ITEM_HASH);
	if (!$hash || $_COOKIE['litespeed_hash'] != $hash) {
		Debug2::debug('[Router] hash not match ' . $_COOKIE['litespeed_hash'] . ' != ' . $hash);
		return;
	}

	$role_uid = $_COOKIE['litespeed_role'];
	Debug2::debug('[Router] role simulate litespeed_role uid ' . $role_uid);

	wp_set_current_user($role_uid);
}

The valid security hash value is fetched from the litespeed.router.hash option and compared to the value in the litespeed_hash cookie. Non-strict and non-constant-time comparison is used which could mean that the security hash is also vulnerable to a type of coercion or timing attack, but in practice, it would be more effort to exploit this than iterating the known hash values.

As we know there are only 1 million possible and known values for the stored security hash (and that its value never changes once it’s initially set) this opens up the possibility of iterating all possible values and sending them in the litespeed_hash cookie in order to discover the valid hash value.

Triggering generation of the hash

We’ve established that this security hash is weak and therefore opens up a vulnerability that will lead to the wp_set_current_user function call, but there is a further consideration that stands in the way of it being exploitable: the crawler feature in LiteSpeed Cache is disabled by default. Unless the crawler feature has been enabled and at least one crawl has been performed then the code path to generate the security hash in the first place will not be called. If the hash value is empty then the weakness can’t be exploited and the total number of vulnerable sites would only be a portion of those using the plugin.

However, there is a further weakness in the plugin that allows the security hash value to be generated and saved even when the crawler feature is disabled. This means all sites using LiteSpeed Cache — not just those with its crawler feature enabled — are vulnerable.

The hash is generated and saved in the Router::get_hash method. This method gets called by some configuration-related methods that get called during crawls, and amazingly these methods can ultimately be triggered by an unprotected Ajax handler. The Task::async_litespeed_handler method is hooked into the wp_ajax_nopriv_async_litespeed action.

public static function get_hash()
{
	// Reuse previous hash if existed
	$hash = self::get_option(self::ITEM_HASH);
	if ($hash) {
		return $hash;
	}

	$hash = Str::rrand(6);
	self::update_option(self::ITEM_HASH, $hash);
	return $hash;
}

A call to /wp-admin/admin-ajax.php?action=async_litespeed&litespeed_type=crawler by any unauthenticated user will call this method with the routing parameter that’s needed to ultimately trigger a call to Router::get_hash and generate the security hash. Below are the chain calls from the AJAX action request:

  • Task::async_litespeed_handler()
  • Crawler::async_handler()
  • Crawler::start()
  • Crawler::_crawl_data()
  • Crawler::_engine_start()
  • Crawler::_do_running()
  • Crawler::_get_curl_options()
  • Router::get_hash()
  • Str::rrand()

This AJAX endpoint can therefore be called as the first step in an attack on the site to guarantee that the security hash exists.

Caveat for Windows

During the in-depth analysis of the vulnerability, we noticed that this vulnerability is not exploited on Windows-based installations. At one point during the generation of the hash, the flow of the code looks like this: _engine_start() -> _adjust_current_threads() -> get_server_load() -> sys_getloadavg(). This function is not available on the Windows platform, which means the hash cannot be generated on Windows-based WP instances, making the vulnerability exploitable on other OS such as Linux environments.

Exploiting the weakness

We were able to determine that a brute force attack that iterates all 1 million known possible values for the security hash and passes them in the litespeed_hash cookie — even running at a relatively low 3 requests per second — is able to gain access to the site as any given user ID within between a few hours and a week. The only prerequisite is knowing the ID of an Administrator-level user and passing it in the litespeed_role cookie. The difficulty of determining such a user depends entirely on the target site and will succeed with a user ID 1 in many cases.

Performing this attack on the front end of the site is possible, but it would be necessary to inspect the HTML response to determine if the hash is valid and the user is logged in. Instead, we can escalate the weakness via the REST API which will respond with a more helpful HTTP status code if the hash is valid.

By sending POST requests to the /wp/v2/users endpoint of the REST API along with our iterated values in the litespeed_hash cookie and target user ID in the litespeed_role cookie, a brand-new Administrator level user account will be created when a valid hash value is used. This attack succeeds without the need for a REST API nonce due to the call to wp_set_current_user function which sets the current user for us in the context of the REST API request when the security hash is valid. When the attack succeeds it will respond with an HTTP 201 status code, otherwise it will respond with a 401.

Note: if the “Debug Log” setting of the Litespeed Cache plugin is set to “ON”, the security hash will also be leaked to the debug.log file in the /wp-content/ folder on any request with a mismatching security hash.

The patch

Since this vulnerability exists because the code uses a weak hash check, the LiteSpeed team decided to apply these additional protections:

  • Added hash validation from async_call-hash option value in Router::async_litespeed_handler() function.
  • Added one-time used litespeed_flash_hash value, an additional hash check that will be cleaned right after validation and set TTL to 120 seconds only.
  • Using 32 random characters length for the async_call-hash, litespeed_flash_hash, and litespeed_hash value.
  • For crawler role simulation, the code will generate a hash each time when the crawler runs again, and once the hash is validated, it will store the current request IP for the next validation.

The full patch can be seen in this changeset.

A little note on the patch, we initially recommend using the hash_equals function for the hash value comparison process to avoid possible timing attacks. We also recommend using a more secure random value generator such as the random_bytes function. This was not implemented due to the need for legacy PHP support. Therefore, we hope the Litespeed team will still consider implementing this using a polyfill library.

Conclusion

This vulnerability highlights the critical importance of ensuring the strength and unpredictability of values that are used as security hashes or nonces. The rand() and mt_rand() functions in PHP return values that may be “random enough” for many use cases, but they are not unpredictable enough to be used in security-related features.

From the PHP documentation on rand() and mt_rand():

> If cryptographically secure randomness is required, the Random\\Randomizer may be used with the Random\\Engine\\Secure engine. For simple use cases, the random_int() and random_bytes() functions provide a convenient and secure API that is backed by the operating system's CSPRNG.

Hash validation should also use the hash_equals function instead of regular value comparison to prevent possible timing attacks. The used hash also needs to have a proper length, we recommend using a minimum of 32 characters on the hash itself. For sensitive Ajax actions, always implement authorization checks.

Want to learn more about finding and fixing vulnerabilities?

Explore our Academy to master the art of finding and patching vulnerabilities within the WordPress ecosystem. Dive deep into detailed guides on various vulnerability types, from discovery tactics for researchers to robust fixes for developers. Join us and contribute to our growing knowledge base.

Timeline

01 August, 2024We received the report from our alliance member (John Blackbourn) and conducted a discussion with the researcher regarding the proper PoC.
05 August, 2024We reached out to the LiteSpeed team regarding the vulnerability.
13 August, 2024LiteSpeed Cache version 6.4 was released to patch the reported issues.
19 August, 2024Added the vulnerabilities to the Patchstack vulnerability database.
21 August, 2024Security advisory article publicly released.

Help us make the Internet a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

  • If you’re a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
  • If you’re a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.

The latest in Security advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu