CSRF 到底是怎么被攻击的
很多站长加固服务器时把力气全花在 SSH、防火墙、WAF 上,却忽略了一类完全不需要拿到你密码就能得手的攻击——CSRF(Cross-Site Request Forgery,跨站请求伪造)。它的原理并不复杂:浏览器有个默认行为,只要你访问某个域名,就会自动带上这个域名下的 Cookie。攻击者不需要知道你的密码,只要诱导你在已登录目标站点的状态下,去访问一个恶意页面,这个页面就能替你向目标站点发出一条「合法」的请求。
举个自建论坛的场景。你的论坛后台有「删除用户」「修改邮箱」「发帖」这类接口,认证靠的是会话 Cookie。攻击者在论坛发帖时插入一张图片:<img src="https://你的论坛/admin/delete_user.php?id=3">。任何一个已登录的管理员只要浏览到这个帖子,浏览器就会带着管理员的 Cookie 去请求这个删除接口,用户 3 就被删了——而管理员全程毫无感知。更阴险的是改邮箱:攻击者用 CSRF 把管理员的邮箱改成自己的,然后走「忘记密码」流程,就能通过邮件重置链接彻底接管账号。
理解 CSRF 的关键在于:浏览器是「凭 Cookie 认人」的,而 Cookie 的发送完全不受发起请求的那个页面控制。同源策略限制了恶意页面「读取」目标站点的响应,但没限制它「发起」请求。CSRF 攻击利用的正是这个不对称——能发不能读,但很多危险的接口只要发出去就够了。
第一道防线:SameSite Cookie 属性
现代浏览器给 Cookie 加了一个 SameSite 属性,专门用来限制跨站请求时是否携带 Cookie。这是成本最低的 CSRF 缓解手段,一行配置就能挡住绝大多数场景。它的取值有三种:
Strict(严格):完全禁止跨站携带 Cookie。从别的网站点链接跳转到你的站点,第一次请求不带 Cookie,用户会看到自己「没登录」,需要刷新一次才恢复。安全性最高,但体验最差,一般不用在主站会话上。
Lax(宽松):跨站的顶级导航(点链接、地址栏输入、GET 跳转)会带 Cookie,但跨站的 AJAX、表单 POST、图片加载、iframe 一律不带。这正好卡住了 CSRF 发动的方式(攻击通常靠 POST 表单或隐藏资源请求),同时不影响正常的「从搜索点进来」体验。Lax 是绝大多数站点的最佳默认值,也是 Chrome 从 2020 年起对未设置 SameSite 的 Cookie 的默认行为。
None:允许跨站携带,但必须同时设置 Secure(只能走 HTTPS)。用于确实需要跨站共享 Cookie 的场景,比如嵌入第三方的 iframe,一般不推荐。
在 Nginx 层给所有会话 Cookie 统一补上 SameSite,可以用 proxy_cookie_flags:
location / {
proxy_pass http://127.0.0.1:8080;
proxy_cookie_flags ~ secure httponly samesite=lax;
}这行的含义是:给所有由后端下发的 Cookie 追加 Secure、HttpOnly、SameSite=Lax 三个标志。HttpOnly 顺手把 Cookie 藏起来不让 JavaScript 读取,能一并防住 XSS 偷 Cookie。注意这里有个前提:站点必须是 HTTPS,否则 Secure 标志会让 Cookie 根本不下发。
但要注意,SameSite 不是万能药。它挡不住同站的子域攻击(比如你有个被攻陷的 user-content.example.com 子域,对主域来说仍是 same-site),也挡不住老浏览器。所以 SameSite 应该作为「第一层兜底」,真正的业务接口还是需要下面这种主动校验。
第二道防线:CSRF Token
Token 方案是业界标准做法,也是一种更可靠的防线。核心思想是:服务端在渲染包含表单的页面时,生成一个与当前会话绑定、攻击者猜不到的随机串(CSRF Token),放进表单的隐藏字段里;表单提交时服务端校验这个 Token 是否和你会话里存的一致。攻击者的恶意页面拿不到这个 Token(因为它读不了你页面里的内容),所以伪造的请求通不过校验。
配置要点有几个容易踩坑的地方:
Token 必须与会话绑定,且不可预测。 用 openssl rand 或 CSPRNG 生成,长度至少 16 字节。绝对不要用时间戳、自增 ID 这种可猜的值。
Token 要放在请求体或自定义头里,不能放在 URL 查询串。 放 URL 里会通过 Referer 头、浏览器历史、日志泄露出去,等于白给。推荐放在 POST 表单的隐藏字段,或者放在 AJAX 请求的自定义头(如 X-CSRF-Token)里。
校验失败必须硬拒绝,也就是返回 403 并记录日志,绝不能「校验不通过也照样放行」。很多漏洞就是因为开发者图省事,校验逻辑只在某些分支生效。
这里有一个实用的加固思路:对所有非幂等的写操作(POST/PUT/DELETE)强制要求 Token,而对 GET 只做读取。这样即使某处漏配,攻击者也无法用 GET 图片标签的方式触发危险操作。把「删除」「修改」这类接口全部限制成只接受 POST,本身就能挡掉大量基于 <img>、<script> 的低成本攻击。
第三道防线:双重提交 Cookie(Double Submit Cookie)
如果你维护的是前后端分离的应用,服务端会话用的是无状态 JWT,那维护一张「会话 ↔ Token」映射表会很麻烦。这时可以用 Double Submit Cookie 方案,思路很巧妙:
服务端生成一个随机值,同时把它写进两个地方——一个是 Cookie(可以是非 HttpOnly 的),另一个是让前端 JavaScript 读出来,放进请求头 X-CSRF-Token。服务端收到请求时,比较请求头里的值和 Cookie 里的值是否一致。攻击者的跨站请求虽然会自动带上 Cookie,但因为同源策略,它读不到 Cookie 的值,也就无法在请求头里放上正确的值,于是校验失败。
这个方案的好处是不用服务端存状态,缺点是依赖子域完全可信——如果攻击者能在你的某个子域上执行 JavaScript,就能读到 Cookie 里的值,方案就失效了。所以用这个方案时,务必保证没有不可信的子域(比如用户上传内容所在的子域要单独隔离)。为了缓解,可以对 Cookie 值做一层 HMAC 签名,只接受带正确签名的值。
第四道防线:Origin / Referer 头校验
浏览器在发跨站请求时,会带上 Origin 头(对 POST 一般都有),或者至少是 Referer。服务端可以校验这两个头是否指向自己的站点。这是零成本、不需改前端的补充手段,尤其适合给历史遗留界面加一层统一防护。
在 Nginx 层做粗粒度校验,可以拦截明显异常的来源:
location /admin/ {
# 无 Origin/Referer 或来源不是本站的写请求,直接 403
if ($request_method = POST) {
set $ok 0;
if ($http_origin ~* "^https://(www\.)?example\.com$") { set $ok 1; }
if ($http_referer ~* "^https://(www\.)?example\.com/") { set $ok 1; }
if ($ok = 0) { return 403; }
}
proxy_pass http://127.0.0.1:8080;
}不过要清楚这套逻辑的边界:Origin / Referer 是可以被部分客户端或隐私插件剥离的,所以它不能作为唯一防线,而是「缺一不可中的一环」。另外注意 if 在 Nginx 的 location 里有名的坑(if is evil),上面的写法能用,但要在测试环境验证清楚,别在关键业务上盲上。
一份可落地的分层防护清单
把上面几层组合起来,个人站点的 CSRF 防护基本就固若金汤了。建议按这个顺序落实:
1. 全部会话 Cookie 设置 HttpOnly; Secure; SameSite=Lax,用 Nginx 的 proxy_cookie_flags 统一补,别指望每个应用都写对。
2. 所有写操作(POST/PUT/DELETE)强制 CSRF Token 校验,Token 与会话绑定、放请求体或自定义头、失败即 403 并记日志。
3. 危险操作只接受 POST,禁止用 GET 触发删除、修改、转账这类副作用。
4. 前后端分离且无状态的场景,用 Double Submit Cookie 或带 HMAC 签名的变体。
5. 在上层加一道 Origin/Referer 校验作为兜底,尤其覆盖管理后台路径。
6. 所有后台登录再加一步 TOTP 两步验证——这样即便 CSRF 侥幸成功改掉了邮箱,攻击者没有动态码也进不来。
怎么验证防线真的生效
写完防护不要想当然。最直接的验证方法是用 curl 手动发一条不带 Token 的伪造请求,看服务端是否返回 403:
# 模拟 CSRF:不带任何 CSRF Token 直接 POST
curl -i -X POST https://www.example.com/admin/delete_user.php \
-d "id=3" \
-H "Cookie: PHPSESSID=伪造的会话"
# 期望结果:HTTP/1.1 403 Forbidden
# 若返回 200 或 302,说明接口没有 Token 校验,存在 CSRF 漏洞再验证 Cookie 属性是否正确下发:
curl -sI https://www.example.com/ | grep -i set-cookie
# 期望看到:Set-Cookie: PHPSESSID=...; path=/; HttpOnly; Secure; SameSite=Lax如果日志里出现大量来自同一 Referer 的异常 403,那往往说明有人正在尝试 CSRF 或爬虫攻击,可以顺藤摸瓜看看是不是该封 IP 了。
小结
CSRF 的危险在于它「不需要密码」——攻击者借的是你已登录的浏览器身份。所以它的防护思路和防密码泄露完全不同:核心不是更强的认证,而是让服务端能区分「用户本人主动发起的请求」和「被第三方页面骗着发出的请求」。SameSite 属性负责在浏览器层面拦跨站携带,CSRF Token 负责在业务层面校验请求来源,Double Submit Cookie 服务无状态场景,Origin/Referer 校验做兜底。这四层叠起来,再配合危险操作只收 POST、后台加两步验证,一个草根站长自己维护的站点就足以扛住绝大多数自动化和手工 CSRF 尝试了。安全从来不是一步到位,而是这样一层一层堆出来的。