FreeBuf 文章:如何防止CSRF攻击?

前端安全系列(二):如何防止CSRF攻击?[美团技术团队]

原文

笔记

CSRF 的特点

  1. 后端接口不能将安全寄托在仅允许POST上面
  2. CSRF的特点
    • 攻击一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生。
    • 攻击利用受害者在被攻击网站的登录凭证,冒充受害者提交操作;而不是直接窃取数据。
    • 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒用”。
    • 跨站请求可以用各种方式:图片URL、超链接、CORS、Form 提交等等。部分请求方式可以直接嵌入在第三方论坛、文章中,难以进行追踪。

CSRF 的防护

整体防护策略

  • CSRF(通常)发生在第三方域名
    > 阻止不明外域的访问:同源检测、Samesite Cookie

  • CSRF攻击者不能获取到Cookie等信息,只是使用
    > 提交时要求附加本域才能获取的信息:CSRF Token、双重 Cookie 验证

同源检测

既然 CSRF 大多来自第三方网站,那么我们就直接禁止外域(或者不受信任的域名)对我们发起请求。如何判断请求是否来自外域呢?

在 HTTP 协议中,每一个异步请求都会携带两个 Header,用于标记来源域名:

1
2
* Origin Header
* Referer Header

这两个 Header 在浏览器发起请求时,大多数情况会自动带上,并且不能由前端自定义内容。服务器可以通过解析这两个 Header 中的域名,确定请求的来源域。

  1. 使用 Origin Header 确定来源域名
    在部分与 CSRF 有关的请求中,请求的 Header 中会携带 Origin 字段。字段内包含请求的域名(不包含 path 及 query)。
    如果 Origin 存在,那么直接使用 Origin 中的字段确认来源域名就可以。
    但是 Origin 在以下两种情况下并不存在:
    • IE11 同源策略:IE11 不会在跨站 CORS 请求上添加 Origin 标头,Referer 头将仍然是唯一的标识。最根本原因是因为 IE11 对同源的定义和其他浏览器有不同,有两个主要的区别,可以参考MDN Same-origin_policy#IE_Exceptions
    • 302 重定向:在 302 重定向之后 Origin 不包含在重定向的请求中,因为 Origin 可能会被认为是其他来源的敏感信息。对于 302 重定向的情况来说都是定向到新的服务器上的 URL,因此浏览器不想将 Origin 泄漏到新的服务器上。

使用 Referer Header 确定来源域名

根据 HTTP 协议,在 HTTP 头中有一个字段叫 Referer,记录了该 HTTP 请求的来源地址。
对于 Ajax 请求,图片和 script 等资源请求,Referer 为发起请求的页面地址。对于页面跳转,Referer 为打开页面历史记录的前一个页面地址。因此我们使用 Referer 中链接的 Origin 部分可以得知请求的来源域名。
这种方法并非万无一失,Referer 的值是由浏览器提供的,虽然 HTTP 协议上有明确的要求,但是每个浏览器对于 Referer 的具体实现可能有差别,并不能保证浏览器自身没有安全漏洞。
使用验证 Referer 值的方法,就是把安全性都依赖于第三方(即浏览器)来保障,从理论上来讲,这样并不是很安全。在部分情况下,攻击者可以隐藏,甚至修改自己请求的 Referer。

隐藏 Referer

攻击者可以在自己的请求中隐藏 Referer。如

1
<img src="http://bank.example/withdraw?amount=10000&for=hacker" referrerpolicy="no-referrer"> 

那么这个请求发起的攻击将不携带Referer。

另外在以下情况下 Referer 没有或者不可信:

  1. IE6、7 下使用 window.location.href=url 进行界面的跳转,会丢失 Referer。
  2. IE6、7 下使用 window.open,也会缺失 Referer。
  3. HTTPS 页面跳转到 HTTP 页面,所有浏览器 Referer 都丢失。
  4. 点击 Flash 上到达另外一个网站的时候,Referer 的情况就比较杂乱,不太可信。

无法确认来源域名情况

当 Origin 和 Referer 头文件不存在时该怎么办?如果 Origin 和 Referer 都不存在,建议直接进行阻止,特别是如果您没有使用随机 CSRF Token(参考下方)作为第二次检查。

如何阻止外域请求

通过 Header 的验证,我们可以知道发起请求的来源域名,这些来源域名可能是网站本域,或者子域名,或者有授权的第三方域名,又或者来自不可信的未知域名。

我们已经知道了请求域名是否是来自不可信的域名,我们直接阻止掉这些的请求,就能防御 CSRF 攻击了吗?
且慢!当一个请求是页面请求(比如网站的主页),而来源是搜索引擎的链接(例如百度的搜索结果),也会被当成疑似 CSRF 攻击。所以在判断的时候需要过滤掉页面请求情况,通常Header符合以下情况:

