上一节讲 XSS 时,我们一直盯着一个问题:不可信内容会不会被浏览器当成本站代码执行。到了 CSRF,页面里可能根本没有陌生脚本,服务端收到的请求也可能格式正确、Cookie 有效、账号权限充足。麻烦在于,用户并没有打算完成这次操作。
这句话看起来有点绕。既然 Cookie 是真的,为什么请求还能是“假的”?因为登录凭据证明的是“这个请求带着谁的会话”,用户意图回答的却是“这个人是否主动发起了这次状态变化”。浏览器擅长自动携带凭据,但它不会替服务端判断用户此刻是否同意保存、解绑、授权或提交。
所以,CSRF 的核心不是伪造一个会话,而是借用了浏览器已经拥有的会话。测试时也不该追求做出多大的业务后果。我们真正要验证的是:服务端有没有要求一份外部页面难以提供的意图证据,并且是否在修改数据之前完成校验。
本章的验证只适用于书面授权的本地靶场、隔离测试环境和专用测试账号。请选择能够立刻回滚的无害状态,例如界面密度或测试标签。不要测试资金、权限、密码、真实邮箱、删除数据、外部通知和第三方调用;一旦影响他人、无法回滚、范围不清或监控方要求停止,应立即终止实验。

我们可以把一个状态变更请求想成服务台前的两次核对。
第一次核对身份。会话 Cookie、HTTP 认证信息或客户端证书都可能告诉服务端:“这个浏览器当前对应测试用户甲。”第二次核对意图。CSRF Token、精确的来源判断、Fetch Metadata 策略和敏感操作确认则在回答:“这次请求是否来自应用允许的交互路径,用户是否真的准备执行它?”
许多 CSRF 缺陷就发生在两次核对被合并的时候。服务端看到有效会话,便直接执行业务操作。可浏览器附带 Cookie 通常是按目标站点和 Cookie 属性决定的,不是按“用户心里想不想做”决定的。一个来自外部页面的请求,只要落入 Cookie 允许发送的场景,就可能带着有效身份抵达目标。
初学者常有一个直觉:浏览器不是有同源策略吗,外部页面应该碰不到本站数据。这里要把“发送请求”和“读取响应”分开。
同源策略主要限制外部脚本读取目标响应和操作目标页面。它不等于禁止所有跨源请求。链接跳转、表单提交和某些资源加载本来就需要跨源工作,否则普通网页连跳转与嵌图都无法完成。对于 CSRF,外部页面能不能读到响应经常不是重点;如果状态修改已经执行,响应即使对外部脚本不可见,业务结果仍然发生了。
CORS 也是同样的道理。它控制服务器愿不愿意把跨源响应开放给脚本,并决定某些非简单请求能否在预检后真正发送。一个传统表单能产生的简单请求不必先通过 CORS 预检。因此,“前端报了 CORS 错误”不能直接证明状态没有被修改。测试必须回到账户状态、数据库结果或审计日志上确认。
判断一个入口是否存在经典 CSRF 风险,可以检查下面几块是否同时出现:
缺少其中一块,结论就可能不同。比如接口只接受显式放在 Authorization 请求头中的短期令牌,而浏览器不会自动替任意外部页面添加这个头,那么经典的“借用 Cookie 会话”路径就弱很多。但这不代表应用天然安全:错误 CORS、令牌落入 Cookie、客户端脚本根据不可信 URL 拼装请求,仍可能建立其他风险路径。测试报告应写清实际身份载体,而不是看到一个 POST 就套用结论。

