vr-shopxo-plugin/reviews/SecurityEngineer-on-GhostSp...

246 lines
12 KiB
Markdown
Raw Permalink Normal View History

# 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` = `<?php echo json_encode($vr_seat_template['seat_map'] ?? []); ?>`(第 186 行)
- `specBaseIdMap` = `<?php echo json_encode($vr_seat_template['spec_base_id_map'] ?? []); ?>`(第 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 产生冲突)。