跳转至

CSRF 与越权测试

这两个漏洞的测试难点不在"知不知道",而在证明:CSRF 要证明"不带 Token 也能成功",越权要证明"拿到的是别人的真实数据"。下面给可复现的验证方法和常见漏判。

如果接口用 Authorization: Bearer 头鉴权,CSRF 天然不成立(攻击者无法跨站设置自定义头,除非有 CORS 配置错误)。所以第一步是看鉴权方式:

Cookie: SESSION=abc123; csrftoken=xyz

只有仅靠 Cookie 自动携带的写操作才需要测 CSRF。

1.1 成立三条件

  1. 鉴权只依赖 Cookie;
  2. 参数可预测(没有随机 token,或 token 可被绕过);
  3. 没有 Origin/Referer 校验或校验可绕过。

1.2 测试步骤(可复现)

<!-- 攻击者页面,受害者已登录 target.com 时访问即触发 -->
<form action="https://target.com/api/user/email" method="POST" id="f">
  <input name="email" value="attacker@evil.com">
</form>
<script>document.getElementById('f').submit()</script>

逐项验证:

实验 操作 若成功说明
A 原样重放,观察是否校验 token 基线
B 删除 csrf_token 参数 无 token 校验
C 用另一个账号的 token token 未与会话绑定
D 用同一个 token 重放 2 次 token 一次性校验缺失
E 置空 token、传 null、传数组 校验逻辑可绕过
F 改 Origin: https://evil.com Origin 未校验
G 删 Referer、改成同域前缀 https://target.com.evil.com Referer 校验用 contains
H Content-Type: text/plain 发 JSON 体 JSON 接口未做 Content-Type 限制(可被表单跨站提交)

H 是最容易被忽略的一条。很多 JSON 接口不校验 Content-Type,而跨站表单无法发 application/json,却能发 text/plain。如果后端用 @RequestBody 且不校验类型,某些框架版本会解析失败;但如果后端手工读 body 再 JSON.parse,就能打:

<form action="https://target.com/api/user/email" method="POST"
      enctype="text/plain">
  <input name='{"email":"attacker@evil.com","x":"' value='"}'>
</form>

提交后 body 变成 {"email":"attacker@evil.com","x":"="},正好是合法 JSON。

1.3 Token 校验的四种错误实现

// 错误 1:只在前端校验(token 只存在 localStorage,请求头里可删)
// 错误 2:token 全局唯一,不与 session 绑定(拿自己的 token 打别人)
String expected = redis.get("global_csrf_token");
// 错误 3:token 可重复使用(没删缓存)
// 错误 4:比较用 equals 但允许 null
if (token != null && token.equals(expected)) { ... }   // 但 token 为空字符串时?

正确实现:

// 生成:与会话绑定、一次性、足够随机
String token = SecureRandom.getInstanceStrong()
        .ints(32, 0, 256)
        .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append)
        .toString();
session.setAttribute("csrf", token);

// 校验:常量时间比较 + 用完即删
String actual = request.getHeader("X-CSRF-Token");
String expected = (String) session.getAttribute("csrf");
if (actual == null || expected == null
        || !MessageDigest.isEqual(actual.getBytes(UTF_8), expected.getBytes(UTF_8))) {
    throw new CsrfException("invalid csrf token");
}
session.removeAttribute("csrf");

Spring Security 默认开启 CSRF(CookieCsrfTokenRepository 或 session 模式),但很多项目为了"前后端分离方便"直接 .csrf().disable()——这就是测试时的重点确认项:

# 看响应头/HTML 里是否有 csrf token
curl -i https://target.com/login | grep -i csrf

1.4 SameSite 不是万能药

值 跨站 POST 表单 跨站顶层 GET 导航 说明
None 携带(需 Secure) 携带 等于没有防护
Lax(Chrome 默认) 不携带 携带 挡住大部分 CSRF
Strict 不携带 不携带 从外站点进来会掉登录态

所以:

  • Lax 下 POST 类 CSRF 基本失效,但 GET 写操作仍然可打(顶层导航会带 Cookie)。
  • 子域同名 Cookie 可能覆盖 SameSite 设置,测试时确认 Set-Cookie 的 domain/path。
  • 老浏览器不支持 SameSite,不能作为唯一防线。

1.5 登录 CSRF 与 CORS 混淆

  • 登录 CSRF:攻击者用自己的账号登录受害者浏览器,之后受害者操作都落在攻击者账号里(常见于"绑定手机号""保存收货地址")。
  • CORS 配置错误:Access-Control-Allow-Origin: * + Allow-Credentials: true 本身不合法,但反射 Origin 的实现会把任意站点加入白名单,等价于绕过同源策略读数据。
curl -i https://api.target.com/user/info \
  -H "Origin: https://evil.com" | grep -i "access-control"

