●
CWE-352 · OWASP A01:2021
跨站请求伪造(CSRF,CWE-352):原理与防御方法
其他网站上的页面可以让已登录用户的浏览器向你的应用发送一个修改数据的请求,而浏览器会自动附上会话 Cookie,于是只检查 Cookie 的路由就会执行这个操作。本文用 Flask 和 Flask-WTF 的示例说明同步令牌、SameSite Cookie 和 Sec-Fetch-Site 检查如何阻止它。
通俗易懂的原理解析 (ELI5)
你的办公室有个收发室,它会执行放在你“待发”托盘里的任何签了名的申请单,并且信任这些单子,因为它们在你的托盘里,而只有你够得着这个托盘。一位访客请你出门时顺手把一个封好的信封放进托盘。信封里的申请单写着:“把我所有包裹的收货地址改掉。”收发室在你的托盘里看到它,以为是你写的,就照办了。CSRF 也是这样:浏览器会给发往你银行的每个请求都附上你的会话 Cookie,包括由别的网站发起的请求。修复方法是一枚秘密印章,只有你自己办公室的页面才能盖在申请单上,收发室就会拒绝没有印章的单子。
核心概念与专有名词
- 环境凭据
- 无论是哪个页面发起的请求,浏览器都会给发往某个网站的请求附上 Cookie 和 HTTP 认证。从
evil.example发往app.example的请求仍会带上app.example的会话 Cookie,除非该 Cookie 的SameSite属性阻止了它。 - Cookie 的 SameSite 属性
Strict在所有跨站请求中都不发送 Cookie,Lax只在顶层 GET 导航时发送,None总是发送(并且要求Secure)。Chromium 把没有该属性的 Cookie 当作Lax,Firefox 和 Safari 不会,而且“同站”还包括相邻的子域名。- 同步令牌
- 与会话绑定的随机值,服务器把它放进每个表单或页面,并在每个不安全的请求中要求它。其他网站读不到它,因此无法伪造有效请求。Flask-WTF 的
CSRFProtect通过csrf_token()生成它,并检查csrf_token字段或X-CSRFToken请求头。 - Fetch Metadata(Sec-Fetch-Site)
- 现代浏览器在每个请求中都会发送
Sec-Fetch-Site: cross-site、same-site、same-origin或none,页面无法伪造它。拒绝非same-origin的不安全方法,就能用一次服务器端检查挡住 CSRF。 - 用 GET 修改状态
- 用 GET 修改数据的路由可以被一个
<img>标签或链接触发,而SameSite=Lax在顶层 GET 导航时仍会发送 Cookie。读取request.values除了表单主体还会接受查询字符串。
攻击执行流程分解
步骤 1
受害者已登录
他持有 app.example 的会话 Cookie,该 Cookie 带有 SameSite=None,或者没有该属性且浏览器默认不使用 Lax。
步骤 2
他打开了攻击者控制的页面
页面里有一个隐藏表单,会向 https://app.example/account/email 提交,并在加载时自动提交。一个提交无害测试值 email=test@example.com 的表单演示了这种情况。
步骤 3
浏览器带着 Cookie 发送请求
请求携带着受害者的会话,服务器看不到任何迹象表明表单来自另一个网站。
步骤 4
服务器修改了账户
邮箱被改成攻击者的,攻击者随后重置密码并接管账户。2008 年,Zeller 和 Felten 在 ING Direct、YouTube 和《纽约时报》网站上展示了这类 CSRF 漏洞。
源代码对比:漏洞与安全实现
存在漏洞的实现
# app.py:会话 Cookie 是用户本意的唯一证明
import os
from flask import Flask, abort, request, session
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_SAMESITE="None",
)
# GET 也能用,而且 request.values 还会读取查询字符串
@app.route("/account/email", methods=["GET", "POST"])
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.values["email"], user_id))
return {"status": "ok"}
加固后的安全修复
# app.py:令牌、SameSite Cookie 和 Fetch Metadata 一起使用
import os
from flask import Flask, abort, request, session
from flask_wtf.csrf import CSRFProtect
from .db import db
app = Flask(__name__)
app.config.update(
SECRET_KEY=os.environ["FLASK_SECRET_KEY"],
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
)
# 每个 POST 都需要 csrf_token;表单渲染 {{ csrf_token() }},fetch() 发送 X-CSRFToken
csrf = CSRFProtect(app)
# 由其他网站发起的写操作在执行路由前就被拒绝
@app.before_request
def reject_cross_site_writes():
if request.method not in ("GET", "HEAD", "OPTIONS"):
if request.headers.get("Sec-Fetch-Site", "same-origin") not in ("same-origin", "none"):
abort(403)
# 只接受 POST,数据来自表单主体
@app.post("/account/email")
def change_email():
user_id = session.get("user_id")
if user_id is None:
abort(401)
db.execute("UPDATE users SET email = ? WHERE id = ?", (request.form["email"], user_id))
return {"status": "ok"}
工程与系统安全加固清单
- 只用 POST、PUT、PATCH 或 DELETE 修改状态,并从请求主体(
request.form)读取表单数据,绝不从request.values或查询字符串读取。 - 为整个应用启用框架的 CSRF 中间件(
CSRFProtect、Django 的CsrfViewMiddleware、Spring Security 的csrf()),并在每个表单和 AJAX 请求中带上令牌。 - 把会话 Cookie 设为
Secure、HttpOnly和SameSite=Lax或Strict;只有必须在跨站 iframe 中使用的 Cookie 才用SameSite=None。 - 拒绝
Sec-Fetch-Site请求头为cross-site或same-site的不安全请求。 - 在 CI 中添加测试,对每个修改状态的路由发送不带令牌的请求,并期望得到 400 或 403。