# 业务逻辑安全检测模块

> 基于定时任务、配置管理等复杂业务流程的安全检测
> 针对企业级应用中的业务逻辑安全风险

## 概述

业务逻辑漏洞利用预期功能违反领域不变量: 免费转账、超出限额、降级后保留特权、绕过审批流程等。与需要载荷的注入漏洞不同，业务逻辑漏洞需要理解业务领域及其不变量。

**关键**: 业务逻辑安全是在对抗性排序、时机和输入下对领域不变量的强制执行。如果任何步骤信任客户端或前置步骤，预期会被滥用。

---

## 系统化方法论

### 1. 角色 × 操作 × 资源矩阵

构建全面的矩阵以识别授权缺口:

**角色（身份）:**
- 未认证用户
- 基础/免费用户
- 高级/付费用户
- 试用用户
- 客服/支持人员
- 管理员/超级管理员
- 跨租户角色（多租户系统中）

**操作:**
- 创建、读取、更新、删除（CRUD）
- 状态转换（审批、拒绝、取消、退款）
- 计算（定价、折扣、配额）
- 后台操作（任务、Webhook、Saga）

**资源:**
- 用户账户、个人资料、设置
- 金融实体（订单、支付、退款、积分）
- 内容（帖子、文件、文档）
- 配置（设置、功能、限制）
- 跨租户资源

**检测模式:**
```java
// 对每个端点，检查:
// 1. 哪些角色可以访问此操作?
// 2. 是否有服务端的角色-资源所有权验证?
// 3. 低权限角色是否可以访问高权限操作?

@PostMapping("/admin/users/{id}/promote")
public Response promoteToAdmin(@PathVariable Long id) {
    // ❌ 缺失: 谁可以调用此接口?
    // ❌ 缺失: 用户能否自行提升权限?
    // ❌ 缺失: 非管理员能否调用此端点?
    userService.setRole(id, "ADMIN");
}
```

### 2. 状态机枚举

对每个关键工作流，枚举:
- **状态**: 草稿、待审批、已批准、已完成、已取消、已退款
- **转换**: 有效的状态变更路径
- **前置条件**: 转换前必须满足的条件
- **后置条件**: 转换后必须满足的条件
- **不变量**: 必须始终成立的规则

**示例: 订单状态机**
```
States: Created → PendingPayment → Paid → Shipped → Delivered → [Cancelled/Refunded]

Invariants:
- 价值守恒: Sum(payments) - Sum(refunds) = 订单总额
- 单调性: 创建时间戳 < 支付时间戳 < 发货时间戳
- 唯一性: 每个购物车会话只有一个活跃订单
- 退款约束: 退款总额 ≤ 已捕获金额
```

**检测模式:**
```java
// ❌ 状态转换未检查前置条件
public void shipOrder(Long orderId) {
    // 缺失: 检查订单是否处于"已支付"状态
    // 缺失: 检查订单是否已被取消
    // 缺失: 检查库存是否仍然可用
    orderRepo.updateStatus(orderId, OrderStatus.SHIPPED);
}

// ✓ 安全: 验证前置条件
public void shipOrder(Long orderId) {
    Order order = orderRepo.findByIdForUpdate(orderId);

    if (order.getStatus() != OrderStatus.PAID) {
        throw new IllegalStateException("Order not paid");
    }

    if (order.isCancelled()) {
        throw new IllegalStateException("Order cancelled");
    }

    if (!inventory.reserve(order.getItems())) {
        throw new BusinessException("Insufficient inventory");
    }

    order.setStatus(OrderStatus.SHIPPED);
    order.setShippedAt(Instant.now());
    orderRepo.save(order);
}
```

### 3. 不变量验证

