LiteSpeed Cache
Unauthenticated Privilege Escalation
The vulnerability in the LiteSpeed Cache plugin was originally reported by Patchstack Alliance community member TaiYou to the Patchstack bug bounty program for WordPress. We are collaborating with the researcher to release the content of this security advisory article.
This blog post is about the LiteSpeed plugin vulnerability. If you’re a LiteSpeed user, please update the plugin to at least version 6.5.2.
All Patchstack users that have protection enabled have been automatically protected from this vulnerability. Sign up for the free Community account first, to scan for vulnerabilities and apply protection for only $5 / site per month with Patchstack.
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 6 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 a similar case previously reported by John Blackbourn in this article. This vulnerability utilizes a weak security hash check that uses known values and can only be reproducible with some additional rare configuration that needs to be performed by the Administrator role user in the plugin’s Crawler feature. The vulnerability has been assigned CVE-2024-50550 and was fixed in version 6.5.2 of the plugin.
Unauthenticated Privilege Escalation
The underlying issue is located in the is_role_simulation function:
public function is_role_simulation()
{
if (is_admin()) {
return;
}
if (empty($_COOKIE['litespeed_hash']) && empty($_COOKIE['litespeed_flash_hash'])) {
return;
}
self::debug('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;
// }
// Flash hash validation
if (!empty($_COOKIE['litespeed_flash_hash'])) {
$hash_data = self::get_option(self::ITEM_FLASH_HASH, array());
if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts']) && !empty($hash_data['uid'])) {
if (time() - $hash_data['ts'] < 120 && $_COOKIE['litespeed_flash_hash'] == $hash_data['hash']) {
self::debug('role simulate uid ' . $hash_data['uid']);
self::delete_option(self::ITEM_FLASH_HASH);
wp_set_current_user($hash_data['uid']);
return;
}
}
}
// Hash validation
if (!empty($_COOKIE['litespeed_hash'])) {
$hash_data = self::get_option(self::ITEM_HASH, array());
if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts']) && !empty($hash_data['uid'])) {
if (time() - $hash_data['ts'] < $this->conf(Base::O_CRAWLER_RUN_DURATION) && $_COOKIE['litespeed_hash'] == $hash_data['hash']) {
if (empty($hash_data['ip'])) {
$hash_data['ip'] = self::get_ip();
self::update_option(self::ITEM_HASH, $hash_data);
} else {
$server_ips = apply_filters('litespeed_server_ips', array($hash_data['ip']));
if (!self::ip_access($server_ips)) {
self::debug('WARNING: role simulator ip check failed [db ip] ' . $hash_data['ip'], $server_ips);
return;
}
}
wp_set_current_user($hash_data['uid']);
return;
}
}
}
self::debug('WARNING: role simulator hash not match');
}
As explained in the previous article regarding the privilege escalation case, a role simulation feature is used on the crawler feature in the LiteSpeed Cache plugin. If we look at the function closely, the first check is for the Flash Hash check, which only will be accepted if the hash time generation is no older than 120 seconds. This check should be enough to prevent a mass brute-forcing of the hash.
However, the second check on $_COOKIE[‘litespeed_hash’] will check the TTL of the hash based on the $this->conf(Base::O_CRAWLER_RUN_DURATION). This configuration value can be viewed on the Crawler settings and has a default value of 400 seconds. This value can be configured by the administrator to a certain high but realistic value such as 2500-4000 seconds and it will make the mentioned vulnerability reproducible.
As mentioned in the patch section of the previous article, the used hash now indeed will have a length of 32 characters. However, the rrand() function is still using mt_srand((int) ((float) microtime() * 1000000)) before the mt_rand() call, thus making the generated hash value limited to 1 million possibilities despite it having 32 random characters.
This vulnerability also needs the $this->conf(Base::O_CRAWLER_LOAD_LIMIT) to have a value of 0. This is because, if the value is set to 0, then the crawler process will be stopped right away when the crawl process is started. Let’s see these two functions below, which are _engine_start and _adjust_current_threads:
private function _engine_start()
{
// check if is running
// if ($this->_summary['is_running'] && time() - $this->_summary['is_running'] < $this->_crawler_conf['run_duration']) {
// $this->_end_reason = 'stopped';
// self::debug('The crawler is running.');
// return;
// }
// check current load
$this->_adjust_current_threads();
if ($this->_cur_threads == 0) {
$this->_end_reason = 'stopped_highload';
self::debug('Stopped due to heavy load.');
return;
}
------------ CUT HERE ------------
private function _adjust_current_threads()
{
$curload = $this->get_server_load();
if ($curload == -1) {
self::debug('set threads=0 due to func sys_getloadavg not exist!');
$this->_cur_threads = 0;
return;
}
$curload /= $this->_ncpu;
// $curload = 1;
if ($this->_cur_threads == -1) {
// init
if ($curload > $this->_crawler_conf['load_limit']) {
$curthreads = 0;
} elseif ($curload >= $this->_crawler_conf['load_limit'] - 1) {
$curthreads = 1;
} else {
$curthreads = intval($this->_crawler_conf['load_limit'] - $curload);
if ($curthreads > $this->conf(Base::O_CRAWLER_THREADS)) {
$curthreads = $this->conf(Base::O_CRAWLER_THREADS);
}
}
} else {
// adjust
$curthreads = $this->_cur_threads;
if ($curload >= $this->_crawler_conf['load_limit'] + 1) {
sleep(5); // sleep 5 secs
if ($curthreads >= 1) {
$curthreads--;
}
} elseif ($curload >= $this->_crawler_conf['load_limit']) {
// if ( $curthreads > 1 ) {// if already 1, keep
$curthreads--;
// }
} elseif ($curload + 1 < $this->_crawler_conf['load_limit']) {
if ($curthreads < $this->conf(Base::O_CRAWLER_THREADS)) {
$curthreads++;
}
}
}
// $log = 'set current threads = ' . $curthreads . ' previous=' . $this->_cur_threads
// . ' max_allowed=' . $this->conf( Base::O_CRAWLER_THREADS ) . ' load_limit=' . $this->_crawler_conf[ 'load_limit' ] . ' current_load=' . $curload;
$this->_cur_threads = $curthreads;
$this->_cur_thread_time = time();
}
So, the crawler process when executed will call the _engine_start() function, then the function will call the _adjust_current_threads() function. The initial value of $this->_cur_threads itself is -1, so when the process is started on the _adjust_current_threads() function and we set the $this->_crawler_conf[‘load_limit’] value to 0, then it will set the $curthreads to 0 value and eventually assign it to $this->_cur_threads variable. Back to the _engine_start() function, if the $this->_cur_threads value is set to 0, then the initial crawl process will be stopped.
Back to the is_role_simulation() function, since there is no hit yet to the role simulation from the initial crawl process, the $hash_data value does not contain the crawler IP, so when the attacker tries to perform a brute force on the hash and successfully done so, it will bypass the check of crawler IP (!self::ip_access($server_ips)).
This vulnerability is realistically reproducible with this plugin configuration:
- Crawler->General Settings->Crawler: ON
- Crawler->General Settings->Run Duration: 2500 – 4000
- Crawler->General Settings->Interval Between Runs: 2500 – 4000
- Crawler->General Settings->Server Load Limit: 0
- Crawler->Simulation Settings->Role Simulation: 1 (ID of user with Administrator role)
- Crawler->Summary->Activate: Turn every row to OFF except Administrator
The patch
Since this vulnerability exists because the code uses a weak hash check and weak implementation of the role simulation process, the LiteSpeed team decided to apply these additional protections:
- The role simulation process is removed.
- Usage of mt_srand on rrand function is removed to not limit the possibility of hash generated value to 1 million possible hashes.
The full patch can be seen in this changeset.
A little note on the patch, we 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. However, according to the LiteSpeed team, the usage of the random_bytes function will be implemented in the future version of the plugin.
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, especially if mt_srand is used in a limited possibility.
Any feature regarding role simulation or other user simulation should also be protected with proper access control.
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
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.