CVE-2026-57722

Enable Media Replace <= 4.2.1 - Authenticated (Editor+) Stored Cross-Site Scripting

mediumImproper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
4.4
CVSS Score
4.4
CVSS Score
medium
Severity
4.2.2
Patched in
7d
Time to patch

Description

The Enable Media Replace plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to, and including, 4.2.1 due to insufficient input sanitization and output escaping. This makes it possible for authenticated attackers, with editor-level access and above, to inject arbitrary web scripts in pages that will execute whenever a user accesses an injected page. This only affects multi-site installations and installations where unfiltered_html has been disabled.

CVSS Vector Breakdown

CVSS:3.1/AV:N/AC:H/PR:H/UI:N/S:C/C:L/I:L/A:N
Attack Vector
Network
Attack Complexity
High
Privileges Required
High
User Interaction
None
Scope
Changed
Low
Confidentiality
Low
Integrity
None
Availability

Technical Details

Affected versions<=4.2.1
PublishedJuly 1, 2026
Last updatedJuly 7, 2026
Affected pluginenable-media-replace

What Changed in the Fix

Changes introduced in v4.2.2

Loading patch diff...

Source Code

WordPress.org SVN
Research Plan
Unverified

This research plan focuses on **CVE-2026-57722**, a Stored Cross-Site Scripting (XSS) vulnerability in the **Enable Media Replace** plugin. The vulnerability exists because the plugin's "search and replace" functionality—designed to update all links to a media file across the site—performs direct …

Show full research plan

This research plan focuses on CVE-2026-57722, a Stored Cross-Site Scripting (XSS) vulnerability in the Enable Media Replace plugin.

The vulnerability exists because the plugin's "search and replace" functionality—designed to update all links to a media file across the site—performs direct database updates on post_content without applying WordPress's KSES filters. This allows users with Editor-level permissions to bypass unfiltered_html restrictions (common in Multi-site setups) and inject malicious scripts into the site's content.


1. Vulnerability Summary

  • Vulnerability: Authenticated Stored XSS
  • Location: build/shortpixel/replacer/src/Replacer.php and its database update logic.
  • Sink: The doReplaceQuery method in Replacer.php which updates wp_posts.post_content and other database fields via direct SQL queries.
  • Cause: Insufficient sanitization of the replacement string (derived from the new filename) and lack of output escaping when the modified content is rendered on the frontend.
  • Constraint: Requires unfiltered_html to be disabled (e.g., Multi-site or define( 'DISALLOW_UNFILTERED_HTML', true );).

2. Attack Vector Analysis

  • Endpoint: /wp-admin/upload.php?page=enable-media-replace/enable-media-replace.php
  • Action: Media Replacement (specifically Option 2: "Replace the file, use the new file name, and update all links").
  • Payload Location: The filename of the uploaded replacement file.
  • Authentication: Editor or Administrator role (specifically requires upload_files capability, as seen in classes/emr-plugin.php line 52).
  • Precondition: The site must have at least one existing post containing the media file being replaced.

3. Code Flow

  1. Entry Point: An Editor accesses the replacement screen for a specific attachment via upload.php?page=enable-media-replace/enable-media-replace.php&attachment_id={ID}.
  2. Processing: The EnableMediaReplace\EnableMediaReplacePlugin::route() method (inferred handler for the menu page) processes the form submission.
  3. Replacer Initialization: The EnableMediaReplace\Replacer\Replacer class is instantiated.
  4. Metadata Generation: Replacer::replace() calls getRelativeURLS() to determine the search string (old filename/URL) and the replacement string (new filename/URL).
  5. Target URL Source: The replacement string is derived from the $this->target_url set during the upload process (e.g., wp-content/uploads/2023/10/malicious<script>.png).
  6. Database Sink: Replacer::replace() calls $this->doReplaceQuery($base_url, $search_urls, $replace_urls).
  7. Injection: doReplaceQuery performs a REPLACE() operation in SQL on wp_posts.post_content. Because it uses direct SQL via $wpdb, it bypasses the wp_kses sanitization that usually restricts Editors from injecting <script> tags when unfiltered_html is disabled.

4. Nonce Acquisition Strategy

The "Replace Media" screen is an admin page that requires a nonce for form submission.

  1. Identify Screen: The replacement form is rendered on the admin page with the slug enable-media-replace/enable-media-replace.php.
  2. Page Creation: No special shortcode is needed, but an attachment must exist.
  3. Manual Navigation:
    • Navigate to wp-admin/upload.php.
    • Identify an attachment ID (e.g., 12).
    • Navigate to wp-admin/upload.php?page=enable-media-replace/enable-media-replace.php&attachment_id=12.
  4. Extraction: The nonce is typically located in a hidden input field or localized JS. In version 4.2.1, EMR enqueues scripts for the UI.
    • JavaScript Variable: Check for window.emr_data or similar.
    • Action String: The nonce action is likely enable-media-replace.
    • Extraction Command: browser_eval("document.querySelector('input[name=\"_wpnonce\"]')?.value")