判定:Access-Control-Allow-Origin 回显了 https://evil.com 且 Allow-Credentials: true → 可跨站读取。

二、越权:核心是"拿到别人的真实数据"

2.1 三种形态与判定标准

类型 操作 判定成立的标准
水平越权 A 的 token 访问 B 的资源 返回 200 且包含 B 的真实字段
垂直越权 低角色调用高角色接口 返回 200 且执行了高权限操作
IDOR 改 ID 直接引用对象 同上,前提是 ID 可枚举/可猜测
租户越权 改 tenantId 跨租户 返回其他租户数据

只看状态码会大量误判。200 + {"data":null} 通常是正常的"不存在或无权",200 + 空数组也可能是正常。判定要满足:

  1. 响应里出现了属于另一个账号的不可预测字段(手机号后四位、订单号、地址、金额);
  2. 该字段与 B 账号在数据库/另一个会话中看到的一致;
  3. 不是缓存或公共数据。

2.2 对照实验脚本

import requests

BASE = "https://api.example.com"
A = {"Authorization": "Bearer <token_A>"}
B = {"Authorization": "Bearer <token_B>"}

def probe(path, headers):
    r = requests.get(BASE + path, headers=headers, timeout=5)
    return r.status_code, r.text[:200]

# 1. 先用 B 确认资源真实存在
status_b, body_b = probe("/api/orders/20002", B)
print("B 自己看:", status_b, body_b)

# 2. 用 A 的 token 访问 B 的资源
status_a, body_a = probe("/api/orders/20002", A)
print("A 越权看:", status_a, body_a)

# 3. 判定:A 的响应里是否包含 B 才有的字段
if status_a == 200 and body_a == body_b:
    print("[!] 水平越权成立")
elif status_a == 200 and "orderNo" in body_a:
    print("[?] 需人工确认是否为空壳响应")

2.3 最容易漏的六个点

  1. 批量接口:ids=1,2,3 里混入他人 ID,只返回部分数据也算越权(信息泄露)。
  2. 导出/下载:/export?taskId=123 下载的是别人任务的结果;文件下载接口只校验文件存在,不校验归属。
  3. 关联对象:/api/orders/20002/user 通过订单反查用户;/api/files/9/download 通过文件 ID 下载。
  4. 修改类接口:PUT /api/user 只校验"用户存在",不校验"是不是当前用户"。传 id 就能改别人。
  5. 前端隐藏:按钮被 v-if 隐藏,接口没加注解。直接从 JS 或 Swagger 里找隐藏接口。
  6. 多租户:tenantId 来自请求参数或 JWT 里的可篡改字段;X-Tenant-Id 头可改。

2.4 UUID 不等于安全

用 UUID 只能提高枚举成本,不能替代归属校验。UUID 常见泄露渠道:

  • 前端列表接口返回了不属于当前页的 UUID
  • 日志、错误信息、导出文件里带出
  • 邀请链接、分享链接里的资源 ID
  • 邮件、短信模板里的链接

所以"ID 不可猜"不是修复方案,服务端校验归属才是。

2.5 修复:把归属校验下沉到查询条件

错误写法(先查再判,容易漏分支):

Order order = orderMapper.selectById(orderId);
if (order == null) throw new NotFoundException();
// 忘了这一步,或者只在这个接口里做了
if (!order.getUserId().equals(currentUserId)) throw new ForbiddenException();

正确写法(让"不属于我"在 SQL 层就不存在):

@Mapper
public interface OrderMapper {
    @Select("SELECT * FROM orders WHERE id = #{id} AND user_id = #{userId}")
    Order selectMine(@Param("id") Long id, @Param("userId") Long userId);
}

@Service
public class OrderService {
    public Order detail(Long orderId, Long currentUserId) {
        Order order = orderMapper.selectMine(orderId, currentUserId);
        if (order == null) throw new NotFoundException();   // 不暴露"存在但无权"
        return order;
    }
}

管理端用注解 + 拦截器:

@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/api/admin/orders/{id}")
public Order adminDetail(@PathVariable Long id) { ... }

要点:

  • 查询条件带 user_id,而不是查完再比;
  • 返回 404 而非 403,避免暴露资源是否存在;
  • 权限注解要覆盖所有写接口,包括内部调用的 /internal/**;
  • 网关层的角色校验不能替代业务层的归属校验。

2.6 验证清单

  • 两个账号、两种角色、两个租户的 token 都准备好
  • 所有带 ID 的接口(含导出、下载、详情、修改、删除)都替换过 ID
  • 批量接口混入他人 ID
  • 删除鉴权头 / 换低权限 token 各试一次
  • 修改类接口尝试改 userId、tenantId、role
  • 判定基于字段内容,不是状态码
  • 修复后回归:越权请求返回 404,正常请求不受影响

相关阅读