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

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.

Dockerfile
# Loopback lab image pin for CVE-2026-13447. Full stack: docker-compose.yml
FROM wordpress:6.4-php8.2-apache
YAML
# 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.

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 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
<?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/:

Plain text
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.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

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.

PHP
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:

PHP
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.
  • alg not RS256.
  • Random kid. Copy one from the live x509 document.
  • aud/iss not poc13447. They have to match project_id in 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_v2 or /?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:

Plain text
google kid status=200
ajax status=200
displayname POCWitness13447
role administrator
SUCCESS CVE-2026-13447

I 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