Enable Media Replace <= 4.2.1 - Authenticated (Editor+) Stored Cross-Site Scripting
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:NTechnical Details
<=4.2.1What Changed in the Fix
Changes introduced in v4.2.2
Source Code
WordPress.org SVNThis 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.phpand its database update logic. - Sink: The
doReplaceQuerymethod inReplacer.phpwhich updateswp_posts.post_contentand 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_htmlto be disabled (e.g., Multi-site ordefine( '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_filescapability, as seen inclasses/emr-plugin.phpline 52). - Precondition: The site must have at least one existing post containing the media file being replaced.
3. Code Flow
- 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}. - Processing: The
EnableMediaReplace\EnableMediaReplacePlugin::route()method (inferred handler for the menu page) processes the form submission. - Replacer Initialization: The
EnableMediaReplace\Replacer\Replacerclass is instantiated. - Metadata Generation:
Replacer::replace()callsgetRelativeURLS()to determine the search string (old filename/URL) and the replacement string (new filename/URL). - Target URL Source: The replacement string is derived from the
$this->target_urlset during the upload process (e.g.,wp-content/uploads/2023/10/malicious<script>.png). - Database Sink:
Replacer::replace()calls$this->doReplaceQuery($base_url, $search_urls, $replace_urls). - Injection:
doReplaceQueryperforms aREPLACE()operation in SQL onwp_posts.post_content. Because it uses direct SQL via$wpdb, it bypasses thewp_ksessanitization that usually restricts Editors from injecting<script>tags whenunfiltered_htmlis disabled.
4. Nonce Acquisition Strategy
The "Replace Media" screen is an admin page that requires a nonce for form submission.
- Identify Screen: The replacement form is rendered on the admin page with the slug
enable-media-replace/enable-media-replace.php. - Page Creation: No special shortcode is needed, but an attachment must exist.
- 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.
- Navigate to
- 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_dataor similar. - Action String: The nonce action is likely
enable-media-replace. - Extraction Command:
browser_eval("document.querySelector('input[name=\"_wpnonce\"]')?.value")
- JavaScript Variable: Check for
5. Exploitation Strategy
- Preparation:
- Create a post as Administrator and insert an image (ID:
X). - Create an Editor user.
- Create a post as Administrator and insert an image (ID:
- Payload Creation:
- Create a valid image file named:
xss_payload"><script>alert(origin)</script>.png.
- Create a valid image file named:
- Execution:
- Log in as Editor.
- Fetch the replacement page for Attachment
X. - Extract the
_wpnonceand any required hidden fields (likeattachment_id). - Submit a
POSTrequest toupload.php?page=enable-media-replace/enable-media-replace.phpusingmultipart/form-data.
- Request Parameters:
action:emr_upload(inferred) or the primary POST action.attachment_id:Xreplace_type:replace_and_search(This is critical to trigger the URL update logic).user_file: The file namedxss_payload"><script>alert(origin)</script>.png._wpnonce: The extracted nonce.
- 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);inwp-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_poststable will contain the payload in thepost_contentcolumn for the post using that image. - When visiting the post on the frontend, the browser will execute
alert(origin). - The
_wp_attached_filemeta for the attachment will also likely contain the payload, potentially causing XSS in the Media Library list view.
8. Verification Steps (WP-CLI)
- Check Post Content:
wp db query "SELECT post_content FROM wp_posts WHERE post_content LIKE '%<script>%'" - Check Attachment Meta:
wp post meta list [ATTACHMENT_ID] --keys=_wp_attached_file
9. Alternative Approaches
- Logger XSS: If
SHORTPIXEL_DEBUGis enabled via?SHORTPIXEL_DEBUG=true, theShortPixelLoggerwill 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.
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
@@ -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(); @@ -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; @@ -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 @@ -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.