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

12 KiB
Raw Blame 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-Q1template_id 指向不存在的场馆时是否拒绝保存code -401

结论:否 — 脏数据被静默保存,存在 P1 安全缺陷。

根因分析

  1. 保存时 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) 返回 5templateId > 0true,代码进入 BatchGenerate 调用。

  2. 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: -2AdminGoodsSaveHandle.php:169 只检查 !== 0

    if ($res['code'] !== 0) {
        return $res;  // -2 !== 0 → 确实返回错误
    }
    

    所以 BatchGenerate 返回 -2 时,错误确实被向上传播,保存被拒绝。

  3. save_thing_end 在返回错误之前,已将修改后的 config 写回 DBAdminGoodsSaveHandle.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 仍保留旧的座位图数据。

  4. 另一个路径SeatSkuService.php:55-57如果模板记录被物理删除find() 返回 nullBatchGenerate 返回 -2阻止保存。但如果 config 中的 template_id0intval(null) 或前端传空),则 templateId > 0false,循环体完全跳过,vr_goods_config 被写回时没有任何校验

S1-Q2幽灵 spec 的产生环节

幽灵 spec 产生于 vr_goods_configspec_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 或已删除)→ 跳过 BatchGenerateconfig 块原样写回 DB

幽灵 spec 不会被过滤,因为保存逻辑中没有针对无效 template_id 配置块的过滤/清理逻辑。


S2. 脏数据注入路径分析

S2-Q1幽灵 spec 是否可被恶意利用来注入/覆盖商品规格?

结论:理论风险存在(中等),但需管理员权限利用。

攻击路径

  1. 通过 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 输入框,所以普通用户在标准编辑流程中无法注入。

  2. ShopXO API 直接提交:任何已登录的管理员可以直接 POST 到商品保存 API携带恶意 vr_goods_config_base64,注入任意 JSON 到 vr_goods_config 字段。

  3. 注入的内容

    • 多个 config 块引用同一个 template_id(重复模板)
    • 引用已删除模板的 template_id
    • template_snapshot 中注入任意字符串(虽然后端会重建,但若模板不存在则保留)
  4. save_thing_endvr_goods_config 的处理AdminGoodsSaveHandle.php:61-66从 DB 读取 vr_goods_config不使用前端传入的 $data['vr_goods_config'](除非 DB 为空)。这意味着即使用户在 save_handle 时注入了恶意 configsave_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

    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 返回 nullseatMapspecBaseIdMap 在前端均为空数组 []。座位图不会渲染。前端 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,经过 htmlspecialcharsShopXO 输出编码),基本安全
    • renderSeatMap() 第 275 行:data-label="'rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" — 硬编码的纯字母数字,无注入风险
  4. 硬编码拼接中的潜在属性注入ticket_detail.html:275

    data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座"
    

    此处 colIndex+1 是 JS 计算值,无注入风险rowLabel 来自 map.row_labelschr(65+index),也是纯字母,无注入风险

  5. submit 函数的 spec_base_idticket_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-173templateId == 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_handleAdminGoodsSaveHandle.php:29-35vr_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.htmlsubmit 函数中,对 spec_base_id 增加服务器端校验(非本文档范围,需在支付 API 入口添加)。


总结

风险等级 数量 说明
P1 2 脏数据未拒绝,直接影响数据完整性和商品保存正确性
P2 3 注入风险低(需管理员权限)、信息泄露、缺少校验
0 无直接 XSS后端输出有编码保护

核心 P1 缺陷:当 template_id 指向不存在的场馆时,系统不拒绝保存,而是静默保留旧的 template_snapshot,导致幽灵 spec 持续存在于数据库中。这是用户遇到「规格不允许重复」错误的根本原因(配置块未清理,残留的 spec_base_id_map 数据与新生成的 SKU 产生冲突)。