Draft List plugin for WordPress(CVE-2026-9104)
前言
参考披露的报告复现
漏洞类型: 存储型跨站脚本 (Stored XSS)
影响组件: WordPress 的 Draft List 插件
影响版本: 所有 2.6.3 及更早的版本
所需权限: 需要具备作者 (Author) 或更高权限的账户才能利用
漏洞描述: 攻击者在草稿标题中注入的属性逃逸脚本,当被没有编辑权限的用户(如普通访客或订阅者)查看时,就会在其浏览器中执行
漏洞复现
环境搭建
旧版本下载地址:https://github.com/dartiss/draft-list/releases
在后台中导入插件并启用

主要使用的功能是页面以及草稿,默认都是创建的新的来测试
注意:页面需要发布,草稿要求看下面坑点
漏洞代码
漏洞的核心位于 inc/create-lists.php 文件中的 draft_list_generate_code 函数
第 388 行,在渲染列表替换 {{draft}} 标签时,代码包含如下逻辑
1 | if ( '' !== $draft_title ) { |
当普通访客或无编辑权限的用户( $can_edit = false )访问渲染了该草稿列表的页面时,代码会跳过 esc_html($draft) 的安全转义环节
插件渲染逻辑
过滤机制
inc/create-lists.php 文件中第 136 行
这意味着整个模板在替换 {{draft}} 之前就已经被 wp_kses() 过滤了
wp_kses() 会移除所有不在白名单中的 HTML 标签和属性
1 | $template = wp_kses( html_entity_decode( $template ), $allowed_list ); |
具体的白名单位于 draft_list_allowed_html() 函数中

验证模板
第 206 行验证模板是否包含 {{draft}} 标签
1 | if ( strpos( $template, '%draft%' ) === false && strpos( $template, '{{draft}}' ) === false ) { |
草稿日期判断(坑点)
第 333 行,判断
1 | if ( ( $post_created > $created ) && ( $post_modified > $modified ) ) { |
随后在第 341 行,只有当 $date_accept 为 true 时,才会将这篇草稿拼接到最终输出的 HTML 中
1 | if ( ( $date_accept ) && ( $enough_words ) && ( '' !== $draft_title ) && ... ) { |
问题就出在 $created 和 $modified 这两个变量的初始化上
让我们看看它们是怎么被处理的(第 210-235 行)
1 | $far_past = '2 January 1970'; |
如果我们在简码中没有传递 created 和 modified 参数,代码会将它们默认设置为 '2 January 1970' ,然后转换为格林威治标准时间
当在 WordPress 中创建一篇文章,但只保存为草稿,从未发布过
WordPress 的数据库( wp_posts 表)中,这篇草稿的 post_date 和 post_date_gmt 字段会是特殊值: 0000-00-00 00:00:00
在 PHP 中,字符串比较 '0000-00-00 00:00:00' > '1970-01-02 00:00:00' 的结果是 false
因为使用 > 比较两个纯字符串时,会严格按字典序(逐字符比较 ASCII 码)进行,不会自动转换为数字或日期
比较第一个字符:'0'(ASCII 48)与 '1'(ASCII 49),因为 48 < 49,所以 '0000...' 已经小于 '1970...'
因此,$date_accept 变成了 false
绕过这个日期检查很简单:修改草稿的状态
- 去后台找到草稿
- 点击 “发布” 它(让它拥有一个真实的
post_date)
- 点击 “发布” 它(让它拥有一个真实的
- 立刻 把它重新改回 “草稿” 状态
- 此时,数据库里这篇草稿就有了一个大于 1970 年的真实时间
输出位置
第 352 行
1 | if ( $list ) { |
利用链
WordPress 的区块编辑器 “短代码属性里直接嵌 HTML” 一直有兼容性问题,尤其是单双引号混用时,内容可能被重写、转块、或校验失败
这和公开的 Gutenberg 兼容性问题一致,可参考:https://github.com/WordPress/gutenberg/issues/13399
所以我们传入简码时,应该传实体编码后的模板
1 | [drafts template='<div title="{{draft}}">Hover Me</div>'] |
正常情况要能在页面看到 Hover Me


使用下面属性逃逸脚本测试
1 | "><svg onload="alert('XSS')"></svg> |

成功弹窗

查看源码

1 | <code>[drafts template='<div title="{{draft}}">Hover Me</div>']</code> |
POC
1 | [drafts template='<div title="{{draft}}">Hover Me</div>'] |