源由协议、主机和端口共同确定。站点边界更宽,通常围绕方案和可注册域判断。于是,主应用 app.example.test 与上传服务 upload.example.test 可以是不同源,却仍可能被浏览器视作同站点。
这个差别会直接影响 SameSite 和 Sec-Fetch-Site。如果上传子域能展示用户内容,旧系统子域维护不严,或者某个同站点主机交给第三方托管,那么“same-site”并不自动等于“可信”。敏感系统需要明确自己信任的是同源,还是整个同站点;如果答案说不清,就不该把同站点流量全部直接放行。
判断 CSRF 时,先写出一句完整的话:“浏览器会自动携带哪一种身份凭据,服务端又用什么证据确认这次状态变化来自允许的交互?”这句话比背一份漏洞特征表更有用,因为它同时暴露了身份机制和意图校验的空缺。
CSRF 的边界很大一部分由浏览器决定。测试者需要观察真实请求,而不是凭印象猜 Cookie、Fetch 或 CORS 会怎么工作。
Cookie 会根据目标主机、路径、Secure、SameSite 等条件被浏览器筛选。前端页面通常不需要逐个把会话 Cookie 填进请求;只要满足发送规则,浏览器就会自动附带。这种“环境里已经有的授权”非常适合维持登录,也正是经典 CSRF 能借用的能力。
下面是一份防御分析用的概念请求。它只表示测试账号把界面密度改为紧凑,不包含可用于真实系统的地址或秘密:
POST /account/preferences HTTP/1.1
Host: lab.example.test
Cookie: __Host-session=<由浏览器管理的测试会话>
Content-Type: application/x-www-form-urlencoded
density=compact这份请求里,Cookie 能证明测试会话存在,density=compact 能说明服务端被要求改什么。可它还没有说明请求是否来自本站界面,也没有与会话绑定的不可预测值。测试时要找的就是缺少的那一层。
SameSite=Strict 的边界最紧。跨站上下文通常不会带上这枚 Cookie,代价是用户从外部链接进入站点时,可能暂时表现得像未登录。
SameSite=Lax 在安全性和正常跳转体验之间折中。同站点请求可以携带 Cookie;跨站的顶层导航如果使用安全方法,也可能携带 Cookie。这就是为什么“用 GET 删除记录”会把边界重新打开。浏览器、搜索引擎、链接预取器和用户点击都可能发出 GET,它不该承担用户主动要求的状态变化。
SameSite=None 允许更广的跨站发送,并要求同时设置 Secure。确实需要跨站嵌入或联合流程时可以使用,但它必须和 Token、来源策略或专用集成认证组合,而不是把“业务需要跨站”理解成“所有来源都可信”。
未显式设置 SameSite 时,现代浏览器常采用类似 Lax 的默认行为,而且某些默认实现对刚设置不久的 Cookie 有更宽松的兼容窗口。安全设计不要依赖隐含默认值,也不要只在一款浏览器里验证一次。应用应明确写出属性,并把新登录、外部跳转、顶层导航、嵌入页面和移动端 WebView 放进回归矩阵。
Set-Cookie: __Host-session=<随机会话值>; Path=/; Secure; HttpOnly; SameSite=Lax这里每个属性解决的问题不同。Secure 限制 Cookie 通过安全连接发送,HttpOnly 降低脚本直接读取 Cookie 的机会,SameSite 控制跨站发送范围。__Host- 前缀要求 Secure、Path=/,并且不能设置 Domain,从而让 Cookie 保持主机专属。它们都不能独立证明用户同意了某次操作。