5. Exploitation Strategy

  1. Preparation:
    • Create a post as Administrator and insert an image (ID: X).
    • Create an Editor user.
  2. Payload Creation:
    • Create a valid image file named: xss_payload"><script>alert(origin)</script>.png.
  3. Execution:
    • Log in as Editor.
    • Fetch the replacement page for Attachment X.
    • Extract the _wpnonce and any required hidden fields (like attachment_id).
    • Submit a POST request to upload.php?page=enable-media-replace/enable-media-replace.php using multipart/form-data.
  4. Request Parameters:
    • action: emr_upload (inferred) or the primary POST action.
    • attachment_id: X
    • replace_type: replace_and_search (This is critical to trigger the URL update logic).
    • user_file: The file named xss_payload"><script>alert(origin)</script>.png.
    • _wpnonce: The extracted nonce.
  5. Trigger: View the post created in Step 1. The image URL in the HTML will now contain the broken attribute and the script tag.

6. Test Data Setup

  • Environment: WordPress Multi-site OR Single site with define('DISALLOW_UNFILTERED_HTML', true); in wp-config.php.
  • User: Editor role.
  • Content: A published post containing an image: <img src=".../old-image.png" ...>.
  • Target Image: An existing attachment in the media library.

7. Expected Results

  • The wp_posts table will contain the payload in the post_content column for the post using that image.
  • When visiting the post on the frontend, the browser will execute alert(origin).
  • The _wp_attached_file meta for the attachment will also likely contain the payload, potentially causing XSS in the Media Library list view.

8. Verification Steps (WP-CLI)

  1. Check Post Content:
    wp db query "SELECT post_content FROM wp_posts WHERE post_content LIKE '%<script>%'"
    
  2. Check Attachment Meta:
    wp post meta list [ATTACHMENT_ID] --keys=_wp_attached_file
    

9. Alternative Approaches

  • Logger XSS: If SHORTPIXEL_DEBUG is enabled via ?SHORTPIXEL_DEBUG=true, the ShortPixelLogger will log the metadata of the replacement. If the logger view (accessible to admins) does not escape the log contents, XSS will fire in the admin dashboard when they view the debug logs.
  • Metadata XSS: Inject the payload into the "Custom Date" fields if the replacement form allows setting custom EXIF or attachment metadata, as these are often displayed unescaped in the attachment editor.
Research Findings
Static analysis — not yet PoC-verified

Summary

The Enable Media Replace plugin for WordPress is vulnerable to Stored Cross-Site Scripting (XSS) in versions up to 4.2.1. This vulnerability allows authenticated attackers with Editor-level access or above to inject arbitrary web scripts into pages and the administrative dashboard, bypassing standard WordPress filters like KSES by performing direct database updates during the media replacement process.

Vulnerable Code