1
2
Accept: text/html
Method: GET

但相应的,页面请求就暴露在了 CSRF 的攻击范围之中。如果你的网站中,在页面的 GET 请求中对当前用户做了什么操作的话,防范就失效了。

另外,前面说过,CSRF大多数情况下来自第三方域名,但并不能排除本域发起。如果攻击者有权限在本域发布评论(含链接、图片等,统称UGC),那么它可以直接在本域发起攻击,这种情况下同源策略无法达到防护的作用。

综上所述:同源验证是一个相对简单的防范方法,能够防范绝大多数的 CSRF 攻击。但这并不是万无一失的,对于安全性要求较高,或者有较多用户输入内容的网站,我们就要对关键的接口做额外的防护措施。

CSRF Token

前面讲到 CSRF 的另一个特征是,攻击者无法直接窃取到用户的信息(Cookie,Header,网站内容等),仅仅是冒用 Cookie 中的信息。

而 CSRF 攻击之所以能够成功,是因为服务器误把攻击者发送的请求当成了用户自己的请求。那么我们可以要求所有的用户请求都携带一个 CSRF 攻击者无法获取到的Token。服务器通过校验请求是否携带正确的 Token,来把正常的请求和攻击的请求区分开,也可以防范 CSRF 的攻击。

  1. 将CSRF Token输出到页面中
    首先,用户打开页面的时候,服务器需要给这个用户生成一个 Token,该 Token 通过加密算法对数据进行加密,一般 Token 都包括随机字符串和时间戳的组合,显然在提交时 Token 不能再放在 Cookie 中了,否则又会被攻击者冒用。因此,为了安全起见 Token 最好还是存在服务器的 Session 中,之后在每次页面加载时,使用 JS 遍历整个 DOM 树,对于 DOM 中所有的 a 和 form 标签后加入 Token。这样可以解决大部分的请求,但是对于在页面加载之后动态生成的 HTML 代码,这种方法就没有作用,还需要程序员在编码时手动添加 Token。
  2. 页面提交的请求携带这个 Token
    对于 GET 请求,Token 将附在请求地址之后,这样 URL 就变成 http://url?csrftoken=tokenvalue。而对于 POST 请求来说,要在 form 的最后加上 <input type="hidden" name="csrftoken" value="tokenvalue" />
  3. 服务器验证 Token 是否正确
    当用户从客户端得到了 Token,再次提交给服务器的时候,服务器需要判断 Token 的有效性,验证过程是先解密 Token,对比加密字符串以及时间戳,如果加密字符串一致且时间未过期,那么这个 Token 就是有效的。

这种方法要比之前检查 Referer 或者 Origin 要安全一些,Token 可以在产生并放于 Session 之中,然后在每次请求时把 Token 从 Session 中拿出,与请求中的 Token 进行比对,但这种方法的比较麻烦的在于如何把 Token 以参数的形式加入请求。如果在请求中找不到 Token,或者提供的值与会话中的值不匹配,则应中止请求,应重置 Token 并将事件记录为正在进行的潜在 CSRF 攻击。

分布式校验

在大型网站中,使用 Session 存储 CSRF Token 会带来很大的压力。访问单台服务器 session 是同一个,但是现在的大型网站中,我们的服务器通常不止一台,可能是几十台甚至几百台之多,甚至多个机房都可能在不同的省份,用户发起的 HTTP 请求通常要经过像 Ngnix 之类的负载均衡器之后,再路由到具体的服务器上,由于 Session 默认存储在单机服务器内存中,因此在分布式环境下同一个用户发送的多次 HTTP 请求可能会先后落到不同的服务器上,导致后面发起的 HTTP 请求无法拿到之前的 HTTP 请求存储在服务器中的 Session 数据,从而使得 Session 机制在分布式环境下失效,因此在分布式集群中 CSRF Token 需要存储在 Redis 之类的公共存储空间。

由于使用 Session 存储,读取和验证 CSRF Token 会引起比较大的复杂度和性能问题,目前很多网站采用 Encrypted Token Pattern 方式。这种方法的 Token 是一个计算出来的结果,而非随机生成的字符串。这样在校验时无需再去读取存储的 Token,只用再次计算一次即可。

这种 Token 的值通常是使用 UserID时间戳随机数,通过加密的方法生成。这样既可以保证分布式服务的 Token 一致,又能保证 Token 不容易被破解。

在 Token 解密成功之后,服务器可以访问解析值, Token 中包含的 UserID 和时间戳将会被拿来被验证有效性,将 UserID 与当前登录的 UserID 进行比较,并将时间戳与当前时间进行比较。

Token 防御总结

