title: XSS和CSRF攻击
categories: 计算机网络
tag:
- web安全
date: 2021-12-11 10:05:34
web 安全
XSS 攻击
Cross-Site Scripting(跨站脚本攻击)简称 XSS,是一种代码注入攻击。攻击者通过在目标网站上注入恶意脚本,使之在用户的浏览器上运行。利用这些恶意脚本,攻击者可获取用户的敏感信息如 Cookie、SessionID 等,进而危害数据安全。
Cross-Site Scripting(跨站脚本攻击)简称 XSS,是一种代码注入攻击。攻击者通过在目标网站上注入恶意脚本,使之在用户的浏览器上运行。利用这些恶意脚本,攻击者可获取用户的敏感信息如 Cookie、SessionID 等,进而危害数据安全。
为了和 CSS 区分,这里把攻击的第一个字母改成了 X,于是叫做 XSS。
XSS 的本质是:恶意代码未经过滤,与网站正常的代码混在一起;浏览器无法分辨哪些脚本是可信的,导致恶意脚本被执行。
而由于直接在用户的终端执行,恶意代码能够直接获取用户的信息,或者利用这些信息冒充用户向网站发起攻击者定义的请求。
在部分情况下,由于输入的限制,注入的恶意脚本比较短。但可以通过引入外部的脚本,并由浏览器执行,来完成比较复杂的攻击策略。
这里有一个问题:用户是通过哪种方法“注入”恶意脚本的呢?
不仅仅是业务上的“用户的 UGC 内容”可以进行注入,包括 URL 上的参数等都可以是攻击的来源。在处理输入时,以下内容都不可信:
- 来自用户的 UGC 信息
- 来自第三方的链接
- URL 参数
- POST 参数
- Referer (可能来自不可信的来源)
- Cookie (可能来自其他子域注入)
XSS 分类
根据攻击的来源,XSS 攻击可分为存储型、反射型和 DOM 型三种。
1. 存储型 XSS《持久化》
存储型 XSS 的攻击步骤:
- 攻击者将恶意代码提交到目标网站的数据库中。
- 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器。
- 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。
这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。
2. 反射型 XSS《非持久化》
反射型 XSS 的攻击步骤:
- 攻击者构造出特殊的 URL,其中包含恶意代码。
- 用户打开带有恶意代码的 URL 时,网站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器。
- 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。
反射型 XSS 跟存储型 XSS 的区别是:存储型 XSS 的恶意代码存在数据库里,反射型 XSS 的恶意代码存在 URL 里。
反射型 XSS 漏洞常见于通过 URL 传递参数的功能,如网站搜索、跳转等。
由于需要用户主动打开恶意的 URL 才能生效,攻击者往往会结合多种手段诱导用户点击。
POST 的内容也可以触发反射型 XSS,只不过其触发条件比较苛刻(需要构造表单提交页面,并引导用户点击),所以非常少见。
3. DOM 型 XSS
DOM 型 XSS 的攻击步骤:
- 攻击者构造出特殊的 URL,其中包含恶意代码。
- 用户打开带有恶意代码的 URL。
- 用户浏览器接收到响应后解析执行,前端 JavaScript 取出 URL 中的恶意代码并执行。
- 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。
DOM 型 XSS 跟前两种 XSS 的区别:DOM 型 XSS 攻击中,取出和执行恶意代码由浏览器端完成,属于前端 JavaScript 自身的安全漏洞,而其他两种 XSS 都属于服务端的安全漏洞。
这个攻击一般都是程序员会加入的攻击。是在 DOM 树中加入攻击
XSS 攻击的预防
通过前面的介绍可以得知,XSS 攻击有两大要素:
- 攻击者提交恶意代码。
- 浏览器执行恶意代码。
针对第一个要素:我们是否能够在用户输入的过程,过滤掉用户输入的恶意代码呢?
1. 输入过滤
在用户提交时,由前端过滤输入,然后提交到后端。这样做是否可行呢?
答案是不可行。一旦攻击者绕过前端过滤,直接构造请求,就可以提交恶意代码了。
那么,换一个过滤时机:后端在写入数据库前,对输入进行过滤,然后把“安全的”内容,返回给前端。这样是否可行呢?
我们举一个例子,一个正常的用户输入了 5 < 7 这个内容,在写入数据库前,被转义,变成了 5 < 7。
问题是:在提交阶段,我们并不确定内容要输出到哪里。
这里的“并不确定内容要输出到哪里”有两层含义:
- 用户的输入内容可能同时提供给前端和客户端,而一旦经过了
escapeHTML(),客户端显示的内容就变成了乱码(5 < 7)。 - 在前端中,不同的位置所需的编码也不同。
- 当
5 < 7作为 HTML 拼接页面时,可以正常显示:<div title="comment">5 < 7</div>复制代码
- 当
5 < 7通过 Ajax 返回,然后赋值给 JavaScript 的变量时,前端得到的字符串就是转义后的字符。这个内容不能直接用于 Vue 等模板的展示,也不能直接用于内容长度计算。不能用于标题、alert 等。
所以,输入侧过滤能够在某些情况下解决特定的 XSS 问题,但会引入很大的不确定性和乱码问题。在防范 XSS 攻击时应避免此类方法。
当然,对于明确的输入类型,例如数字、URL、电话号码、邮件地址等等内容,进行输入过滤还是必要的。
既然输入过滤并非完全可靠,我们就要通过“防止浏览器执行恶意代码”来防范 XSS。这部分分为两类:
- 防止 HTML 中出现注入。
- 防止 JavaScript 执行时,执行恶意代码。
预防存储型和反射型 XSS 攻击
存储型和反射型 XSS 都是在服务端取出恶意代码后,插入到响应 HTML 里的,攻击者刻意编写的“数据”被内嵌到“代码”中,被浏览器所执行。
预防这两种漏洞,有两种常见做法:
- 改成纯前端渲染,把代码和数据分隔开。
- 对 HTML 做充分转义。
纯前端渲染的过程:
- 浏览器先加载一个静态 HTML,此 HTML 中不包含任何跟业务相关的数据。
- 然后浏览器执行 HTML 中的 JavaScript。
- JavaScript 通过 Ajax 加载业务数据,调用 DOM API 更新到页面上。
在纯前端渲染中,我们会明确的告诉浏览器:下面要设置的内容是文本(.innerText),还是属性(.setAttribute),还是样式(.style)等等。浏览器不会被轻易的被欺骗,执行预期外的代码了。
但纯前端渲染还需注意避免 DOM 型 XSS 漏洞(例如 onload 事件和 href 中的 javascript:xxx 等,请参考下文”预防 DOM 型 XSS 攻击“部分)。
在很多内部、管理系统中,采用纯前端渲染是非常合适的。但对于性能要求高,或有 SEO 需求的页面,我们仍然要面对拼接 HTML 的问题。
转义 HTML
如果拼接 HTML 是必要的,就需要采用合适的转义库,对 HTML 模板各处插入点进行充分的转义。
常用的模板引擎,如 doT.js、ejs、FreeMarker 等,对于 HTML 转义通常只有一个规则,就是把 & < > " ' / 这几个字符转义掉,确实能起到一定的 XSS 防护作用,但并不完善:

预防 DOM 型 XSS 攻击
DOM 型 XSS 攻击,实际上就是网站前端 JavaScript 代码本身不够严谨,把不可信的数据当作代码执行了。
在使用 .innerHTML、.outerHTML、document.write() 时要特别小心,不要把不可信的数据作为 HTML 插到页面上,而应尽量使用 .textContent、.setAttribute() 等。
如果用 Vue/React 技术栈,并且不使用 v-html/dangerouslySetInnerHTML 功能,就在前端 render 阶段避免 innerHTML、outerHTML 的 XSS 隐患。
DOM 中的内联事件监听器,如 location、onclick、onerror、onload、onmouseover 等,<a> 标签的 href 属性,JavaScript 的 eval()、setTimeout()、setInterval() 等,都能把字符串作为代码运行。如果不可信的数据拼接到字符串中传递给这些 API,很容易产生安全隐患,请务必避免。
其他防范措施
- 严格的 CSP 在 XSS 的防范中可以起到以下的作用:
- 禁止加载外域代码,防止复杂的攻击逻辑。
- 禁止外域提交,网站被攻击后,用户的数据不会泄露到外域。
- 禁止内联脚本执行(规则较严格,目前发现 GitHub 使用)。
- 禁止未授权的脚本执行(新特性,Google Map 移动版在使用)。
- 合理使用上报可以及时发现 XSS,利于尽快修复问题。
- HTTP-only Cookie
- 禁止 JavaScript 读取某些敏感 Cookie,攻击者完成 XSS 注入后也无法窃取此 Cookie。
- 验证码:防止脚本冒充用户提交危险操作。
XSS 的检测
两个方法:
- 使用通用 XSS 攻击字符串手动检测 XSS 漏洞。
- 使用扫描工具自动检测 XSS 漏洞。
字符串手动检测
jaVasCript: /*-/*`/*\`/*'/*"/**/ /* */ oNcliCk = alert() //%0D%0A%0d%0a//</stYle/</titLe/</teXtarEa/</scRipt/--!>\x3csVg/<sVg/oNloAd=alert()//>\x3e
它能够检测到存在于 HTML 属性、HTML 文字内容、HTML 注释、跳转链接、内联 JavaScript 字符串、内联 CSS 样式表等多种上下文中的 XSS 漏洞,也能检测 eval()、setTimeout()、setInterval()、Function()、innerHTML、document.write() 等 DOM 型 XSS 漏洞,并且能绕过一些 XSS 过滤器。
小明只要在网站的各输入框中提交这个字符串,或者把它拼接到 URL 参数上,就可以进行检测了。
扫描工具
除了手动检测之外,还可以使用自动扫描工具寻找 XSS 漏洞,例如 Arachni、Mozilla HTTP Observatory、w3af 等。
总结
虽然很难通过技术手段完全避免 XSS,但我们可以总结以下原则减少漏洞的产生:
- 利用模板引擎 开启模板引擎自带的 HTML 转义功能。例如: 在 ejs 中,尽量使用
<%= data %>而不是<%- data %>; 在 doT.js 中,尽量使用{{! data }而不是{{= data }; 在 FreeMarker 中,确保引擎版本高于 2.3.24,并且选择正确的freemarker.core.OutputFormat。 - 避免内联事件 尽量不要使用
onLoad="onload('{{data}}')"、onClick="go('{{action}}')"这种拼接内联事件的写法。在 JavaScript 中通过.addEventlistener()事件绑定会更安全。 - 避免拼接 HTML 前端采用拼接 HTML 的方法比较危险,如果框架允许,使用
createElement、setAttribute之类的方法实现。或者采用比较成熟的渲染框架,如 Vue/React 等。 - 时刻保持警惕 在插入位置为 DOM 属性、链接等位置时,要打起精神,严加防范。
- 增加攻击难度,降低攻击后果 通过 CSP、输入长度配置、接口安全措施等方法,增加攻击的难度,降低攻击的后果。
- 主动检测和发现 可使用 XSS 攻击字符串和自动扫描工具寻找潜在的 XSS 漏洞
CSRF 跨站请求伪造
我们可以理解为 CSRF:是攻击者盗用了你的身份,你的名义发送恶意请求。CSRF 能够做的事情包括:以你名义发送邮件,发消息,盗取你的账号,甚至于购买商品,虚拟货币转账……造成的问题包括:个人隐私泄露以及财产安全。
下面有一个很经典的图,说明了 CSRF 攻击的思想

从上图可以看出,要完成一个 CSRF 攻击,受害者必须完成两个步骤。
- 登录受信任网站 A,并在本地生成 Cookie。
- 在不登出 A 的情况下,访问危险网站 B。
CSRF 的特点
- 攻击一般发起在第三方网站,而不是被攻击的网站。被攻击的网站无法防止攻击发生。
- 攻击利用受害者在被攻击网站的登录凭证,冒充受害者提交操作;而不是直接窃取数据。
- 整个过程攻击者并不能获取到受害者的登录凭证,仅仅是“冒用”。
- 跨站请求可以用各种方式:图片 URL、超链接、CORS、Form 提交等等。部分请求方式可以直接嵌入在第三方论坛、文章中,难以进行追踪。
CSRF 通常是跨域的,因为外域通常更容易被攻击者掌控。但是如果本域下有容易被利用的功能,比如可以发图和链接的论坛和评论区,攻击可以直接在本域下进行,而且这种攻击更加危险。
几种常见的攻击类型
- GET 类型的 CSRF
get 类型的 CSRF 是 CSRF 中最常见,危害最大,但也是最简单的一种类型了,只要一个 http 请求就可以了,这种类型又可以分为手动型和自动型,手动型就是需要我们自己去点击才会发生攻击,比如各种 URL 链接,各种 a 标签包裹的 DOM 元素。自动型的是不需要我们点击,只要当我们访问具有某些标签的网站的时候就会自动发生攻击,比如网页中有
<img src="http://www.123.com/transaction?id=3&amount=1000" /><script src="http://www.123.com/transaction?id=3&amount=1000"></script>
因为这些标签需要访问指定的 URL 才能发挥作用,所以会发送 http 请求,又因为这些标签是网页加载的时候就会自动发送 http 请求,所以当网页加载的时候 CSRF 就会自动发生。要实现这种 CSRF 就必须实现跨域访问,比如利用上面这两个可以发送跨域请求的标签,AJAX 是不能实现这种 CSRF 的,因为 AJAX 有同源策略,当使用 AJAX 的时候,接受数据的 URL 地址的域名、端口、协议、必须和当前地址的域名、端口、协议都一致才能成功发送数据,不然数据是发送不成功的。
- POST 类型的 CSRF
post 请求和 get 请求是不同的,post 请求是要把参数放在 http 的请求 body 里发送给服务器,所以 post 类型的 CSRF 需要用 post 的方式发送请求。我们常用的 post 请求方式一般就两个,AJAX 和表单,当正如我上面所说,AJAX 是有同源策略的,所以不能用 AJAX 的方式发送 post 请求,所以就剩下表单的方式了,没错,表单是支持跨域发送请求的。通常的方法就是创建一个自动提交的表单,比如
静态创建一个自动提交的表单
<form action="http://www.123.com/transaction" method="post" id="form"><input type="text" name="id" value="3" /><input type="text" name="amount" value="1000" /></form><script> document.getElementById("form").submit()</script>
访问该页面之后,表单会自动提交,相当于模拟用户完成了一次 POST 操作
POST 类型的攻击通常比 GET 要求更加严格一点,但是仍然不复杂。
当然也会有自动提交的表单
<script>let form = document.createElement('form'); form.action ='http://www.123.com/transaction'; form.method = 'post'; let input =document.createElement('input'); input.type = 'text'; input.name = 'id';input.value = '3'; form.appendChild(input); input =document.createElement('input'); input.type = 'text'; input.name = 'amount';input.value = '1000'; form.appendChild(input);document.body.appendChild(form); form.submit();</script>
- 链接类型的 CSRF
链接类型的 CSRF 并不常见,比起其他两种用户打开页面就中招的情况,这种需要用户点击链接才会触发。这种类型通常是在论坛中发布的图片中嵌入恶意链接,或者以广告的形式诱导用户中招,攻击者通常会以比较夸张的词语诱骗用户点击,例如:
<a href="http://test.com/csrf/withdraw.html?amount=1000&for=hacker" taget="_blank">重磅消息!!<a/>
由于之前用户登录了信任的网站 A,并且保存登录状态,只要用户主动访问上面的这个页面,则表示攻击成功。
防御 CSRF
CSRF 通常从第三方网站发起,被攻击的网站无法仿制攻击发生,只能通过增强自己网站对 CSRF 的防护能力来提升安全性。
上文中,讲了 CSRF 的两个特点
- CSRF 通常发生在第三方域名
- CSRF 攻击者不能获取到 Cookie 等信息,只是使用。
针对上面的两点,我们可以专门制定防护策略,如下:
阻止不明外域的访问
- 同源策略
- Samesite Cookie
提交时要求附加本域才能获取的信息
- CSRF Token
- 双重 Cookie 验证
1. 同源检测
既然 CSRF 大多来自于第三方网站,我们就直接禁止外域(或者不受信任的域名)对我们发起的请求。那么如何判断请求来自于外域呢?
在 HTTP 协议中,每一个异步请求都会携带两个 Header,用于标记来源域名。
- Origin Header
- Referer Header
这两个 Header 在浏览器发起请求时,大多数情况会自动带上,并且不能由前端自定义内容。 服务器可以通过解析这两个 Header 中的域名,确定请求的来源域。
1. 使用 Origin Header 确定来源域名
在部分与 CSRF 有关的请求中,请求的 Header 中会携带 Origin 字段。字段内包含请求的域名(不包含 path 及 query)。
如果 Origin 存在,那么直接使用 Origin 中的字段确认来源域名就可以。
但是 Origin 在以下两种情况下并不存在:
- IE11 同源策略: IE 11 不会在跨站 CORS 请求上添加 Origin 标头,Referer 头将仍然是唯一的标识。最根本原因是因为 IE 11 对同源的定义和其他浏览器有不同,有两个主要的区别,可以参考MDN Same-origin_policy#IE_Exceptions
- 302 重定向: 在 302 重定向之后 Origin 不包含在重定向的请求中,因为 Origin 可能会被认为是其他来源的敏感信息。对于 302 重定向的情况来说都是定向到新的服务器上的 URL,因此浏览器不想将 Origin 泄漏到新的服务器上。
2. 使用 Referer Header 确定来源域名
根据 HTTP 协议,在 HTTP 头中有一个字段叫 Referer,记录了该 HTTP 请求的来源地址。 对于 Ajax 请求,图片和 script 等资源请求,Referer 为发起请求的页面地址。对于页面跳转,Referer 为打开页面历史记录的前一个页面地址。因此我们使用 Referer 中链接的 Origin 部分可以得知请求的来源域名。
这种方法并非万无一失,Referer 的值是由浏览器提供的,虽然 HTTP 协议上有明确的要求,但是每个浏览器对于 Referer 的具体实现可能有差别,并不能保证浏览器自身没有安全漏洞。使用验证 Referer 值的方法,就是把安全性都依赖于第三方(即浏览器)来保障,从理论上来讲,这样并不是很安全。在部分情况下,攻击者可以隐藏,甚至修改自己请求的 Referer。
如果攻击者这样写的话,就会隐藏 Referer
<img src="http://bank.example/withdraw?amount=10000&for=hacker" referrerpolicy="no-referrer">
另外在以下情况下 Referer 没有或者不可信:
IE6、7 下使用 window.location.href=url 进行界面的跳转,会丢失 Referer。
IE6、7 下使用 window.open,也会缺失 Referer。
HTTPS 页面跳转到 HTTP 页面,所有浏览器 Referer 都丢失。
点击 Flash 上到达另外一个网站的时候,Referer 的情况就比较杂乱,不太可信。
如果 Origin 和 Referer 头文件不存在时该怎么办?如果 Origin 和 Referer 都不存在的话,建议直接进行组织,特别是如果没有使用随机 CSRF Token(参考下方)作为第二次检查。
如何阻止外域请求
- 通过 Header 的验证,我们可以知道发起请求的来源域名,这些来源域名可能是网站本域,或者子域名,或者有授权的第三方域名,又或者来自不可信的未知域名。
- 我们已经知道了请求域名是否是来自不可信的域名,我们直接阻止掉这些的请求,就能防御 CSRF 攻击了吗?
- 且慢!当一个请求是页面请求(比如网站的主页),而来源是搜索引擎的链接(例如百度的搜索结果),也会被当成疑似 CSRF 攻击。所以在判断的时候需要过滤掉页面请求情况,通常 Header 符合以下情况:
Accept: text / htmlMethod: GET
但相应的,页面请求就暴露在了 CSRF 的攻击范围之中。如果你的网站中,在页面的 GET 请求中对当前用户做了什么操作的话,防范就失效了。
GET https://example.com/addComment?comment=XXX&dest=orderId
综上所述:同源验证是一个相对简单的防范方法,能够防范绝大多数的 CSRF 攻击。但这并不是万无一失的,对于安全性要求较高,或者有较多用户输入内容的网站,我们就要对关键的接口做额外的防护措施。
2. Samesite Cookie 属性
防止 CSRF 攻击的办法已经有上面的预防措施。为了从源头上解决这个问题,Google 起草了一份草案来改进 HTTP 协议,那就是为 Set-Cookie 响应头新增 Samesite 属性,它用来标明这个 Cookie 是个“同站 Cookie”,同站 Cookie 只能作为第一方 Cookie,不能作为第三方 Cookie,Samesite 有两个属性值,分别是 Strict 和 Lax 和 none,下面分别讲解:
1. Samesite=Strict
这种称为严格模式,表明这个 Cookie 在任何情况下都不可能作为第三方 Cookie,绝无例外,换言之,只有当前网页的 URL 与请求目标一致,才会带上 Cookie。比如说 b.com 设置了如下 Cookie:
Set-Cookie: foo=1; Samesite=StrictSet-Cookie: bar=2; Samesite=LaxSet-Cookie: baz=3
我们在 a.com 下发起对 b.com 的任意请求,foo 这个 Cookie 都不会被包含在 Cookie 请求头中,但 bar 会。举个实际的例子就是,假如淘宝网站用来识别用户登录与否的 Cookie 被设置成了 Samesite=Strict,那么用户从百度搜索页面甚至天猫页面的链接点击进入淘宝后,淘宝都不会是登录状态,因为淘宝的服务器不会接受到那个 Cookie,其它网站发起的对淘宝的任意请求都不会带上那个 Cookie。
这个规则过于严格,可能造成非常不好的用户体验。比如,当前网页有一个 GitHub 链接,用户点击跳转就不会带有 GitHub 的 Cookie,跳转过去总是未登陆状态。
2. Samesite=Lax
Lax规则稍稍放宽,大多数情况也是不发送第三方 Cookie,但是导航到目标网址的 Get 请求除外。
Set-Cookie: CookieName=CookieValue; SameSite=Lax;
导航到目标网址的 GET 请求,只包括三种情况:链接,预加载请求,GET 表单。详见下表。

3. Samesite=Lax
Chrome 计划将Lax变为默认设置。这时,网站可以选择显式关闭SameSite属性,将其设为None。不过,前提是必须同时设置Secure属性(Cookie 只能通过 HTTPS 协议发送),否则无效。
下面的设置无效。
Set-Cookie: widget_session=abc123; SameSite=None
下面的设置才有效
Set-Cookie: widget_session=abc123; SameSite=None; Secure
如果 SamesiteCookie 被设置为 Strict,浏览器在任何跨域请求中都不会携带 Cookie,新标签重新打开也不携带,所以说 CSRF 攻击基本没有机会。
但是跳转子域名或者是新标签重新打开刚登陆的网站,之前的 Cookie 都不会存在。尤其是有登录的网站,那么我们新打开一个标签进入,或者跳转到子域名的网站,都需要重新登录。对于用户来讲,可能体验不会很好。
如果 SamesiteCookie 被设置为 Lax,那么其他网站通过页面跳转过来的时候可以使用 Cookie,可以保障外域连接打开页面时用户的登录状态。但相应的,其安全性也比较低。
另外一个问题是 Samesite 的兼容性不是很好,现阶段除了从新版 Chrome 和 Firefox 支持以外,Safari 以及 iOS Safari 都还不支持,现阶段看来暂时还不能普及。
而且,SamesiteCookie 目前有一个致命的缺陷:不支持子域。例如,种在 topic.a.com 下的 Cookie,并不能使用 a.com 下种植的 SamesiteCookie。这就导致了当我们网站有多个子域名时,不能使用 SamesiteCookie 在主域名存储用户登录信息。每个子域名都需要用户重新登录一次。
总之,SamesiteCookie 是一个可能替代同源验证的方案,但目前还并不成熟,其应用场景有待观望。
3. CSRF Token
前面讲到 CSRF 的另一个特征是,攻击者无法直接窃取到用户的信息(Cookie,Header,网站内容等),仅仅是冒用 Cookie 中的信息。
而 CSRF 攻击之所以能够成功,是因为服务器误把攻击者发送的请求当成了用户自己的请求。那么我们可以要求所有的用户请求都携带一个 CSRF 攻击者无法获取到的 Token。服务器通过校验请求是否携带正确的 Token,来把正常的请求和攻击的请求区分开,也可以防范 CSRF 的攻击。
token 防治原理
- 将 CSRF Token 输出到页面中
首先,用户打开页面的时候,服务器需要给这个用户生成一个 Token,该 Token 通过加密算法对数据进行加密,一般 Token 都包括随机字符串和时间戳的组合,显然在提交时 Token 不能再放在 Cookie 中了,否则又会被攻击者冒用。因此,为了安全起见 Token 最好还是存在服务器的 Session 中,之后在每次页面加载时,使用 JS 遍历整个 DOM 树,对于 DOM 中所有的 a 和 form 标签后加入 Token。这样可以解决大部分的请求,但是对于在页面加载之后动态生成的 HTML 代码,这种方法就没有作用,还需要程序员在编码时手动添加 Token。
- 页面提交的请求携带这个 Token
对于 GET 请求,Token 将附在请求地址之后,这样 URL 就变成
http://url?csrftoken=tokenvalue
而对于 POST 请求来说,要在 form 的最后加上:
<input type=”hidden” name=”csrftoken” value=”tokenvalue”/>
这样,就把 Token 以参数的形式加入请求了。
- 服务器验证 Token 是否正确
当用户从客户端得到了 Token,再次提交给服务器的时候,服务器需要判断 Token 的有效性,验证过程是先解密 Token,对比加密字符串以及时间戳,如果加密字符串一致且时间未过期,那么这个 Token 就是有效的。
这种方法要比之前检查 Referer 或者 Origin 要安全一些,Token 可以在产生并放于 Session 之中,然后在每次请求时把 Token 从 Session 中拿出,与请求中的 Token 进行比对,但这种方法的比较麻烦的在于如何把 Token 以参数的形式加入请求。
这个 Token 的值必须是随机生成的,这样它就不会被攻击者猜到,通常,开发人员只需为当前会话生成一次 Token。在初始生成此 Token 之后,该值将存储在会话中,并用于每个后续请求,直到会话过期。当最终用户发出请求时,服务器端必须验证请求中 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(前端无法使用纯静态页面),每一个 Form 及 Ajax 请求都携带这个 Token,后端对每一个接口都进行校验,并保证页面 Token 及请求 Token 一致。这就使得这个防护策略不能在通用的拦截上统一拦截处理,而需要每一个页面和接口都添加对应的输出和校验。这种方法工作量巨大,且有可能遗漏。
4. 双重 cookie 的认证
在会话中存储 CSRF Token 比较繁琐,而且不能在通用的拦截上统一处理所有的接口。
那么另一种防御措施是使用双重提交 Cookie。利用 CSRF 攻击不能获取到用户 Cookie 的特点,我们可以要求 Ajax 和表单请求携带一个 Cookie 中的值。
双重 Cookie 采用以下流程:
- 在用户访问网站页面时,向请求域名注入一个 Cookie,内容为随机字符串(例如
csrfcookie=v8g9e4ksfhw)。 - 在前端向后端发起请求时,取出 Cookie,并添加到 URL 的参数中(接上例
POST https://www.a.com/comment?csrfcookie=v8g9e4ksfhw)。 - 后端接口验证 Cookie 中的字段与 URL 参数中的字段是否一致,不一致则拒绝。
此方法相对于 CSRF Token 就简单了许多。可以直接通过前后端拦截的的方法自动化实现。后端校验也更加方便,只需进行请求中字段的对比,而不需要再进行查询和存储 Token。
当然,此方法并没有大规模应用,其在大型网站上的安全性还是没有 CSRF Token 高,原因我们举例进行说明。
由于任何跨域都会导致前端无法获取 Cookie 中的字段(包括子域名之间),于是发生了如下情况:
- 如果用户访问的网站为
www.a.com,而后端的 api 域名为api.a.com。那么在www.a.com下,前端拿不到api.a.com的 Cookie,也就无法完成双重 Cookie 认证。 - 于是这个认证 Cookie 必须被种在
a.com下,这样每个子域都可以访问。 - 任何一个子域都可以修改
a.com下的 Cookie。 - 某个子域名存在漏洞被 XSS 攻击(例如
upload.a.com)。虽然这个子域下并没有什么值得窃取的信息。但攻击者修改了a.com下的 Cookie。 - 攻击者可以直接使用自己配置的 Cookie,对 XSS 中招的用户再向
www.a.com下,发起 CSRF 攻击。
用双重 Cookie 防御 CSRF 的优点:
- 无需使用 Session,适用面更广,易于实施。
- Token 储存于客户端中,不会给服务器带来压力。
- 相对于 Token,实施成本更低,可以在前后端统一拦截校验,而不需要一个个接口和页面添加。
缺点:
- Cookie 中增加了额外的字段。
- 如果有其他漏洞(例如 XSS),攻击者可以注入 Cookie,那么该防御方式失效。
- 难以做到子域名的隔离。
- 为了确保 Cookie 传输安全,采用这种防御方式的最好确保用整站 HTTPS 的方式,如果还没切 HTTPS 的使用这种方式也会有风险。
总结
简单总结一下上文的防护策略:
- CSRF 自动防御策略:同源检测(Origin 和 Referer 验证)。
- CSRF 主动防御措施:Token 验证 或者 双重 Cookie 验证 以及配合 Samesite Cookie。
- 保证页面的幂等性,后端接口不要在 GET 页面中做用户操作。
为了更好的防御 CSRF,最佳实践应该是结合上面总结的防御措施方式中的优缺点来综合考虑,结合当前 Web 应用程序自身的情况做合适的选择,才能更好的预防 CSRF 的发生。
