CVE-2026-13447: WordPress MStore API, unauthenticated RCE
MStore API 4.18.4 FirebasePhoneAuthHelper::verify_id_token checks alg, kid, aud, and iss, then returns phone_number. It never calls openssl_verify. Forge a JWT, impersonate a phone, get an admin cookie. 4.21.1 verifies the signature.
- Name
- CVE-2026-13447: WordPress MStore API, unauthenticated RCE
- Type
- N-day analysis
- CVE
- CVE-2026-13447
- CVE Risk
- critical
- Disclosure Status
- public
- Vendor
- inspireUI
- Affected
- MStore API (mstore-api), lab on 4.18.4; NVD lists through 4.20.0; patched in 4.21.1
- Published
- 19 Sept 2026
- Updated
- 19 Sept 2026
- Tags
- n-day, wordpress, jwt, auth-bypass, cwe-287, unauthenticated, rce
The kid is not a signature
I am @abraxas_null. The proof of concept is on GitHub: abraxas/CVE-2026-13447 (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, docker-compose.override.yml. Authorized lab only. It talks to loopback.
CVE-2026-13447 (NVD, GHSA-6wfp-pwm3-667v, Wordfence) is unauthenticated authentication bypass via JWT forgery in MStore API 4.18.4, slug mstore-api. CWE-287. 9.8 Critical. NVD lists through 4.20.0. No 4.20.0 zip in the lab; 4.18.4 is the tree that ran. Patched in 4.21.1.
The advisory names FirebasePhoneAuthHelper::verify_id_token. That is a PHP method. HTTP is POST /wp-json/api/flutter_user/firebase_sms_v2 with JSON id_token. If you POST action=verify_id_token you get the theme back, or a REST 404.
The helper checks alg == RS256, that kid is in Google's x509 metadata key list, that aud matches the uploaded Firebase project_id, that iss is https://securetoken.google.com/ plus that id. Then it returns phone_number. It never calls openssl_verify. A self-generated RSA key and a copied kid is enough. Lookup registered_phone_number, generateCookieByUserId. That is an admin session. That is RCE on WordPress.
This is the map I used to get from id_token is invalid to a JSON body that named the administrator. Isolated lab, loopback only. I am not printing a JWT factory here. The client on GitHub does. The stack is in the CVE repo so you can run it at home.
What an attacker can do
Mint an RS256 JWT with a live Google kid, matching aud/iss, and a phone_number bound to an existing user. POST it. You get wp_user_id, cookie, displayname. That is admin if that phone is on admin. I am not printing the token.
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 4.18.4 of the plugin next to compose as ./mstore-api (SVN tag). The lab host has to GET Google's x509 list so the kid check can pass.
# Loopback lab image pin for CVE-2026-13447. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache# CVE-2026-13447 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
- ./mstore-api:/var/www/html/wp-content/plugins/mstore-api:ro
depends_on:
db:
condition: service_healthy
wpcli:
image: wordpress:cli
user: "33:33"
volumes:
- wp_data:/var/www/html
- ./mstore-api:/var/www/html/wp-content/plugins/mstore-api: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 a Firebase config JSON whose project_id you control, that id in aud/iss, and an admin with registered_phone_number equal to the JWT phone_number. Witness is the admin display_name.
<?php
/**
* Lab fixture for MStore API 4.18.4 (CVE-2026-13447).
* Firebase config file + admin phone meta. Witness is display_name.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
$uploads = wp_upload_dir();
$dir = trailingslashit( $uploads['basedir'] ) . 'flutter_firebase';
if ( ! is_dir( $dir ) ) {
wp_mkdir_p( $dir );
}
file_put_contents(
$dir . '/poc13447.json',
wp_json_encode( array( 'project_id' => 'poc13447' ) )
);
update_option( 'mstore_firebase_file_name', 'poc13447.json' );
$admin = get_user_by( 'login', 'admin' );
if ( $admin ) {
wp_update_user(
array(
'ID' => $admin->ID,
'display_name' => 'POCWitness13447',
'nickname' => 'POCWitness13447',
)
);
update_user_meta( $admin->ID, 'registered_phone_number', '15551213447' );
}Bring-up from the CVE repo lab/:
git clone https://github.com/abraxas/CVE-2026-13447
cd CVE-2026-13447/lab
svn export https://plugins.svn.wordpress.org/mstore-api/tags/4.18.4 mstore-api
docker compose up -d --force-recreate
docker compose exec -T wpcli wp core install \
--url=http://127.0.0.1:8088 \
--title='CVE-2026-13447 Lab' \
--admin_user=admin \
--admin_password=labadmin \
--admin_email=lab@localhost.invalid \
--skip-email
docker compose exec -T wpcli wp plugin activate mstore-api
docker compose cp seed.php wpcli:/tmp/seed.php
docker compose exec -T wpcli wp eval-file /tmp/seed.php
python3 ../CVE-2026-13447-Abraxas-Labs.pyThe 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
FlutterUserController registers POST /firebase_sms_v2 under namespace api/flutter_user. permission_callback is checkApiPermission(). In this tree that is not a logged-in user.
register_rest_route($this->namespace, '/firebase_sms_v2', array(
array(
'methods' => 'POST',
'callback' => function ($request) {
$phone = $this->firebase_sms_verify_id_token($request);
if (is_wp_error($phone)) {
return $phone;
}
return $this->firebase_sms_login_v2($phone);
},
'permission_callback' => function () {
return parent::checkApiPermission();
}
),
));firebase_sms_verify_id_token reads id_token from php://input. Then the helper:
public function verify_id_token($id_token){
$splitToken = explode(".", $id_token);
$decodedHeader = json_decode(urldecode(base64_decode($splitToken[0])), true);
if (!isset($decodedHeader['alg']) || $decodedHeader['alg'] != 'RS256') {
return false;
}
$public_keys = $this->get_public_keys();
if (!isset($decodedHeader['kid']) || !in_array($decodedHeader['kid'], $public_keys)) {
return false;
}
$decodedPayload = json_decode(urldecode(base64_decode($splitToken[1])), true);
// aud == project_id, iss == https://securetoken.google.com/{project_id}
return isset($decodedPayload['phone_number'])
? trim($decodedPayload['phone_number'])
: false;
}get_public_keys returns array_keys of the Google JSON. Membership of kid in that list is not a signature check. The third JWT segment is never used.
firebase_sms_login_v2 does get_users on registered_phone_number. One match: cookie for that user. Zero matches: it will try to create or find by email. The lab binds the admin phone so the JSON comes back as user 1.
4.21.1 verifies the signature. That is the patch. Update.
The 200 that was invalid_login
The first client I pointed at this was polite. It used action=verify_id_token or a JWT whose kid was not in Google's list, or aud that did not match the uploaded JSON. id_token is invalid. Firebase private key file is not found is a missing config file, not a signature.
A few other ways to lose without learning anything:
- GET. The route is POST.
algnotRS256.- Random
kid. Copy one from the live x509 document. aud/issnotpoc13447. They have to matchproject_idin the uploaded file.- No user with that
registered_phone_number.User does not exist. - Theme HTML. Wrong path. Use
/wp-json/api/flutter_user/firebase_sms_v2or/?rest_route=/api/flutter_user/firebase_sms_v2. - A reverse shell. Theatre. The witness is the display name in the JSON.
The tell is small JSON with wp_user_id, cookie, displayname. Not a homepage.
What I actually did
Treat the on-disk product as the spec. Wordfence named the missing openssl_verify. I read the helper, then the REST route, then bound a lab phone to admin. Proof of concept: CVE-2026-13447-Abraxas-Labs.py.
Kid, claims, POST. GET Google's x509. Pick a kid. Build RS256 with that header, aud/iss from the lab Firebase file, phone_number matching admin meta. POST {id_token}. I am not printing the token here.
Witness in the body. POCWitness13447 as displayname, plus a cookie. If that string is present, verify_id_token accepted a JWT it never verified and firebase_sms_login_v2 issued a session for that phone. That is the proof.
Last lab run, trimmed:
google kid status=200
ajax status=200
displayname POCWitness13447
role administrator
SUCCESS CVE-2026-13447I am not reprinting the cookie. The client that produced it is on GitHub.
Wrong turns: action=verify_id_token (theme or REST 404); GET; alg not RS256; random kid; aud/iss not matching project_id (poc13447 in the lab file); no user with that registered_phone_number (User does not exist); Firebase private key file is not found (missing config, not a signature).
What this is not
It is not "Firebase phone auth is broken." Google's tokens are fine. This plugin treats a kid that exists in Google's list as proof the payload is Google's.
Update to 4.21.1. Re-run the loopback client against the patched build: the witness must not appear.
I am not going to print a JWT you can paste at someone else's firebase_sms_v2. The missing openssl_verify 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-13447 · CVE-2026-13447-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · override - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CVE-2026-13447 · NVD · GHSA-6wfp-pwm3-667v · Wordfence · CWE-287
- Trac 4.18.4:
verify_id_token,firebase_sms_v2,firebase_sms_login_v2 - Google: securetoken x509
- Source: SVN tags · Trac browser