掘金 后端 ( ) • 2024-04-19 11:12

前言

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

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

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

偷懒的删除功能

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

图片

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

<a href='/delete?id=3'>删除</a>

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

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

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

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

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

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

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

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

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

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

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

<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

<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)

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

<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 了吧!」

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

<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

{ "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,例如:

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 中。

<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 并且进行攻击。不过前提是你的后端接受这个域名的请求。

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

Double Submit Cookie

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

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

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,并且顺利攻击了。

SPA 的 Double Submit Cookie

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

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

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

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

// `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 长这样:

Set-Cookie: session_id=ewfewjf23o1;

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

Set-Cookie: session_id=ewfewjf23o1; SameSite

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

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 有更全面的认识。