**价值守恒:**
```java
// 金融交易必须平衡
// 检测: 不为零的账本条目
// 检测: 退款超过已捕获金额

// ❌ 无守恒检查
public void refund(Long orderId, BigDecimal amount) {
    refundService.process(orderId, amount);
}

// ✓ 强制守恒
public void refund(Long orderId, BigDecimal amount) {
    Order order = orderRepo.findById(orderId);
    BigDecimal totalRefunded = refundRepo.sumByOrderId(orderId);

    if (totalRefunded.add(amount).compareTo(order.getTotalPaid()) > 0) {
        throw new BusinessException("Refund exceeds paid amount");
    }

    refundService.process(orderId, amount);
}
```

**唯一性:**
```java
// 一次性令牌、唯一优惠券码、一个活跃订阅
// 检测: 数据库缺少唯一约束
// 检测: 缺乏原子性的检查后执行模式

// ❌ 唯一性竞态条件
if (!couponRepo.existsByCode(code)) {
    couponRepo.save(new Coupon(code));  // 竞态窗口!
}

// ✓ 数据库强制唯一性
@Entity
public class Coupon {
    @Column(unique = true, nullable = false)
    private String code;
}
```

**单调性:**
```java
// 时间戳、版本号、序列 ID 应只增不减
// 检测: 时间回退、版本回滚

// ❌ 无单调性检查
public void updateVersion(Long id, int newVersion) {
    entity.setVersion(newVersion);  // 可能回退!
}

// ✓ 强制单调性
public void updateVersion(Long id, int newVersion) {
    Entity entity = repo.findById(id);
    if (newVersion <= entity.getVersion()) {
        throw new BusinessException("Version must increase");
    }
    entity.setVersion(newVersion);
}
```

### 4. 攻击场景测试

**步骤跳过:**
```java
// 能否在未完成步骤 1 和 2 的情况下调用步骤 3?
// 示例: 未经授权直接捕获支付
// 示例: 未经支付直接发货

// 检测: 直接调用完成/结束端点的 API 调用
// 检测: 缺少状态验证
```

**步骤重复:**
```java
// 能否重复执行只应发生一次的步骤?
// 示例: 多次应用同一优惠券码
// 示例: 多次兑换礼品卡

// 检测: 缺少幂等性控制
// 检测: 无"已使用"标记或状态跟踪
```

**步骤重排:**
```java
// 能否乱序执行步骤?
// 示例: 在捕获之前退款
// 示例: 在发货之后取消

// 检测: 缺少前置条件检查
// 检测: 状态机未强制执行
```

**延迟篡改:**
```java
// 能否在验证之后、提交之前修改输入?
// 示例: 审批后更改价格
// 示例: 库存检查后替换商品

// 检测: TOCTOU (Time-of-check Time-of-use) 间隔
// 检测: 验证与操作分离
```

---

## 高价值目标类别

### 1. 金融逻辑

**定价与折扣:**
```java
// ❌ 接受客户端计算的总价
@PostMapping("/checkout")
public Order checkout(@RequestBody CheckoutRequest req) {
    // 信任来自客户端的 req.total!
    return paymentService.charge(req.getTotal());
}

// ✓ 服务端重新计算一切
@PostMapping("/checkout")
public Order checkout(@RequestBody CheckoutRequest req) {
    BigDecimal total = pricingEngine.calculate(req.getItems());
    BigDecimal discount = discountEngine.apply(req.getCouponCode(), total);
    BigDecimal tax = taxService.calculate(total.subtract(discount), req.getAddress());
    BigDecimal finalTotal = total.subtract(discount).add(tax);

    return paymentService.charge(finalTotal);
}
```

**折扣叠加:**
```java
// ❌ 无互斥检查
public BigDecimal applyDiscount(List<String> couponCodes, BigDecimal amount) {
    BigDecimal discounted = amount;
    for (String code : couponCodes) {
        discounted = discounted.multiply(getDiscountRate(code));
    }
    return discounted;  // 可能叠加不兼容的折扣!
}

// ✓ 强制互斥
public BigDecimal applyDiscount(List<String> couponCodes, BigDecimal amount) {
    if (couponCodes.size() > 1) {
        List<String> categories = couponCodes.stream()
            .map(this::getCouponCategory)
            .distinct()
            .collect(Collectors.toList());

        if (categories.size() > 1) {
            throw new BusinessException("Cannot stack coupons from different categories");
        }
    }

    // 仅应用最优折扣
    return couponCodes.stream()
        .map(code -> calculateDiscount(code, amount))
        .min(Comparator.naturalOrder())
        .orElse(amount);
}
```

