你真的了解CSRF吗

前言

最近我碰到了一些 CSRF(跨站请求伪造)的案例,借此机会我深入研究了一番。研究后发现,CSRF 攻击确实挺可怕的,因为它很容易被忽视。幸运的是,现在很多开发框架都内置了防御 CSRF 的功能,可以很方便地启用。

即便如此,我还是认为有必要深入了解一下 CSRF 究竟是什么,它是通过什么手段进行攻击的,以及我们应该如何防范。下面,我们就来简单介绍一下 CSRF

CSRF 是一种 Web 攻击手法,全称是 Cross Site Request Forgery,即跨站请求伪造。注意不要和 XSS(跨站脚本攻击)混淆,它们是两种不同的攻击方式。那么,CSRF 到底是什么呢?让我们从我自己的一个小案例说起。

偷懒的删除功能

以前我做过一个简单的后台页面,可以看作是一个博客。用户可以发表、删除和编辑文章。页面大概长这样:

可以看到删除按钮,点击后就可以删除一篇文章。当时因为图方便,我把这个功能做成了 GET 请求,我甚至可以直接用一个链接来完成删除操作,前端几乎不需要写任何代码:

ini 复制代码
<a href='/delete?id=3'>删除</a>

很方便对吧?然后我在后端做一些验证,检查请求是否有带上 session,同时也验证这篇文章是否是当前用户发表的,都符合条件才删除文章。

听起来我好像已经做了所有应该做的,我已经确保了「只有作者本人可以删除自己的文章」,应该很安全了吧?

的确是「只有作者本人可以删除自己的文章」,但如果他不是自己「主动删除」,而是在不知情的情况下删除呢?你可能会觉得我在说些奇怪的事情,怎么会有这种情况发生,不是作者主动删的还能怎么删?

好的,我来给你展示一下还能怎么删!

假设小黑是一个邪恶的坏蛋,他想让小明在不知情的情况下删除自己的文章,他该怎么做呢?他知道小明很喜欢心理测验,于是他做了一个心理测验网站,并发给小明。但这个心理测验网站和其他网站不同的地方在于,「开始测验」的按钮长这样:

ini 复制代码
<a href='https://small-min.blog.com/delete?id=3'>开始测验</a>

小明收到网页之后很开心,就点击「开始测验」。点击之后浏览器就会发送一个 GET 请求给 https://small-min.blog.com/delete?id=3,并且因为浏览器的运行机制,一并把 small-min.blog.comcookie 都一起带上去。

后端收到之后检查了一下 session,发现是小明,而且这篇文章也真的是小明发的,于是就把这篇文章给删除了。

这就是 CSRF,你现在明明在心理测验网站,假设是 https://test.com 好了,但是却在不知情的状况下删除了 https://small-min.blog.com 的文章,你说这可不可怕?

你可能会说:可是这样小明不就知道了吗,跳转到博客页面了呀?不符合不知情的状况啊!

好的,那如果我们改成这样呢:

ini 复制代码
<img src='https://small-min.blog.com/delete?id=3' width='0' height='0' />
<a href='/test'>开始测验</a>

在打开页面的同时,一样发送一个删除的请求出去,但这次小明是真的完全不知道这件事情。这样就符合了吧!

CSRF 就是在不同的域名下却能够伪造出「使用者本人发出的请求」。要达成这件事也很简单,因为浏览器的机制,你只要发送请求给某个网站,就会把关联的 cookie 一并带上去。如果使用者是登录状态,那这个请求就理所当然包含了他的信息(比如 session id),这个请求看起来就像是使用者本人发出的。

那我把删除改成 POST 不就好了吗?

没错,聪明!我们不要那么懒,好好把删除的功能做成 POST,这样不就无法通过 <a> 或是 <img> 来攻击了吗?除非有哪个 HTML 元素可以发送 POST 请求!

有,正好有一个,就叫做 form

ini 复制代码
<form action="https://small-min.blog.com/delete" method="POST">
  <input type="hidden" />
  <input type="submit" value="开始测验"/>
</form>

小明点下去以后,照样中招,一样删除了文章。你可能又疑惑说,但是这样小明不就知道了吗?我跟你一样很疑惑,于是我 Google 到了这篇:Example of silently submitting a POST FORM (CSRF)

