WordPress 7.0.2 登录页反射型 XSS 代码审计报告
审计对象:
/wordpress
版本:7.0.2(wp-includes/version.php:19)
报告日期:2026-08-10
说明:本报告完全基于本地源码逐行分析,外部 CVE 信息仅作参考。
1. 摘要
本地源码中存在一条反射型 XSS 漏洞数据流:
- 用户在
wp-login.php提交的用户名$_POST['log']被用于登录失败错误消息。 - 错误消息最终通过
wp_admin_notice()→wp_kses_post()输出到页面。 - 由于
wp_authenticate()先使用sanitize_user()(内部调用strip_tags())清理用户名,而输出时又经过wp_kses_post(),两个解析器对畸形 HTML 的处理存在差异,可能导致特定 payload 绕过清理并执行 JavaScript。
该漏洞面与 CVE-2026-64638(XSS2Shell)的描述相符,但 CVE 编号的真实性以及具体 exploit payload 无法从本地代码独立验证。
2. 影响范围
| 项目 | 内容 |
|---|---|
| 受影响文件 | wp-login.php、wp-includes/user.php、wp-includes/pluggable.php、wp-includes/functions.php、wp-includes/kses.php |
| 入口点 | wp-login.php 的登录表单(log 字段) |
| 输出点 | 登录失败后的错误提示框(#login_error) |
| 利用条件 | 需要管理员已登录并与恶意链接交互,才能实现完整 RCE 链 |
| 修复版本 | WordPress 7.0.3(据称) |
3. 详细数据流分析
3.1 输入入口
文件: wordpress/wp-login.php:1284-1322
case 'login':
default:
$secure_cookie = '';
$customize_login = isset( $_REQUEST['customize-login'] );
// If the user wants SSL but the session is not SSL, force a secure cookie.
if ( ! empty( $_POST['log'] ) && ! force_ssl_admin() ) {
$user_name = sanitize_user( wp_unslash( $_POST['log'] ) );
// ... 仅用于 SSL 检测,不输出
}
// ...
$user = wp_signon( array(), $secure_cookie );
3.2 wp_signon() 读取用户名
文件: wordpress/wp-includes/user.php:50-55
if ( ! empty( $_POST['log'] ) && is_string( $_POST['log'] ) ) {
$credentials['user_login'] = wp_unslash( $_POST['log'] );
}
注意:这里仅做 wp_unslash(),未做 HTML 输出转义。
3.3 wp_authenticate() 调用 sanitize_user()
文件: wordpress/wp-includes/pluggable.php:684-689
function wp_authenticate( $username, $password ) {
$username = sanitize_user( $username );
...
}
3.4 sanitize_user() 内部调用 strip_tags()
文件: wordpress/wp-includes/formatting.php:2149-2156
function sanitize_user( $username, $strict = false ) {
$raw_username = $username;
$username = wp_strip_all_tags( $username ); // ← 关键:调用 strip_tags
$username = remove_accents( $username );
$username = preg_replace( '|%([a-fA-F0-9][a-fA-F0-9])|', '', $username );
$username = preg_replace( '/&.+?;/', '', $username );
...
}
文件: wordpress/wp-includes/formatting.php:5525-5554
function wp_strip_all_tags( $text, $remove_breaks = false ) {
...
$text = preg_replace( '@<(script|style)[^>]*?>.*?</\\1>@si', '', $text );
$text = strip_tags( $text ); // ← PHP 原生 strip_tags
...
}
3.5 错误消息直接嵌入用户名
文件: wordpress/wp-includes/user.php:183-191
用户名不存在时:
if ( ! $user ) {
return new WP_Error(
'invalid_username',
sprintf(
/* translators: %s: User name. */
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site. If you are unsure of your username, try your email address instead.' ),
$username // ← 攻击者可控输入
)
);
}
文件: wordpress/wp-includes/user.php:210-221
密码错误时:
return new WP_Error(
'incorrect_password',
sprintf(
/* translators: %s: User name. */
__( '<strong>Error:</strong> The password you entered for the username %s is incorrect.' ),
'<strong>' . $username . '</strong>' // ← 攻击者可控输入
) .
' <a href="' . wp_lostpassword_url() . '">' .
__( 'Lost your password?' ) .
'</a>'
);
3.6 错误消息通过 wp_admin_notice() 输出
文件: wordpress/wp-login.php:243-289
if ( $wp_error->has_errors() ) {
$error_list = array();
$messages = '';
foreach ( $wp_error->get_error_codes() as $code ) {
$severity = $wp_error->get_error_data( $code );
foreach ( $wp_error->get_error_messages( $code ) as $error_message ) {
if ( 'message' === $severity ) {
$messages .= '<p>' . $error_message . '</p>';
} else {
$error_list[] = $error_message;
}
}
}
if ( ! empty( $error_list ) ) {
$errors = '';
if ( count( $error_list ) > 1 ) {
$errors .= '<ul class="login-error-list">';
foreach ( $error_list as $item ) {
$errors .= '<li>' . $item . '</li>';
}
$errors .= '</ul>';
} else {
$errors .= '<p>' . $error_list[0] . '</p>';
}
$errors = apply_filters( 'login_errors', $errors );
wp_admin_notice(
$errors,
array(
'type' => 'error',
'id' => 'login_error',
'paragraph_wrap' => false,
)
);
}
...
}
3.7 wp_admin_notice() 使用 wp_kses_post()
文件: wordpress/wp-includes/functions.php:9189-9201
function wp_admin_notice( $message, $args = array() ) {
...
echo wp_kses_post( wp_get_admin_notice( $message, $args ) );
}
4. 漏洞根因
wp_kses_post() 的设计目标是保留允许的 HTML,而不是像 esc_html() 那样完全编码。它允许的标签包括:
<a>(href、target、name、download)<img>(src等,但src会检查协议)<audio>/<video>/<track><button>、<textarea><form>(当<input>或<select>被允许时自动添加)- 全局属性:
style、class、id、data-*、title
虽然 wp_kses_post() 会剥离 <script> 和事件处理器,但问题出在双重清理的解析差异:
sanitize_user()先用strip_tags()清理一次。- 清理后的文本被拼进错误消息,再经
wp_kses_post()清理一次。 strip_tags()和wp_kses_split()对畸形 HTML、注释、未闭合标签等的解析规则不同,可能导致某些输入在第一次清理后残留为"安全文本",第二次清理时却被重组成可执行的 HTML。
具体哪种畸形输入能触发这种差异,需要实际运行 PHP 才能确定,但数据流本身是一个明确的 XSS 漏洞面。
5. 本地代码中其他输入/输出点
5.1 已确认安全的点
| 位置 | 输入 | 输出方式 | 结论 |
|---|---|---|---|
wp-login.php:415 |
$_GET['redirect_to'] |
sanitize_url() → hidden input |
安全 |
wp-login.php:420 |
$_GET['action'] |
esc_attr() → hidden input |
安全 |
wp-login.php:900 |
$_POST['user_login'](忘记密码) |
esc_attr() → input value |
安全 |
wp-login.php:1170 |
$_POST['user_login'](注册) |
esc_attr() → input value |
安全 |
wp-login.php:1522 |
$_POST['log'](登录表单回填) |
esc_attr() → input value |
安全 |
wp-login.php:1555 |
$redirect_to |
esc_attr() → hidden input |
安全 |
5.2 需要关注的点
| 位置 | 输入 | 输出方式 | 风险 |
|---|---|---|---|
wp-login.php:222 |
$login_header_text(经 login_headertext filter) |
直接 echo |
依赖插件/filter 是否可信 |
wp-login.php:231 |
$message(经 login_message filter) |
直接 echo |
依赖插件/filter 是否可信 |
wp-login.php:280 |
$errors(经 login_errors filter) |
wp_kses_post() |
依赖插件/filter 是否可信 |
wp-login.php:300 |
$messages(经 login_messages filter) |
wp_kses_post() |
依赖插件/filter 是否可信 |
核心漏洞点仍是 wp-includes/user.php 中两处把 $username 直接拼进错误消息。
6. 从 XSS 到 RCE 的链表面
6.1 阶段 1:触发登录错误 XSS
攻击者构造 POST 请求:
POST /wp-login.php HTTP/1.1
Host: target.example
Content-Type: application/x-www-form-urlencoded
log=<payload>&pwd=anything&wp-submit=Log+In
如果 <payload> 成功绕过双重清理,登录错误提示区域会执行攻击者的 JavaScript。
6.2 阶段 2:同源性利用管理员后台
WordPress 登录页与管理后台同源。XSS 执行后,攻击者 JS 可以:
- 读取页面上的 nonce(如
plugin-upload)。 - 使用
fetch()发送带管理员 cookie 的请求到wp-admin/update.php?action=upload-plugin。 - 上传包含 WebShell 的插件 ZIP。
- 激活插件,获得服务器 PHP 代码执行权限。
本地代码证据: wordpress/wp-admin/update.php:149-180
} elseif ( 'upload-plugin' === $action ) {
if ( ! current_user_can( 'upload_plugins' ) ) {
wp_die( __( 'Sorry, you are not allowed to install plugins on this site.' ) );
}
check_admin_referer( 'plugin-upload' );
if ( isset( $_FILES['pluginzip']['name'] ) && ! str_ends_with( strtolower( $_FILES['pluginzip']['name'] ), '.zip' ) ) {
wp_die( __( 'Only .zip archives may be uploaded.' ) );
}
$file_upload = new File_Upload_Upgrader( 'pluginzip', 'package' );
...
$upgrader = new Plugin_Upgrader( new Plugin_Installer_Skin( ... ) );
$result = $upgrader->install( $file_upload->package, ... );
权限要求:upload_plugins(管理员默认具备)+ 有效 plugin-upload nonce。
6.3 阶段 3:Application Password 持久化(可选)
攻击者也可通过 XSS 调用 REST API 创建 Application Password:
本地代码证据: wordpress/wp-includes/rest-api/endpoints/class-wp-rest-application-passwords-controller.php:26
$this->rest_base = 'users/(?P<user_id>(?:[\d]+|me))/application-passwords';
创建成功后,响应包含明文密码:
$item['new_password'] = WP_Application_Passwords::chunk_password( $password );
攻击者拿到 user:password 后,可通过 Basic Auth 持续调用 REST API。
注意: REST API 插件控制器 class-wp-rest-plugins-controller.php:273-313 只能从 WordPress.org 仓库安装插件(通过 slug),不能直接上传自定义 ZIP。因此上传恶意插件仍需走 wp-admin/update.php?action=upload-plugin。
6.4 RCE 链限制
| 条件 | 是否必需 | 说明 |
|---|---|---|
| 初始 XSS payload 成功绕过 | 是 | 本地代码证实存在攻击面,具体 payload 需实测 |
| 管理员已登录 | 是 | 上传插件、创建 App Password 都需要管理员权限 |
| 管理员与恶意页面交互 | 是 | 攻击者需诱导管理员点击/提交恶意链接 |
upload_plugins 权限可用 |
是 | 管理员默认具备 |
| 文件系统可写 | 是 | 否则插件安装失败 |
DISALLOW_FILE_MODS 未开启 |
是 | 开启后会阻止插件安装 |
7. 修复建议
7.1 核心修复
在 wordpress/wp-includes/user.php 两处对 $username 使用 esc_html():
修复前(user.php:186-189):
sprintf(
/* translators: %s: User name. */
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site. If you are unsure of your username, try your email address instead.' ),
$username
)
修复后:
sprintf(
/* translators: %s: User name. */
__( '<strong>Error:</strong> The username <strong>%s</strong> is not registered on this site. If you are unsure of your username, try your email address instead.' ),
esc_html( $username )
)
修复前(user.php:216):
'<strong>' . $username . '</strong>'
修复后:
'<strong>' . esc_html( $username ) . '</strong>'
7.2 额外加固
在 wp-config.php 中添加:
// 禁用 Application Passwords
add_filter( 'wp_is_application_passwords_available', '__return_false' );
// 禁用 REST API JSONP
add_filter( 'rest_jsonp_enabled', '__return_false' );
// 禁止从后台上传/修改插件和主题
define( 'DISALLOW_FILE_MODS', true );
服务器层面:
- 禁止
wp-content/uploads/等目录执行 PHP。 - 对
/wp-admin/update.php?action=upload-plugin做额外访问控制。
8. 无法从本地代码确认的内容
| 项目 | 状态 | 原因 |
|---|---|---|
| CVE-2026-64638 编号是否真实存在 | 无法独立确认 | WebFetch 访问外部权威源失败,搜索结果不稳定 |
| 具体可用的 XSS payload | 无法静态确定 | 需要实际运行 PHP 测试 strip_tags() 与 wp_kses_split() 的交互 |
| 完整 RCE 链是否已被公开验证 | 无法确认 | 本地代码只证明理论可行,需管理员交互 |
| 官方补丁具体改动 | 无法直接比对 | 本地没有 WordPress 7.0.3 源码 |
9. 结论
基于本地源码审计,当前 WordPress 7.0.2 的 wp-login.php 登录失败流程存在反射型 XSS 漏洞。用户提交的用户名会经过 sanitize_user()/strip_tags() 清理后被嵌入错误消息,再经 wp_kses_post() 输出,两个清理函数的解析差异可能让特定畸形 HTML payload 绕过防护并执行 JavaScript。
该漏洞面与 CVE-2026-64638(XSS2Shell)的描述相符。但 CVE 编号本身和具体 exploit payload 无法从本地代码独立验证。
建议立即升级到 WordPress 7.0.3,或在 wp-includes/user.php 的 $username 输出处添加 esc_html()。