**退款逻辑:**
```java
// ❌ 多条退款路径缺乏协调
// 路径 1: UI 退款
// 路径 2: 客服工具退款
// 路径 3: 自动退款（拒付）
// 结果: 可能重复退款

// ✓ 集中式退款账本
public void refund(Long orderId, BigDecimal amount, RefundSource source) {
    synchronized (getLock(orderId)) {  // 或使用数据库锁
        BigDecimal totalRefunded = refundLedger.sumByOrderId(orderId);
        BigDecimal totalCaptured = paymentLedger.sumCapturedByOrderId(orderId);

        if (totalRefunded.add(amount).compareTo(totalCaptured) > 0) {
            throw new BusinessException("Total refunds would exceed captured amount");
        }

        refundLedger.create(orderId, amount, source);
        paymentGateway.refund(orderId, amount);
    }
}
```

### 2. 配额与限制

```java
// ❌ 非原子配额检查
int usage = quotaService.getUsage(userId);
if (usage < LIMIT) {
    // 竞态窗口 - 配额可被超出
    processRequest();
    quotaService.increment(userId);
}

// ✓ 原子配额强制执行
long newUsage = quotaService.incrementAndGet(userId);
if (newUsage > LIMIT) {
    quotaService.decrement(userId);  // 回滚
    throw new QuotaExceededException();
}
processRequest();
```

**限制切片:**
```java
// ❌ 仅有单笔限额而无总额限制
// 用户发送 100 笔 $99 的交易以绕过 $100 限额

// ✓ 同时检查单笔限额和总额限制
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
    if (amount.compareTo(PER_TRANSACTION_LIMIT) > 0) {
        throw new BusinessException("Per-transaction limit exceeded");
    }

    BigDecimal dailyTotal = transferRepo.sumTodayByUser(fromUserId);
    if (dailyTotal.add(amount).compareTo(DAILY_LIMIT) > 0) {
        throw new BusinessException("Daily limit exceeded");
    }

    executeTransfer(fromUserId, toUserId, amount);
}
```

### 3. 订阅与账户生命周期

```java
// ❌ 降级后保留角色
public void downgradeSubscription(Long userId) {
    subscriptionRepo.updateTier(userId, "FREE");
    // 缺失: 移除高级功能/权限
    // 用户保留了高级能力!
}

// ✓ 完整状态转换
@Transactional
public void downgradeSubscription(Long userId) {
    User user = userRepo.findByIdForUpdate(userId);

    // 检查前置条件
    if (user.getSubscription().getTier() == Tier.FREE) {
        throw new BusinessException("Already on free tier");
    }

    // 转换状态
    user.getSubscription().setTier(Tier.FREE);

    // 强制后置条件
    user.setMaxProjects(FREE_TIER_LIMIT);
    user.getPremiumFeatures().clear();
    featureGateService.revokePremiumAccess(userId);

    // 删除多余资源
    projectService.deleteExcessProjects(userId, FREE_TIER_LIMIT);

    userRepo.save(user);
    auditLog.record("SUBSCRIPTION_DOWNGRADED", userId);
}
```

### 4. 多租户隔离

