前言
最近我碰到了一些 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.com
的 cookie
都一起带上去。
后端收到之后检查了一下 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-urlencoded
、multipart/form-data
跟 text/plain
。在上面的攻击中我们用的是最后一种,text/plain
,如果你在你的后端服务有检查这个 content type
的话,是可以避免掉上面这个攻击的。
只是,上面这几个攻击我们都还没讲到一种情况:如果你的 API
接受 cross origin
的请求呢?
意思就是,如果你的 API
的 Access-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
里面加上一列 hidden
的 input
,叫做 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
并且进行攻击。不过前提是你的后端接受这个域名的请求。
接着让我们来看看另外一种防御方法
Double Submit Cookie
上一种防御方法需要后端将 csrf token
保存下来的,才能验证正确性。而现在这个防御方法的好处就是完全不需要后端储存数据。
这个防御方法的前半部分与刚刚的相似,由后端生成一组随机的 token
并且附加到 form
上面。但不同的点在于,除了不用把这个值写在 session
以外,还需要让前端设置一个名叫 csrftoken
的 cookie
,值就是生成的 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
内的 csrftoken
与 form
里面的 csrftoken
,检查是否有值并且相等,就知道是不是使用者发的了。
为什么呢?假设现在攻击者想要攻击,他可以随便在 form 里面写一个 csrftoken
,这当然没问题,可是因为浏览器的限制,他并不能在他的域名设置 small-min.blog.com
的 cookie
啊!所以他发来的请求的 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
里面的值。
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。
启用这个功能有多简单?超级无敌简单。
你原本设置 Cookie
的 header
长这样:
ini
Set-Cookie: session_id=ewfewjf23o1;
你只要在后面多加一个 SameSite
就好:
ini
Set-Cookie: session_id=ewfewjf23o1; SameSite
但其实 SameSite
有两种模式,Lax
跟Strict
,默认是后者,你也可以自己指定模式:
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
的攻击原理以及两种防御方法,针对比较常见的场景做介绍。一般在做网页开发的时候,比起 XSS
,CSRF
是一个比较常被忽略的重点。在网页上有任何比较重要的操作时,都要特别留意是否有被 CSRF
的风险。希望这篇文章能让大家对 CSRF
有更全面的认识。