Token 是一个比较有效的 CSRF 防护方法,只要页面没有 XSS 漏洞泄露 Token,那么接口的 CSRF 攻击就无法成功。
但是此方法的实现比较复杂,需要给每一个页面都写入 Token(前端无法使用纯静态页面),每一个 Form 及 Ajax 请求都携带这个 Token,后端对每一个接口都进行校验,并保证页面 Token 及请求 Token 一致。这就使得这个防护策略不能在通用的拦截上统一拦截处理,而需要每一个页面和接口都添加对应的输出和校验。这种方法工作量巨大,且有可能遗漏。(验证码和密码其实也可以起到 CSRF Token 的作用,而且更安全)

在会话中存储 CSRF Token 比较繁琐,而且不能在通用的拦截上统一处理所有的接口。
那么另一种防御措施是使用双重提交 Cookie。利用 CSRF 攻击不能获取到用户 Cookie 的特点,我们可以要求 Ajax 和表单请求携带一个 Cookie 中的值。

双重Cookie采用以下流程:

  1. 在用户访问网站页面时,向请求域名注入一个 Cookie,内容为随机字符串(例如 csrfcookie=v8g9e4ksfhw)。
  2. 在前端向后端发起请求时,取出 Cookie,并添加到 URL 的参数中(接上例 POST https://www.a.com/comment?csrfcookie=v8g9e4ksfhw)。
  3. 后端接口验证 Cookie 中的字段与 URL 参数中的字段是否一致,不一致则拒绝。

此方法相对于 CSRF Token 就简单了许多,可以直接通过前后端拦截的的方法自动化实现。后端校验也更加方便,只需进行请求中字段的对比,而不需要再进行查询和存储 Token。

但是此方法并没有大规模应用,其在大型网站上的安全性还是没有 CSRF Token 高,原因我们举例进行说明。

由于任何跨域都会导致前端无法获取 Cookie 中的字段(包括子域名之间),于是发生了如下情况:

  1. 如果用户访问的网站为 www.a.com,而后端的 api 域名为 api.a.com。那么在 www.a.com 下,前端拿不到 api.a.com 的 Cookie,也就无法完成双重 Cookie 认证。
  2. 于是这个认证 Cookie 必须被种在 a.com 下,这样每个子域都可以访问。
  3. 任何一个子域都可以修改 a.com 下的 Cookie。
  4. 某个子域名存在漏洞被 XSS 攻击(例如 upload.a.com)。虽然这个子域下并没有什么值得窃取的信息,但攻击者修改了 a.com 下的 Cookie。
  5. 攻击者可以直接使用自己配置的 Cookie,对 XSS 中招的用户再向 www.a.com 下,发起 CSRF 攻击。

双重 Cookie 验证 总结

用双重 Cookie 防御 CSRF 的优点:

  • 无需使用 Session,适用面更广,易于实施。
  • Token 储存于客户端中,不会给服务器带来压力。
  • 相对于 Token,实施成本更低,可以在前后端统一拦截校验,而不需要一个个接口和页面添加。

缺点:

  • Cookie 中增加了额外的字段。
  • 如果有其他漏洞(例如 XSS),攻击者可以注入 Cookie,那么该防御方式失效。
  • 难以做到子域名的隔离。
  • 为了确保 Cookie 传输安全,采用这种防御方式的最好确保用整站 HTTPS 的方式,如果还没切 HTTPS 的使用这种方式也会有风险。

防止 CSRF 攻击的办法已经有上面的预防措施。为了从源头上解决这个问题,Google 起草了一份草案来改进 HTTP 协议,那就是为 Set-Cookie 响应头新增 Samesite 属性,它用来标明这个 Cookie 是个 同站 Cookie,同站 Cookie 只能作为第一方 Cookie,不能作为第三方 Cookie,Samesite 有两个属性值,分别是 Strict 和 Lax,下面分别讲解:

Samesite=Strict

这种称为 严格模式,表明这个 Cookie 在任何情况下都不可能作为第三方 Cookie,绝无例外。比如说 b.com 设置了如下 Cookie:

1
2
3
Set-Cookie: foo=1; Samesite=Strict
Set-Cookie: bar=2; Samesite=Lax
Set-Cookie: baz=3

我们在 a.com 下发起对 b.com 的任意请求,foo 这个 Cookie 都不会被包含在 Cookie 请求头中,但 bar 会。举个实际的例子就是,假如淘宝网站用来识别用户登录与否的 Cookie 被设置成了 Samesite=Strict,那么用户从百度搜索页面甚至天猫页面的链接点击进入淘宝后,淘宝都不会是登录状态,因为淘宝的服务器不会接受到那个 Cookie,其它网站发起的对淘宝的任意请求都不会带上那个 Cookie。

Samesite=Lax

这种称为 宽松模式,比 Strict 放宽了点限制:假如这个请求是这种请求(改变了当前页面或者打开了新页面)且同时是个 GET 请求,则这个 Cookie 可以作为第三方 Cookie。比如说 b.com 设置了如下 Cookie:

1
2
3
Set-Cookie: foo=1; Samesite=Strict
Set-Cookie: bar=2; Samesite=Lax
Set-Cookie: baz=3

当用户从 a.com 点击链接进入 b.com 时,foo 这个 Cookie 不会被包含在 Cookie 请求头中,但 bar 和 baz 会,也就是说用户在不同网站之间通过链接跳转是不受影响了。但假如这个请求是从 a.com 发起的对 b.com 的异步请求,或者页面跳转是通过表单的 post 提交触发的,则 bar 也不会发送。

Samesite 的问题

Samesite的兼容性不是很好,现阶段除了从新版 Chrome 和 Firefox 支持以外,Safari 以及 iOS Safari 都还不支持,现阶段看来暂时还不能普及。

而且,Samesite Cookie 目前有一个致命的缺陷:不支持子域。例如,种在 topic.a.com 下的 Cookie,并不能使用 a.com 下种植的 SamesiteCookie。这就导致了当我们网站有多个子域名时,不能使用 SamesiteCookie 在主域名存储用户登录信息。每个子域名都需要用户重新登录一次。

总之,SamesiteCookie是一个可能替代同源验证的方案,但目前还并不成熟,其应用场景有待观望。

查缺补漏

X-Content-Type-Options: nosniff

如果服务器发送响应头 X-Content-Type-Options: nosniff,则 script 和 styleSheet 元素会拒绝包含错误的 MIME 类型的响应。这是一种安全功能,有助于防止基于 MIME 类型混淆的攻击。

JWT

JWT 即 JSON Web Token,是目前最流行的跨域身份验证解决方案,服务器不保存任何会话数据,即服务器变为无状态,使其更容易扩展。

JSON Web Token 由三部分组成使用 . 分割开:

1
2
3
Header
Payload
Signature

一个 JWT 形式上类似于下面的样子:
xxxxx.yyyy.zzzz

Header 一般由两个部分组成:

  • alg:alg 是所使用的 hash 算法例如 HMAC SHA256 或 RSA
  • typ:typ 是 Token 的类型自然就是 JWT。

如:

1
2
3
4
{
"alg": "HS256",
"typ": "JWT"
}

然后使用 Base64Url 编码成第一部分:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.<second part>.<third part>

Payload

这一部分是 JWT 主要的信息存储部分,其中包含了许多种的声明(claims)。
Claims 的实体一般包含用户和一些元数据,这些 claims 分成三种类型:reserved, public, 和 private claims。

  • (保留声明)reserved claims :预定义的 一些声明,并不是强制的但是推荐,它们包括 iss (issuer), exp (expiration time), sub (subject),aud(audience) 等。
  • (公有声明)public claims : 这个部分可以随便定义,但是要注意和 IANA JSON Web Token 冲突。
  • (私有声明)private claims : 这个部分是共享被认定信息中自定义部分。

一个 Pyload 可以是这样子的:

1
2
3
4
5
{
"sub": "1234567890",
"name": "John Doe",
"admin": true
}

这部分同样使用 Base64Url 编码成第二部分:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.<third part>

Signature

在创建该部分时候你应该已经有了编码后的 Header 和 Payload 还需要一个秘钥,这个加密的算法应该 Header 中指定。密钥是保存在服务端的,服务端会根据这个密钥进行生成 Token 和验证,所以需要保护好。
一个使用 HMAC SHA256 的例子如下:

1
2
3
4
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)

这个 signature 是用来验证发送者的 JWT 的同时也能确保在期间不被篡改。

所以,做后你的一个完整的 JWT 应该是如下形式:

1
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.TJVA95OrM7E2cBab30RMHrHDcEfxjoYZgeFONFh7HgQ

JSON Web Token 的工作流程

在用户使用证书或者账号密码登入的时候,服务器返回一个 JSON Web Token,同时可以把这个 JWT 存储在 local storage、或者 cookie 中,用来替代传统的在服务器端创建一个 session 返回一个 cookie。

此后,客户端在与服务器交互中都会带上 JWT。注意,将它存储在 Cookie 中不能跨域,因此一般是将它放入 HTTP 请求的 Header Authorization 中:

也可以将 JWT 放置于 POST 请求中。

来呀快活呀


FreeBuf 文章:如何防止CSRF攻击?
https://www.tr0y.wang/2018/11/07/CSRFMeiTuan/
作者
Tr0y
发布于
2018年11月7日
更新于
2024年4月19日
许可协议