```java
// ❌ 缺少租户边界检查
public List<Document> search(String query) {
    return documentRepo.search(query);  // 跨租户泄漏!
}

// ✓ 租户范围查询
public List<Document> search(String query) {
    String tenantId = SecurityContext.getCurrentTenantId();
    return documentRepo.searchByTenant(tenantId, query);
}

// ❌ 聚合操作无租户过滤
public void resetAllUserCounters() {
    userRepo.updateAll("counter", 0);  // 影响所有租户!
}

// ✓ 租户感知操作
public void resetTenantUserCounters(String tenantId) {
    validateTenantAdmin(tenantId);  // 授权检查
    userRepo.updateByTenant(tenantId, "counter", 0);
    auditLog.record("BULK_COUNTER_RESET", tenantId);
}
```

---

## 检测模式

### 客户端计算值

```bash
# 搜索接受客户端传入总价、价格、折扣的端点
grep -rn "BigDecimal.*total\|Double.*price\|int.*discount" --include="*.java" -B 3

# 查找通过 @RequestBody 或 @RequestParam 接收金融值的端点
grep -rn "@Request.*total\|@Request.*price\|@Request.*amount" --include="*.java" -A 5
```

**警示信号:**
- 请求 DTO 包含 `total`、`finalPrice`、`discountedAmount` 字段
- 服务端未重新计算价格
- 注释如 "// TODO: validate total"

### 缺少状态验证

```bash
# 查找缺少状态检查的状态变更方法
grep -rn "public.*void.*(complete\|finalize\|approve\|ship\|refund)" --include="*.java" -A 10 | \
grep -v "if.*status\|if.*state\|getStatus()\|getState()"
```

### 幂等性缺口

```bash
# 查找应该具备幂等性但缺少控制机制的操作
grep -rn "@Post.*charge\|@Post.*refund\|@Post.*apply\|@Post.*redeem" --include="*.java" -A 15 | \
grep -v "idempotency\|idempotent\|@Transactional.*SERIALIZABLE"
```

### 后台任务安全

```bash
# 查找定时任务和异步任务
grep -rn "@Scheduled\|@Async\|@RabbitListener\|@KafkaListener" --include="*.java" -A 10

# 检查是否绕过授权
grep -rn "@Scheduled" --include="*.java" -A 10 | \
grep -v "checkPermission\|hasRole\|@RequiresPermissions"
```

---

## 🔍 风险模式库

### 风险模式1: 定时任务管理业务逻辑漏洞（高危）

#### 漏洞代码示例
```java
// ❌ 高危: 定时任务创建无业务逻辑验证
@PostMapping("/monitor/job/add")
public AjaxResult addSave(SysJob job) {
    // 缺少业务逻辑验证:
    // - 方法调用权限验证
    // - 参数内容安全检查
    // - 执行频率限制验证
    return toAjax(jobService.insertJobCron(job));
}

// ❌ 高危: 定时任务执行无资源限制
public class ScheduleRunnable implements Runnable {
    public void run() {
        // 无资源使用限制
        // 无执行时间限制
        // 无异常处理限制
        method.invoke(target, params);
    }
}
```

### 风险模式2: 配置管理业务逻辑漏洞（中危）

#### 漏洞代码示例
```java
// ❌ 中危: 系统配置修改无权限验证
@PostMapping("/system/config/edit")
public AjaxResult editSave(SysConfig config) {
    // 缺少配置修改的业务逻辑验证:
    // - 配置项权限验证
    // - 配置值格式验证
    // - 配置影响范围评估
    return toAjax(configService.updateConfig(config));
}
```

### 风险模式3: 数据导出业务逻辑漏洞（中危）

#### 漏洞代码示例
```java
// ❌ 中危: 数据导出无权限和范围控制
@PostMapping("/system/user/export")
public AjaxResult export(SysUser user) {
    // 缺少数据导出的业务逻辑验证:
    // - 导出数据范围控制
    // - 导出频率限制
    // - 敏感数据过滤
    List<SysUser> list = userService.selectUserList(user);
    return util.exportExcel(list, "user");
}
```

## 🔧 检测命令集

