● 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"}

工程与系统安全加固清单

参考来源

← 浏览完整的安全目录 全部漏洞指南 →