这篇提供的范例如下,网页的世界真是博大精深:

xml 复制代码
<iframe style="display:none" ></iframe>
<form method='POST' action='https://small-min.blog.com/delete' target="csrf-frame" id="csrf-form">
  <input type='hidden' name='id' value='3'>
  <input type='submit' value='submit'>
</form>
<script>document.getElementById("csrf-form").submit()</script>

打开一个看不见的 iframe,让 form 提交之后的结果出现在 iframe 里面,而且这个 form 还可以自动提交,完全不需要经过小明的任何操作。到了这步,你就知道改成 POST 是没用的。

那我后端改成只接收 json 呢?

聪明的你灵机一动:「既然在前端只有 form 可以发送 POST 请求的话,那我的 API 改成用 json 接收数据不就可以了吗?这样总不能用 form 了吧!」

直接告诉你这还是没用的!

ini 复制代码
<form action="https://small-min.blog.com/delete" method="post" enctype="text/plain">
<input name='{"id":3, "ignore_me":"' value='test"}' type='hidden'>
<input type="submit"
  value="delete!"/>
</form>

这样子会生成如下的请求 body

json 复制代码
{ "id": 3,
"ignore_me": "=test"
}

但这边值得注意的一点是,form 能够带的 content type 只有三种:application/x-www-form-urlencodedmultipart/form-datatext/plain。在上面的攻击中我们用的是最后一种,text/plain,如果你在你的后端服务有检查这个 content type 的话,是可以避免掉上面这个攻击的。

只是,上面这几个攻击我们都还没讲到一种情况:如果你的 API 接受 cross origin 的请求呢?

意思就是,如果你的 APIAccess-Control-Allow-Origin 设成 * 的话,代表任何域名都可以发送 ajax 请求到你的后端服务,这样无论你是改成 json 接收数据,或是把请求方式改成 PUT, DELETE 都没有用。

我们举的例子是删除文章,这你可能觉得没什么,那如果是银行转帐呢?攻击者只要在自己的网页上写下转帐给自己帐号的代码,再把这个网页散布出去就好,就可以收到一大堆钱。

讲了这么多,来讲该怎么防御吧!先从最简单的「使用者」开始讲。

使用者的防御

CSRF 攻击之所以能成立,是因为使用者在被攻击的网页是处于已经登入的状态,所以才能做出一些行为。虽然说这些攻击应该由网页那边负责处理,但如果你真的很害怕受到CSRF 攻击,担心网页会处理不好的话,你可以在每次使用完网站就退出登录,就可以避免掉 CSRF。

使用者能做的其实有限,合理有效的防御手段还是后端那边!

后端的防御

CSRF 之所以可怕是因为 CS 两个字:Cross Site,你可以在任何一个网站底下发动攻击。CSRF 的防御就可以从这个方向思考,简单来说就是:「我要怎么挡掉从别的网站来的请求」

你仔细想想,CSRF 的请求跟使用者本人发出的请求有什么区别?区别在于请求域名的不同,前者是从任意一个网站发出的,后者是从同一个网站发出的(这边假设你的 API 跟你的前端网站在同一个域名下)

检查 Referer

请求头里面会带一个叫做 referer 的属性,代表这个请求是从哪个地方过来的,可以检查这个属性值看是不是合法的域名,不是的话直接拒绝掉即可。

但这个方法要注意的地方有三个,第一个是有些浏览器可能不会带 referer,第二个是有些使用者可能会关闭自动带 referer 的这个功能,这时候你的服务就会拒绝掉由真的使用者发出的请求。

第三个是你判定是不是合法域名的代码必须要保证没有 bug,例如:

arduino 复制代码
const referer = request.headers.referer;
if (referer.indexOf('small-min.blog.com') > -1) {
  // pass
}

你看出上面这段的问题了吗?如果攻击者的网页是 small-min.blog.com.attack.com 的话,你的检查就失效了。

所以,检查 referer 并不是一个很完善的防御方法

加上图形验证码、短信验证码等等

就跟网络银行转帐的时候一样,都会要填写短信验证码,多了这一道检查就可以确保不会被 CSRF 攻击。

图形验证码也是,攻击者并不知道图形验证码的答案是什么,所以就不可能攻击了。