### 业务逻辑接口检测
```bash
# 1. 定时任务管理业务检测
grep -rn "@.*Mapping.*/monitor/job" --include="*.java" -A 15

# 2. 配置管理业务检测
grep -rn "@.*Mapping.*/system/config" --include="*.java" -A 15

# 3. 数据导出业务检测
grep -rn "@.*Mapping.*/export" --include="*.java" -A 15

# 4. 权限控制业务检测
grep -rn "@RequiresPermissions" --include="*.java" -B 2 -A 5

# 5. 业务逻辑验证检测
grep -rn "validate\|check\|verify" --include="*.java" -B 2 -A 2
```

### 业务数据流检测
```bash
# 1. 用户输入到业务逻辑的完整路径
grep -rn "@.*Mapping" --include="*.java" | head -20

# 2. 业务逻辑处理链条检测
grep -rn "Service\." --include="*.java" | grep -E "save|update|delete|execute"

# 3. 数据库操作业务逻辑检测
grep -rn "Mapper\." --include="*.java" -B 3 -A 3
```

## 🛡️ 安全修复方案

### 修复方案1: 业务逻辑权限验证

```java
// ✓ 安全: 业务逻辑权限验证
@RequiresPermissions("monitor:job:add")
@PostMapping("/monitor/job/add")
public AjaxResult addSave(SysJob job) {
    // 业务逻辑权限验证
    if (!hasJobCreationPermission(job)) {
        return error("No permission to create this job");
    }

    // 业务逻辑参数验证
    if (!isValidJobConfiguration(job)) {
        return error("Invalid job configuration");
    }

    // 业务逻辑资源限制验证
    if (!hasSufficientResources(job)) {
        return error("Insufficient resources for this job");
    }

    return toAjax(jobService.insertJobCron(job));
}

private boolean hasJobCreationPermission(SysJob job) {
    // 细粒度业务权限控制
    return SecurityUtils.getSubject().isPermitted("job:create:" + job.getJobGroup());
}

private boolean isValidJobConfiguration(SysJob job) {
    // 业务逻辑配置验证
    return job.getCronExpression() != null &&
           job.getMethodName() != null &&
           isAllowedMethod(job.getMethodName());
}
```

### 修复方案2: 业务数据范围控制

```java
// ✓ 安全: 业务数据范围控制
@PostMapping("/system/user/export")
public AjaxResult export(SysUser user) {
    // 业务数据范围控制
    if (!hasDataExportPermission(user)) {
        return error("No permission to export this data");
    }

    // 业务数据过滤
    user = filterSensitiveData(user);

    // 业务频率限制
    if (!checkExportFrequency()) {
        return error("Export frequency limit exceeded");
    }

    List<SysUser> list = userService.selectUserList(user);
    return util.exportExcel(list, "user");
}

private boolean hasDataExportPermission(SysUser user) {
    // 业务数据权限控制
    User currentUser = SecurityUtils.getCurrentUser();
    return currentUser.hasPermission("data:export:" + user.getDeptId());
}
```

### 修复方案3: 业务资源限制控制

```java
// ✓ 安全: 业务资源限制控制
public class ScheduleRunnable implements Runnable {
    private static final long MAX_EXECUTION_TIME = 30000; // 30秒
    private static final int MAX_MEMORY_USAGE = 1024; // 1GB

    public void run() {
        long startTime = System.currentTimeMillis();

        try {
            // 执行时间限制
            if (System.currentTimeMillis() - startTime > MAX_EXECUTION_TIME) {
                throw new TimeoutException("Execution time exceeded");
            }

            // 内存使用限制
            if (getMemoryUsage() > MAX_MEMORY_USAGE) {
                throw new MemoryLimitException("Memory usage exceeded");
            }

            method.invoke(target, params);

        } catch (Exception e) {
            log.error("Task execution failed", e);
        }
    }
}
```

## 📊 风险评级矩阵