同源 fetch() 默认可以带上同源凭据。跨源脚本若想携带凭据,通常要显式设置 credentials: "include";目标服务还要用精确来源和允许凭据的 CORS 响应同意这条路径。即便脚本设置了 include,Cookie 自身的 SameSite 规则仍然生效,脚本不能用一个选项绕过 Cookie 策略。
还要区分简单请求与需要预检的请求。传统表单能够产生的 application/x-www-form-urlencoded、multipart/form-data 和 text/plain 请求具有较宽的跨源兼容性。使用 application/json 或自定义防护头,通常会让跨源脚本先经过 CORS 预检。对只接受 JSON 的 API 来说,这是有价值的约束,但前提是服务端真的拒绝简单内容类型、严格配置 CORS,并验证自定义头;只让前端“通常发送 JSON”不算服务端安全控制。
“外部脚本读不到响应”“控制台出现 CORS 错误”“接口平时由 fetch 调用”都不是独立的 CSRF 结论。必须确认真实请求有没有到达写入逻辑、凭据有没有附带、服务端状态有没有改变,以及哪一道规则做出了允许或拒绝。
测试 CSRF 时,最容易漏的不是某种技巧,而是某个不显眼的状态入口。先从业务动作出发整理清单,再去看它们对应的 HTTP 请求。
把账号偏好、资料编辑、订阅变更、会话管理、权限委派、审批动作和文件操作等所有“会改变什么”的入口列出来,再标记实际方法、认证载体、内容类型、前端调用者和是否有二次确认。尤其检查 GET 路由、旧版兼容接口、批量入口、移动端接口、GraphQL mutation、方法覆盖参数和登录接口。
HTTP 把 GET、HEAD、OPTIONS 和 TRACE 定义为安全方法,意思是客户端不应请求服务端状态变化。日志计数这类非用户要求的附带效果不违反这个语义;删除、授权、下单这类由请求明确触发的业务动作则不应放在安全方法上。基础设施和浏览器会依赖这项约定进行预取、缓存和自动访问,所以方法语义本身就是防线的一部分。
登录也值得单独检查。登录请求发生在会话建立之前,常规会话 Token 可能尚不存在,但用户被迫进入错误账号仍会造成数据混淆。登录表单可以使用预会话 Token、严格来源判断和清楚的账号确认;认证成功后再轮换会话标识,不能因为“还没登录”就忽略请求意图。
理想的测试状态有四个特征:只影响专用测试账号,改变前后容易观察,不触发外部流程,能够立刻恢复。界面密度、实验标签、测试昵称或本地靶场里的颜色偏好都比修改密码、添加管理员、删除文件、提交订单更合适。
如果系统只有高影响或不可逆操作,不要为了证明漏洞而真的执行。可以停在代码审查、防护配置、请求结构和写入前校验位置,给出受限结论,并等待负责人提供隔离环境。漏洞是否成立不需要靠制造伤害来证明。
先在正常页面中手动完成一次允许的无害修改,然后恢复原值。记录这些信息:
Origin、Referer 与 Sec-Fetch-* 分类;没有基线,一个 403 可能来自权限不足、参数错误、网关策略或 CSRF 校验;一个 200 也可能只是统一响应壳,里面的业务操作实际失败。基线让后面的单变量实验有可比较的对象。
这里不需要自动提交页面,也不需要诱导任何人点击。我们只在隔离靶场中,用两个完全由测试者控制的页面、一个专用账号和一项无害偏好,观察防护层是否按设计工作。
实验前先写好停止条件:目标、账号和接口都在授权清单中;状态只影响当前账号;不存在真实通知或第三方调用;每次变化后都能恢复;测试窗口内有联系人;日志可以用关联编号定位。一项条件不满足,就不要继续。
在目标应用的正常页面中把测试账号的界面密度从“标准”改为“紧凑”,确认业务状态真的变化,记下关联编号,然后恢复为“标准”。这一步建立允许样本。
复制正常请求为测试副本,保持方法、路径和无害参数不变,只移除 CSRF Token。预期是服务端在写入前拒绝,界面密度保持“标准”,日志明确记录“令牌缺失”。
把 Token 改成固定的无效占位值。服务端应继续在写入前拒绝,不能因为字段存在就放行,也不能先修改状态再返回错误。
使用两个完全由你控制的测试账号检查会话绑定。账号甲页面取得的 Token 不应授权账号乙的请求。记录“是否拒绝”和关联编号,不记录 Token 原值。
这个流程刻意一次只改一个变量。若同时换来源、删 Token、改内容类型和切换账号,即使最终失败,也不知道是哪层防护生效。可复核的证据比复杂的演示更有价值。
有状态应用通常优先使用框架内置的同步 Token 模式。服务端生成足够随机的值,将它与当前会话关联;正常页面把值放入隐藏表单字段或由本站脚本放入自定义请求头;服务端验证通过后才执行状态变化。
处理状态变更(请求):
会话 = 验证登录状态(请求)
如果 请求中的令牌缺失:
记录拒绝原因("令牌缺失")
结束,不写入
如果 请求令牌与会话期望值不匹配:
记录拒绝原因("令牌不匹配")
结束,不写入
如果 当前用户无权操作目标对象:
记录拒绝原因("对象权限不足")
结束,不写入
执行状态变化
记录成功审计顺序非常重要。先写入再校验,即使最后返回 403,防护也已经失败。所有等价入口都要经过同一服务端策略:传统表单、异步接口、批量入口、旧版路径和移动端后端不能各自漏一块。
传统表单可以把 Token 放在隐藏字段中,异步请求更适合放在自定义请求头。不要把 Token 放进 URL,因为 URL 容易进入浏览历史、代理日志、分析系统、截图和 Referer。错误日志也不要打印完整 Token。
每请求 Token 暴露窗口短,但会影响多标签页、返回按钮和失败重试;每会话 Token 更容易兼容真实交互,但存活时间更长。应根据框架能力和产品流程选择,而不是为了形式上更严格破坏正常操作。无论选哪种,登出、会话失效和关键的会话轮换都要使旧值失效。
Token 必须不可预测,并与正确会话绑定。只检查字段是否存在、使用固定全局值、把值放在 Cookie 中却不要求请求显式回传,或者接受另一个账号的 Token,都没有建立可靠的意图证据。

