ARTICLE · 1056887
WordPress插件MStore API存在认证绕过漏洞(POC已公开)CVE-2026-13447

一、漏洞描述
MStore API 是 inspireui 给 WordPress 做的插件,把站点里的商品、用户、订单接到 FluxBuilder,用来生成配套的手机 App。WordPress 官方目录显示活跃安装两千以上。
出事的是短信登录。
插件在 /wp-json/api/flutter_user 下注册了 firebase_sms 与 firebase_sms_v2 两条路径,都接收 POST 过来的 id_token。
整个请求过程不要求 WordPress 账号。
路由的权限回调指向 checkApiPermission,内部只剩一句 return true,原先读取采购码并比对哈希的那几行被注释保留着。
令牌校验集中在 FirebasePhoneAuthHelper 的 verify_id_token,只取前两段,比对 alg、kid、aud、iss 四项。
openssl_verify 之类的验签调用不在这条路径上,用自建 RSA 密钥签发的一条令牌照样被接受。
CVE 编号 CVE-2026-13447,归类 CWE-287。CVSS 3.1 打分 9.8,由 CVE 记录中的 CNA 提交,NVD 一栏没有给出自己的分值。
受影响区间记为 ≤ 4.20.0。官方 SVN 里 4.18.4 之后直接是 4.21.1,修复落在 4.21.1。
公开的验证脚本只连本地 127.0.0.1,容器编排文件随复现仓库一起给出,命中标记是响应体里出现目标账号的显示名。
二、漏洞原理
firebase_sms_v2 的 permission_callback 指向 checkApiPermission,这条路最终落到 isPurchaseCodeVerified。
// controllers/flutter-user.php 4.18.4
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();
}
),
));
// controllers/flutter-base.php 4.18.4
public function checkApiPermission()
{
return isPurchaseCodeVerified();
}
// functions/index.php 4.18.4
function isPurchaseCodeVerified(){
return true;
// $random_key = get_option('mstore_active_random_key');
// $hash_code = get_option('mstore_active_hash_code');
// return md5('inspire@123%$'.$random_key) == $hash_code && strlen($code) > 0;
}
函数体第一行就是 return true,授权码比对的那段代码被留在注释里。
请求体由 firebase_sms_verify_id_token 从 php://input 读出,交给 verify_id_token。
// controllers/helpers/firebase-phone-auth-helper.php 4.18.4
public function verify_id_token($id_token){
$splitToken = explode(".", $id_token);
$headerBase64 = $splitToken[0];
$decodedHeader = json_decode(urldecode(base64_decode($headerBase64)), 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;
}
$payloadBase64 = $splitToken[1];
$decodedPayload = json_decode(urldecode(base64_decode($payloadBase64)), true);
$projectId = $json['project_id'];
if (!isset($decodedPayload['aud']) || $decodedPayload['aud'] !== $projectId) {
return false;
}
if (!isset($decodedPayload['iss']) || $decodedPayload['iss'] !== 'https://securetoken.google.com/'.$projectId) {
return false;
}
return isset($decodedPayload['phone_number']) ? trim($decodedPayload['phone_number']) : false;
// $splitToken[2] 全文没有再出现过
}
// 公开验证脚本构造的令牌,第三段是 base64 编码的字母 x
id_token = eyJhbGciOiJSUzI1NiIsImtpZCI6... . eyJhdWQiOi... . eA==
// 前两段凑齐 kid、aud、iss、phone_number 四处取值即可
explode 按点号把令牌切成三段,下标 0 和 1 之后就没人再往下取。
get_public_keys 拉 Google 公布的 x509 元数据,返回 array_keys 的结果,证书内容只被当成一串 kid 字符串比对。
project_id 读自站点管理员上传的那份 Firebase 配置,aud 与 iss 要跟它一致,这两项需要预先知道。
四项都过,函数就把那个手机号交回去,第三段的内容不影响结果。
拿到手机号后,firebase_sms_login_v2 去元信息 registered_phone_number 查人,命中了就签发登录 Cookie。
// controllers/flutter-user.php 4.18.4 firebase_sms_login_v2 片段
$args = array('meta_key' => 'registered_phone_number', 'meta_value' => $phone);
$search_users = get_users($args);
if (empty($search_users)) {
$domain = $_SERVER['SERVER_NAME'];
$user = get_user_by('email', $phone . "@" . $domain);
if (!$user) {
return parent::sendError("invalid_login", "User does not exist", 400);
}
}
$user = $search_users[0];
$cookie = generateCookieByUserId($user->ID);
$response['cookie'] = $cookie;
$response['user'] = $this->getResponseUserInfo($user);
// functions/index.php 4.18.4
function generateCookieByUserId($user_id, $seconds = 1209600){
$expiration = time() + 365 * DAY_IN_SECONDS;
$cookie = wp_generate_auth_cookie($user_id, $expiration, 'logged_in');
return $cookie;
}
第二个参数收进来就放着没用,有效期在下一行写死成 365 天,发出去的是 WordPress 登录态 Cookie。
另一条路径 firebase_sms 落在 firebase_sms_login,最后走到 createSocialAccount,手机号拼一个邮箱交给 wp_insert_user。
userdata 数组里不带 role 键,新建账号继承站点默认角色。
4.21.1 换成了 firebase/php-jwt 的 JWT::decode。
// controllers/helpers/firebase-phone-auth-helper.php 4.21.1
$key_objects = [];
foreach ($public_keys as $kid => $key) {
$key_objects[$kid] = new Key($key, 'RS256');
}
$decoded_token = JWT::decode($id_token, $key_objects);
if ($decoded_token->aud !== $actual_project_id) {
return new WP_Error('firebase_auth_error', 'Invalid token audience.', array('status' => 401));
}
if (!isset($decoded_token->auth_time) || $decoded_token->auth_time > time()) {
return new WP_Error('firebase_auth_error', 'Invalid auth_time.', array('status' => 401));
}
return trim($decoded_token->phone_number);
公钥按 kid 组装成 Key 对象交给库,验签连同过期时间与生效时间一起交给 JWT::decode 处理。
补丁里还留了一段注释。旧版本的 urldecode 先把号码里的加号转成空格,随后 trim 又把首尾清掉,老版本建的账号只剩纯数字。
新版按标准 base64url 解码,加号保留下来,查号时带加号和不带加号两种写法各试一遍,回头客不会再被当成新号。
三、修复建议
MStore API 升到 4.21.1 或更高