| 风险类型 | 严重性 | 利用难度 | 检测难度 | 修复优先级 |
|----------|--------|----------|----------|------------|
| 定时任务业务逻辑 | 🔴 高危 | 中 | 高 | 立即修复 |
| 配置管理业务逻辑 | 🟡 中危 | 中 | 中 | 计划修复 |
| 数据导出业务逻辑 | 🟡 中危 | 高 | 中 | 计划修复 |
| 权限控制业务逻辑 | 🟡 中危 | 低 | 低 | 计划修复 |

## ⚠️ 安全最佳实践

1. **业务权限控制**: 实现细粒度的业务逻辑权限验证
2. **数据范围控制**: 严格限制业务数据的访问和操作范围
3. **资源使用限制**: 对业务操作的资源使用进行限制
4. **异常处理机制**: 完善的业务异常处理和日志记录
5. **业务流程审计**: 完整的业务流程操作审计追踪

## 🎯 检测优先级

### 高危检测项（立即执行）
- [ ] 定时任务业务逻辑安全检测
- [ ] 方法调用权限验证缺失检测
- [ ] 资源使用限制缺失检测

### 中危检测项（计划执行）
- [ ] 配置管理业务逻辑检测
- [ ] 数据导出业务逻辑检测
- [ ] 权限控制业务逻辑检测

### 基础检测项（常规执行）
- [ ] 业务流程数据流追踪
- [ ] 业务异常处理机制检测
- [ ] 业务操作审计日志检测

---

## 📊 真实案例：若依管理系统数据权限过滤

### 案例背景
**项目**: RuoYi v3.1
**模块**: 数据权限过滤（Data Scope）
**风险**: MyBatis动态SQL拼接安全风险
**CVSS**: 6.5 (Medium)

### 漏洞代码分析

#### Mapper XML配置
```xml
<!-- SysDeptMapper.xml:38-52 -->
<select id="selectDeptList" parameterType="SysDept" resultMap="SysDeptResult">
    <include refid="selectDeptVo"/>
    where d.del_flag = '0'
    <if test="parentId != null and parentId != 0">
        AND parent_id = #{parentId}
    </if>
    <if test="deptName != null and deptName != ''">
        AND dept_name like concat('%', #{deptName}, '%')  <!-- ✓ 安全的参数化 -->
    </if>
    <!-- ❌ 关键风险点: 使用${}进行SQL拼接 -->
    ${params.dataScope}
</select>
```

#### AOP切面实现
```java
// DataScopeAspect.java:74-105
@Aspect
@Component
public class DataScopeAspect {

    @Before("dataScopePointCut()")
    public void doBefore(JoinPoint point) throws Throwable {
        handleDataScope(point);
    }

    protected void handleDataScope(final JoinPoint joinPoint) {
        // 获得注解
        DataScope controllerDataScope = getAnnotationLog(joinPoint);
        if (controllerDataScope == null) {
            return;
        }

        // 获取当前的用户
        SysUser currentUser = ShiroUtils.getSysUser();
        if (currentUser != null && !currentUser.isAdmin()) {
            // ❌ 非管理员需要数据权限过滤
            dataScopeFilter(joinPoint, currentUser, controllerDataScope.tableAlias());
        }
    }

    public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String alias) {
        StringBuilder sqlString = new StringBuilder();

        for (SysRole role : user.getRoles()) {
            String dataScope = role.getDataScope();

            if (DATA_SCOPE_ALL.equals(dataScope)) {
                // 全部数据权限 - 清空过滤条件
                sqlString = new StringBuilder();
                break;
            }
            else if (DATA_SCOPE_CUSTOM.equals(dataScope)) {
                // ❌ 关键问题: 使用字符串格式化拼接SQL
                sqlString.append(StringUtils.format(
                    " OR {}.dept_id IN ( SELECT dept_id FROM sys_role_dept WHERE role_id = {} ) ",
                    alias,           // ❌ 虽然来自注解，但设计不安全
                    role.getRoleId() // ❌ 来自数据库，但仍是字符串拼接
                ));
            }
            else if (DATA_SCOPE_DEPT.equals(dataScope)) {
                sqlString.append(StringUtils.format(
                    " OR {}.dept_id = {} ",
                    alias, user.getDeptId()
                ));
            }
        }

        if (StringUtils.isNotBlank(sqlString.toString())) {
            BaseEntity baseEntity = (BaseEntity) joinPoint.getArgs()[0];
            // ❌ 将拼接的SQL片段放入params，然后在XML中用${}
            baseEntity.getParams().put(DATA_SCOPE, " AND (" + sqlString.substring(4) + ")");
        }
    }
}
```

