mall 项目代码审计复盘
一、审计基线与项目信息收集
1.1 审计环境
| 项目 | 信息 |
|---|---|
| 项目地址 | https://github.com/macrozheng/mall |
| 审计版本 | v1.0.3 |
| JDK 版本 | JDK 17 |
| 审计重点 | Spring Security、JWT、业务逻辑、支付与订单、库存及优惠券 |
1.2 项目模块分析
首先对项目整体架构进行梳理,明确各模块的职责、对外服务端口以及主要依赖。初步模块信息如下。
| 模块 | 类型 | 端口 | 主要职责 | 独有依赖及版本 |
|---|---|---|---|---|
| mall-common | 公共库 | — | 统一返回、异常处理、日志切面、Redis 工具 | pagehelper 6.1.1、logstash-logback-encoder 8.0、janino 3.1.12、springdoc 2.8.17、validation |
| mall-mbg | 公共库 | — | MyBatis Generator 生成的 Model、Mapper、Example | pagehelper-spring-boot-starter 2.1.1、mybatis-spring-boot-starter 3.0.4(MyBatis 3.5.19)、druid-spring-boot-3-starter 1.2.24、mybatis-generator-core 1.4.2、mysql-connector-j 9.3.0 |
| mall-security | 公共库 | — | JWT 认证、动态权限控制、白名单配置 | spring-boot-starter-security(Spring Security 6.5.10)、spring-data-redis(Lettuce 6.6.0) |
| mall-demo | 应用模块 | 8082 | 演示与测试 | Thymeleaf 3.1.5.RELEASE、Spring Security |
| mall-admin | 应用模块 | 8080(默认) | 后台管理 API | aliyun-sdk-oss 3.18.5、MinIO 8.6.0(排除其 OkHttp 依赖)、okhttp-jvm 5.1.0 |
| mall-search | 应用模块 | 8081 | 商品搜索 | spring-boot-starter-data-elasticsearch(elasticsearch-java 8.18.8) |
| mall-portal | 应用模块 | 8085 | 前台商城 API,包括购物车、订单及会员功能 | spring-data-mongodb(MongoDB Driver 5.5.2)、spring-data-redis、spring-rabbit(RabbitMQ 3.2.10 / amqp-client 5.25.0)、alipay-sdk-java 4.40.630.ALL |
所有模块均继承根 POM 中的公共依赖,主要包括:
spring-boot-starter-actuatorspring-boot-starter-aophutool-all 5.8.40lombok 1.18.46
从模块划分可以看出,项目采用多模块架构,将公共能力、安全控制、后台管理、前台业务和商品搜索进行了拆分。其中,mall-security 是认证与授权机制的核心模块,mall-admin 和 mall-portal 则分别承载后台管理与商城交易业务,是本次审计的重点。
1.3 核心框架版本
| 组件 | 版本 | 组件 | 版本 |
|---|---|---|---|
| Spring Boot | 3.5.14 | Spring Framework | 6.2.18 |
| Spring Security | 6.5.10 | Tomcat Embed | 10.1.54 |
| Jackson | 2.21.2 | Logback | 1.5.32 |
| Spring Data Commons | 3.5.11 | SnakeYAML | 2.4 |
| Hibernate Validator | 8.0.3.Final | Springdoc / Swagger Core | 2.8.17 / 2.2.47 |
| Micrometer | 1.15.11 | Swagger UI WebJar | 5.32.2 |
1.4 值得关注的第三方依赖链
在梳理依赖关系时,记录以下值得后续专项排查的依赖组合。本阶段只记录依赖及版本信息,暂不直接据此判定漏洞。
1. mall-portal:支付宝 SDK 依赖链
alipay-sdk-java 引入了以下依赖:
- Fastjson 1.2.83_noneautotype
- OkHttp 3.12.13
- Bouncy Castle
bcprov-jdk15on 1.62 - dom4j 1.6.1
其中,项目依赖树中同时存在多个版本的 Bouncy Castle,包括 1.62 与 1.84,后续需要结合实际调用点和依赖解析结果进一步分析。
2. mall-admin:MinIO 依赖链
MinIO 8.6.0 引入了 Guava 33.4.8-jre 和 bcprov-jdk18on 1.84。项目显式排除了 MinIO 原有的 OkHttp 依赖,改用 okhttp-jvm 5.1.0。
3. mall-admin:阿里云 OSS 依赖链
aliyun-sdk-oss 引入了 HttpClient 4.5.13、Jettison 1.5.4 和 JDOM2。其中,HttpClient 4.5.13 与项目中其他位置使用的 4.5.14 存在版本差异。
4. mall-search:Redis 依赖排除
mall-search 显式排除了整个 Redis 依赖链,是当前模块配置中唯一未引入 Redis 的应用模块。
依赖层面的后续审计重点包括:旧版本组件的实际使用情况、依赖冲突、传递依赖是否进入运行时,以及是否存在能够被外部输入触发的危险调用链。
1.5 外部基础设施与配置
根据项目配置文件,梳理各基础设施在开发环境和生产部署环境中的连接信息。
| 服务 | 开发环境 | 生产环境(Docker) |
|---|---|---|
| MySQL | localhost:13306,root/QWE123qwe | db:3306,reader/123456 |
| Redis | localhost:16379,无密码 | redis:6379,无密码 |
| MongoDB | localhost:27017,数据库 mall-port | mongo:27017 |
| RabbitMQ | localhost:5672,mall/mall,vhost /mall | rabbit:5672 |
| Elasticsearch | localhost:9200 | es:9200 |
| MinIO | localhost:19000,minioadmin/minioadmin | 192.168.3.101:9090 |
| Logstash | 关闭,enableInnerLog: false | logstash:4560-4563 |
此外,根 POM 中配置了以下 Docker 构建宿主地址:
docker.host=http://192.168.3.101:2375
该配置指向未启用 TLS 的远程 Docker API,是值得关注的部署安全配置。
1.6 初步审计思路
综合模块架构、业务特点与第三方依赖情况,mall 是一个基于 Spring Boot 的多模块商城系统,后台鉴权采用 Spring Security 与 JWT,业务侧包含商品、订单、支付、库存、优惠券及会员管理等功能。
据此,确定以下几个主要审计方向:
- 认证与授权: 检查 JWT 解析与验证、身份创建、角色授权、敏感接口保护及账户状态变更后的 Token 失效机制。
- 业务逻辑: 优先审计支付、订单、价格、库存和优惠券等与资金及业务状态直接相关的流程,重点检查数据归属、状态转换和金额计算。
- 第三方组件: 关注 Jackson、SnakeYAML、Fastjson 等组件的具体使用场景,进一步分析是否存在危险反序列化或其他可被外部输入触发的问题。
- 配置与基础设施: 关注文件上传、对象存储、管理控制台、远程服务接口以及部署配置中的安全风险。
二、后台管理模块:认证与权限审计
本阶段以 mall-security 和 mall-admin 为主要审计对象,先从 Spring Security 过滤链入手,梳理身份如何建立、权限如何加载,以及各业务接口受到怎样的访问控制。
2.1 JWT 过滤器审计
首先检查 mall-security 模块下的 SecurityConfig,确认系统的安全过滤链配置。
项目采用自定义 JWT 过滤器,并将其添加到 UsernamePasswordAuthenticationFilter 之前。除配置的白名单外,请求需要通过相应的认证与授权检查。白名单路径由配置加载,后续还需要结合具体 Controller 梳理各接口的实际访问要求。
随后定位 JwtAuthenticationTokenFilter,分析其认证处理流程。
整体流程如下:
- 从请求头中读取 Token。
- 检查请求头是否包含预期的 Token 前缀。
- 从 Token 中解析用户名。
- 判断用户名是否为空,以及当前安全上下文是否已经存在认证信息。
- 根据用户名调用
UserDetailsService加载用户信息。 - 调用
jwtTokenUtil.validateToken(authToken, userDetails)验证 Token。 - 验证通过后,创建
UsernamePasswordAuthenticationToken,将用户权限写入认证对象,并设置到SecurityContextHolder中。 - 继续执行后续过滤链。
在这条调用链中,JWT 的签名、用户名与有效期均由相关工具方法处理,初步审计未发现明显的 Token 伪造或绕过问题。
值得注意的是,用户名在过滤器中已经被解析一次,validateToken() 内部又会解析用户名并进行比较。这一过程存在重复解析的可能,但 validateToken() 同时负责其他有效性检查,因此不能简单地将该方法整体视为冗余。
完成过滤器审计后,继续检查敏感路径是否存在未受有效访问控制保护的情况。
2.2 角色与资源关系梳理
为了判断系统是否存在越权问题,先从数据库 SQL 文件中梳理角色、资源和接口 URL 之间的对应关系。
项目通过数据库中的资源信息进行动态授权,角色与资源的关联关系保存在相应的数据表中,而不是完全依赖 Controller 上的权限注解。
整理得到以下角色与资源映射关系:
| 角色 | 资源 ID | 可访问的主要资源 |
|---|---|---|
| 超级管理员(5) | 1–6、8–30 | 商品域、订单域、营销域、权限管理域,以及优惠专区、专题、会员等级等 |
| 商品管理员(1) | 1–6、23、24、31、32 | 品牌、商品属性、商品分类、商品、SKU、优惠专区、专题,以及部分后台基础接口 |
| 订单管理员(2) | 8–12、31、32 | 订单、退货申请、退货原因、订单设置、公司地址,以及部分后台基础接口 |
系统包含三类管理员角色和普通登录用户;如果将未登录访问者也作为独立的访问主体,则可以从五类访问身份的角度分析其访问边界。
在此基础上,先审计登录和身份创建流程,再对各 Controller 的路由进行归类。
2.3 登录与身份创建流程
定位 UmsAdminController 中的登录接口,并继续跟踪对应的 Service 实现。
登录流程主要包括:
- 接收客户端提交的用户名与密码。
- 根据用户名加载用户信息。
- 使用密码编码器比对密码。
- 检查账户状态。
- 根据用户信息及其权限构建认证对象。
- 生成 JWT,并将 Token 返回客户端。
继续检查 loadUserByUsername() 及其相关方法。用户信息首先尝试从缓存获取,缓存未命中时再从数据库查询;获取用户信息后,系统进一步加载角色与资源,并将相应权限放入 UserDetails。
随后检查 generateToken()。Token 的主要声明包括用户名和创建时间,并根据配置生成相应的过期时间。
截至此处,身份创建、密码比对、权限加载及 Token 生成流程均已完成初步审计,暂未发现明显的认证绕过问题。
不过,身份创建只是认证生命周期的一部分。用户被禁用、删除或撤销权限之后,已签发的 Token 是否能够及时失效,同样属于认证机制必须覆盖的范围。
2.4 后台管理接口与访问控制映射
接下来整理 mall-admin 模块的 Controller 路由,重点识别匿名接口、动态授权接口以及可能存在配置缺失的敏感接口。
mall-admin 模块包含 31 个 Controller。根据白名单配置、角色资源关联关系及实际路由,整理如下。
| 访问级别 | 路由前缀 | 说明 |
|---|---|---|
| 完全匿名(白名单) | /admin/register、/admin/login | 注册与登录接口,位于 UmsAdminController.java |
| 完全匿名(白名单) | /minio/upload、/aliyun/oss/policy | 文件上传及对象存储相关接口 |
| 基础设施暴露 | /actuator/**、/druid/**、Swagger 相关路径、/*/*.html/js/css/png | 需要结合配置进一步确认实际可访问内容 |
| 白名单免鉴权,但依赖登录态 | /admin/info、/admin/logout | 方法内部检查 Principal;主体为空时返回未授权 |
| 仅超级管理员 | /coupon/**、/couponHistory/**、/flash/**、/flashProductRelation/**、/flashSession/** | 营销相关管理接口 |
| 仅超级管理员 | /home/advertise/**、/home/brand/**、/home/newProduct/**、/home/recommendProduct/**、/home/recommendSubject/** | 首页广告与推荐内容管理 |
| 仅超级管理员 | /admin/** 中除白名单外的管理操作、/role/**、/menu/**、/resource/**、/resourceCategory/** | 后台用户管理及权限配置,包括 /admin/refreshToken |
| 仅超级管理员 | /memberLevel/list | 会员等级查询 |
| 商品管理员与超级管理员 | /brand/**、/productAttribute/**、/productAttribute/category/**、/productCategory/**、/product/**、/sku/**、/prefrenceArea/**、/subject/** | 商品管理相关接口 |
| 订单管理员与超级管理员 | /order/**、/returnApply/**、/returnReason/**、/orderSetting/**、/companyAddress/** | 订单管理相关接口 |
| 所有角色均无法访问 | /minio/delete、/aliyun/oss/callback | 未配置在白名单中,且未匹配到有效的资源授权条目 |
其中,/minio/delete 和 /aliyun/oss/callback 在动态授权过程中未匹配到对应资源。根据 DynamicAuthorizationManager 对空权限集合的处理逻辑,这类请求将被拒绝,因此超级管理员也无法通过现有动态授权规则访问这些路径。
通过以上整理,可以从 URL 级别了解各角色的访问范围,并对敏感接口的实际保护情况进行核对。
这里还发现两个值得进一步检查的匿名接口:/minio/upload 和 /aliyun/oss/policy。前者涉及文件上传,后者涉及对象存储上传策略,需要分别审计其上传限制、存储权限和策略生成逻辑。
2.5 账户禁用与 JWT 生命周期审计
完成接口权限梳理后,进一步检查账户状态变化能否及时影响现有 Token。
此前已经确认,登录时会检查用户状态;但登录校验通过,并不意味着账户在整个 Token 有效期内始终处于有效状态。因此,需要回到 JwtAuthenticationTokenFilter,进一步分析每次请求中的身份状态检查。
在 v1.0.3 对应的实现中,过滤器会从 Token 中获取用户名,再加载用户信息,随后调用 validateToken()。
问题在于,该版本的 validateToken() 主要检查以下内容:
- Token 中的用户名是否与加载出的用户信息一致;
- Token 是否已经过期。
但该方法没有检查用户本身的启用状态。因此,如果管理员登录后获得了 Token,之后账户被禁用,已有 Token 仍可能在有效期内通过验证,直到 Token 自身过期才失效。
继续检查 UmsAdminController 中的 /admin/refreshToken,发现其调用 adminService.refreshToken(),而服务方法进一步调用 jwtTokenUtil.refreshHeadToken()。
该刷新逻辑主要验证原 Token 的格式、签名和有效期,并根据创建时间判断是否需要刷新。由于刷新流程没有重新校验用户当前的账户状态,已被禁用的账户仍可能在原 Token 到期前持续刷新 Token。
由此形成完整的问题链:
- 用户在账户正常时登录,取得有效 Token。
- 管理员将账户禁用。
- 请求过滤器未在 Token 验证阶段检查账户是否仍启用。
- 用户继续使用尚未过期的 Token 访问业务接口。
- 在 Token 有效期内调用刷新接口,还可能延长其可使用时间。
审计结论: 账户禁用机制未能有效约束已签发 Token 的使用与刷新,导致账户状态变更后,用户身份不能及时失效。
2.6 后台模块阶段性结论
后台模块审计主要覆盖了 JWT 过滤器、登录与权限加载、角色和资源映射、Controller 路由保护,以及账户禁用后的 Token 生命周期。
本阶段未发现明显的基础认证流程绕过问题,但发现账户状态检查与 Token 刷新机制之间存在缺口。另外,匿名文件上传与对象存储策略接口值得继续深入检查。
完成后台部分后,将审计重点转向 mall-portal,集中检查商城交易链路中的身份归属、金额计算、订单状态转换、库存一致性及优惠券核销。
三、前台商城模块:业务逻辑审计
3.1 审计范围与业务流程梳理
mall-portal 是前台商城模块,承载购物车、商品、会员、订单、支付及优惠券等业务。本阶段优先关注以下资源:
- 商品及商品价格;
- 购物车与购买数量;
- 订单创建、取消与支付;
- 库存锁定和扣减;
- 优惠券领取、使用与核销;
- 退单及退款流程;
- 订单、购物车等业务对象的归属校验。
审计前先梳理一次典型购物流程:
选择商品 → 加入购物车 → 生成订单 → 发起支付 → 支付结果回调 → 订单进入已支付状态 → 商品等待发货及后续履约。
实际业务中,还涉及优惠券抵扣、库存锁定、支付超时取消、退单和退款等分支。
因此,本阶段不只检查单个接口的参数校验,而是沿着关键业务数据的传递路径追踪其来源、计算过程和最终影响。
3.2 支付流程审计
3.2.1 paySuccess 接口缺少支付结果校验
首先审计订单支付成功后的处理逻辑。
在 OmsPortalOrderController 中,/order/paySuccess 接收 orderId 和 payType,随后调用 portalOrderService.paySuccess()。
继续跟进 Service 实现,可以看到该方法根据传入的订单 ID 创建订单更新对象,将订单状态修改为已支付,设置支付时间与支付方式,随后执行库存更新逻辑。
该调用链没有在当前方法中验证该订单是否已经获得真实、有效的支付结果,也没有在状态更新时限制订单必须处于待付款状态。
因此,只要能够调用该接口并指定有效订单 ID,就可以触发订单支付状态变更,而无须经过实际支付流程。
此外,支付成功后的库存处理也依赖该方法。如果同一订单可以重复触发支付成功流程,那么库存数据也可能受到重复更新的影响。
问题归纳:
- 支付状态变更缺少可信支付结果校验;
- 订单状态转换缺少严格的前置状态约束;
- 支付成功处理缺少充分的幂等性保证;
- 支付状态与库存更新之间存在连锁影响。
3.2.2 支付请求中的金额可由客户端控制
发现 paySuccess 的问题后,继续检查支付宝支付接口,确认真实支付请求是否存在金额校验缺失。
在 AlipayController 中,/alipay/pay 接收一个 AliPayParam 参数,并将其传递至 AlipayService。
查看 AliPayParam 类,可以看到其包含以下字段:
outTradeNo:商户订单号;subject:交易标题;totalAmount:订单总金额。
继续跟进 AlipayServiceImpl.pay(),可以看到服务端直接将 aliPayParam.getTotalAmount() 写入支付宝交易请求的 total_amount 字段。
该金额来自客户端提交的请求参数,而不是从服务端已保存的订单金额中重新读取。因此,客户端可以尝试修改 totalAmount,使第三方支付请求按照被篡改的金额创建交易。
如果客户端提交的金额低于订单真实应付金额,而支付回调又未对实际支付金额与订单应付金额进行一致性校验,就可能导致低价支付的订单被标记为支付成功。
问题归纳: 支付请求中的金额缺乏可信的数据来源约束,且支付结果处理未充分校验第三方实际支付金额与本地订单金额的一致性,可能造成订单少付或未足额支付。
3.3 订单接口审计
3.3.1 取消订单接口缺少明确的归属校验
继续检查用户取消订单的处理逻辑,发现相关接口只接收一个 orderId,随后根据订单 ID 执行取消流程。
该方法没有将当前登录会员 ID 与订单所属会员 ID 进行明确绑定。虽然最初判断实际利用条件时,需要考虑订单 ID 的获取情况,但不能将订单号本身视为对象归属校验机制。
本次记录的重点是:订单取消操作应当同时检查订单状态和订单归属,而不能仅依赖客户端提供的订单 ID。
3.3.2 订单相关操作应统一检查对象归属
沿订单相关接口继续检查时,需要特别关注以下类型的操作:
- 根据 ID 查询订单详情;
- 取消指定订单;
- 确认收货;
- 删除订单;
- 修改订单状态。
这些操作虽然可能都属于同一个登录用户可调用的业务功能,但不能因此允许当前用户操作任意 ID 对应的订单。服务端应根据当前登录身份对目标订单进行归属校验,并进一步检查订单状态是否允许执行相应操作。
该检查原则同样适用于购物车、收货地址和退单申请等业务对象。
3.4 购物车归属校验审计
3.4.1 购物车修改规格接口存在归属校验问题
继续审计购物车相关服务,在购物车模块中发现一个修改商品规格的操作存在归属校验问题。
该接口接收客户端提交的购物车信息,服务端在修改原记录时,未充分通过当前会员 ID 限定待修改记录的范围。后续重新添加购物车商品的逻辑虽然会设置当前会员信息,但这不能替代对原购物车记录的归属校验。
因此,未经充分校验的购物车 ID 可能被用于修改或删除其他会员的购物车数据。
与支付及订单金额问题相比,该问题直接造成的经济影响相对有限,但仍属于需要修复的对象级访问控制缺失。
审计结论: 购物车修改操作应当以当前登录会员 ID 和购物车记录 ID 共同限定目标记录,避免客户端通过替换对象标识操作其他会员的数据。
3.5 退单与退款业务审计
在退单模块中,发现部分处理逻辑直接接收客户端提交的退单信息,未对关键业务字段进行充分校验。随后继续追踪至后台退单管理相关流程,发现退款处理涉及订单金额等参数。
这部分需要重点关注:
- 退单申请是否属于当前会员;
- 申请关联的订单是否属于当前会员;
- 当前订单状态是否允许退单;
- 申请退款金额是否超过可退款金额;
- 退款金额是否来自服务端可信的订单与支付数据;
- 管理员审批通过后,实际退款金额是否经过二次校验。
虽然退款需要管理员审批,但审批环节并不能代替服务端对退款金额的约束。若关键金额参数仍可由客户端控制,就应继续检查审批通过后实际退款的执行路径。
因此,退单审计的核心不仅是检查“是否需要管理员同意”,还需要确认申请数据、审批结果和实际退款金额之间的一致性。
3.6 购物车数量校验与库存逻辑审计
在检查价格参数后,继续沿订单创建链路追踪购买数量。因为订单金额通常与商品价格和购买数量直接相关,所以数量异常同样可能影响交易金额、库存及订单状态。
3.6.1 购物车数量缺少正数与边界校验
首先检查 OmsCartItemController 的 /cart/add 接口。该接口接收 OmsCartItem 对象,并交由 OmsCartItemServiceImpl.add() 处理。
其核心逻辑如下:
public int add(OmsCartItem cartItem) { UmsMember currentMember = memberService.getCurrentMember(); cartItem.setMemberId(currentMember.getId()); cartItem.setMemberNickname(currentMember.getNickname()); cartItem.setDeleteStatus(0); // 后续查询或创建购物车记录}可以看到,服务端主要补充了会员 ID、会员昵称及删除状态,商品数量和价格字段则进入后续的购物车处理流程。
继续检查 /cart/update/quantity,该接口同样接收客户端提交的 quantity,并将其更新至购物车记录中。相关处理没有对数量进行充分的正数及边界校验。
因此,异常数量可以被写入购物车,并进一步影响订单创建时的计算逻辑。
3.6.2 库存校验无法阻止负数数量
接下来检查订单创建流程中的 hasStock() 方法:
private boolean hasStock(List<CartPromotionItem> cartPromotionItemList) { for (CartPromotionItem cartPromotionItem : cartPromotionItemList) { if (cartPromotionItem.getRealStock() == null || cartPromotionItem.getRealStock() <= 0 || cartPromotionItem.getRealStock() < cartPromotionItem.getQuantity()) { return false; } } return true;}该方法主要检查三个条件:
- 实际可用库存是否为空;
- 实际可用库存是否小于或等于零;
- 实际可用库存是否小于请求购买数量。
其中,前两个条件只判断库存本身,没有限制购买数量必须大于零。第三个条件虽然检查库存能否满足请求,但并不能拒绝负数数量。
例如,假设实际可用库存为 100,购买数量为 -1,则:
realStock == null为false;realStock <= 0为false;realStock < quantity即100 < -1,结果仍为false。
三个条件均未触发拒绝逻辑,负数数量因此能够通过这项库存检查。
3.6.3 负数数量影响订单金额
通过库存检查后,订单服务继续计算商品总金额。calcTotalAmount() 以商品价格乘以购买数量,并累加得到订单总额:
private BigDecimal calcTotalAmount(List<OmsOrderItem> orderItemList) { BigDecimal totalAmount = new BigDecimal("0"); for (OmsOrderItem item : orderItemList) { totalAmount = totalAmount.add( item.getProductPrice().multiply( new BigDecimal(item.getProductQuantity()) ) ); } return totalAmount;}该计算过程没有在金额计算前再次限制购买数量为正数。
例如,商品单价为 2699,购买数量为 -1,则该商品对应的金额为:
2699 × (-1) = -2699
订单应付金额的后续计算同样基于订单总额及各类优惠金额进行加减。如果没有对负数总额或最终应付金额设置合理的下限约束,异常数量就会进一步影响最终订单金额。
在本地测试中,向购物车写入 quantity=-1、price=2699 的记录后,订单创建成功,数据库中的订单信息如下:
oms_order:id=80total_amount=-2699.00pay_amount=-2699.00status=0这说明异常购买数量不仅能够通过库存检查,还会进入订单金额计算流程,最终生成负金额订单。
3.6.4 负数数量导致库存反增
继续向后追踪订单支付成功后的库存更新逻辑。
订单服务在支付成功后调用 updateSkuStock() 更新 SKU 库存。SQL 使用类似以下的减法逻辑:
stock = stock - #{productQuantity}当 productQuantity=-1 时,更新逻辑变为:
stock - (-1) = stock + 1
因此,负数数量不仅会影响订单金额,还会在支付成功后的库存更新中导致库存增加。
在本地测试中,以 SKU ID 100 为例,调用 paySuccess 前库存为 100,执行后库存增加至 101。
由此形成完整的漏洞链:
购物车数量缺少校验 → 负数数量通过库存检查 → 生成负金额订单 → 支付成功处理修改订单状态 → 库存更新出现反向增加。
该问题的影响不局限于订单金额异常,还可能导致库存数据失真及后续订单处理异常。
3.7 优惠券业务审计
在检查完价格与数量后,继续分析订单创建请求中剩余的金额相关参数——couponId。
优惠券涉及可抵扣金额、使用门槛、会员归属和使用状态,因此需要重点检查优惠券是否可用,以及核销操作能否抵御并发竞争。
3.7.1 优惠券可用性校验
在 generateOrder() 中,服务端根据客户端提交的 couponId 调用 getUseCoupon() 获取对应的优惠券信息。
继续跟踪后发现,优惠券查询逻辑会结合当前登录会员的 ID、优惠券使用状态等条件筛选可用优惠券,并对最低消费金额等使用门槛进行判断。
因此,该部分已经包含优惠券归属和部分可用性检查:当前会员不能直接使用其他会员名下的优惠券,且优惠券必须满足相应的使用条件。
不过,可用性检查只解决了“请求进入时优惠券是否可用”的问题,并不能单独保证优惠券在并发请求下只被使用一次。
3.7.2 优惠券核销存在并发竞争问题
下单成功后,服务端调用 updateCouponStatus(),将对应优惠券标记为已使用。
相关逻辑如下:
private void updateCouponStatus( Long couponId, Long memberId, Integer useStatus) {
// 查询第一张优惠券 SmsCouponHistoryExample example = new SmsCouponHistoryExample();
example.createCriteria() .andMemberIdEqualTo(memberId) .andCouponIdEqualTo(couponId) .andUseStatusEqualTo(useStatus == 0 ? 1 : 0);
List<SmsCouponHistory> couponHistoryList = couponHistoryMapper.selectByExample(example);
if (!CollectionUtils.isEmpty(couponHistoryList)) { SmsCouponHistory couponHistory = couponHistoryList.get(0);
couponHistory.setUseTime(new Date()); couponHistory.setUseStatus(useStatus);
couponHistoryMapper.updateByPrimaryKeySelective( couponHistory ); }}该方法先查询符合会员、优惠券 ID 和使用状态条件的记录,再将查询结果按主键更新。
问题在于,查询与更新是分离的。更新操作没有将“优惠券当前仍处于未使用状态”作为更新条件的一部分,因此无法仅凭这段逻辑保证同一张优惠券在并发情况下只能被一个请求成功核销。
在两个请求同时执行时,可能出现以下时序:
- 请求 A 查询到优惠券尚未使用。
- 请求 B 也查询到同一张优惠券尚未使用。
- 两个请求均通过可用性检查。
- 两个请求分别创建订单,并按各自的计算结果获得优惠抵扣。
- 两个请求再分别执行优惠券状态更新。
即使最终数据库中只有一条优惠券记录,也不代表优惠金额只被使用了一次。关键在于两笔订单是否都获得了完整的优惠抵扣。
因此,该问题的根因是优惠券使用状态的检查与占用缺少可靠的原子性保证,而不是简单的查询条件缺失。
3.7.3 优惠券领取与库存扣减的并发问题
最后检查优惠券领取逻辑中的限领和库存控制。
在领取流程中,每人限领判断通过查询已有领取记录的数量,再决定是否插入新的领取记录。这种“先查询、再插入”的处理,如果没有适当的事务约束或数据库唯一性限制,就可能受到并发请求影响。
同样,在库存扣减流程中,如果程序先查询整条优惠券记录,在内存中修改库存数量,再将整行记录更新回数据库,那么两个并发请求可能基于相同的旧库存值完成各自的更新,导致库存扣减不符合预期。 部分漏洞复现参考: https://github.com/macrozheng/mall/issues/998 https://github.com/macrozheng/mall/issues/999