吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 702|回复: 7
上一主题 下一主题
收起左侧

[Android 原创] 注销重注册,无限领新人券:一次APP业务逻辑漏洞的完整剖析

[复制链接]
跳转到指定楼层
楼主
W4nk3r 发表于 2026-9-24 11:14 回帖奖励
这类逻辑漏洞在中小型应用中非常普遍,核心问题就一个——账号注销后数据被物理删除,营销风控完全失效。本文用抽象Demo还原完整攻击链路,并从代码层面详细分析漏洞成因,适合安全入门和SRC挖洞参考。


一、漏洞背景各类互联网产品的新用户福利场景,极易出现业务逻辑漏洞。攻击者可利用设计缺陷绕过营销限制,反复领取限时权益,若免费权益为付费资源,则直接造成运营资损。这类问题几乎都源于四大底层设计疏漏:
  • 用户身份校验体系单一,缺少多维度追溯能力
  • 权益发放判断逻辑简单,未核查历史操作记录
  • 注册、注销、营销三大业务模块完全割裂,无状态联动
  • 数据库存储模型设计不合理,数据丢失导致风控失效
本文以一款会员时长类应用作为测试样本,完整拆解因账号注销物理删库带来的权限校验缺陷,复现“注销-重注册”无限领取新人权益的攻击链路,同时搭配抽象伪代码定位底层代码问题,给出通用化修复与审计思路。
二、漏洞实战复现测试目标为一款提供付费时长服务的会员应用,新注册用户可自动领取两天免费使用时长。(由于APP还在投入使用,这里用模拟图代替)完整攻击步骤:
  • 全新临时账号登录,系统自动下发两天免费时长
  • 等待权益剩余时长缩减至1天,复现真实用户使用场景
  • 执行账号注销、清空本地缓存,服务端物理删除当前账号全部数据
  • 让系统二次下发临时账号
  • 系统识别为全新用户,再次下发完整两天新人免费时长,循环操作可无限重置权益






整个流程可以用下图概括: 三、漏洞原理:从代码层面细说这一章我们从数据库设计、注册流程、权益发放、注销流程四个层面,逐行分析为什么会出现这个漏洞。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/设备/手机号冷却
模块联动 注销与营销割裂 状态同步+风控台账

核心问题一句话总结:账号数据生命周期与营销风控体系完全脱节。注销即物理删库,导致风控系统失去历史依据;权益发放只认“当前账号”,不认“人”。

免费评分

参与人数 1热心值 +1 收起 理由
oldbabyjsx + 1 我很赞同!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

推荐
zhouxinyi 发表于 2026-9-24 15:32
这恰好说明人家公司的产品是真正的注销。如果要防重新注册,那么都或多或少的留存了用户身份,不算完全的注销
3#
samson12138 发表于 2026-9-24 16:04
zhouxinyi 发表于 2026-9-24 15:32
这恰好说明人家公司的产品是真正的注销。如果要防重新注册,那么都或多或少的留存了用户身份,不算完全的注 ...

没毛病,但是估计也会有备份,只不过没有校验
4#
 楼主| W4nk3r 发表于 2026-9-24 16:07 |楼主
zhouxinyi 发表于 2026-9-24 15:32
这恰好说明人家公司的产品是真正的注销。如果要防重新注册,那么都或多或少的留存了用户身份,不算完全的注 ...

确实如此师傅,但是吧,真注销没错,但防重注册不用留明文身份,风控表存个不可逆哈希就行,既不泄露隐私也能拦截。漏洞根因不是真给注销了,而是权益记录和用户表绑死、级联删除,注销时把风控也一起删了
5#
 楼主| W4nk3r 发表于 2026-9-24 16:11 |楼主
samson12138 发表于 2026-9-24 16:04
没毛病,但是估计也会有备份,只不过没有校验

这个的话,有备份也白搭,备份是离线灾备,不会拿来实时使用
6#
中二 发表于 2026-9-24 17:43
审核:危害不足,忽略
7#
 楼主| W4nk3r 发表于 2026-9-24 18:07 |楼主
中二 发表于 2026-9-24 17:43
审核:危害不足,忽略

8#
zhouxinyi 发表于 2026-9-24 21:34
对于用户来说是有利的
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-9-24 22:26

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表