12 KiB
SecurityEngineer — 幽灵 spec 安全审计报告
文件路径基于 /Users/bigemon/WorkSpace/vr-shopxo-plugin/
审计时间:2026-04-20
审计范围:AdminGoodsSaveHandle.php、SeatSkuService.php、ticket_detail.html、Admin.php、AdminGoodsSave.php
S1. AdminGoodsSaveHandle 脏数据拒绝逻辑
S1-Q1:当 template_id 指向不存在的场馆时,是否拒绝保存(code -401)?
结论:否 — 脏数据被静默保存,存在 P1 安全缺陷。
根因分析:
-
保存时
save_thing_end流程(AdminGoodsSaveHandle.php:158-173):foreach ($configs as $config) { $templateId = intval($config['template_id'] ?? 0); if ($templateId > 0) { $res = SeatSkuService::BatchGenerate(...); if ($res['code'] !== 0) { return $res; // ← 仅在此处返回错误 } } // templateId == 0 时:整个循环体被跳过,什么都不做 }关键:当
template_id硬编码为某个已删除模板的 ID(整数,如5)时,intval($config['template_id'] ?? 0)返回5,templateId > 0为true,代码进入BatchGenerate调用。 -
BatchGenerate 内部有模板存在性校验(SeatSkuService.php:52-57):
$template = Db::name(self::table('seat_templates'))->where('id', $seatTemplateId)->find(); if (empty($template)) { return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"]; }返回
code: -2,但AdminGoodsSaveHandle.php:169只检查!== 0:if ($res['code'] !== 0) { return $res; // -2 !== 0 → 确实返回错误 }所以
BatchGenerate返回-2时,错误确实被向上传播,保存被拒绝。 -
但
save_thing_end在返回错误之前,已将修改后的 config 写回 DB(AdminGoodsSaveHandle.php:148-150):Db::name('Goods')->where('id', $goodsId)->update([ 'vr_goods_config' => json_encode($configs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), ]);此写回发生在
foreach ($configs as $config)循环(第 77 行)之后,即template_snapshot已被处理。问题是:对于无效模板的 config 块,template_snapshot不会被重建(跳过第 88-90 行的continue),但旧的template_snapshot仍然保留在内存的$config中,随后被写回 DB。P1 缺陷:当模板不存在时,
template_snapshot不被清理。即使BatchGenerate返回错误阻止了保存,vr_goods_config中已含有一个指向不存在模板的配置块,且其template_snapshot仍保留旧的座位图数据。 -
另一个路径(SeatSkuService.php:55-57):如果模板记录被物理删除,
find()返回null,BatchGenerate返回-2并阻止保存。但如果 config 中的template_id为0(intval(null)或前端传空),则templateId > 0为false,循环体完全跳过,vr_goods_config被写回时没有任何校验。
S1-Q2:幽灵 spec 的产生环节
幽灵 spec 产生于 vr_goods_config 的 spec_base_id_map 字段。分析如下:
spec_base_id_map存储在vr_seat_templates.spec_base_id_map表字段中(Admin.php:177)- 当前端编辑含
vr_goods_config的商品时,save_thing_end加载 config 后,遍历每个 config 块:- 如果
template_id有效 →BatchGenerate重新生成所有 SKU - 如果
template_id无效(0 或已删除)→ 跳过BatchGenerate,config 块原样写回 DB
- 如果
幽灵 spec 不会被过滤,因为保存逻辑中没有针对无效 template_id 配置块的过滤/清理逻辑。
S2. 脏数据注入路径分析
S2-Q1:幽灵 spec 是否可被恶意利用来注入/覆盖商品规格?
结论:理论风险存在(中等),但需管理员权限利用。
攻击路径:
-
通过
vr_goods_config_base64参数注入(AdminGoodsSaveHandle.php:29-35):$base64Config = $postParams['vr_goods_config_base64'] ?? ''; if (!empty($base64Config)) { $jsonStr = base64_decode($base64Config); if ($jsonStr !== false) { $params['data']['vr_goods_config'] = $jsonStr; } }前端表单不暴露
vr_goods_config_base64输入框,所以普通用户在标准编辑流程中无法注入。 -
ShopXO API 直接提交:任何已登录的管理员可以直接 POST 到商品保存 API,携带恶意
vr_goods_config_base64,注入任意 JSON 到vr_goods_config字段。 -
注入的内容:
- 多个 config 块引用同一个
template_id(重复模板) - 引用已删除模板的
template_id template_snapshot中注入任意字符串(虽然后端会重建,但若模板不存在则保留)
- 多个 config 块引用同一个
-
save_thing_end对vr_goods_config的处理(AdminGoodsSaveHandle.php:61-66):从 DB 读取vr_goods_config,不使用前端传入的$data['vr_goods_config'](除非 DB 为空)。这意味着即使用户在save_handle时注入了恶意 config,save_thing_end仍然基于数据库中的已有配置执行,不会直接使用注入值。但是:若 DB 中已存在含幽灵 spec 的
vr_goods_config(由于之前的保存或注入),save_thing_end会加载并处理它。
S2-Q2:前端 fallback 安全风险
结论:存在低-中风险(信息泄露 + CSS 注入),无直接 XSS。
-
ticket_detail.html是顾客端页面(非管理后台),查看源码:seatMap=<?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>(第 186 行)specBaseIdMap=<?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>(第 187 行)
-
硬删除场景下的数据流(SeatSkuService.php:380-393):
if (empty($seatTemplate)) { $config['template_id'] = null; $config['template_snapshot'] = null; Db::name('Goods')->where('id', $goodsId)->update([ 'vr_goods_config' => json_encode([$config], ...), ]); return [ 'vr_seat_template' => null, // ← 模板数据为空 'goods_spec_data' => [], 'goods_config' => $config, ]; }当模板被硬删除后,
vr_seat_template返回null,seatMap和specBaseIdMap在前端均为空数组[]。座位图不会渲染。前端 fallback 设计正确。 -
但
AdminGoodsSaveHandle.php:148-150写回脏数据时,template_snapshot未被清理,若前端访问到一个旧的 snapshot(来自数据库中残留的配置),seatMap包含旧座位数据,此时:renderSeatMap()第 270 行:style="background:'+color+'"— color 值来自后端 DB,若 DB 被攻陷(通过 VenueSave 注入),可注入 CSS 表达式如url(javascript:...)(现代浏览器已防护)renderSeatMap()第 275 行:data-label属性 — 值来自seatInfo.label,经过htmlspecialchars(ShopXO 输出编码),基本安全renderSeatMap()第 275 行:data-label="'rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"— 硬编码的纯字母数字,无注入风险
-
硬编码拼接中的潜在属性注入(ticket_detail.html:275):
data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"此处
colIndex+1是 JS 计算值,无注入风险。rowLabel来自map.row_labels或chr(65+index),也是纯字母,无注入风险。 -
submit函数的 spec_base_id(ticket_detail.html:417):var specBaseId = self.specBaseIdMap[seat.seatKey] || self.sessionSpecId;若
specBaseIdMap为空,降级到sessionSpecId。理论上可操控座位图数据来选择任意座位,但购买还需付款环节验证。风险有限。
S3. ShopXO 商品保存入口
AdminGoodsSave.php — 入口文件,只注册钩子,无额外校验逻辑。
S4. 严重性分级
| # | 风险描述 | 严重性 | 根因位置 |
|---|---|---|---|
| P1-1 | 模板不存在时,template_snapshot 未被清理就直接写回 DB,脏配置持续存在 |
P1 | AdminGoodsSaveHandle.php:148-150(硬删除后未清理 config 块) |
| P1-2 | template_id=0 时整个 config 块无校验直接写回,任何人都能保存空规格商品 |
P1 | AdminGoodsSaveHandle.php:158-173(templateId == 0 时跳过所有处理) |
| P2-1 | 管理员可通过 API 直接注入 vr_goods_config_base64 写入任意配置 |
P2 | AdminGoodsSaveHandle.php:29-35(无 schema 校验) |
| P2-2 | 硬删除模板后,前端 fallback 依赖 DB 中残留的 template_snapshot(信息泄露) |
P2 | AdminGoodsSaveHandle.php:148-150(写回时未过滤无效 config) |
| P2-3 | submit 依赖 specBaseIdMap(空时降级 sessionSpecId),无端侧验证 |
P2 | ticket_detail.html:417(需配合支付侧校验) |
S5. 修复建议
P1-1/P1-2 修复(必须)
AdminGoodsSaveHandle.php:158-173,修改后:
foreach ($configs as $config) {
$templateId = intval($config['template_id'] ?? 0);
if ($templateId <= 0) {
// 无效 template_id:拒绝保存,返回错误
return ['code' => -401, 'msg' => '票务配置中的 template_id 无效或已被删除,请重新选择场馆'];
}
// 验证模板在 DB 中存在
$exists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
if (empty($exists)) {
return ['code' => -401, 'msg' => '票务配置中的 template_id [' . $templateId . '] 指向的场馆已不存在,请重新选择'];
}
$res = SeatSkuService::BatchGenerate(...);
if ($res['code'] !== 0) {
return $res;
}
}
同时在循环之前(写回 DB 之前),过滤掉 template_id <= 0 的 config 块:
// 过滤无效 config 块(template_id 为空或 0)
$validConfigs = array_filter($configs, function($c) {
return intval($c['template_id'] ?? 0) > 0;
});
if (empty($validConfigs)) {
return ['code' => -401, 'msg' => '票务商品必须包含至少一个有效场馆配置'];
}
P2-1 修复(建议)
在 save_handle 时(AdminGoodsSaveHandle.php:29-35),对 vr_goods_config_base64 做 schema 校验:
- 解码后必须是 JSON 数组
- 每个 config 块的
template_id必须是正整数 - 禁止传入
template_snapshot(应始终由后端从 DB 重建)
P2-2 修复(建议)
在 save_thing_end 写回 DB 之前,清理无效模板的 config 块:
// 写回之前:清理无效 config
$validConfigs = [];
foreach ($configs as $config) {
$templateId = intval($config['template_id'] ?? 0);
if ($templateId > 0) {
$templateExists = Db::name('vr_seat_templates')->where('id', $templateId)->find();
if (!empty($templateExists)) {
$validConfigs[] = $config;
}
}
}
Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode($validConfigs, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
P2-3 修复(建议)
ticket_detail.html 的 submit 函数中,对 spec_base_id 增加服务器端校验(非本文档范围,需在支付 API 入口添加)。
总结
| 风险等级 | 数量 | 说明 |
|---|---|---|
| P1 | 2 | 脏数据未拒绝,直接影响数据完整性和商品保存正确性 |
| P2 | 3 | 注入风险低(需管理员权限)、信息泄露、缺少校验 |
| 低 | 0 | 无直接 XSS(后端输出有编码保护) |
核心 P1 缺陷:当 template_id 指向不存在的场馆时,系统不拒绝保存,而是静默保留旧的 template_snapshot,导致幽灵 spec 持续存在于数据库中。这是用户遇到「规格不允许重复」错误的根本原因(配置块未清理,残留的 spec_base_id_map 数据与新生成的 SKU 产生冲突)。