无状态系统有时使用双重提交 Cookie:浏览器收到一个值,同时在请求字段或请求头中显式回传,服务端比较两者。朴素的相等比较容易受到 Cookie 注入等边界问题影响。更稳妥的实现用服务端秘密对 Token 做消息认证,并把它绑定到当前会话的不可变标识;验证时既检查签名,也检查绑定关系。
不要把会话标识本身直接当成 CSRF Token,也不要把原始会话标识拼进可见值。报告与日志只需记录 Token 是否存在、是否匹配、属于哪种生命周期,不需要保存秘密原文。
上一节的 XSS 不是已经结束的话题。如果攻击者能在本站源内执行脚本,它可能读取页面里的 Token,或直接调用本站已有的请求函数。CSRF Token 解决的是跨站请求意图,不是站内任意脚本执行。输出编码、内容安全策略、会话安全和 CSRF 校验必须一起维护。
Token 回答“请求是否掌握与会话绑定的秘密”,来源信号回答“浏览器是在什么上下文中发出请求”。两者可以组合成多层判断。
对状态变更请求,服务端可以优先检查 Origin,在它缺失时按设计回退到 Referer。比较时要先解析为协议、主机和端口,再与明确允许列表精确比较。不能使用“字符串包含本站域名”或“以某段文字结尾”这样的宽松规则,否则相似域名也可能通过。
目标来源同样要确定。反向代理后,应用看到的 Host 可能是内部地址,而用户访问的是公网地址。团队应在受信任的代理边界内统一传递原始目标主机,并只信任由该代理写入的转发头。客户端直接送来的任意转发头不能拿来决定安全边界。
Origin 可能是 null,来源头也可能因浏览器、隐私策略、重定向或非浏览器客户端而缺失。高敏感接口可以选择缺失即拒绝;兼容性要求较高的入口可以转入严格 Token 校验。无论策略如何,都要分别记录“缺失”“空来源”“不匹配”,不能把它们混成一个无法排查的错误。
现代浏览器会附加 Fetch Metadata 请求头。Sec-Fetch-Site 描述请求发起者与目标的关系:same-origin、same-site、cross-site 或 none。Sec-Fetch-Mode 描述导航、CORS 等模式,Sec-Fetch-Dest 表示文档、图片或其他目标类型,Sec-Fetch-User 可辅助判断顶层导航是否带有用户激活。
这些头带有 Sec- 前缀,网页脚本不能随意把它们改成想要的值。服务端可以默认拒绝 Sec-Fetch-Site: cross-site 的非安全方法,再为真正需要的跨源集成建立窄而清楚的例外。对于没有这些头的旧客户端,必须有事先定义的回退路径,例如精确来源校验加 Token,而不是临时选择放行。
检查状态变更请求(请求):
如果 Sec-Fetch-Site 是 "cross-site":
拒绝并记录("跨站状态变更")
如果 Sec-Fetch-Site 是 "same-site" 且团队不信任全部子域:
继续要求精确 Origin 和会话 Token
如果 Fetch Metadata 缺失:
进入兼容校验,不直接假定可信
如果 请求属于登记过的跨源集成:
使用该集成专属的来源、认证、CORS 和审计规则none 常见于用户直接输入地址、打开书签等没有网页发起者的场景。它也不应被简单理解为“用户确认了所有后果”。正常顶层 GET 导航可以保留,但前提还是 GET 不改变状态。

Fetch Metadata 策略适合先运行在只记录模式。收集正常流量中 same-origin、same-site、cross-site 和缺失头的分布,找出支付跳转、身份联合、浏览器扩展、移动端 WebView 和旧客户端等真实例外,再逐步启用阻断。
例外必须按路由和业务场景登记,不能写成“跨站全部允许”。如果响应会根据 Origin 或 Sec-Fetch-Site 改变,还要正确设置 Vary,避免缓存复用不同上下文的响应。Vary 是缓存正确性措施,不是 CSRF 校验本身。
有些操作即使来源正确、Token 有效,也值得再次确认,例如修改认证因素、授予高权限或改变关键安全设置。因为 Token 证明的是请求来自允许的会话流程,它不能证明坐在屏幕前的人刚刚重新确认了高风险后果。
常见做法包括重新输入密码、完成一次性验证、使用 WebAuthn,或者在独立确认页中清楚展示对象和影响。确认应该靠近最终写入,设置较短有效期,并绑定具体操作,不能让一次重新认证无限期覆盖所有敏感动作。
这层交互也不能替代对象级授权。请求通过 CSRF 校验,只能说明它不像是跨站伪造;它仍然必须检查当前用户能否操作当前对象。反过来,用户本来有权限,也不表示任何带着他 Cookie 的请求都表达了真实意图。
可以把写入前的门禁顺序整理成这样:
方法语义正确
→ 会话身份有效
→ 请求来源与 Fetch Metadata 符合策略
→ CSRF Token 与会话匹配
→ 当前用户拥有对象级权限
→ 高敏感操作完成重新认证或明确确认
→ 执行状态变化
→ 写入脱敏审计日志每一道门解决的都是不同问题。把它们混成一个“已经登录”检查,才是 CSRF 容易藏进去的地方。
一份好的测试矩阵同时包含请求条件、预期的服务端决策、业务状态和日志证据。下面这张表可以按产品实际情况扩展。

