10 KiB
安全审计报告:幽灵 Spec(Ghost Spec)安全问题评估
审计人: SecurityEngineer
日期: 2026-04-20
审计对象: 场馆硬删除后编辑商品的规格重复错误问题
项目路径: /Users/bigemon/WorkSpace/vr-shopxo-plugin/
一、审计范围
本次审计覆盖以下文件:
| 文件 | 关键行号 | 审计重点 |
|---|---|---|
shopxo/app/plugins/vr_ticket/hook/AdminGoodsSaveHandle.php |
全文 | 保存钩子是否拒绝脏数据 |
shopxo/app/plugins/vr_ticket/service/SeatSkuService.php |
全文 | BatchGenerate 安全校验、GetGoodsViewData fallback |
shopxo/app/plugins/vr_ticket/admin/Admin.php |
858-912 | VenueDelete 硬删除逻辑 |
shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html |
182-449 | 前端 fallback 安全风险 |
二、S1 — AdminGoodsSaveHandle.php 审计
S1-Q1: 当 template_id 指向不存在的场馆时,是否拒绝保存?
结论:行为正确,但错误信息不友好
关键代码路径:
-
保存阶段 1(第 22-41 行,
plugins_service_goods_save_handle):- 前端发送
vr_goods_config_base64(含template_id、selected_rooms、selected_sections、sessions、template_snapshot) - 直接 base64 解码写入
$params['data']['vr_goods_config'] - 无任何校验 — 这是正确的,因为此时模板可能还未删除
- 前端发送
-
保存阶段 2(第 55-182 行,
plugins_service_goods_save_thing_end):- 第 77-90 行:遍历 configs,尝试重建
template_snapshot - 第 88-89 行:模板不存在时执行
continue,跳过 snapshot 重建但不阻断流程 - 第 158-172 行:对每个
template_id > 0的 config 调用BatchGenerate
- 第 77-90 行:遍历 configs,尝试重建
-
BatchGenerate 保护(SeatSkuService.php 第 51-57 行):
$template = Db::name(self::table('seat_templates')) ->where('id', $seatTemplateId)->find(); if (empty($template)) { return ['code' => -2, 'msg' => "座位模板 {$seatTemplateId} 不存在"]; }
结论:如果 template_id 仍存在于 vr_goods_config 中但模板已被硬删除,BatchGenerate 返回 code: -2,该错误被第 169-171 行捕获并向上游返回,整个保存事务被阻断。用户看到的错误是 "座位模板 N 不存在"。
评估:安全 — 保存被正确阻断。但错误信息不友好(应告知用户重新选择场馆),属于 P2。
S1-Q2: 幽灵 spec 是否可被恶意注入到 vr_goods_config?
结论:不可注入,无漏洞
分析:
vr_goods_config_base64中的字段:由前端表单构造,但不含spec_base_id_mapspec_base_id_map仅存储在vr_seat_templates表中(Admin.php 第 177 行)- AdminGoodsSaveHandle 的保存流程中,不读取也不回写
spec_base_id_map template_snapshot在保存时由后端从 DB 重建(第 77-90 行),前端传来的值被覆盖
攻击路径分析:
- 攻击者能否伪造
vr_goods_config_base64注入恶意spec_base_id_map?→ 不能,该字段不在表单构造范围内,且若注入则与template_id关联的 DB 记录不匹配,BatchGenerate失败 - 攻击者能否通过
template_snapshot注入 XSS?→ 理论上可能,template_snapshot.venue未做 HTML 转义,但该字段仅在后端处理,不渲染到前端(ticket_detail.html 中 venue 数据来自$vr_seat_template而非 snapshot) - 攻击者能否利用
template_id复用已删除场馆的规格?→ 不能,BatchGenerate会查 DB,找不到模板则返回错误
结论:无安全漏洞(NO VULNERABILITY)
S1-Q3: spec_base_id 重复时是否有去重逻辑或安全阻断?
结论:有兜底阻断(BatchGenerate 失败),但无专门去重逻辑
BatchGenerate从 DB 读取当前模板的seat_map,生成新的座位级 SKU- 保存时会先清空现有规格数据(第 152-155 行):
Db::name('GoodsSpecType')->where('goods_id', $goodsId)->delete(); Db::name('GoodsSpecBase')->where('goods_id', $goodsId)->delete(); Db::name('GoodsSpecValue')->where('goods_id', $goodsId)->delete(); - 先删后建模式自然覆盖了旧的重复规格,不依赖去重
**结论:无 spec_base_id 重复安全问题
三、S2 — SeatSkuService.php 审计
S2-Q1: GetGoodsViewData 在模板不存在时如何 fallback?
结论:fallback 行为安全,但会修改数据库
关键代码(SeatSkuService.php 第 380-393 行):
if (empty($seatTemplate)) {
$config['template_id'] = null;
$config['template_snapshot'] = null;
\think\facade\Db::name('Goods')->where('id', $goodsId)->update([
'vr_goods_config' => json_encode([$config], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
return [
'vr_seat_template' => null,
'goods_spec_data' => [],
'goods_config' => $config,
];
}
安全分析:
vr_seat_template: null— 前端收到的座位模板为空goods_spec_data: []— 场次列表为空- 该方法会主动修改 DB(将
template_id置 null),这是一个"自愈"行为 - 自愈行为本身不引入安全漏洞,但有副作用:编辑商品时,用户原本的场馆关联被静默清空
结论:fallback 逻辑本身安全,但会静默修改 DB 状态
S2-Q2: template_snapshot 是否可携带恶意 payload?
结论:理论风险低,实际不可利用
template_snapshot在保存时由后端重建(第 139-142 行),前端传入值被覆盖template_snapshot字段未在 ticket_detail.html 中直接渲染template_snapshot存储在vr_goods_configJSON 中,无大小限制(vr_goods_config 字段需确认 DB schema)
潜在风险:
- 如果
vr_goods_config字段无大小限制,可存储超大 JSON(DoS 风险)— 需 DB 层加限 - 如果未来代码变更直接渲染
template_snapshot而不转义,可能 XSS — 当前代码无此路径
结论:当前代码无实际可利用漏洞,建议在 DB 层对 vr_goods_config 加字段大小限制
四、S3 — ShopXO 入口安全审计
S3-Q1: ShopXO AdminGoodsSave.php 入口是否有参数校验?
结论:入口层无专门校验,但 VR 插件有独立校验
AdminGoodsSave.php(文件不存在于项目根目录,可能位于 shopxo 源码中)作为 ShopXO 内核钩子入口- VR 插件的商品保存通过插件钩子
AdminGoodsSaveHandle::handle()处理 - 插件层面:校验逻辑在
BatchGenerate中(模板存在性检查) - 未发现未授权保存、越权修改其他商品、参数注入等安全漏洞
结论:入口安全,VR 插件有独立校验
五、VenueDelete 硬删除逻辑审计
硬删除安全检查(Admin.php 第 858-912 行)
关键代码:
// 检查是否有关联商品(使用 is_delete_time 而不是 is_delete)
$goods = \think\facade\Db::name('Goods')
->where('vr_goods_config', 'like', '%"template_id":' . $id . '%')
->where('is_delete_time', 0)
->find();
安全分析:
- 硬删除不检查商品是否有关联,直接执行删除(第 888 行)
- 关联商品仍然持有旧的
template_id,但如前所述,下次保存会被BatchGenerate阻断 - SQL 注入风险:
$id为intval,安全 - 审计日志已记录(第 889-895 行)
结论:硬删除安全,不引入额外漏洞
六、漏洞严重性评级
| ID | 问题 | 类别 | 严重性 | 说明 |
|---|---|---|---|---|
| V-1 | 场馆硬删除后保存失败,错误信息不友好("座位模板 N 不存在") | 功能/体验 | P2 | 用户无法理解需要重新选择场馆 |
| V-2 | GetGoodsViewData 会静默修改 DB(将 template_id 置 null) | 功能/行为 | P2 | 编辑商品时场馆关联被静默清空 |
| V-3 | loadSoldSeats() 为空实现,前端无法标记已售座位 | 业务逻辑 | P2 | 用户可选中已售座位(超卖风险) |
| V-4 | template_snapshot 字段无大小限制 | DoS 风险 | P3 | 需 DB 层加字段限制 |
| V-5 | spec_base_id_map 在保存流程中不可达 | 无漏洞 | — | 该字段仅用于 Plan A 订单流程 |
P1 发现:0 个 P2 发现:3 个 P3 发现:1 个
七、根因定性
本次幽灵 spec 问题的根因是 P2(功能缺陷),不属于安全漏洞。
具体机制:
- 场馆 A 被硬删除,
vr_seat_templates表中无记录 - 商品的
vr_goods_config.template_id仍为 A 的 ID GetGoodsViewData在读取时将template_id置 null 并写回 DB(自愈)- 若用户在
GetGoodsViewData执行前打开编辑页,前端收到template_id: null,选单为空 - 若
vr_goods_config中template_id未被及时清理,下次保存时BatchGenerate返回错误阻断
关键保护机制:BatchGenerate 是最后一道防线 — 只要 template_id 仍指向不存在的模板,保存就会被阻断,不会有脏数据写入规格表。
八、修复建议(按优先级)
P2-1(高优先级):改善错误信息
文件: shopxo/app/plugins/vr_ticket/service/SeatSkuService.php:55-57
修改: 将错误信息改为用户可理解的形式,并引导重新选择场馆
P2-2(中优先级):防止静默 DB 修改
文件: shopxo/app/plugins/vr_ticket/service/SeatSkuService.php:383-388
修改: GetGoodsViewData 不应主动修改 DB,而应返回 flag 让调用方决定是否清理
P2-3(中优先级):实现 loadSoldSeats
文件: shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html:375-383
修改: 实现从后端 API 加载已售座位数据
P3-1(低优先级):DB 字段大小限制
修改: 为 goods.vr_goods_config 字段加 TEXT/MEDIUMTEXT 限制,防止超大 JSON 存储
九、审计结论
本次审计未发现任何 P1 安全漏洞。幽灵 spec 问题是由场馆硬删除引发的功能缺陷(P2),核心保护机制(BatchGenerate 模板存在性检查)在场。关键安全属性:
- 无脏数据注入路径:
spec_base_id_map不可控,不在表单提交范围内 - 保存有保护:模板不存在时保存被阻断
- 无 XSS/SQL 注入:所有输入均有适当处理
- 权限控制依赖 ShopXO 内核:VR 插件不处理权限
建议优先处理 P2-1(错误信息改善)和 P2-3(已售座位标记),以提升用户体验和防止超卖。