name: csrf-testing description: 检测 CSRF(跨站请求伪造)风险;当目标存在状态变更操作(密码修改、数据删除、转账、设置变更)时触发。 when-to-use: 当目标存在状态变更操作(密码修改、数据删除、转账、设置变更)且依赖 Cookie 自动鉴权时 allowed-tools: bash,read_file,list_files,rg user-invocable: false
CSRF 跨站请求伪造检测
成因引用
跨站请求(受害者已登录,浏览器自动携带 Cookie)→ 状态变更端点(服务端依赖 Cookie 自动鉴权且不验证请求来源 / 不要求 CSRF token / SameSite 防护缺失)= CSRF 跨站请求伪造。一句话讲清成因:状态变更操作只检查"是否已登录"而不检查"操作是否由本站发起",攻击者借受害者会话从外部站点发出请求即可触发越权写操作。业务命名不可作筛选——不能用"这是设置接口/这是支付接口"作为是否检测的依据;CSRF 关注的是所有改变服务端状态的端点(POST / PUT / DELETE / PATCH,以及触发状态变更的 GET),按 sink 语义(是否依赖 Cookie 自动鉴权 + 是否缺乏来源验证)判断,不按业务命名筛选。详见同根目录 pentest/web-security-testing/SKILL.md 漏洞成因图谱 · CSRF 行(不在本 skill 重复成因)。
触发线索(基线检查项)
以下是已知的常见 CSRF 触发线索,作为基线起点而非必检硬清单:
- 适用且已完成 → 标注
[x] done - 明确不适用 → 标注
[-] n/a (原因) - 基线未列出但实际发现 → 新增条目并标注
[+] added (来源)
基线触发线索按"sink 语义"分类(不按业务命名):
- Cookie 自动鉴权 sink:状态变更端点鉴权完全依赖 Cookie(无 Authorization 头、无自定义头要求);登录响应设置的 Cookie 属性中缺
SameSite或为SameSite=None。 - 写操作端点 sink:POST / PUT / PATCH / DELETE 端点改密码、改邮箱、改 2FA、改地址、转账、删除资源、改权限、改账户配置;上传/删除文件;管理后台批量操作。
- GET 副作用 sink:GET 方法实际触发状态变更(如
/api/delete?id=、/logout?confirm=1、/follow?user=、邮件订阅退订链接);这种最容易被忽略,但 CSRF 通过<img src>即可触发。 - token 校验弱 sink:CSRF token 存在但服务端不校验、为空值仍接受、来自其他用户的 token 仍接受、token 全局共用而非 per-session、token 出现在 URL query 易被 Referer 泄露。
- Referer/Origin 校验弱 sink:移除 Referer 头后请求仍接受;Origin 为空仍接受;子域
evil.target.com仍接受。 - 代码模式:后端代码出现
@PostMapping/app.post(...)中处理状态变更但缺少 CSRF middleware;Spring 关闭csrf().disable();Django@csrf_exempt;Express 未引入csurf。
思考检查点
加载本 skill 时按这些问题思考:
- 这个端点是状态变更吗?包括"看起来是 GET 但实际有副作用"的形态。
- 鉴权方式是什么?纯 Cookie 自动携带 → 直接 CSRF 候选;Authorization 头 → 浏览器跨站不自动携带 → 默认不易 CSRF(但 SSR 场景仍要确认)。
- 服务端的来源验证是哪一层?CSRF token / SameSite / Referer / Origin / 自定义头?每一层都要独立验证而非"看到一种就推全套"。
- SameSite=Lax 是否足够?Lax 允许顶层导航 GET 请求和某些 POST 形态;同站不同端口(
http://app.com:8080vshttp://app.com:8081)、同站不同子域 (evil.app.comvstarget.app.com)在某些浏览器和配置下不被视为跨站。 - 跨端点不能推全:同一项目里"改密码"有 CSRF token、"改邮箱"可能没有;必须按端点独立验证。
前置条件与安全边界
- 仅在授权环境测试。
- 单接口最多 8 次请求。
- 不构造面向真实用户的钓鱼页面,仅在测试环境验证。
- CSRF PoC 仅用于证明概念,不实际发送给目标用户。
- 跨站触发的状态变更若为删除/覆盖/不可逆动作,按
common/closure-verification.md的《破坏性 / 不可逆动作的闭环边界》执行——回读证明改走哨兵自证或非破坏差分,二者都做不到就停suspected,禁止对真实业务数据执行破坏动作来强行闭环。
检测步骤
Step 1:防护机制识别
检查目标是否具备 CSRF 防护:
- CSRF Token:表单中是否有隐藏 token 字段(
csrf_token、_token、authenticity_token、__RequestVerificationToken);请求头中是否要求X-CSRF-Token。 - SameSite Cookie:
Set-Cookie响应头是否包含SameSite=Strict/SameSite=Lax/SameSite=None。 - Referer/Origin 校验:服务端是否检查请求来源。
- 自定义 Header:是否要求
X-Requested-With: XMLHttpRequest等浏览器跨站不自动携带的自定义头。 - Content-Type 约束:是否仅接受
application/json(浏览器表单跨站发不出 JSON 加预检 CORS)。
Step 2:防护绕过测试
| 防护机制 | 绕过尝试 |
|---|---|
| CSRF Token | 移除 token 参数、使用空值、使用另一用户的 token、使用过期 token、token 仅校验存在不校验值 |
| SameSite=Lax | 顶层导航 GET 触发;同站不同端口 / 同站不同子域 POST 触发;form 表单中 method 为 GET 时 Lax 不拦截 |
| Referer 校验 | 移除 Referer (<meta name="referrer" content="no-referrer">);Referer 中含 target 域名子串绕过 |
| Origin 校验 | 子域 Origin、空 Origin、null Origin |
| 自定义 Header | 简单请求绕过(form-data / text/plain 不触发预检 CORS) |
Step 3:PoC 构造
构造最小 CSRF PoC 页面:
<form action="TARGET_URL" method="POST">
<input type="hidden" name="param1" value="value1">
<input type="submit" value="Submit">
</form>
<script>document.forms[0].submit();</script>
对 GET 副作用端点用 <img src="TARGET_URL"> 即可触发。
Step 4:闭环验证
- 用受害者账户登录浏览器。
- 在同一浏览器中打开 PoC 页面(来源为攻击者域名,与目标不同源)。
- 确认状态变更是否实际生效(回读验证)。
不可逆动作例外:状态变更若为删除/转账/不可逆动作,不得在受害者真实数据上执行;改用受害者名下哨兵数据或非破坏差分证明,按
common/closure-verification.md《破坏性 / 不可逆动作的闭环边界》,做不到则降suspected。
示例库
正例形态(代码层根因)
- Spring
http.csrf().disable()+ Cookie 鉴权的/api/user/emailPOST — 经典 token 缺失(cookie-auth-state-change-no-token) /api/delete?id=123用 GET 且只看登录态 — GET 副作用 + 图片标签即可触发(get-side-effect-csrf)- 服务端把
SameSite=Lax当万能防护,未额外验证;evil.target.com:8080向app.target.com:443发 POST 仍被处理(samesite-lax-cross-port-bypass) - Referer 校验代码
if ("target.com" in request.headers.get("Referer", ""))—Referer: http://attacker.com/target.com.html即可绕过(referer-check-empty-bypass)
窄化反例(必须避免)
以下是 CSRF 维度的典型窄化误判:
- "已测一个端点有 CSRF token → 推断全站状态变更端点都有" — 错。CSRF 防护通常在路由级别声明,开发者经常对部分端点
@csrf_exempt或忘记加 middleware;必须按端点独立验证。 - "SameSite=Lax 设置了 → 安全" — 错。Lax 允许顶层导航 GET 触发;同站不同端口、同站不同子域在部分场景不被视为跨站;XS-Leaks 与子域接管可能间接绕过。
- "已测 Cookie 鉴权端点 → 跳过 Authorization 头鉴权端点" — 错。两类是不同鉴权 sink 形态;Authorization 头默认 CSRF 不易触发,但 SSR/GraphQL/前端注入 token 模式下仍可能存在跨站触发路径,需独立验证。
- "看起来是 GET 不变更状态 → 跳过" — 错。需追踪 GET 是否实际触发状态变更(删除、关注、退订、确认);GET 副作用 + 图片标签是最易隐藏的 CSRF 形态。
- "Content-Type 是 application/json → 跨站发不出来" — 错。
text/plain+ body 拼成 JSON 字符串、Flash / XHR 旧绕过、CORS 配置过宽都可能突破;不能只看 Content-Type 推断。
反例义务(必须遵守)
为什么这里是「必须」:反例义务属于交付契约——"该子系统无 CSRF"或"已防护"结论是覆盖完整性的产物声明,缺失反向验证清单会让下游误信"该维度全站安全"。
写"未发现 CSRF"或"已防护"前,产物必须包含:
- 测过的状态变更候选端点完整清单(按 sink 语义枚举,不按业务命名筛选;含改密码/改邮箱/改 2FA/改地址、转账/支付、删除、上传、管理后台、GET 副作用端点)
- 每个端点测过的形态:token 缺失/空值/复用、SameSite 绕过(同站不同端口/子域)、Referer/Origin 移除、自定义头去除、Content-Type 切换
- 每个端点的浏览器实测证据(PoC 是否真触发状态变更,回读结果)
清单不完整 → 结论降级为 partial-coverage 并显式声明未覆盖范围。
特别警示:最容易漏的是"GET 副作用端点"和"已测 cookie 鉴权端点就跳过 Authorization 鉴权端点"。前者用 <img> 即可触发;后者在前端把 token 写入 cookie 再回填 Authorization 的模式下仍可能存在跨站路径。
闭环验证要求(必须遵守)
通用闭环口径见同根目录 common/closure-verification.md(技能表 path 列同一抽取根下,需要时 read_file 读取)。核心:完整证据链才判 confirmed,中间信号最多 suspected。本漏洞特有要点:
- 仅凭"没有 CSRF Token"不得直接判 confirmed,必须确认状态变更通过跨站请求实际生效(回读)。
- SameSite Cookie 可能已阻止跨站请求,需在攻击者域真实发起跨站请求验证。
- 破坏性 / 不可逆动作的回读改走哨兵数据或非破坏差分,不得对受害者真实数据真执行。
判定标准
| 现象 | 判定 |
|---|---|
| 跨站请求成功触发状态变更,通过回读确认生效 | confirmed |
| 缺少 CSRF Token 但有 SameSite Cookie 或其他防护,未实际验证 | suspected |
| CSRF Token 有效、SameSite=Strict、且绕过尝试均失败且覆盖完整 | not vulnerable |
| 覆盖清单不完整(如未测 GET 副作用 / 未测同站不同端口 SameSite 绕过 / 未跨子系统) | partial-coverage |
不可逆动作例外:上表 confirmed 行的状态变更若为删除/转账/不可逆动作,按
common/closure-verification.md《破坏性 / 不可逆动作的闭环边界》——回读改用哨兵数据或非破坏差分,不得对受害者真实数据真执行,做不到则降suspected。
修复建议
- 对所有状态变更操作添加 CSRF Token(Synchronizer Token Pattern 或 Double Submit Cookie),服务端严格校验值,per-session 生成。
- Cookie 设置
SameSite=Strict(敏感操作)或SameSite=Lax(普通操作);明确禁止SameSite=None除非搭配额外 CSRF token。 - 验证
Origin和Referer头,按域名结构解析,不接受空值。 - 对敏感操作要求二次验证(密码确认、验证码、二次 OTP)。
- 杜绝 GET 触发状态变更;所有写操作收敛到 POST/PUT/PATCH/DELETE,并接受同等 CSRF 防护。