14 KiB
vr-shopxo-plugin 安全审计报告
审计人:SecurityEngineer 日期:2026-04-15 范围:EventListener.php / TicketService.php / BaseService.php / ticket_detail.html / Verification.php / Ticket.php / Goods.php / plugin.json 评级说明:🔴 严重 / 🟡 中等 / 🟢 轻微 / 💡 建议
一、执行摘要
vr-shopxo-plugin 是一个在 ShopXO 基础上扩展票务功能的插件,核心功能包括座位模板管理、电子票生成、QR 码核销、观演人信息收集。经过全面审计,共发现1 个严重漏洞、5 个中等风险、3 个轻微问题、4 项改进建议。最关键的问题是支付回调 onOrderPaid 存在并发竞态条件,可导致一张票被多次核销。
二、插件架构审计
2.1 生命周期钩子(EventListener.php)
| 钩子 | 实现 | 评估 |
|---|---|---|
vr_ticket_install |
✅ | 建表语句规范,使用 IF NOT EXISTS 防止重复执行 |
vr_ticket_uninstall |
✅ | 默认不删除数据(注释状态),安全 |
vr_ticket_upgrade |
⚠️ | 仅有空壳,无版本迁移逻辑 |
🟡 中等 — 升级迁移缺失
vr_ticket_upgrade($old_version) 是空实现。若未来表结构变更(如新增字段),升级流程无法自动执行 ALTER TABLE。建议引入基于版本号的增量迁移表或脚本。
2.2 plugin.json 配置
"hooks": [
"plugins_service_order_pay_success_handle_end",
"plugins_service_order_delete_success"
]
💡 建议 — 缺少 plugins_service_order_delete_success 处理逻辑
plugin.json 声明监听订单删除钩子,但代码中没有对应处理函数。若订单删除后票未同步处理,会导致票与订单状态不一致。
2.3 ALTER TABLE 安全风险
$db->query("ALTER TABLE `{$prefix}goods` ADD COLUMN `item_type` VARCHAR(20) ...");
🟢 轻微 — 直接执行 DDL 的并发问题 ShopXO 可能在高并发场景下执行此 ALTER TABLE 时出现锁表。风险极低(仅执行一次),但建议在 install 中用事务或预先检测。
三、票务核心安全审计
3.1 🔴 严重 — onOrderPaid 并发竞态条件
文件: TicketService.php:23-68
// 第 54-60 行:逐个生成票
foreach ($order_goods as $og) {
$ticket_id = self::issueTicket($order, $og);
// ...
}
问题分析:
- ShopXO 支付回调通过 HTTP 请求触发,如果用户使用多设备或重复点击支付,同一订单可能被多次触发
onOrderPaid - 没有幂等性保护(无 order_id 锁或已处理标记)
- 结果:同一订单商品可能生成多张电子票(等于支付金额可入场多次)
攻击场景:
- 攻击者购买一张票,支付 100 元
- 支付成功回调触发 2 次(网络重试)
- 系统生成 2 张票,每张都可独立入场核销
修复建议:
// 在 issueTicket 前增加幂等检查
$existing = \Db::name(BaseService::table('tickets'))
->where('order_id', $order['id'])
->where('spec_base_id', $order_goods['spec_base_id'] ?? 0)
->count();
if ($existing > 0) {
return 0; // 已发放,跳过
}
3.2 🟡 中等 — verifyTicket 核销状态竞争
文件: TicketService.php:138-196
// 第 148-154 行:状态检查
if ($ticket['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
// ... 中间无锁 ...
// 第 159-166 行:更新状态
\Db::name(BaseService::table('tickets'))
->where('id', $ticket['id'])
->update(['verify_status' => 1, ...]);
问题分析:
- 核销过程中,检查(SELECT)与更新(UPDATE)分离
- 两个核销员同时扫描同一张票,在极短时间窗口内可能同时通过状态检查
- 修复:使用数据库行级锁或原子更新:
$affected = \Db::name(BaseService::table('tickets'))
->where('id', $ticket['id'])
->where('verify_status', 0) // 乐观锁:只更新未核销票
->update([...]);
if ($affected === 0) {
return ['code' => -2, 'msg' => '该票已核销'];
}
3.3 🟡 中等 — QR 码 URL 直接暴露 ticket_code
文件: TicketService.php:220-228
public static function getQrCodeUrl($ticket_code)
{
$content = base64_encode(json_encode([
'type' => 'vr_ticket',
'code' => $ticket_code, // 直接明文
]));
return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H';
}
问题分析:
- QR 码内容直接 Base64 编码,ticket_code 可见
- QR 码打印在纸上后,攻击者可抄录 ticket_code 尝试重放
- 虽然 ticket_code 是 UUID v4(随机),但若泄露仍可被使用
修复建议:
- 使用加密后的
qr_data而非原始ticket_code - 或使用一次性 token + HMAC 签名:
$token = hash_hmac('sha256', $ticket_code, $secret);
$content = base64_encode(json_encode([
'type' => 'vr_ticket',
'token' => $token,
'exp' => time() + 3600, // 1小时有效
]));
3.4 🟡 中等 — verifyTicket 缺少权限验证
文件: Ticket.php:110-128(手动核销接口)
public function verify()
{
$ticket_code = input('ticket_code', '', null, 'trim');
$verifier_id = input('verifier_id', 0, 'intval');
// ...
$result = \app\plugins\vr_ticket\service\TicketService::verifyTicket($ticket_code, $verifier_id);
问题分析:
- 接口未验证当前登录用户是否为有效核销员
- 任何登录用户(包括普通买家)只要知道 ticket_code 就可以调用此接口为他人核销
verifier_id来自前端 POST 参数,可被伪造
修复建议:
$verifier_id = input('verifier_id', 0, 'intval');
$current_user_id = session('?user_id') ? session('user_id') : 0;
// 验证当前用户是否为注册核销员
$verifier = \Db::name(BaseService::table('verifiers'))
->where('user_id', $current_user_id)
->where('status', 1)
->find();
if (empty($verifier)) {
return DataReturn('无权核销', -1);
}
$verifier_id = $verifier['id']; // 不接受前端传入的 verifier_id
3.5 🟢 轻微 — decryptQrData 无法验证完整性
文件: BaseService.php:68-93
问题分析:
- AES-256-CBC 不提供认证加密(无 GMAC/GMAC 模式)
- 若密文被篡改,解密可能成功但数据异常(padding oracle 攻击理论上可能)
- ShopXO 环境风险较低(攻击者难以接触密文),但最佳实践应使用 AES-GCM
修复建议: 切换到 AES-256-GCM,或追加 HMAC-SHA256 验证。
四、前端安全审计(ticket_detail.html)
4.1 🟡 中等 — XSS 风险
文件: ticket_detail.html:125
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|raw}</div>
问题分析:
|raw过滤器禁用所有 HTML 转义,直接输出simple_desc- 若管理员在商品副标题中注入恶意脚本(如
<img src=x onerror=alert(1)>),所有访问该商品页的用户都会执行 - 同样风险存在于
$goods.title(第 124 行,无|raw安全)
修复建议:
- 移除
|raw,或对simple_desc使用白名单过滤:
<div class="vr-event-subtitle">{$goods.simple_desc|default=''|htmlspecialchars}</div>
4.2 🟢 轻微 — 已选座位标签 XSS 风险
文件: ticket_detail.html:275
'data-label="'+rowLabel+(colIndex+1)+'排'+(colIndex+1)+'座" '
问题分析:
seatInfo.label来自后端 JSON,理论上可信,但如果 seat_map JSON 数据被污染,可导致 DOM XSS- 风险较低(需要插件管理员配合注入恶意数据)
4.3 🟢 轻微 — 观演人表单无服务端验证
文件: ticket_detail.html:352-368
问题分析:
- 观演人表单(姓名、手机、身份证)仅有 HTML5
required属性 - 无服务端校验:攻击者可在提交前修改 DOM 移除
required,或直接 POST 构造请求上传恶意数据 - 身份证格式、手机号格式均无后端校验
4.4 💡 建议 — loadSoldSeats 未实现
文件: ticket_detail.html:370-378
loadSoldSeats: function() {
// TODO: 从后端加载已售座位
已售座位数据完全依赖 JS 内存标记(空对象),后端未实时同步座位状态。用户可能在前端看到可选择的座位,但提交时被告知已售(库存超卖)。需实现后端座位锁定 API。
4.5 💡 建议 — 座位选中数量无硬上限
用户可选任意数量座位,理论上可购买全场的票。无单次购买上限限制。
五、数据库安全审计
5.1 SQL 注入评估
评估结果:✅ 未发现 SQL 注入
代码全面使用 ShopXO ORM(\Db::name()->where()->find()/select()),所有用户输入均通过框架参数化查询:
Ticket.php:21—where('ticket_code|verifier_name', 'like', "%{$keywords}%")Verification.php:29-36— 日期范围strtotime()处理BaseService.php:139—where('category_id', $goods['category_id'])
框架自动处理参数化,无 SQL 拼接风险。
5.2 索引评估
✅ 索引设计合理
vr_tickets表:(ticket_code)唯一索引、(order_id)、(user_id)、(goods_id)、(verify_status)— 覆盖所有查询场景vr_verifications表:(ticket_id)、(verifier_id)— 合理vr_seat_templates表:(category_id)唯一索引 — 合理
5.3 🟡 中等 — 观演人信息明文存储
文件: EventListener.php:49-51
`real_name` VARCHAR(60)
`phone` CHAR(15)
`id_card` CHAR(18)
问题分析:
- 观演人身份证、姓名、手机号明文存储在数据库
- 若数据库被拖库或内部人员泄露,可导致大规模个人信息泄露(中国法律要求敏感个人信息加密存储)
- 违反《个人信息保护法》第 51 条
修复建议:
- 对身份证号使用 AES-256-CBC 加密存储
- 或在
vr_tickets表中使用单独的加密字段:
'id_card_encrypted' => encrypt($attendee['id_card'] ?? ''),
六、支付安全审计
6.1 🟡 中等 — 支付回调无签名验证
文件: TicketService.php:23(onOrderPaid 回调)
ShopXO 的 plugins_service_order_pay_success_handle_end 钩子由 ShopXO 内部触发,但未提供签名或 nonce 验证机制。若存在插件注册了同名钩子,理论上可伪造回调(风险依赖 ShopXO 框架安全性)。
当前缓解措施: 订单状态二次验证($order['pay_status'] == 1)是正确做法。
6.2 💡 建议 — 退款后票状态未自动更新
文件: plugin.json:24
声明了 plugins_service_order_delete_success 钩子但未实现。若订单退款,票状态仍为"未核销",退款用户仍可持票入场。需监听退款成功事件并将 verify_status 设为 2。
七、AES QR 加密方案评估
7.1 加密算法选择
✅ AES-256-CBC 基本安全
- IV 使用
random_bytes(16)随机生成 ✅ - 密钥从环境变量读取 ✅
- 过期时间
exp字段防止长期泄露 ✅
7.2 🟡 中等 — 密钥回退到硬编码默认值
文件: BaseService.php:106
return config('shopxo.app_key', 'shopxo_default_secret_change_me');
问题分析:
- 若环境变量
VR_TICKET_QR_SECRET未配置,且 ShopXOapp_key未修改默认值 - 所有 QR 密文使用同一已知密钥加密,攻击者可离线解密所有 QR 数据
shopxo_default_secret_change_me极可能出现在生产环境中
修复建议:
$secret = env('VR_TICKET_QR_SECRET', '');
if (empty($secret)) {
throw new \Exception('VR_TICKET_QR_SECRET 环境变量未配置');
}
八、问题汇总
| 编号 | 严重程度 | 类别 | 文件 | 描述 |
|---|---|---|---|---|
| S-01 | 🔴 严重 | 业务逻辑 | TicketService.php:54-60 | onOrderPaid 无幂等保护,同一订单可生成多张票 |
| M-01 | 🟡 中等 | 业务逻辑 | TicketService.php:138-166 | verifyTicket 存在 TOCTOU 竞态条件 |
| M-02 | 🟡 中等 | 鉴权 | Ticket.php:110-128 | 手动核销接口未验证核销员身份,可被任意登录用户调用 |
| M-03 | 🟡 中等 | 数据安全 | EventListener.php:49-51 | 观演人身份证明文存储,违反个人信息保护法律 |
| M-04 | 🟡 中等 | 前端安全 | ticket_detail.html:125 | simple_desc 使用 ` |
| M-05 | 🟡 中等 | 加密 | BaseService.php:106 | QR 加密密钥回退到硬编码默认值 |
| L-01 | 🟢 轻微 | 前端安全 | ticket_detail.html:275 | data-label 属性可能含未转义数据 |
| L-02 | 🟢 轻微 | 数据完整性 | BaseService.php:80 | AES-CBC 无认证加密,可被 padding oracle 攻击 |
| L-03 | 🟢 轻微 | 业务逻辑 | ticket_detail.html:370-378 | loadSoldSeats 未实现,存在超卖风险 |
| I-01 | 💡 建议 | 架构 | EventListener.php:127 | 升级迁移逻辑为空 |
| I-02 | 💡 建议 | 业务逻辑 | plugin.json:24 | 退款钩子已注册但未实现 |
| I-03 | 💡 建议 | 前端 | ticket_detail.html:384 | 购买数量无上限控制 |
| I-04 | 💡 建议 | 前端 | ticket_detail.html:397-407 | 观演人表单无服务端格式校验 |
九、修复优先级建议
立即修复(上线前必须处理)
- S-01 — 添加
onOrderPaid幂等检查(防止一张票多次入场) - M-02 — 手动核销接口鉴权(防止权限绕过)
- M-04 — 移除
|raw或使用htmlspecialchars - M-05 — 移除 QR 密钥硬编码回退
上线后尽快修复
- M-01 —
verifyTicket乐观锁 - M-03 — 身份证字段加密存储
- I-02 — 实现退款后票状态更新
迭代修复
- I-01 — 升级迁移框架
- L-03 — 实现实时座位锁定
- L-02 — 升级到 AES-GCM
十、整体评价
| 维度 | 评分 | 说明 |
|---|---|---|
| SQL 注入防护 | ⭐⭐⭐⭐⭐ | 全面使用 ORM 参数化,无注入风险 |
| 加密方案 | ⭐⭐⭐ | AES-256-CBC 基础安全,但无认证加密,密钥管理有缺陷 |
| 鉴权设计 | ⭐⭐ | 核销接口鉴权严重缺失 |
| 并发安全 | ⭐ | 核心业务流程存在明显竞态条件 |
| 数据安全合规 | ⭐⭐ | 敏感个人信息明文存储 |
| 前端安全 | ⭐⭐ | 存在存储型 XSS |
综合评级:中等风险(B) — 核心业务逻辑(票务核销)存在并发安全缺陷,需优先修复后才能安全上线。