这是一个很完善的解决方法,但如果使用者每次删除 blog 都要输入一次图形验证码,他们应该会烦死吧!

加上 CSRF token

要防止 CSRF 攻击,我们其实只要确保有些信息「只有使用者知道」即可。那该怎么做呢?

我们在 form 里面加上一列 hiddeninput,叫做 csrftoken,这里面的值由后端随机生成,并且存在后端的 session 中。

ini 复制代码
<form action="https://small-min.blog.com/delete" method="POST">
  <input type="hidden" />
  <input type="hidden" />
  <input type="submit" value="删除文章"/>
</form>

按下 submit 之后,后端比对表单中的 csrftoken 与自己 session 里面存的是不是一样的,是的话就代表这的确是由使用者本人发出的 请求。这个 csrftoken 由后端生成,并且一段时间的 session 就应该要更换一次。

那这个为什么可以防御呢?因为攻击者并不知道 csrftoken 的值是什么,也猜不出来,所以自然就无法进行攻击了。

可是有另外一种状况,假设你的后端支持 cross origin 的 请求,会发生什么事呢?攻击者就可以在他的页面发起一个 请求,顺利拿到这个 csrf token 并且进行攻击。不过前提是你的后端接受这个域名的请求。

接着让我们来看看另外一种防御方法

上一种防御方法需要后端将 csrf token 保存下来的,才能验证正确性。而现在这个防御方法的好处就是完全不需要后端储存数据。

这个防御方法的前半部分与刚刚的相似,由后端生成一组随机的 token 并且附加到 form 上面。但不同的点在于,除了不用把这个值写在 session 以外,还需要让前端设置一个名叫 csrftokencookie,值就是生成的 token

ini 复制代码
Set-Cookie: csrftoken=fj1iro2jro12ijoi1

<form action="https://small-min.blog.com/delete" method="POST">
  <input type="hidden" />
  <input type="hidden" />
  <input type="submit" value="删除文章"/>
</form>

你可以仔细思考一下 CSRF 攻击的请求与使用者本人发出的请求有什么不一样?不一样的点就在于,前者来自不同的域名,后者来自相同的域名。所以我们只要有办法区分出这个请求是不是从同样的域名来,我们就胜利了。

Double Submit Cookie 这个防御方法正是从这个想法出发。

当使用者按下 submit 的时候,后端会比对 cookie 内的 csrftokenform 里面的 csrftoken,检查是否有值并且相等,就知道是不是使用者发的了。

为什么呢?假设现在攻击者想要攻击,他可以随便在 form 里面写一个 csrftoken,这当然没问题,可是因为浏览器的限制,他并不能在他的域名设置 small-min.blog.comcookie 啊!所以他发来的请求的 cookie 里面就没有 csrftoken,就会被挡下来。

当然,这个方法看似好用,但也是有缺点的,可以参考:Double Submit Cookies vulnerabilities,攻击者如果掌握了你底下任何一个子域名,就可以替代你来写 cookie,并且顺利攻击了。

会特别提到前端,是因为我之前所碰到的项目是 Single Page Application,上网搜索一下就会发现有人在问:「SPA 该如何拿到 CSRF token?」,难道要后端再提供一个 API 吗?

其实我们可以利用 Double Submit Cookie 的原理来解决这个问题。而解决这问题的关键就在于:由前端来生 csrf token。就不用跟后端 API 有任何的交互了。

其他的流程都跟之前一样,生成之后放到 form 里面以及写到 cookie。或者说如果你是 SPA 的话,也可以把这信息直接放到请求header,你就不用在每一个表单都做这件事情,只要统一加一个地方就好。

比如常用的 axios 就有提供这样的功能,你可以设置 header 名称跟 cookie 名称,设置好以后你每一个请求,它都会自动帮你把 header 填上 cookie 里面的值。

vbnet 复制代码
// `xsrfCookieName` is the name of the cookie to use as a value for xsrf token
xsrfCookieName: 'XSRF-TOKEN', // default

// `xsrfHeaderName` is the name of the http header that carries the xsrf token value
xsrfHeaderName: 'X-XSRF-TOKEN', // default

