这类逻辑漏洞在中小型应用中非常普遍,核心问题就一个——账号注销后数据被物理删除,营销风控完全失效。本文用抽象Demo还原完整攻击链路,并从代码层面详细分析漏洞成因,适合安全入门和SRC挖洞参考。
一、漏洞背景各类互联网产品的新用户福利场景,极易出现业务逻辑漏洞。攻击者可利用设计缺陷绕过营销限制,反复领取限时权益,若免费权益为付费资源,则直接造成运营资损。这类问题几乎都源于四大底层设计疏漏:- 用户身份校验体系单一,缺少多维度追溯能力
- 权益发放判断逻辑简单,未核查历史操作记录
- 注册、注销、营销三大业务模块完全割裂,无状态联动
- 数据库存储模型设计不合理,数据丢失导致风控失效
本文以一款会员时长类应用作为测试样本,完整拆解因账号注销物理删库带来的权限校验缺陷,复现“注销-重注册”无限领取新人权益的攻击链路,同时搭配抽象伪代码定位底层代码问题,给出通用化修复与审计思路。
二、漏洞实战复现测试目标为一款提供付费时长服务的会员应用,新注册用户可自动领取两天免费使用时长。(由于APP还在投入使用,这里用模拟图代替)完整攻击步骤:
- 执行账号注销、清空本地缓存,服务端物理删除当前账号全部数据
- 系统识别为全新用户,再次下发完整两天新人免费时长,循环操作可无限重置权益
整个流程可以用下图概括:
三、漏洞原理:从代码层面细说这一章我们从数据库设计、注册流程、权益发放、注销流程四个层面,逐行分析为什么会出现这个漏洞。3.1 数据库表结构设计缺陷
先看存在问题的表结构(伪代码):
[SQL] 纯文本查看 复制代码 -- 用户表
CREATE TABLE user (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
phone VARCHAR(20) UNIQUE,
email VARCHAR(100) UNIQUE,
status ENUM('active', 'deleted') DEFAULT 'active',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 权益记录表
CREATE TABLE benefit_record (
record_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT,
benefit_type VARCHAR(50),
grant_time DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES user(user_id) ON DELETE CASCADE
);
致命问题就在 ON DELETE CASCADE:- 当用户注销时,如果执行 DELETE FROM user WHERE user_id = ?,数据库会自动级联删除 benefit_record 中所有关联记录。
- 这意味着:用户的历史领取记录随着账号一起被物理删除,永久丢失。
- 即使没有级联删除,如果注销时手动删除了权益记录,效果也一样。
正确做法应该是软删除:
[SQL] 纯文本查看 复制代码 -- 注销时只标记,不删除
UPDATE user SET status = 'deleted', deleted_at = NOW() WHERE user_id = ?;
-- 权益记录表保留,用于历史追溯
3.2 注册流程代码分析有缺陷的注册接口伪代码:[Java] 纯文本查看 复制代码 @RestController
@RequestMapping("/api/user")
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/register")
public Result register(@RequestParam String phone, @RequestParam String email) {
return userService.register(phone, email);
}
}[Java] 纯文本查看 复制代码 @Service
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private BenefitService benefitService;
public Result register(String phone, String email) {
// 1. 检查手机号/邮箱是否已存在(仅查 active 状态)
User existing = userMapper.selectByPhoneAndStatus(phone, "active");
if (existing != null) {
return Result.fail(400, "该手机号已注册");
}
// 2. 创建新用户
User user = new User();
user.setPhone(phone);
user.setEmail(email);
user.setStatus("active");
userMapper.insert(user);
// 3. 调用权益发放逻辑
benefitService.grantNewUserBenefit(user.getUserId());
return Result.success(user.getUserId());
}
}
对应的MyBatis Mapper:
[XML] 纯文本查看 复制代码 <select id="selectByPhoneAndStatus" resultType="com.demo.entity.User">
SELECT * FROM user WHERE phone = #{phone} AND status = #{status}
</select> 问题点:- 第1步只查询 status = 'active' 的用户,已注销(或被物理删除)的账号完全不在查询范围内。
- 如果注销时是物理删除,那么 SELECT 根本查不到任何记录,直接判定为全新手机号。
- 即使注销时是软删除(status = 'deleted'),但这里只查 active,依然会漏掉历史账号。
正确做法:注册时应查询该手机号/邮箱的所有历史账号,包括已注销的:
[Java] 纯文本查看 复制代码 public Result register(String phone, String email) {
// 查询所有历史账号,不限制 status
List<User> historical = userMapper.selectAllByPhone(phone);
if (historical != null && !historical.isEmpty()) {
// 存在历史账号,进一步检查是否领取过新人权益
for (User u : historical) {
if (benefitService.hasClaimedNewUserBenefit(u.getUserId())) {
return Result.fail(403, "该手机号已领取过新人权益");
}
}
}
// ... 继续注册
}
[XML] 纯文本查看 复制代码 <select id="selectAllByPhone" resultType="com.demo.entity.User">
SELECT * FROM user WHERE phone = #{phone}
</select> 3.3 权益发放代码分析
有缺陷的权益发放逻辑:
[Java] 纯文本查看 复制代码 @Service
public class BenefitService {
@Autowired
private UserMapper userMapper;
@Autowired
private BenefitRecordMapper benefitRecordMapper;
@Autowired
private BenefitDurationService durationService;
public boolean grantNewUserBenefit(Long userId) {
// 1. 判断当前账号是否存在
User user = userMapper.selectById(userId);
if (user == null) {
return false;
}
// 2. 检查该账号是否已领取过新人权益
BenefitRecord record = benefitRecordMapper
.selectByUserIdAndType(userId, "new_user");
if (record != null) {
return false; // 已领取过
}
// 3. 发放权益
BenefitRecord newRecord = new BenefitRecord();
newRecord.setUserId(userId);
newRecord.setBenefitType("new_user");
benefitRecordMapper.insert(newRecord);
durationService.addBenefitDuration(userId, 2);
return true;
}
}
对应的 Mapper:
[XML] 纯文本查看 复制代码 <select id="selectByUserIdAndType" resultType="com.demo.entity.BenefitRecord">
SELECT * FROM benefit_record
WHERE user_id = #{userId} AND benefit_type = #{benefitType}
</select>
问题点:- 第2步只检查当前 userId 是否领取过。如果账号被物理删除,benefit_record 也被级联删除,那么新注册的 userId 完全不同,查询结果为空,自然判定为“未领取”。
- 即使 benefit_record 没被删除,但新注册的 userId 是新的,旧记录关联的是旧 userId,依然查不到。
- 根本原因:权益发放只绑定 userId,没有绑定手机号/邮箱/设备指纹等真实身份标识。
正确做法:权益发放应追溯该手机号/邮箱的所有历史账号:[Java] 纯文本查看 复制代码 public boolean grantNewUserBenefit(Long userId, String phone, String email) {
// 查询该手机号/邮箱的所有历史账号
List<User> historicalUsers = userMapper.selectAllByPhoneOrEmail(phone, email);
for (User u : historicalUsers) {
BenefitRecord record = benefitRecordMapper
.selectByUserIdAndType(u.getUserId(), "new_user");
if (record != null) {
return false; // 该身份已领取过
}
}
// 发放权益
BenefitRecord newRecord = new BenefitRecord();
newRecord.setUserId(userId);
newRecord.setBenefitType("new_user");
benefitRecordMapper.insert(newRecord);
durationService.addBenefitDuration(userId, 2);
return true;
}[XML] 纯文本查看 复制代码 <select id="selectAllByPhoneOrEmail" resultType="com.demo.entity.User">
SELECT * FROM user WHERE phone = #{phone} OR email = #{email}
</select>
3.4 注销流程代码分析
有缺陷的注销逻辑:
[Java] 纯文本查看 复制代码 @Service
public class AccountService {
@Autowired
private UserMapper userMapper;
public Result deleteAccount(Long userId) {
// 直接物理删除用户
userMapper.deleteById(userId);
// 由于外键 ON DELETE CASCADE,benefit_record 自动删除
return Result.success("账号已注销");
}
}
[XML] 纯文本查看 复制代码 <delete id="deleteById">
DELETE FROM user WHERE user_id = #{userId}
</delete>
问题点:- 物理删除用户,没有任何留存。
- 级联删除权益记录,风控数据彻底消失。
- 没有记录注销台账(如注销时间、注销原因、手机号哈希等)
正确做法:
[Java] 纯文本查看 复制代码 @Service
public class AccountService {
@Autowired
private UserMapper userMapper;
@Autowired
private AccountDeletionLogMapper deletionLogMapper;
@Transactional
public Result deleteAccount(Long userId) {
// 软删除:标记状态,保留数据
userMapper.softDelete(userId);
// 权益记录表不动,保留审计线索
// 记录注销日志
User user = userMapper.selectById(userId);
AccountDeletionLog log = new AccountDeletionLog();
log.setUserId(userId);
log.setPhone(user.getPhone());
log.setDeletedAt(new Date());
deletionLogMapper.insert(log);
return Result.success("账号已注销");
}
}
[XML] 纯文本查看 复制代码 <update id="softDelete">
UPDATE user SET status = 'deleted', deleted_at = NOW() WHERE user_id = #{userId}
</update>
3.5 完整攻击链路代码时序图结合以上代码,攻击链路如下:[Asm] 纯文本查看 复制代码 攻击者 服务端
| |
|-- 注册手机号A ------------->|
| |-- 查询user表(仅active):无记录
| |-- INSERT user (phone=A, status=active)
| |-- 发放新人权益:INSERT benefit_record
|<-- 获得2天免费时长 ---------|
| |
|-- 使用至剩余1天 ----------->|
| |
|-- 注销账号A --------------->|
| |-- DELETE FROM user WHERE phone=A
| |-- ON DELETE CASCADE 删除 benefit_record
|<-- 注销成功 ----------------|
| |
|-- 重新注册手机号A --------->|
| |-- 查询user表(仅active):无记录(已被物理删除)
| |-- INSERT user (phone=A, status=active) 新userId
| |-- 检查benefit_record:新userId无记录
| |-- 再次发放2天新人权益
|<-- 再次获得2天免费时长 -----|
| |
|-- 循环注销-注册 ----------->| 无限领取 核心结论:- 物理删除 + 级联删除 → 历史数据归零
- 注册只查 active → 历史账号不可见
- 权益发放只绑 userId → 新账号新ID,查不到旧记录
- 注销无冷却、无频率限制 → 可脚本批量循环
四者叠加,就形成了“注销即重置身份,重注册即刷新权益”的漏洞四、修复方案修复方案一:注销数据持久化(软删除)
[SQL] 纯文本查看 复制代码 -- 用户表增加删除标记
ALTER TABLE user ADD COLUMN deleted_at DATETIME DEFAULT NULL;
-- 取消级联删除
ALTER TABLE benefit_record DROP FOREIGN KEY fk_user_id; [Java] 纯文本查看 复制代码 @Transactional
public Result deleteAccount(Long userId) {
userMapper.softDelete(userId);
return Result.success("账号已注销");
}[XML] 纯文本查看 复制代码 <update id="softDelete">
UPDATE user SET status = 'deleted', deleted_at = NOW() WHERE user_id = #{userId}
</update> 修复方案二:多维度身份追溯
[Java] 纯文本查看 复制代码 @Transactional
public boolean grantNewUserBenefit(Long userId, String phone,
String email, String deviceId, String ip) {
// 1. 查询该手机号/邮箱的历史所有账号(含已注销)
List<User> historicalUsers = userMapper.selectAllByPhoneOrEmail(phone, email);
for (User u : historicalUsers) {
BenefitRecord record = benefitRecordMapper
.selectByUserIdAndType(u.getUserId(), "new_user");
if (record != null) {
return false;
}
}
// 2. 设备指纹校验
if (deviceRiskService.isBlacklisted(deviceId)) {
return false;
}
// 3. 发放
BenefitRecord newRecord = new BenefitRecord();
newRecord.setUserId(userId);
newRecord.setBenefitType("new_user");
benefitRecordMapper.insert(newRecord);
durationService.addBenefitDuration(userId, 2);
return true;
}
修复方案三:注册频率冷却风控
[Java] 纯文本查看 复制代码 @Transactional
public Result register(String phone, String email, String deviceId, String ip) {
// 1. IP频率限制:同一IP 24小时内最多注册3次
int ipCount = registerLogMapper.countByIpWithinHours(ip, 24);
if (ipCount >= 3) {
throw new RateLimitException("注册频率过高");
}
// 2. 设备指纹黑名单
if (deviceRiskService.isBlacklisted(deviceId)) {
throw new ForbiddenException("设备已被封禁");
}
// 3. 手机号冷却期:注销后30天内不可重新注册
if (registerLogMapper.isInCooldown(phone, 30)) {
throw new ForbiddenException("该手机号处于冷却期");
}
User user = new User();
user.setPhone(phone);
user.setEmail(email);
user.setDeviceId(deviceId);
user.setRegisterIp(ip);
user.setStatus("active");
userMapper.insert(user);
return Result.success(user.getUserId());
}
修复方案四:数据库模型优化
[SQL] 纯文本查看 复制代码 -- 软删除
ALTER TABLE user MODIFY COLUMN status ENUM('active','deleted') DEFAULT 'active';
-- 取消级联删除,保留权益记录
ALTER TABLE benefit_record DROP FOREIGN KEY fk_user_id;
-- 添加历史查询索引
CREATE INDEX idx_phone ON user(phone);
CREATE INDEX idx_email ON user(email);
CREATE INDEX idx_benefit_user ON benefit_record(user_id);
五、漏洞总结
| 维度 | 问题 | 修复方向 | | 身份校验 | 仅看当前账号状态 | 多维度追溯历史身份 | | 数据存储 | 物理删除+级联删除 | 软删除+审计台账 | | 风控策略 | 无频率限制 | IP/设备/手机号冷却 | | 模块联动 | 注销与营销割裂 | 状态同步+风控台账 |
核心问题一句话总结:账号数据生命周期与营销风控体系完全脱节。注销即物理删库,导致风控系统失去历史依据;权益发放只认“当前账号”,不认“人”。
|