Every WordPress file-upload plugin walks the same tightrope. Core WordPress has a strict media allowlist for good reason. HTML, SVG, PHP and a handful of other types never touch wp-content for a non-admin user. A plugin that offers "frontend file upload" is, by definition, loosening that allowlist. The question is how it loosens, and what safety net it puts underneath.
CVE-2025-4392 lived in that safety net. Shared Files is a WordPress plugin with about 4,000 active installs that lets site owners place a [shared_files file_upload=1] shortcode on a public page. Visitors hit the page, pick a file, it goes to wp-content/uploads via the plugin's own handler. Authentication not required. That's the plugin's whole selling point.
A sanitizer was supposed to stop anything dangerous. It did, for one file type. Everything else went through unchanged.
This is a stored XSS. CWE-79. The attack vector is an SVG file crafted so that the content-sniffer the plugin uses does not recognise it as SVG. A leading blank line, or a leading line of plain text before the <svg> root, is enough. The plugin's per-MIME sanitizer is gated on what finfo_file() reports about the content, so a file finfo reads as text/plain skips the sanitizer entirely and lands on disk byte-for-byte. The file is saved with a .svg extension. Apache picks the Content-Type from the extension, not the bytes, so it serves the saved file back as image/svg+xml. The browser parses the served bytes as SVG, fires any onload or script the SVG asked for, and the script runs in the site's origin against whoever opens the file from the Shared Files list. Unauthenticated in, script-in-origin out.
Here is the function that was supposed to stop this, verbatim from Shared Files 1.7.48, in admin/class-sf-admin-allow-more-file-types.php:
public static function sanitize_file( $tmp_name ) {
$file_contents = file_get_contents( $tmp_name );
$mime_type = '';
if ( function_exists( 'finfo_open' ) && function_exists( 'finfo_file' ) ) {
$finfo = finfo_open( FILEINFO_MIME_TYPE );
$mime_type = finfo_file( $finfo, $tmp_name );
finfo_close( $finfo );
} elseif ( function_exists( 'mime_content_type' ) ) {
$mime_type = mime_content_type( $tmp_name );
}
if ( $mime_type == 'image/svg+xml' ) {
$sanitizer = new Sanitizer();
$file_contents_sanitized = $sanitizer->sanitize( $file_contents );
return $file_contents_sanitized;
} else {
return $file_contents;
}
}Read that else. The function is called sanitize_file. It sanitizes exactly one MIME type. For everything else it returns the raw bytes of whatever the user uploaded.
This is not a bug of forgetting to escape. It is a bug of scope, compounded by a gate that trusts content-sniffing. The developer thought about SVG. SVG is the classic WordPress upload XSS vector, every auditor flags it. So they plugged that hole specifically, using the enshrined/svg-sanitize library, which is the right tool. What they did not think about is that the gate on the sanitizer was finfo_file() against the literal string image/svg+xml, and finfo_file() is fooled by tiny content changes. An SVG with a leading whitespace line, a leading text comment outside the root element, or a malformed XML header no longer reads as image/svg+xml under finfo. It reads as text/plain. text/plain is in WordPress core's allowed-MIME list (because .txt files are allowed), so the upstream allowed-MIME check accepts it. The file is the same SVG. The sanitizer never sees it.
The function's name is a lie. It reads as "returns a sanitized version of the file". The actual contract is "returns a sanitized version only when finfo says the file is exactly image/svg+xml". That gap between name and contract is the whole bug. Any auditor skimming the plugin for upload-handling looks for whether a sanitizer is called, sees $sanitized = sanitize_file( $tmp ); in the upload path and moves on. The name bought the trust. The name wasn't telling the truth.
The upload path that lands in this function has no capability check. It hangs off the public frontend shortcode, so any anonymous visitor can trigger it. A crafted SVG flows through sanitize_file(), hits the else branch because finfo doesn't see it as image/svg+xml, comes out the other side byte-for-byte identical. The bytes get written to an uploads directory the plugin manages, a download URL gets stored in the database, and the .svg file sits there waiting for someone to open it. When someone does, Apache reads the extension, sets Content-Type: image/svg+xml regardless of what the bytes actually are, and the browser dutifully parses the file as SVG.
The fix in 1.7.49 replaces the MIME gate with a filename/extension consistency check:
public static function sanitize_file( $tmp_name, $filename ) {
$extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
$file_contents = file_get_contents( $tmp_name );
[ 'ext' => $wp_ext, 'type' => $wp_mime ] = wp_check_filetype_and_ext( $tmp_name, $filename );
if ( $extension !== $wp_ext ) {
die( 'Extension mismatch' );
}
if ( $wp_mime == 'image/svg+xml' ) {
$sanitizer = new Sanitizer();
return $sanitizer->sanitize( $file_contents );
}
return $file_contents;
}wp_check_filetype_and_ext() looks at the filename and the content together. It returns the extension WordPress would assign the file based on both signals. The new gate requires the user-declared extension and the WordPress-derived extension to agree. An SVG whose content was crafted to read as text/plain under finfo is caught here either way: if wp_check_filetype_and_ext also fails to recognise it as SVG, the extensions disagree and the upload dies; if it does recognise it as SVG (the function reads more than finfo does), the SVG branch runs and the sanitizer scrubs the file. The bypass closes.
The new check also generalises beyond the SVG-specific path. The principle is "the bytes have to match the label". Anything that tries to misdirect content-sniffing (leading magic bytes, double extensions, mismatched declared MIME) collapses against a check that reads both ends and demands they converge. Type-gated sanitisation only works when the type detection cannot be cheated, and the patch swaps a single-source content sniff for a two-source consistency check.
That's the right shape for the fix: WordPress already has a filetype allowlist, use it. The original code was trying to be permissive ("allow more file types than core does") and then bolt a single-purpose sanitizer on top. The permissive part did its job. The sanitizer was given a scope that did not match, and a content-sniffer that could be argued with.
The obvious next question: why isn't this RCE? Upload a .php file, trigger execution, done. Two things in the way.
First, the plugin has a separate allowed-MIME gate, allowed_mime_types() in the same class, that runs in the upload handler before sanitize_file() ever sees the bytes. It compares the detected MIME against WordPress's get_allowed_mime_types() plus the plugin's own additions: SVG, WebP, AVIF, JSON, CSV, TTF, WOFF, WOFF2. PHP's MIME (application/x-php) isn't in that set. The upload is rejected with "file mime type is not allowed" before a single byte lands on disk. SVG is in the set, which is why the SVG path was reachable. PHP is not, which is why the PHP path is not.
Second, even if a PHP file somehow landed on disk (it doesn't, but even if), most WordPress installs block PHP execution under wp-content/uploads via server-level config. Managed hosts (Kinsta, WP Engine, most Cloudways stacks) ship that rule by default. On plain Apache boxes it's a common hardening step. An uploaded .php file that happens to be written to /wp-content/uploads/... returns 403 or the PHP source, not execution.
PHP has two locks. SVG had one lock, sanitize_file(), and that one lock was the broken else branch. The XSS path existed because the SVG path was reachable by design, not because someone ignored a general filetype check.
The general pattern I look for is type-gated sanitization. Any function whose name implies a universal safety promise (sanitize_file, clean_input, escape_value, validate_payload) but whose body branches on input shape and only hardens one branch. The default path is where bugs live. When you review code, every if is a question: what happens if the condition is false? If the false path is "return unchanged", the function is lying about its name.
A second pattern, specific to WordPress: any plugin whose category description includes "frontend", "anonymous" or "more file types" is a place to spend five minutes. Core WordPress is careful with uploads. Every plugin that widens the surface is rebuilding that discipline from scratch, and usually the plugin author focused on one attack (SVG) and missed the rest.
This bug also illustrates why I don't love chasing XSS-only findings in plugins with modest install counts. 4,000 installs is a small blast radius. The interesting angle here is the pattern, not the impact. If the same developer wrote other upload-related plugins, the same sanitize-one-type gate probably exists there too. The pattern recognition is transferable. The install-count is not.
Now the part that matters if you're starting out. How do you find these yourself?
Auditing WordPress upload plugins is a narrower slice of the plugin-audit work than the AJAX-endpoint grind, but it scales well once you know the shape. Every plugin that accepts files has roughly the same anatomy: an entry point that captures $_FILES, a sanitizer of some kind, a write to wp-content/uploads and a retrieval URL. The bugs live in the joints.
Start by finding plugins that advertise uploads. The wordpress.org directory lets you filter by tag. "frontend upload", "file upload form", "file sharing", "document management", "client portal". Every one of those tags is a candidate. Skip anything with over a million installs (everyone's already there) and anything under a thousand (vendor won't respond). The useful band for upload-specific bugs is wide, because most site owners don't install upload plugins, so even a 10K-install plugin is niche enough to be under-audited.
Once you have a target, grep for the entry points:
grep -rn '\$_FILES\|wp_handle_upload\|move_uploaded_file' wp-content/plugins/target-plugin/Every file upload goes through one of those. Follow each hit backwards to the handler that invoked it. That handler is either hooked into wp_ajax_*, registered as a REST route, bound to a shortcode that processes $_POST or attached to a form submission. Whichever it is, the first question is: what authentication does it require? If the handler is reached through wp_ajax_nopriv_*, a REST route with permission_callback: '__return_true', or a shortcode that processes $_POST without a current_user_can() guard, you're in unauthenticated territory.
Next, find the sanitizer. Between the entry point and move_uploaded_file() there is almost always a function that reads the file and decides whether to accept it. Names vary: sanitize_file, clean_upload, validate_file, check_file. The tell is a call like $clean = sanitize_upload( $_FILES['file']['tmp_name'] ) just before the file hits disk. Open that function and read the whole thing. Do not trust the name. The Shared Files bug is entirely inside a function with "sanitize" in its name.
Audit the else branch. If the sanitizer is type-gated ("if SVG then clean; if PDF then clean; if image then resize"), look at what it returns when nothing matches. A silent return of the original bytes is the bug. The right default for a function named sanitize_file is to reject, not to pass through.
Check the content-sniffer for fragility. finfo_file() reads bytes and returns a MIME type based on content, but its detection is brittle. A leading whitespace line, a leading text comment outside the root element, a malformed XML header, can all bump an SVG file from image/svg+xml to text/plain under finfo without changing how the browser eventually parses the same bytes. If the sanitizer is gated on the finfo MIME and the file is later served by extension, that gap is the bypass. The Shared Files case is exactly this shape: <svg ... onload="alert(1)"/> becomes a text/plain to finfo with a leading newline, passes the allowed-MIME check because text/plain is in core's allowlist via .txt, skips the SVG sanitizer because the MIME check fails, lands as .svg on disk and gets served back as image/svg+xml by Apache from the extension. Same principle, different magic. The defence is to cross-check the user-declared extension against what WordPress would have assigned based on content, which is what wp_check_filetype_and_ext() does and what the Shared Files patch started calling.
Find the serving path. An uploaded file is only interesting if it's served back from the same origin in a way that triggers execution. Look for routes that serve /uploads/ directly, or plugin-specific URLs like /?shared-files-download=123. The Content-Type in the response, and whether the response uses Content-Disposition: attachment to force download, is what determines execution. Forced download neuters most of the file-based XSS. Inline serving with the file's own MIME enables it. Most plugins serve inline because that's what users expect.
Confirm with a harmless payload. For stored XSS from file upload on plugins that allow SVG (Shared Files included), the proof is:
<!-- Uploaded as proof.svg, with a leading blank line to fool finfo -->
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>For plugins that allow HTML uploads outright (rarer, but they exist), the equivalent is:
<!-- Uploaded as proof.html -->
<script>alert(document.domain)</script>alert(document.domain) is the standard PoC. It proves origin, closes with one click, and nobody can claim you exfiltrated anything. Go further when the program expects full impact: cookie theft to a listener you control, a forced admin action, an account takeover chain. Programs like Wordfence and Patchstack tend to be happy with the alert box for WordPress plugin XSS; private HackerOne programs often want you to show what the bug actually costs. Read the program rules, match the payload to the rules.
Report it. Wordfence and Patchstack both handle WordPress plugin disclosures, coordinate with the vendor, assign CVEs and publish advisories. Shared Files 1.7.49 went through Wordfence. 2025-05-07 I reported the bug, 2025-05-31 the patched 1.7.49 shipped and 2025-06-03 the CVE was published. Under four weeks.
Upload-focused audits compound. Once you've found one sanitize-the-wrong-thing pattern, you recognize it everywhere. The function name, the MIME branch, the else that returns raw content. You'll see it twice more in the next plugin you open. The hardest part of plugin audit is never the bug class. It's having the muscle memory to read every function that a name implies is safe.
