246 lines
12 KiB
Markdown
246 lines
12 KiB
Markdown
|
|
# 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 产生冲突)。
|