WordPress-CVE-2026-64638-audit-report

29次阅读
没有评论

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> 和事件处理器,但问题出在双重清理的解析差异:

  1. sanitize_user() 先用 strip_tags() 清理一次。
  2. 清理后的文本被拼进错误消息,再经 wp_kses_post() 清理一次。
  3. 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 可以:

  1. 读取页面上的 nonce(如 plugin-upload)。
  2. 使用 fetch() 发送带管理员 cookie 的请求到 wp-admin/update.php?action=upload-plugin。
  3. 上传包含 WebShell 的插件 ZIP。
  4. 激活插件,获得服务器 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()。

正文完
 0
评论(没有评论)