#### StringUtils.format实现
```java
// StrFormatter.java:30-91
public static String format(final String strPattern, final Object... argArray) {
    // ❌ 简单的字符串替换，没有SQL安全处理
    final int strPatternLength = strPattern.length();
    StringBuilder sbuf = new StringBuilder(strPatternLength + 50);

    int handledPosition = 0;
    int delimIndex;

    for (int argIndex = 0; argIndex < argArray.length; argIndex++) {
        delimIndex = strPattern.indexOf(EMPTY_JSON, handledPosition);  // 查找 {}
        if (delimIndex == -1) {
            if (handledPosition == 0) {
                return strPattern;
            } else {
                sbuf.append(strPattern, handledPosition, strPatternLength);
                return sbuf.toString();
            }
        } else {
            sbuf.append(strPattern, handledPosition, delimIndex);
            sbuf.append(Convert.utf8Str(argArray[argIndex]));  // ❌ 直接拼接
            handledPosition = delimIndex + 2;
        }
    }

    sbuf.append(strPattern, handledPosition, strPattern.length());
    return sbuf.toString();
}
```

### 审计发现过程

```bash
# 1. 搜索MyBatis ${}用法
grep -rn '\$\{' --include="*.xml"
# 发现: ${params.dataScope} 在多个Mapper中出现

# 2. 追踪dataScope来源
grep -rn "dataScope" --include="*.java"
# 发现: DataScopeAspect.java

# 3. 分析@DataScope注解使用
grep -rn "@DataScope" --include="*.java"
# 发现: 在Service层广泛使用

# 4. 检查StringUtils.format实现
grep -rn "public.*format" --include="*.java" -A 20 | grep -i "sql"

# 5. 对比安全的concat用法
grep -rn "concat\(" --include="*.xml"
# 发现: 大部分地方正确使用concat('%', #{param}, '%')
```

### 风险分析

虽然这不是直接的SQL注入（因为数据源相对可信），但存在以下问题：

#### 1. 设计缺陷
```
违反"所有SQL必须参数化"的基本安全原则
使用字符串拼接而非MyBatis的#{}参数化
```

#### 2. 潜在风险
```
如果注解配置可被篡改（配置注入）
如果存在其他代码注入点影响alias
如果数据库数据被污染（roleId被篡改）
```

#### 3. 维护风险
```
后续开发可能错误地将用户输入注入到params中
代码审查时容易忽视这种"内部"SQL拼接
增加了漏洞的攻击面
```

### 安全修复方案

#### 方案1: 完全消除${}（推荐）

