# 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 安全缺陷。** **根因分析**: 1. **保存时 `save_thing_end` 流程**(AdminGoodsSaveHandle.php:158-173): ```php 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` 调用。 2. **BatchGenerate 内部有模板存在性校验**(SeatSkuService.php:52-57): ```php $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`**: ```php if ($res['code'] !== 0) { return $res; // -2 !== 0 → 确实返回错误 } ``` 所以 `BatchGenerate` 返回 `-2` 时,**错误确实被向上传播**,保存被拒绝。 3. **但 `save_thing_end` 在返回错误之前,已将修改后的 config 写回 DB**(AdminGoodsSaveHandle.php:148-150): ```php 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` 仍保留旧的座位图数据。 4. **另一个路径**(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 是否可被恶意利用来注入/覆盖商品规格? **结论:理论风险存在(中等),但需管理员权限利用。** **攻击路径**: 1. **通过 `vr_goods_config_base64` 参数注入**(AdminGoodsSaveHandle.php:29-35): ```php $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` 输入框,所以普通用户在标准编辑流程中无法注入。 2. **ShopXO API 直接提交**:任何已登录的管理员可以直接 POST 到商品保存 API,携带恶意 `vr_goods_config_base64`,注入任意 JSON 到 `vr_goods_config` 字段。 3. **注入的内容**: - 多个 config 块引用同一个 `template_id`(重复模板) - 引用已删除模板的 `template_id` - `template_snapshot` 中注入任意字符串(虽然后端会重建,但若模板不存在则保留) 4. **`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。** 1. **`ticket_detail.html` 是顾客端页面**(非管理后台),查看源码: - `seatMap` = ``(第 186 行) - `specBaseIdMap` = ``(第 187 行) 2. **硬删除场景下的数据流**(SeatSkuService.php:380-393): ```php 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 设计正确**。 3. **但 `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)+'座"` — 硬编码的纯字母数字,无注入风险 4. **硬编码拼接中的潜在属性注入**(ticket_detail.html:275): ```html data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" ``` 此处 `colIndex+1` 是 JS 计算值,**无注入风险**。`rowLabel` 来自 `map.row_labels` 或 `chr(65+index)`,也是纯字母,**无注入风险**。 5. **`submit` 函数的 spec_base_id**(ticket_detail.html:417): ```javascript 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**,修改后: ```php 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 块: ```php // 过滤无效 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 块: ```php // 写回之前:清理无效 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 产生冲突)。