解释每个结果时,至少要对齐三类信息:请求上下文发生了什么变化,业务状态是否真的改变,服务端为什么允许或拒绝。
比如,无 Token 请求返回 200,但界面密度保持原值,可能只是前端统一响应;跨站请求得到 403,可能是边缘层 Fetch Metadata 策略生效,并不能证明应用层 Token 配置正确;请求返回 302,也可能跳到登录页、错误页或最终成功页。状态码只是证据之一。
测试报告应写成:“在专用账号和无害偏好入口中,移除会话 Token 后,请求在写入前被应用层拒绝,状态保持不变,日志关联编号对应令牌缺失规则。”这种表述能复核前置条件、控制变量和实际结果,比“无法利用”准确得多。
每次状态变更可以记录时间、规范化路由、方法、账号不可逆标识、来源分类、Sec-Fetch-Site、Token 校验结果、拒绝原因、规则版本、关联编号和最终是否写入。不要记录完整 Cookie、会话标识、CSRF Token、密码、个人信息或敏感业务参数。
单次 Token 失败可能来自页面过期、双击或多标签页,不应自动升级为攻击告警。更值得关注的是组合信号:同一会话连续出现缺失与不匹配,cross-site 流量集中访问状态变更路由,某个来源反复命中拒绝,或正常只收同源请求的入口突然出现大量来源头缺失。
修复时先找框架已有的 CSRF 能力。成熟框架通常已经处理 Token 生成、表单注入、请求头约定和服务端验证,正确启用它比临时写一套随机字符串更可靠。然后再收紧 Cookie 范围、来源校验、Fetch Metadata、CORS、敏感操作确认和审计日志。
复测不能只重放最初那一个请求。至少覆盖正常表单、异步请求、Token 缺失、Token 无效、跨会话 Token、登出后的旧 Token、外部顶层导航、同站点不同源、受信任跨源集成、旧客户端回退和日志定位。还要检查所有状态变化不再依赖安全方法,错误分支不会先写入后报错。
如果多层防护都工作,也不要把结论写成绝对的“没有 CSRF”。更准确的边界是:哪些入口经过测试,Cookie 使用什么策略,跨站请求在哪一层被拒绝,Token 是否绑定会话,哪些同站点子域和旧客户端没有覆盖。安全结论依赖这些假设继续成立。
最后回到本章开头:CSRF 不是在问请求有没有带上一个真的 Cookie,而是在问服务端有没有把身份与意图区分开。浏览器负责按规则发送凭据,应用必须在写入前核对请求上下文、会话绑定证明、对象权限和必要的用户确认。测试者则要用无害状态、单变量矩阵和脱敏日志,把这条判断链完整地验证出来。
下一节会进入文件处理漏洞。表面上看,它从“浏览器替用户发请求”跳到了“服务器怎样接收文件”,但方法没有变:文件名、扩展名和内容类型也只是输入信号,真正的安全来自服务端在存储、解析、访问与执行之前逐层作出明确判断。
按产品设计核对生命周期。若 Token 是单次使用,成功使用后的重复请求应失败;若 Token 是会话级,退出登录、会话轮换或重新认证后,旧 Token 应失效。
在隔离的本地测试源中放置一个明确可见、必须手动点击的测试控件,只提交同一项无害偏好。通过开发者工具观察 Cookie 是否发送,以及 Origin、Referer、Sec-Fetch-Site、Sec-Fetch-Mode 和 Sec-Fetch-Dest 如何变化。不要使用隐藏触发或自动提交。
每改变一个条件,就检查业务状态和服务端日志,再恢复原值。若请求返回成功状态但状态没变,应记录业务拒绝;若返回拒绝,则继续确认到底是哪一层规则拒绝。
实验结束后恢复全部测试数据,注销测试会话,核对没有通知、任务或第三方调用遗留,再请监控方确认日志样本齐全。