The Gate Checked the Role, Never the Object: AI Engine’s Editor Media Helpers
At 07:16:52 UTC on 18 October 2026, NVD published CVE-2026-110976 and CVE-2026-111041 against AI Engine, the chatbot, AI-framework and MCP plugin that WordPress.org reports running on 90,000 active installations. Both come from WPScan, both are CWE-862 missing authorization, both are scored 4.9 medium by the submitting CNA and carry no CVSS at NVD at all, and both say the same thing in different words: an Editor could hand an AI media endpoint the ID of an object that had nothing to do with them, and the plugin would act on it. One of the two converts arbitrary posts into attachments and then permanently deletes them — navigation menus, block templates, global styles, anything with a post ID.
That is a clean bug with a clean fix, and the fix shipped on 18 September, a month before the identifiers. The reason to write about it is what sits on either side of the CVE. We downloaded and read 3.7.8, 3.7.9, 3.8.5 and 3.8.6 from WordPress.org and diffed them, and the record is unusually candid: the vendor’s own source comments state the defect in plain English, the 3.7.9 release fixed a third endpoint that has no CVE, and yesterday’s 3.8.6 added six more object checks to a neighbouring set of helpers — the same defect class, a different file region, no identifier, no advisory, and a code comment citing a WPScan report dated three days ago. The CVEs that landed today describe the first round of a sweep that is still running.
The route gate is a role gate
All three affected endpoints are registered the same way in classes/rest.php. Read at tag 3.7.8:
register_rest_route( $this->namespace, '/helpers/approve_media', [
'methods' => 'POST',
'permission_callback' => [ $this->core, 'can_access_features' ],
'callback' => [ $this, 'rest_helpers_approve_media' ],
] );
And can_access_features(), in classes/core.php, is this:
public function can_access_features() {
$editor_or_admin = current_user_can( 'editor' ) || current_user_can( 'administrator' );
return apply_filters( 'mwai_allow_usage', $editor_or_admin );
}
Two things are worth naming. First, it tests role names rather than capabilities — a WordPress idiom that works, but that answers “who is this?” instead of “may they do this to that?”. Second, and this is the whole bug, a permission_callback in the WordPress REST API runs before the handler and knows nothing about the request body. It is structurally incapable of checking an object the caller has not yet named. Any per-object authorization has to happen inside the handler, and in 3.7.8 none of these three handlers did it.
Deletion by type conversion
rest_helpers_reject_media() is the one WPScan titles “Editor+ Arbitrary Post Deletion.” Its entire input validation, at 3.7.8, is a check that the ID is not zero:
$attachment_id = absint( $params['attachmentId'] );
if ( empty( $attachment_id ) ) {
throw new Exception( 'Attachment ID is required.' );
}
// Convert from mwai_image/mwai_video to attachment post type first
// This ensures wp_delete_attachment properly deletes the physical file
wp_update_post( [
'ID' => $attachment_id,
'post_type' => 'attachment'
] );
// Delete attachment from WordPress (now that it's a proper attachment, files will be deleted)
wp_delete_attachment( $attachment_id, true );
The type conversion is the mechanism, and it is worth dwelling on because it is why the blast radius is “any post of any type” rather than “any image.” The plugin keeps unsaved generations in custom post types (mwai_image, mwai_video), so rejecting a draft legitimately needs a conversion step before wp_delete_attachment() will clean up the file. Applied to an arbitrary ID, that same two-step launders any object into something the deletion primitive will accept. A navigation menu item is a post. A block template is a post. A WooCommerce coupon is a post. Rewrite the type, call the attachment deleter, and the object is gone along with whatever file it was pointed at. The force flag on wp_delete_attachment() is set, so this is not the trash.
rest_helpers_approve_media() is the same move minus the deletion: wp_update_post() with 'post_type' => 'attachment' and 'post_status' => 'inherit' on whatever ID arrives. The object is not destroyed, it is reclassified — which, for anything that is enumerated by post type, amounts to the same disappearance with a slower recovery path.
The function that was already hardened
The third endpoint, rest_helpers_update_media_metadata() (CVE-2026-111041), overwrites post_title, post_content, post_excerpt and optionally post_name on the supplied ID. It is also where the pattern gets its sharpest illustration, because the same function in the same vulnerable release already carried an explicit, deliberate, commented security control — just aimed at a different threat:
// Security: Validate file extension to prevent arbitrary file upload attacks
$original_ext = strtolower( pathinfo( $file_path, PATHINFO_EXTENSION ) );
$new_ext = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
// Allowlist of safe media extensions (no executable types)
$allowed_extensions = [ 'jpg', 'jpeg', 'png', ... ];
Someone sat in this function, thought carefully about what an attacker could do with the filename parameter, and wrote an allowlist with a comment explaining why. Nobody asked what the attacker could do with the ID parameter sitting four lines above it. That is not incompetence; it is the ordinary shape of security work done per-parameter and per-report instead of per-boundary — the same structure we described in Arcane’s three CVEs, where one check written at each call site produced the same defect three times, and in VulnCheck’s 122-CVE batch across twenty AI platforms, where the recurring failure was a decorator that authenticated without authorizing.
The fix, and the comments the vendor did not have to write
Version 3.7.9’s rest.php is dated 18 September 2026 in the distributed package — the same day 3.8.0 shipped with the changelog line “Fix: Image, media and post creation routes now check permissions before acting.” The fix is the right one: a single ownership primitive, introduced once and routed through from every affected call site.
/**
* Whether this id is one of the current user's own draft images or videos. Drafts are
* not attachments until saved, so the regular attachment checks refuse them, yet the
* Image Studio has to keep editing them.
*/
private function is_user_draft_media( $id ) {
...
// The list is plain user meta, so it is never the only signal: the post must really be
// one of the media types the studios create, and it must belong to this user.
if ( !in_array( get_post_type( $id ), [ 'mwai_image', 'mwai_video', 'attachment' ], true ) ) {
return false;
}
return (int) get_post_field( 'post_author', $id ) === get_current_user_id();
}
Three independent signals — the ID is listed in the caller’s own mwai_draft_media user meta, the post is one of the types the studios actually create, and the author is the caller — with an in-source justification for why the first one alone is not trusted. User meta is attacker-adjacent state; treating it as a hint rather than a verdict is the correct instinct, and seeing it written down in a comment is rarer than it should be.
The guard lands in reject_media and approve_media with comments that are, bluntly, confessions:
// Any id used to be accepted, which let an Editor turn any post into an attachment.
if ( !$this->is_user_draft_media( $attachment_id ) ) {
throw new Exception( 'This media is not one of your drafts.' );
}
…and in reject_media, the matching line reads “Any id used to be accepted, which let an Editor delete any attachment.” update_media_metadata gets a two-branch check: the caller’s own draft, or a real attachment they hold edit_post on. And a fourth endpoint nobody has given a number to — rest_helpers_create_post() — gained a check with its own confession: “The post type used to be taken as is, so any Editor could create drafts of any type, including ones they have no right to create.” That one has no CVE, no advisory, and will never appear in a scanner.
Yesterday, the same bug, next door
Here is the part that changes the triage advice. Version 3.8.6 shipped on 17 October 2026 — one day before these CVEs were published — and its rest.php diff against 3.8.5 adds six more current_user_can() calls and a shared 403 helper. The comment introducing it states the general rule the plugin had been missing, and cites its source:
// The route gates (can_access_features) only check the role. Every helper that takes an object
// id must also check that object: an Editor could rename WooCommerce coupons, navigation menus
// and templates through these routes, which core's REST API refuses (WPScan, 2026-10-15).
The newly-guarded handlers are a different set from the three in the CVEs: update_title and update_excerpt now require edit_post; the Copilot image route drops an attached postId the caller cannot edit; two content-reading paths require read_post; and an attachment-deletion route requires delete_post. The 3.7.9 sweep covered the media helpers. The 3.8.6 sweep covers the post helpers. Both were the same defect. Only the first has identifiers.
We want to be precise about what the public record does and does not establish. The 3.8.6 comment cites a WPScan report dated 15 October; WPScan’s two published advisories for the media helpers went public on 16 October and reached NVD on 18 October. Whether the 15 October report is the same submission or a follow-up finding is not something the available sources settle, and we are not asserting either. What is verifiable is the state of the tree: as of 3.8.6, six endpoints received their first object-level authorization check, and no CVE, GHSA or WPScan entry for them existed at the time of writing.
Ten CVEs, one defect
Querying NVD for AI Engine returns a long history, and ten records since June 2025 describe the same class — the caller’s role or capability was checked, the object was not. Eight of the ten landed in 2026 alone: CVE-2026-8719 (privilege escalation on a missing capability check), CVE-2026-12510 (chatbot conversations not owner-checked), CVE-2026-16953 (guest file deletion authorized solely by a client-supplied session), CVE-2026-75796 (privileged user-management operations without checking the target account), CVE-2026-75798 (an administration-only feature relying on a token handed to anonymous users), CVE-2026-89141 (IDOR), and today’s pair. Before them, CVE-2025-5071 and CVE-2025-8268, both missing capability checks on REST handlers.
Nor is the cadence slowing. The last three releases each carry security lines in the vendor’s own changelog: 3.8.4 on 8 October (shortcode-controlled prompt, model and API key; a search endpoint restricted to administrators), 3.8.5 on 14 October (“MCP no longer returns API keys or tokens when reading WordPress options”; “Pro MCP tools now refuse accounts that cannot manage the site instead of running as the first user”), and 3.8.6 on 17 October. Three security-bearing releases in nine days, on a plugin with a 98/100 rating across 871 reviews. The maintainer is clearly engaged and clearly fixing things; what the record shows is a plugin whose surface is growing faster than its authorization model is being generalized.
Why a WordPress plugin bug is an agent-security bug
If this were only a chatbot plugin, a medium-severity Editor-to-Editor authorization gap would be a footnote. It is not. AI Engine is an MCP server, and version 3.8.0 — the same release that carried the 3.7.9 security fix — added “Editor Access for MCP, letting Editors connect with their own account and work on content only.” Version 3.8.6 adds a flow that opens Claude with the site’s connector pre-filled. The plugin’s MCP tool set includes creating and updating posts and comments, generating images into the Media Library, and — per the 3.8.4 changelog — reading and editing plugin and theme files.
That reframes can_access_features(). It is no longer a convenience check deciding which admin screens render. It is the authorization boundary standing between a remote model and 90,000 content estates, and “work on content only” is a promise made entirely out of per-handler checks that, until 3.7.9 and again until 3.8.6, several handlers did not perform. The 3.8.5 note that Pro MCP tools used to run “as the first user” is the same category of problem one layer up: an identity assumed rather than resolved. We saw this exact shape in a different WordPress MCP plugin’s three auth failures last month, and the structural reading in our WordPress 7.0 agent-infrastructure piece has aged well: when the CMS becomes agent infrastructure, every role check in it becomes an agent permission boundary, whether it was designed to be one or not.
The threat model that makes an Editor-level bug matter is not a rogue employee. It is a connected model with Editor scope, consuming untrusted input — a page it was asked to summarize, a comment thread, a draft someone pasted in — and holding a tool that posts to /helpers/reject_media. The role gate passes, because the model is an Editor. The object check is what decides whether a prompt-injected tool call can reach the site’s navigation menus. That is precisely the check these CVEs record as absent.
What to do
Update to 3.8.6, not to 3.7.9. The CVEs name 3.7.9 as the fixed version and that is accurate for the two media helpers, but it is four releases and three security-bearing changelogs behind current; a site patched strictly to the advisory would still be running the post helpers that were fixed yesterday. Neither CVE is in the CISA KEV catalogue (checked against version 2026.10.16, 1,748 entries), and no exploitation has been reported; WPScan is holding both proofs of concept until 30 October 2026, which is the realistic clock for public exploit code.
Beyond the version bump, three things are worth doing. Re-derive what your Editors are, since on an AI Engine site “Editor” now potentially means “a model holding an MCP connector,” and the 3.8.5 fix means some Pro MCP tools previously executed as the first user regardless of who called them — if an MCP connection existed before 14 October, treat its effective privilege as unknown rather than as the connecting account’s. Rotate anything MCP could have read, given the 3.8.5 line about API keys and tokens being returned when reading WordPress options. And if you maintain anything with a similar shape, ask the 3.8.6 comment’s question of your own codebase: does every handler that accepts an object ID check that object, or does it inherit a gate that only knows who is calling? A route-level permission callback cannot answer a question about an object it has not seen yet. That is an architectural fact, not an oversight — which means the only durable fix is one ownership primitive that every handler is forced through, exactly as is_user_draft_media() is.
Our verification was read-only and local. Both CVE identifiers, publication timestamps (07:16:52.713Z and 07:16:52.920Z), vulnStatus (Received), source identifier (contact@wpscan.com), descriptions and reference URLs were read first-hand from the NVD 2.0 API; neither record carries a CVSS score at NVD. Severities (4.9 medium), CWE-862, the researcher credit (Karthik Ramakrishnan), the affected-endpoint names, the fixed version and the 30 October PoC-release date were read from the two WPScan advisory pages. All PHP quoted above was read from the official WordPress.org distribution zips for versions 3.7.8, 3.7.9, 3.8.5 and 3.8.6, downloaded and diffed locally; every code comment is verbatim. Version, active-install count, rating and all changelog quotations come from the WordPress.org plugin information API. The CVE class tally was derived from a keyword query against NVD and classified by reading each description. KEV status was checked directly against CISA’s catalogue. Brave Search returned no independent technical analysis of either CVE — only automated mirrors (VulDB 421670 and 421663, Threat Radar, strix.ai) reprinting the NVD text. We installed nothing, stood up no WordPress site, sent no request to any third-party host beyond fetching the cited public records and the vendor’s own release packages, created no user, called no endpoint, and executed no proof of concept.
Sources:
- NVD — CVE-2026-110976 (AI Engine before 3.7.9, arbitrary post deletion via a media action; published 18 October 2026)
- NVD — CVE-2026-111041 (AI Engine before 3.7.9, arbitrary post modification via AI media management actions)
- WPScan — “AI Engine < 3.7.9 – Editor+ Arbitrary Post Deletion via reject_media” (CWE-862, 4.9 medium, PoC withheld until 30 October 2026)
- WPScan — “AI Engine < 3.7.9 – Editor+ Arbitrary Post Modification via update_media_metadata and approve_media”
- WordPress.org — AI Engine plugin page and changelog (90,000 active installs; 3.8.4, 3.8.5 and 3.8.6 security entries; MCP Editor Access in 3.8.0)
- WordPress.org — AI Engine 3.7.8 release package (vulnerable
classes/rest.phpandclasses/core.phpread here) - WordPress.org — AI Engine 3.7.9 release package (
is_user_draft_media()and the quoted fix comments) - WordPress.org — AI Engine 3.8.6 release package (17 October 2026; six added object checks and the
forbidden_post_response()comment) - CISA — Known Exploited Vulnerabilities catalogue (version 2026.10.16, 1,748 entries; neither CVE listed)
- al-ice.ai — “A Guest Writes Your Error Log, the AI Reads It, the Admin Executes It” (2 October 2026; CVE-2026-96561 in the same plugin)
- al-ice.ai — “A WordPress MCP Plugin Shipped Three Auth Failures” (26 September 2026; the same boundary in a different plugin)
- al-ice.ai — “The Guard Lived in One File” (17 October 2026; one check written per call site, three CVEs)
- al-ice.ai — “Twenty AI Platforms in Eighteen Seconds” (17 October 2026; missing authorization as the dominant AI-platform defect)
- al-ice.ai — “WordPress 7.0: AI Agent Infrastructure and API Key Theft Risk” (23 May 2026; the CMS as agent infrastructure)
- al-ice.ai — “The Container Name Was the Command” (15 October 2026; a patch landing long before its advisory)