CSRF 与越权测试¶
这两个漏洞的测试难点不在"知不知道",而在证明:CSRF 要证明"不带 Token 也能成功",越权要证明"拿到的是别人的真实数据"。下面给可复现的验证方法和常见漏判。
一、CSRF:先确认鉴权是不是靠 Cookie¶
如果接口用 Authorization: Bearer 头鉴权,CSRF 天然不成立(攻击者无法跨站设置自定义头,除非有 CORS 配置错误)。所以第一步是看鉴权方式:
只有仅靠 Cookie 自动携带的写操作才需要测 CSRF。
1.1 成立三条件¶
- 鉴权只依赖 Cookie;
- 参数可预测(没有随机 token,或 token 可被绕过);
- 没有
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()——这就是测试时的重点确认项:
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 的实现会把任意站点加入白名单,等价于绕过同源策略读数据。
判定: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 + 空数组也可能是正常。判定要满足:
- 响应里出现了属于另一个账号的不可预测字段(手机号后四位、订单号、地址、金额);
- 该字段与 B 账号在数据库/另一个会话中看到的一致;
- 不是缓存或公共数据。
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 最容易漏的六个点¶
- 批量接口:
ids=1,2,3里混入他人 ID,只返回部分数据也算越权(信息泄露)。 - 导出/下载:
/export?taskId=123下载的是别人任务的结果;文件下载接口只校验文件存在,不校验归属。 - 关联对象:
/api/orders/20002/user通过订单反查用户;/api/files/9/download通过文件 ID 下载。 - 修改类接口:
PUT /api/user只校验"用户存在",不校验"是不是当前用户"。传id就能改别人。 - 前端隐藏:按钮被
v-if隐藏,接口没加注解。直接从 JS 或 Swagger 里找隐藏接口。 - 多租户:
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,正常请求不受影响