Object Cache 4 everyone <= 2.3.2 - Information Exposure
Description
The Object Cache 4 everyone plugin for WordPress is vulnerable to Sensitive Information Exposure in all versions up to, and including, 2.3.2. This makes it possible for unauthenticated attackers to extract sensitive user or configuration data.
CVSS Vector Breakdown
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:NTechnical Details
<=2.3.2What Changed in the Fix
Changes introduced in v2.3.3
Source Code
WordPress.org SVNThis research plan focuses on **CVE-2026-54834**, an information exposure vulnerability in the "Object Cache 4 everyone" plugin. The vulnerability arises from storing serialized cache data in web-accessible files with predictable names and locations when using the disk-based caching backend. --- #…
Show full research plan
This research plan focuses on CVE-2026-54834, an information exposure vulnerability in the "Object Cache 4 everyone" plugin. The vulnerability arises from storing serialized cache data in web-accessible files with predictable names and locations when using the disk-based caching backend.
1. Vulnerability Summary
The Object Cache 4 everyone plugin (<= 2.3.2) provides a disk-based fallback when Memcached is unavailable. This disk backend stores cached objects (which often include sensitive site configuration, user metadata, and session transients) as serialized PHP data. These files are stored within the wp-content/cache/object/ directory.
Because the directory structure and filenames are derived from a simple MD5 hash of the cache key, and the files use a .object.php extension without proper access controls (like .htaccess or placement outside the web root), an unauthenticated attacker can predict the path to sensitive cache entries and download them directly via HTTP.
2. Attack Vector Analysis
- Endpoint: Direct HTTP GET requests to files located under
/wp-content/cache/object/. - Vulnerable File:
object-cache-disk-template.php(specifically the_get_pathandsetmethods). - Payload: No payload is required for the request itself; the "attack" is the prediction of the file path.
- Authentication: None (Unauthenticated).
- Preconditions:
- The plugin must be active.
- The disk cache must be in use (Memcached must be unavailable or disabled via
OC4EVERYONE_DISABLE_DISK_CACHE). - The web server must allow access to the
wp-content/cache/directory.
3. Code Flow
The vulnerability can be traced through the following call stack:
- Entry: A WordPress process calls
wp_cache_set( $key, $value, $group ). - Processing: The
WP_Object_Cacheclass (installed by the plugin) formats the internal key, typically using the pattern$blog_prefix . $group . ':' . $key. - Sink (Write):
ObjectCacheDisk::set($key, $value)is called inobject-cache-disk-template.php. - Path Generation:
ObjectCacheDisk::_get_path($key)(line 157) calculates the file path:$hash = md5($key); // Returns 32-character hex string $array_hash = str_split($hash, 8); // Splits into four 8-character segments $path = $this->local_path . implode(DIRECTORY_SEPARATOR, $array_hash); // wp-content/cache/object/S1/S2/S3/S4/ // ... $path .= DIRECTORY_SEPARATOR . '.object.php'; // Final filename: .object.php - Storage: The serialized data is written to the file via
$wp_filesystem->put_contents($path, @serialize($value)). - Exposure: The attacker requests the URL corresponding to the generated
$path.
4. Nonce Acquisition Strategy
No nonce is required. This vulnerability involves direct access to static files on the filesystem. WordPress nonces are not checked by the web server when serving .php or other files from the wp-content/cache directory.
5. Exploitation Strategy
The goal is to retrieve the alloptions cache entry, which contains a massive amount of site configuration data.
- Identify Target Key: The standard key for site options is
options:alloptions. In a default single-site WordPress installation (Blog ID 1), the prefixed key used by the cache engine is1:options:alloptions. - Calculate Path:
- Target Key:
1:options:alloptions - MD5 Hash:
0043818e100f95470d046c879d72a5a5 - Split segments:
0043818e,100f9547,0d046c87,9d72a5a5 - Predicted Path:
/wp-content/cache/object/0043818e/100f9547/0d046c87/9d72a5a5/.object.php
- Target Key:
- Execute Request: Use the
http_requesttool to fetch the file. - Parse Data: The response will be a serialized PHP string. Use a script or
unserialize()to extract the site configuration.
6. Test Data Setup
To verify the vulnerability in a test environment:
- Environment: A standard WordPress installation with the plugin "Object Cache 4 everyone" <= 2.3.2.
- Trigger Fallback: Ensure no Memcached server is running on the local host to force the plugin to use the
ObjectCacheDiskbackend. - Populate Cache: Log in as an administrator and browse the dashboard. This ensures the
alloptionsandusers:1keys are cached to disk. - Confirm Storage: Check that the directory
wp-content/cache/object/has been created and populated with subdirectories.
7. Expected Results
- An HTTP GET request to the predicted path returns a
200 OKstatus. - The response body starts with serialized PHP markers (e.g.,
a:NNN:{...}orO:8:"stdClass":...). - The data contains sensitive information such as the
admin_email,siteurl,active_plugins, and potentially database schema information or transient secrets.
8. Verification Steps
- Verify File Existence: Use WP-CLI to confirm the path the plugin should be using:
wp eval "echo WP_CONTENT_DIR . '/cache/object/' . implode('/', str_split(md5('1:options:alloptions'), 8)) . '/.object.php';" - Check Content: Compare the output of the HTTP request with the actual option value:
wp option get alloptions --format=json - Check Direct Access: Ensure that attempting to access the directory itself (
/wp-content/cache/object/) returns a 403 or an emptyindex.php, but the specific.object.phpfile remains accessible.
9. Alternative Approaches
If 1:options:alloptions does not exist, the plugin or WordPress configuration might not be using a blog prefix. Try these variations:
- No Prefix:
options:alloptions- MD5:
53066d9341498b5e9036c57f2023d8c2 - Path:
/wp-content/cache/object/53066d93/41498b5e/9036c57f/2023d8c2/.object.php
- MD5:
- User Data:
1:users:1(Admin User Data)- MD5:
7f26798e8334465439a38f36c5339c0f - Path:
/wp-content/cache/object/7f26798e/83344654/39a38f36/c5339c0f/.object.php
- MD5:
- Custom Salt: If the site defines
WP_CACHE_KEY_SALTinwp-config.php, the key will beSALT + blog_prefix + group + : + key. The agent would need to discover this salt (e.g., via a different leak) to predict the MD5.
Summary
The Object Cache 4 everyone plugin (<= 2.3.2) for WordPress stores cached data in predictable locations on the filesystem using MD5 hashes of cache keys. Due to a lack of directory access controls and the use of a .php extension, unauthenticated attackers can directly access and download these files to extract sensitive site configuration or user information.
Vulnerable Code
// object-cache-disk-template.php line 106 public function set($key, $value, $expiration = 0) { global $wp_filesystem; //Find file and put $path = $this->_get_path($key); //Folder for file $dir = dirname($path); if (!$wp_filesystem->is_dir($dir)) { $return = $wp_filesystem->mkdir($dir, 0755); if (!$return) { $this->result_code = self::RES_FAILURE; return false; } } if (is_object($value)) { $value = clone $value; } $return = $wp_filesystem->put_contents($path, @serialize($value)); if (!$return) { $this->result_code = self::RES_FAILURE; return false; } $this->result_code = self::RES_SUCCESS; return true; } --- // object-cache-disk-template.php line 211 private function _get_path($key) { $hash = md5($key); //Returns the hash as a 32-character hexadecimal number. $array_hash = str_split($hash, 8); //8 name based $path = $this->local_path . implode(DIRECTORY_SEPARATOR, $array_hash); // Ensure directory exists with an index.php to prevent directory listing if (!file_exists($path . DIRECTORY_SEPARATOR . 'index.php')) { @file_put_contents($path . DIRECTORY_SEPARATOR . 'index.php', '<?php // Silence is golden.'); } $path .= DIRECTORY_SEPARATOR . '.object.php'; return $path; }
Security Fix
Only in /home/deploy/wp-safety.org/data/plugin-versions/object-cache-4-everyone/2.3.3: changelog.txt @@ -5,7 +5,7 @@ * Description: Memcached or disk backend support for the WP Object Cache. Memcached server running and PHP Memcached class needed for better performance. No configuration needed, runs automatically * Plugin URI: https://wordpress.org/plugins/object-cache-4-everyone * Author: fpuenteonline - * Version: 2.3.2 + * Version: 2.3.3 * Author URI: https://twitter.com/fpuenteonline * License: GPLv2 or later * License URI: http://www.gnu.org/licenses/old-licenses/gpl-2.0.html @@ -133,7 +133,7 @@ '127.0.0.1:20001' ); - if (defined('OC4EVERYONE_MEMCACHED_SERVER')) { + if (defined('OC4EVERYONE_MEMCACHED_SERVER') && is_string(OC4EVERYONE_MEMCACHED_SERVER) && preg_match('/^[A-Za-z0-9.\\-]+:[0-9]{1,5}$/', OC4EVERYONE_MEMCACHED_SERVER)) { $memcached_servers = array(OC4EVERYONE_MEMCACHED_SERVER); } else { // Try SG Memcached server first. @@ -188,8 +188,8 @@ * */ ?>" . $template; - $template .= '//Detected memcached server - ' . gmdate('d/m/Y G:i:s', current_time('timestamp', 0)) . PHP_EOL; - $template .= "define('OC4EVERYONE_PREDEFINED_SERVER', '$found_server');" . PHP_EOL; + $template .= '//Detected memcached server - ' . gmdate('d/m/Y G:i:s', time()) . PHP_EOL; + $template .= 'define(\'OC4EVERYONE_PREDEFINED_SERVER\', ' . var_export($found_server, true) . ');' . PHP_EOL; $template .= "if (! defined('WP_CACHE_KEY_SALT')) {" . PHP_EOL; global $wpdb; @@ -231,8 +231,8 @@ */ ?>" . $template; - $template .= '//No detected memcached server - ' . gmdate('d/m/Y G:i:s', current_time('timestamp', 0)) . PHP_EOL; - $template .= "define('OC4EVERYONE_PREDEFINED_SERVER', '');" . PHP_EOL; + $template .= '//No detected memcached server - ' . gmdate('d/m/Y G:i:s', time()) . PHP_EOL; + $template .= 'define(\'OC4EVERYONE_PREDEFINED_SERVER\', ' . var_export('', true) . ');' . PHP_EOL; $template .= "if (! defined('WP_CACHE_KEY_SALT')) {" . PHP_EOL; $template .= "define('WP_CACHE_KEY_SALT', '" . filemtime(__FILE__) . "');" . PHP_EOL; @@ -254,31 +254,40 @@ global $wp_object_cache; if (strpos($plugin_file_name, basename(__FILE__)) && class_exists('Memcached') && method_exists($wp_object_cache, 'getStats') && file_exists(WP_CONTENT_DIR . DIRECTORY_SEPARATOR . 'object-cache.php')) { + // Read the stats once; every value below comes from the Memcached server, so it is treated as untrusted and escaped on output. + $all_stats = $wp_object_cache->getStats(); + // Extra check. - if(!defined('OC4EVERYONE_PREDEFINED_SERVER') || !array_key_exists(OC4EVERYONE_PREDEFINED_SERVER, $wp_object_cache->getStats())){ - return $links_array; + if (!defined('OC4EVERYONE_PREDEFINED_SERVER') || !array_key_exists(OC4EVERYONE_PREDEFINED_SERVER, $all_stats)) { + return $links_array; } - $hits = $wp_object_cache->getStats()[OC4EVERYONE_PREDEFINED_SERVER]['get_hits']; + $stats = $all_stats[OC4EVERYONE_PREDEFINED_SERVER]; + + $hits = isset($stats['get_hits']) ? (int) $stats['get_hits'] : 0; // Extra check. - if($hits == 0) { - return $links_array; + if ($hits === 0) { + return $links_array; } - $misses = $wp_object_cache->getStats()[OC4EVERYONE_PREDEFINED_SERVER]['get_misses']; - $total = $hits + $misses; - $found = @round((100 / $total) * $hits, 2); + $misses = isset($stats['get_misses']) ? (int) $stats['get_misses'] : 0; + $total = $hits + $misses; + $found = $total > 0 ? round((100 / $total) * $hits, 2) : 0; if (defined('WP_DEBUG') && WP_DEBUG) { - error_log('Object Cache 4 everyone::Memcached Server running'); + error_log('Object Cache 4 everyone::Memcached Server running'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } $nonce = wp_create_nonce('flush_memcached_nonce'); - $links_array[] = '<a href="' . esc_url(admin_url('admin-post.php?action=oc4flush_memcached&nonce=' . $nonce)) . '"><strong>' . esc_html__('Flush cache', 'object-cache-4-everyone') . '</strong></a>' . + $uptime = isset($stats['uptime']) ? (int) $stats['uptime'] : 0; + $curr_items = isset($stats['curr_items']) ? $stats['curr_items'] : 0; + $total_items = isset($stats['total_items']) ? $stats['total_items'] : 0; + + $links_array[] = '<a href="' . esc_url(admin_url('admin-post.php?action=oc4flush_memcached&nonce=' . $nonce)) . '"><strong>' . esc_html__('Flush cache', 'object-cache-4-everyone') . '</strong></a>' . '<br/><br/>' . - esc_html__('Memcached Server running:', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . OC4EVERYONE_PREDEFINED_SERVER . '</code></strong>' . '<br/>' . - esc_html__('Cache Hit Ratio', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . $found . '%</code></strong>' . '<br/>' . - esc_html__('Uptime:', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . secondsToHumanReadable($wp_object_cache->getStats()[OC4EVERYONE_PREDEFINED_SERVER]['uptime']) . '</strong></code>' . '<br/>' . - esc_html__('Current Unique Items / Total Items:', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . $wp_object_cache->getStats()[OC4EVERYONE_PREDEFINED_SERVER]['curr_items'] . ' / ' . $wp_object_cache->getStats()[OC4EVERYONE_PREDEFINED_SERVER]['total_items'] . '</strong></code>' . '<br/>'; + esc_html__('Memcached Server running:', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . esc_html(OC4EVERYONE_PREDEFINED_SERVER) . '</code></strong>' . '<br/>' . + esc_html__('Cache Hit Ratio', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . esc_html($found) . '%</code></strong>' . '<br/>' . + esc_html__('Uptime:', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . esc_html(secondsToHumanReadable($uptime)) . '</code></strong>' . '<br/>' . + esc_html__('Current Unique Items / Total Items:', 'object-cache-4-everyone') . ' <strong><code style="background-color: inherit;">' . absint($curr_items) . ' / ' . absint($total_items) . '</code></strong>' . '<br/>'; } return $links_array; @@ -288,6 +297,10 @@ add_action('admin_post_oc4flush_memcached', 'oc4flush_memcached'); function oc4flush_memcached() { + // Authorization: only administrators may flush the cache. The nonce alone is not an authorization check. + if (!current_user_can('manage_options')) { + wp_die(esc_html__('Access denied.', 'object-cache-4-everyone'), '', array('response' => 403)); + } if (!class_exists('Memcached')) { wp_die(esc_html__('Failed to flush Memcached server', 'object-cache-4-everyone')); } @@ -295,7 +308,6 @@ if (isset($_GET['nonce']) && wp_verify_nonce(sanitize_key($_GET['nonce']), 'flush_memcached_nonce')) { // Flush cache. global $wp_object_cache; - error_log(print_r($wp_object_cache->getStats()[OC4EVERYONE_PREDEFINED_SERVER], true)); $wp_object_cache->flush(); $memcached = new Memcached(); @@ -304,7 +316,7 @@ if ($memcached->flush(0)) { if (defined('WP_DEBUG') && WP_DEBUG) { - error_log('Object Cache 4 everyone::Memcached server flushed.'); + error_log('Object Cache 4 everyone::Memcached server flushed.'); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log } wp_redirect(admin_url('plugins.php')); exit; @@ -33,9 +33,49 @@ return; } } + // Block direct web access and directory listing of the cache tree. + $this->protect_dir($this->local_path); + $this->result_code = self::RES_SUCCESS; } + /** + * Drops an index.php and a deny-all .htaccess in a cache directory so the + * serialized cache files cannot be listed or served over the web. + * @param string $dir Directory to protect (with trailing separator). + */ + private function protect_dir($dir) + { + global $wp_filesystem; + + if (!$wp_filesystem->exists($dir . 'index.php')) { + $wp_filesystem->put_contents($dir . 'index.php', '<?php // Silence is golden.'); + } + + if (!$wp_filesystem->exists($dir . '.htaccess')) { + $htaccess = "<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\nOrder allow,deny\nDeny from all\n</IfModule>\n"; + $wp_filesystem->put_contents($dir . '.htaccess', $htaccess); + } + } + + /** + * Returns the secret used to sign cache files on disk. + * @return string + */ + private function hmac_key() + { + if (defined('AUTH_SALT') && AUTH_SALT) { + return AUTH_SALT; + } + if (defined('SECURE_AUTH_SALT') && SECURE_AUTH_SALT) { + return SECURE_AUTH_SALT; + } + return 'oc4everyone-disk-cache'; + } + public function quit() { return false; @@ -125,7 +165,11 @@ $value = clone $value; } - $return = $wp_filesystem->put_contents($path, @serialize($value)); + // Sign the serialized payload so a tampered cache file is rejected before unserialize(). + $serialized = @serialize($value); + $blob = hash_hmac('sha256', $serialized, $this->hmac_key()) . $serialized; + + $return = $wp_filesystem->put_contents($path, $blob); if (!$return) { $this->result_code = self::RES_FAILURE; return false; @@ -146,12 +190,22 @@ } $objData = $wp_filesystem->get_contents($path); - if ($objData === false) { + if ($objData === false || strlen($objData) < 64) { $this->result_code = self::RES_FAILURE; return false; } - $data = unserialize($objData); + // Verify the HMAC signature before unserializing. + $stored_hmac = substr($objData, 0, 64); + $serialized = substr($objData, 64); + if (!hash_equals(hash_hmac('sha256', $serialized, $this->hmac_key()), $stored_hmac)) { + $this->result_code = self::RES_FAILURE; + return false; + } + + $data = unserialize($serialized); $this->result_code = self::RES_SUCCESS; return $data; @@ -164,13 +218,9 @@ $array_hash = str_split($hash, 8); //8 name based $path = $this->local_path . implode(DIRECTORY_SEPARATOR, $array_hash); - - // Ensure directory exists with an index.php to prevent directory listing - if (!file_exists($path . DIRECTORY_SEPARATOR . 'index.php')) { - @file_put_contents($path . DIRECTORY_SEPARATOR . 'index.php', '<?php // Silence is golden.'); - } - $path .= DIRECTORY_SEPARATOR . '.object.php'; + // Cache payload stored with a non-PHP extension so it is never executed even if served directly. + $path .= DIRECTORY_SEPARATOR . '.object.cache'; return $path; } }
Exploit Outline
The exploit involves predicting the URI of sensitive cache entries stored on disk when Memcached is unavailable. The plugin generates paths by calculating the MD5 hash of a cache key (such as '1:options:alloptions' for site settings or '1:users:1' for user metadata) and splitting that hash into 8-character segments to form a directory structure within '/wp-content/cache/object/'. Since these files use the '.object.php' extension and are not protected by .htaccess or authorization checks in vulnerable versions, an unauthenticated attacker can perform a direct HTTP GET request to the calculated path to download the serialized PHP data and extract sensitive information.
Check if your site is affected.
Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.