// classes/emr-plugin.php line 608
        <div class="misc-pub-section curtime">
            <span id="timestamp"><?php echo esc_html__('Revised', 'enable-media-replace'); ?>: <b><?php echo $modified; ?></b></span>
        </div>

            <?php
        }

        if ($author_id = get_post_meta($post->ID, '_active_author_id', true)) {
            $display_name = get_the_author_meta('display_name', $author_id);
            ?>
      <div class="misc-pub-section replace_author">
        <span><?php echo esc_html__('Replaced By', 'enable-media-replace'); ?>: <b><?php echo $display_name; ?></b></span>
      </div>

---

// build/shortpixel/replacer/src/Replacer.php around line 172
	    Log::addDebug('Doing meta search and replace -', array($search_urls, $replace_urls) );
	    Log::addDebug('Searching with BaseuRL ' . $base_url);

	    do_action('emr/replacer/replace_urls', $search_urls, $replace_urls);
	    $updated = 0;

	    $updated += $this->doReplaceQuery($base_url, $search_urls, $replace_urls);

Security Fix

diff -ru /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/build/shortpixel/filesystem/src/Model/File/DirectoryModel.php /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/build/shortpixel/filesystem/src/Model/File/DirectoryModel.php
--- /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/build/shortpixel/filesystem/src/Model/File/DirectoryModel.php	2026-04-22 16:33:10.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/build/shortpixel/filesystem/src/Model/File/DirectoryModel.php	2026-06-28 20:49:46.000000000 +0000
@@ -350,6 +350,11 @@
 	public function getPermissionRecursive()
 	{
 		 $parent = $this->getParent();
+      // Edge-case when the whole structure doesn't exist and / or getParent can only retrieve same-level path ? 
+      if (false === $parent)
+      {
+          return false; 
+      }
 		  if (! $parent->exists())
 			{
 				 return $parent->getPermissionRecursive();
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/build/shortpixel/log/src/ShortPixelLogger.php /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/build/shortpixel/log/src/ShortPixelLogger.php
--- /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/build/shortpixel/log/src/ShortPixelLogger.php	2026-04-22 16:33:10.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/build/shortpixel/log/src/ShortPixelLogger.php	2026-06-28 20:49:46.000000000 +0000
@@ -54,10 +54,8 @@
     $this->namespace = substr($ns, 0, strpos($ns, '\\')); // try to get first part of namespace
 
     // phpcs:ignore WordPress.Security.NonceVerification.Recommended  -- This is not a form
-    if (isset($_REQUEST['SHORTPIXEL_DEBUG'])) 
+    if (isset($_REQUEST['SHORTPIXEL_DEBUG']) && true === $this->checkUserLevel()) // manual takes precedence over constants
     {
-
-      // Note! User access level is checked in Addlog and Loadview to prevent lower than administrator access. It can't be checked early, because the user functions might not be loaded before first logs
       $this->is_manual_request = true;
       $this->is_active = true;
 
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/build/shortpixel/replacer/src/Replacer.php /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/build/shortpixel/replacer/src/Replacer.php
--- /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/build/shortpixel/replacer/src/Replacer.php	2026-06-24 15:55:14.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/build/shortpixel/replacer/src/Replacer.php	2026-06-28 20:49:46.000000000 +0000
@@ -318,12 +318,10 @@
 
 	        $sql = $wpdb->prepare($sql, '%' . $url . '%');
 
-			Log::addTemp('Checking -- ', $sql);
 
 	        // This is a desparate solution. Can't find anyway for wpdb->prepare not the add extra slashes to the query, which messes up the query.
 	        $rsmeta = $wpdb->get_results($sql, ARRAY_A);
 
-			Log::addTemp('result -- ' . count($rsmeta) , $rsmeta);
 
 	        if (! empty($rsmeta))
 	        {
@@ -474,7 +472,6 @@
 			 			$in_deep === false && (is_array($content) || is_object($content))
 						)
 			{
-				Log::addTemp('Content is array or object - not json, - maybe serializing');
 				$content = maybe_serialize($content);
 			}
 	    return $content;
@@ -582,18 +579,15 @@
 
 			if (! isset($this->source_metadata['sizes'][$sizeName]) || ! isset($this->target_metadata['width'])) // This can happen with non-image files like PDF.
 			{
-
+				 // Check if metadata-less item is a svg file. Just the main file to replace all thumbnails since SVG's don't need thumbnails.
+				 if (strpos($this->target_url, '.svg') !== false)
+				 {
+					$svg_file = wp_basename($this->target_url);
+					return $svg_file;  // this is the relpath of the mainfile.
+				 }
 
 				return false;
 			}
-
-			// Check if metadata-less item is a svg file. Just the main file to replace all thumbnails since SVG's don't need thumbnails.
-			if (strpos($this->target_url, '.svg') !== false)
-			{
-			$svg_file = wp_basename($this->target_url);
-			return $svg_file;  // this is the relpath of the mainfile.
-			}
-
 			$old_width = $this->source_metadata['sizes'][$sizeName]['width']; // the width from size not in new image
 			$new_width = $this->target_metadata['width']; // default check - the width of the main image
 
diff -ru /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/classes/emr-plugin.php /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/classes/emr-plugin.php
--- /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.1/classes/emr-plugin.php	2026-06-17 09:46:32.000000000 +0000
+++ /home/deploy/wp-safety.org/data/plugin-versions/enable-media-replace/4.2.2/classes/emr-plugin.php	2026-06-28 20:49:46.000000000 +0000
@@ -608,7 +608,7 @@
             $modified = date_i18n(__('M j, Y @ H:i'), strtotime($post->post_modified));
             ?>
         <div class="misc-pub-section curtime">
-            <span id="timestamp"><?php echo esc_html__('Revised', 'enable-media-replace'); ?>: <b><?php echo $modified; ?></b></span>
+            <span id="timestamp"><?php echo esc_html__('Revised', 'enable-media-replace'); ?>: <b><?php echo esc_html($modified); ?></b></span>
         </div>
 
             <?php
@@ -619,7 +619,7 @@
             $display_name = get_the_author_meta('display_name', $author_id);
             ?>
       <div class="misc-pub-section replace_author">
-        <span><?php echo esc_html__('Replaced By', 'enable-media-replace'); ?>: <b><?php echo $display_name; ?></b></span>
+        <span><?php echo esc_html__('Replaced By', 'enable-media-replace'); ?>: <b><?php echo esc_html($display_name); ?></b></span>
       </div>
             <?php
         }

Exploit Outline

The exploit is achieved by an Editor-level user uploading a malicious replacement file. The attacker navigates to the 'Replace Media' screen for an existing attachment, selects Option 2 ('Replace the file, use the new file name, and update all links'), and uploads a file with a crafted filename containing an XSS payload (e.g., `payload"><script>alert(origin)</script>.png`). Because the plugin uses direct SQL queries via $wpdb to update links in the `wp_posts` table, it bypasses WordPress's standard content sanitization. When the updated post is viewed on the frontend, the script executes. Additionally, the attacker's display name, if malicious, is rendered unescaped on the 'Edit Media' admin screen after the replacement occurs.

Check if your site is affected.

Run a free security audit to detect vulnerable plugins, outdated versions, and misconfigurations.