```java
// ✓ Mapper接口增加参数
List<SysDept> selectDeptList(@Param("dept") SysDept dept,
                              @Param("dataScopeSql") String dataScopeSql,
                              @Param("dataScopeIds") List<Long> dataScopeIds);

// ✓ XML改为完全参数化
<select id="selectDeptList" resultMap="SysDeptResult">
    <include refid="selectDeptVo"/>
    where d.del_flag = '0'
    <if test="deptName != null and deptName != ''">
        AND dept_name like concat('%', #{deptName}, '%')
    </if>

    <!-- ✓ 使用foreach处理数据权限过滤 -->
    <if test="dataScopeIds != null and dataScopeIds.size() > 0">
        AND d.dept_id IN
        <foreach collection="dataScopeIds" item="id" open="(" close=")" separator=",">
            #{id}
        </foreach>
    </if>
</select>

// ✓ Aspect改为传递ID列表
public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String alias) {
    List<Long> allowedDeptIds = new ArrayList<>();

    for (SysRole role : user.getRoles()) {
        String dataScope = role.getDataScope();

        if (DATA_SCOPE_ALL.equals(dataScope)) {
            allowedDeptIds.clear();  // 清空表示不过滤
            break;
        }
        else if (DATA_SCOPE_CUSTOM.equals(dataScope)) {
            // ✓ 查询角色允许的部门ID列表
            List<Long> roleDeptIds = deptMapper.selectDeptIdsByRoleId(role.getRoleId());
            allowedDeptIds.addAll(roleDeptIds);
        }
        else if (DATA_SCOPE_DEPT.equals(dataScope)) {
            allowedDeptIds.add(user.getDeptId());
        }
    }

    BaseEntity baseEntity = (BaseEntity) joinPoint.getArgs()[0];
    baseEntity.getParams().put("dataScopeIds", allowedDeptIds);
}
```

#### 方案2: alias白名单验证（次优）

```java
// ✓ 如果必须使用${}，至少验证alias
private static final Set<String> ALLOWED_ALIASES = new HashSet<>(
    Arrays.asList("d", "u", "r", "dept", "user", "role")
);

public static void dataScopeFilter(JoinPoint joinPoint, SysUser user, String alias) {
    // ✓ 白名单验证alias
    if (!ALLOWED_ALIASES.contains(alias)) {
        throw new SecurityException("Invalid table alias: " + alias);
    }

    // ✓ 使用PreparedStatement风格
    StringBuilder sqlString = new StringBuilder();
    List<Object> params = new ArrayList<>();

    for (SysRole role : user.getRoles()) {
        if (DATA_SCOPE_CUSTOM.equals(role.getDataScope())) {
            sqlString.append(" OR ").append(alias)
                     .append(".dept_id IN (SELECT dept_id FROM sys_role_dept WHERE role_id = ?)");
            params.add(role.getRoleId());
        }
    }

    // 注: 这种方式仍然不够理想，但比直接拼接好
}
```

### 业务逻辑检测清单

#### 数据权限控制检测
- [ ] 检查@DataScope注解使用是否正确
- [ ] 验证数据权限过滤SQL是否参数化
- [ ] 确认角色权限配置的安全性
- [ ] 测试越权访问场景
- [ ] 检查管理员权限绕过逻辑

#### AOP切面安全检测
- [ ] 检查切面中的SQL拼接操作
- [ ] 验证切面参数来源的安全性
- [ ] 确认切面异常处理的完整性
- [ ] 测试切面在各种场景下的行为

### 关键教训

1. **内部数据也需参数化**
   - 即使数据来自数据库，也应使用参数化查询
   - 字符串拼接永远是不安全的

2. **AOP切面是安全盲区**
   - AOP逻辑容易被忽视
   - 切面中的SQL操作需要特别关注

3. **业务逻辑复杂度增加风险**
   - 复杂的数据权限逻辑更容易出错
   - 需要完整的安全测试覆盖

4. **代码审查要深入**
   - 不能只看表面的CRUD操作
   - 需要追踪完整的数据流

---

## 最小 PoC 示例
```bash
# 越权访问（IDOR）
curl -H "Authorization: Bearer USER" https://app.example.com/api/orders/2
curl -H "Authorization: Bearer USER" https://app.example.com/api/orders/3

# 数据权限 AOP SQL 拼接
rg -n "DataScope|dataScope" --glob "*.{java,kt}"

# 流程跳过/重放
curl -X POST https://app.example.com/api/checkout -d "step=3"  # 跳过前置步骤
```

---

通过本模块的检测规则和若依数据权限案例，能够有效识别企业级应用中的业务逻辑安全风险，特别是复杂的业务流程和后台系统操作的安全问题。
