This blog post is about the LiteSpeed Cache plugin vulnerability which is originally reported by TaiYou to the Patchstack bug bounty program for WordPress. We are collaborating with the researcher to release the content of this security advisory article. If you’re a LiteSpeed Cache user, please update the plugin to at least version 6.5.1.
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 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
This plugin suffers from unauthenticated stored XSS vulnerability. It could allow any unauthenticated user from stealing sensitive information to, in this case, privilege escalation on the WordPress site by performing a single HTTP request. The described vulnerability was fixed in version 6.5.1 and assigned CVE-2024-47374.
The CCSS and UCSS generation functions _ccss() and _load() take the required parameters and HTTP headers to generate and save the data. The queue is generated using the following code lines.
private function _ccss()
{
global $wp;
$request_url = home_url($wp->request);
$filepath_prefix = $this->_build_filepath_prefix('ccss');
$url_tag = $this->_gen_ccss_file_tag($request_url);
$vary = $this->cls('Vary')->finalize_full_varies();
---------- CUT HERE ----------
// Store it to prepare for cron
Core::comment('QUIC.cloud CCSS in queue');
$this->_queue = $this->load_queue('ccss');
if (count($this->_queue) > 500) {
self::debug('CCSS Queue is full - 500');
return null;
}
$queue_k = (strlen($vary) > 32 ? md5($vary) : $vary) . ' ' . $url_tag;
$this->_queue[$queue_k] = array(
'url' => apply_filters('litespeed_ccss_url', $request_url),
'user_agent' => substr($ua, 0, 200),
'is_mobile' => $this->_separate_mobile(),
'is_webp' => $this->cls('Media')->webp_support() ? 1 : 0,
'uid' => $uid,
'vary' => $vary,
'url_tag' => $url_tag,
); // Current UA will be used to request
$this->save_queue('ccss', $this->_queue);
---------- CUT HERE ----------
public function load($request_url, $dry_run = false)
{
---------- CUT HERE ----------
$vary = $this->cls('Vary')->finalize_full_varies();
---------- CUT HERE ----------
// Store it for cron
$this->_queue = $this->load_queue('ucss');
if (count($this->_queue) > 500) {
self::debug('UCSS Queue is full - 500');
return false;
}
$queue_k = (strlen($vary) > 32 ? md5($vary) : $vary) . ' ' . $url_tag;
$this->_queue[$queue_k] = array(
'url' => apply_filters('litespeed_ucss_url', $request_url),
'user_agent' => substr($ua, 0, 200),
'is_mobile' => $this->_separate_mobile(),
'is_webp' => $this->cls('Media')->webp_support() ? 1 : 0,
'uid' => $uid,
'vary' => $vary,
'url_tag' => $url_tag,
); // Current UA will be used to request
$this->save_queue('ucss', $this->_queue);
---------- CUT HERE ----------
Notice that the $vary variable will be stored via the $this->save_queue function. The $vary variable itself is constructed from the finalize_full_varies function:
public function finalize_full_varies()
{
$vary = $this->_finalize_curr_vary_cookies(true);
$vary .= $this->finalize_default_vary(get_current_user_id());
$vary .= $this->get_env_vary();
return $vary;
}
/**
* Get request environment Vary
*
* @since 4.0
*/
public function get_env_vary()
{
$env_vary = isset($_SERVER['LSCACHE_VARY_VALUE']) ? $_SERVER['LSCACHE_VARY_VALUE'] : false;
if (!$env_vary) {
$env_vary = isset($_SERVER['HTTP_X_LSCACHE_VARY_VALUE']) ? $_SERVER['HTTP_X_LSCACHE_VARY_VALUE'] : false;
}
return $env_vary;
}
The value is coming from $_SERVER[‘LSCACHE_VARY_VALUE’] which is a X-LSCACHE-VARY-VALUE HTTP header and there is no sanitization or escaping when saving the queue.
This vulnerability occurs because the code that handles the view of the queue doesn’t implement sanitization and output escaping. The plugin outputs a list of URLs that are queued for unique CSS generation and with the URL another functionality called “Vary Group” is printed on the Admin page. As explained on the plugin vendor’s website, the Vary Group functionality combines the concepts of “cache varies” and “user roles”. The vulnerability occurs because Vary Group can be supplied by a user via an HTTP Header and printed on the admin page without sanitization.
<?php if (!empty($ucss_queue)) : ?>
<div class="litespeed-callout notice notice-warning inline">
<h4>
<?php echo sprintf(__('URL list in %s queue waiting for cron', 'litespeed-cache'), 'UCSS'); ?> ( <?php echo count($ucss_queue); ?> )
<a href="<?php echo Utility::build_url(Router::ACTION_UCSS, UCSS::TYPE_CLEAR_Q); ?>" class="button litespeed-btn-warning litespeed-right">Clear</a>
</h4>
<p>
<?php $i = 0;
foreach ($ucss_queue as $k => $v) : ?>
<?php if ($i++ > 20) : ?>
<?php echo '...'; ?>
<?php break; ?>
<?php endif; ?>
<?php if (!is_array($v)) continue; ?>
<?php if (!empty($v['_status'])) : ?><span class="litespeed-success"><?php endif; ?>
<?php echo esc_html($v['url']); ?>
<?php if (!empty($v['_status'])) : ?></span><?php endif; ?>
<?php if ($pos = strpos($k, ' ')) echo ' (' . __('Vary Group', 'litespeed-cache') . ':' . substr($k, 0, $pos) . ')'; ?>
<?php if ($v['is_mobile']) echo ' <span data-balloon-pos="up" aria-label="mobile">📱</span>'; ?>
<?php if (!empty($v['is_webp'])) echo ' WebP'; ?>
<br />
<?php endforeach; ?>
</p>
</div>
As we can see from the code block, the UCSS queue is printed. Other aspects of the output including the URL is escaped expect the ‘Vary Group’ which is printed directly without any sanitization.
For this vulnerability to be exploitable these two settings in Page Optimization should be enabled.
- Page Optimization->CSS Combine: ON
- Page Optimization->Generate UCSS: ON
The patch
The patch for the vulnerability is fairly simple. The output is sanitized using esc_html and can be seen below:
Conclusion
We recommend applying escaping and sanitization to any message that will be displayed as an admin notice. Depending on the context of the data, we recommend using sanitize_text_field to sanitize value for HTML output (outside of HTML attribute) or esc_html. For escaping values inside of attributes, you can use the esc_attr function. We also recommend applying a proper permission or authorization check to the registered rest route endpoints.
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.