那为什么由前端来生这个 token 也可以呢?因为这个 token 本身的目的其实不包含任何信息,只是为了「不让攻击者」猜出而已,所以由前端还是由后端来生成都是一样的,只要确保不被猜出来即可。Double Submit Cookie 的核心概念是:「攻击者的没办法读写目标网站的 cookie,所以请求的 csrf token 会跟 cookie 内的不一样」

浏览器本身的防御

我们刚刚提到了使用者自己可以做的事、网页前后端可以做的事情,那浏览器呢?之所以能有 CSRF 攻击,是因为浏览器的机制所导致的,有没有可能从浏览器方面下手,来解决这个问题呢?

有!而且已经有了。而且启用的方法非常非常简单。

Google 在 Chrome 51 版的时候正式加入了这个功能:SameSite cookie,对详细运行原理有兴趣的可参考:draft-west-first-party-cookies-07。

启用这个功能有多简单?超级无敌简单。

你原本设置 Cookieheader 长这样:

ini 复制代码
Set-Cookie: session_id=ewfewjf23o1;

你只要在后面多加一个 SameSite 就好:

ini 复制代码
Set-Cookie: session_id=ewfewjf23o1; SameSite

但其实 SameSite 有两种模式,LaxStrict,默认是后者,你也可以自己指定模式:

ini 复制代码
Set-Cookie: session_id=ewfewjf23o1; SameSite=Strict
Set-Cookie: foo=bar; SameSite=Lax

我们先来谈谈默认的 Strict模式,当你加上 SameSite 这个关键字之后,就代表说「我这个 cookie 只允许 same site 使用,不应该在任何的 cross site 请求被加上去」。

意思就是你加上去之后,我们上面所讲的<a href="">, <form>, new XMLHttpRequest,只要是浏览器验证不是在同一个 site 底下发出的 请求,全部都不会带上这个 cookie

可是这样其实会有个问题,连<a href="..."都不会带上 cookie 的话,当我从 Google 搜索结果或者是朋友分享给我的链接点进某个网站的时候,因为不会带 cookie 的关系,所以那个网站就会变成是登出状态。这样的话对使用者体验非常不好。

有两种防御方法,第一种是准备两组不同的 cookie,第一组是让你维持登入状态,第二组则是做一些敏感操作的时候会需要用到的(例如说购买、设置帐户等等)。第一组不设置 SameSite,所以无论你从哪边来,都会是登入状态。但攻击者就算有第一组 cookie 也不能干嘛,因为不能做任何操作。第二组因为设置了 SameSite 的缘故,所以完全避免掉 CSRF

但这样子还是有点小麻烦,所以你可以考虑第二种,就是调整为 SameSite 的另一种模式:Lax

Lax 模式放宽了一些限制,例如说<a>, <link rel="prerender">, <form method="GET"> 这些都还是会带上 cookie。但是 POST 方法 的 form,或是只要是 POST、 PUT、 DELETE 这些方法,就不会带上 cookie

所以一方面你可以保有弹性,让使用者从其他网站连进你的网站时还能够维持登入状态,一方面也可以防止掉 CSRF 攻击。但 Lax 模式之下就没办法挡掉 GET 形式的 CSRF,这点要特别注意一下。

总结

这篇主要介绍了 CSRF 的攻击原理以及两种防御方法,针对比较常见的场景做介绍。一般在做网页开发的时候,比起 XSSCSRF 是一个比较常被忽略的重点。在网页上有任何比较重要的操作时,都要特别留意是否有被 CSRF 的风险。希望这篇文章能让大家对 CSRF 有更全面的认识。

相关推荐
河西石头几秒前
一步一步从asp.net core mvc中访问asp.net core WebApi
后端·asp.net·mvc·.net core访问api·httpclient的使用
前端Hardy7 分钟前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
2401_8574396912 分钟前
SpringBoot框架在资产管理中的应用
java·spring boot·后端
怀旧66613 分钟前
spring boot 项目配置https服务
java·spring boot·后端·学习·个人开发·1024程序员节
落樱坠入星野33 分钟前
拿下阿里云之后如何在本地运行镜像进行分析
经验分享·安全·网络安全·阿里云·云计算
阿华的代码王国34 分钟前
【SpringMVC】——Cookie和Session机制
java·后端·spring·cookie·session·会话
web Rookie37 分钟前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust44 分钟前
css:基础
前端·css
帅帅哥的兜兜44 分钟前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
yi碗汤园1 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#