跨域和跨站的区别

涉及到 Web 安全,会有一个跨站的概念,跨站和跨域是不同的。

跨域

跨域即 cross-domain,它和同源(same-origin)相对,要求两个 URL 的协议、端口号、域名都一致才能称为同源。

跨站

跨站即 cross-site,它和同站(same-site)相对,对协议和端口号无要求,只要两个 URL 的 eTLD + 1 一致,就能称为同站。那么什么是 eTLD 呢?

eTLD 即 effective top level domain,有效顶级域名,比如 http://juejin.cneTLD.cnhttp://test.orgeTLD.org,而 http://chorer.github.io 则是 github.io(注意不是 .io)。而 eTLD + 1 指的是有效顶级域名 + 二级域名,比如对于 http://juejin.cn 来说就是 juejin.cn,对于 http://test.org 来说就是 test.org

**PS:**不过需要注意的是,same-site 实际上也分为两种,一种是上面定义的协议松散型 same-site,即 scheme-less same-site,在判断是否同站时并不需要考虑协议;另一种则是协议严格型 same-site,即 schemeful same-site,要求协议必须一致才能认定为是同站。

XSS

XSS 即 Cross-Site Scripting(跨站脚本攻击),指的是黑客将恶意代码注入页面中,只要打开页面,代码就会执行。XSS 攻击可能导致 Cookie 被窃取、个人信息泄露、劫持流量实现恶意跳转等。

分类

XSS 基本可以分为两类,一个是反射型 XSS(非持久型 XSS),一个是存储型 XSS(持久型 XSS)。

反射型 XSS

黑客诱导用户点击带有特殊参数的 URL,从而往页面中注入恶意代码。比如说,正常向服务器发起请求的 URL 是 http://test.com?name=jack,服务器拿到参数 jack 之后,不做处理,直接返回一个响应 Hello jack,HTML 是这样的:

<div>Hello jack</div>

这样当然没问题,但如果用户点击了黑客的 URL 是 http://test.com?name=<script>alert(1)</script>,那么服务器拿到了 name 参数,如果不做处理就返回响应,HTML 会是这样的:

<div>
	<script>alert(1)</script>    
</div>

那么解析 HTML 的时候,实际上是会执行中间这段脚本的。alert(1) 只是一个示例 —— 这里可以是任何的脚本操作,包括通过 document.cookie 窃取用户 Cookie,通过 window.location 实现跳转等,会有很大的安全风险。

存储型 XSS

存储型 XSS 是持久的,而且风险会更大,因为恶意代码会存储到数据库中,无论哪个用户访问页面,都会被波及。比如说,黑客给某篇文章的评论区留言,写下 <script>alert(1)</script>,之后提交表单给服务器。服务器不做任何处理,只是把留言存储到数据库中。下次无论哪位用户访问这篇文章,服务器都会从数据库中获取留言并返回给浏览器,这当然也包括了 <script>alert(1)</script> ,只要一执行就会产生弹窗,对于所有用户都是如此。同样,这里弹窗只是一个举例,它可以是任何危害到用户信息安全的脚本操作。

防御措施

1)HTML 转义

< 用于定义标签的开始,如果我们希望浏览器确实显示 < 这个字符本身,而不是把它当作一个标签去解析,那么就必须对字符进行转义(escape),编写字符实体而不是字符。

同理,为了安全起见,我们不应该把 <script>alert(1)</script> 作为标签去解析,而只是希望它是一个单纯的字符串,所以可以考虑在服务端这边进行 HTML 转义:

&lt;script&gt;alert(1)&lt;/script&gt;

这个转义的结果最终返回给浏览器, <script>alert(1)</script> 会作为字符串在页面上显示出来,而不再是可执行的脚本。

2)用户输入验证

转义指的是对 <>等特殊字符进行转义,如果说注入的恶意脚本都是用 <script></script>包裹的,那么 HTML 转义确实可以避免 XSS 攻击 —— 但实际上,有其他的方式可以进行脚本注入。比如某社区网站允许用户在个人资料中填写自己博客的地址,并最终作为 <a href="xxx">我的博客地址</a>展示出来,那么别有用心的黑客就可以填入 javascript:alert(1);,这是不会经过 HTML 转义的,因此最终黑客的博客地址是这样的:

<a href="javascript:alert(1);">这是用户博客的地址</a>

只要有人点击查看他资料中的博客地址,就会发生弹窗。

同样的,如果网站还允许用户通过填写 URL 的方式设置头像图片,并最终作为 <img src="xxx"> 展示出来,那么黑客可以填入 xxx" onerror="alert(1),这同样是不会经过 HTML 转义的,因此最终 img 标签是这样的:

<img src="xxx" onerror="alert(1)">

这里的 src 明显是不合法的,所以会触发 error 事件,发生弹窗。

因此,单纯的 HTML 转义并不能规避所有 XSS 攻击,我们还必须对用户输入的数据进行验证。

3)CSP

CSP 即 Content Security Policy(内容安全策略),开发者提供一个白名单,告诉浏览器只能加载特定来源的代码,从而禁止某些第三方脚本的运行。

CSP 有两种使用方式:

  • 服务端响应一个 content-security-policy 头部字段,约束浏览器的加载行为:
Content-Security-Policy: script-src 'self'; style-src cdn.example.org third-party.org; child-src https:
  • HTML 中使用 meta 标签,约束浏览器的加载行为:
<meta http-equiv="Content-Security-Policy" content="script-src 'self'; style-src cdn.example.org third-party.org; child-src https:">

它们的形式不同,但作用都是一样的:

  • script-src:设置只允许加载哪些来源的脚本,设置为 self 代表只能加载本域名的脚本。注意它会禁止内联脚本的事件监听,比如前面例子的 onerror,使用的时候会报错提示违反了 CSP
  • style-src:设置只允许加载哪些来源的样式文件,这里只能加载 cdn.example.orgthird-party.org
  • child-src:设置为 https 表示必须使用 https 去加载 iframe

4)HttpOnly

前面说过,黑客可以注入脚本窃取用户的 Cookie,这本质上是因为可以通过 document.cookie 去访问 Cookie,因此服务端可以在给客户端响应的 Set-Cookie 头部字段,声明一个 HttpOnly 来禁止通过脚本获取 Cookie。

Set-Cookie: id=a3fWa; Expires=Wed, 21 Oct 2015 07:28:00 GMT; Secure; HttpOnly

CSRF

案例

CSRF 即跨站请求伪造,黑客利用请求会携带 Cookie 的特点,冒充用户身份向正常网站发出请求,执行某些非法操作。它的作用过程大概是这样的:

  • 用户登录 http://article.com,服务器验证通过,返回 Cookie 给浏览器保存
  • 假设 Cookie 没过期,这期间黑客诱导用户访问恶意网站 http://evil.com,这个网站中有这么一段代码:
<img src="http://article/delete?id=1">
  • 那么恶意网站就会向 http://article.com 发起一个携带 Cookie 的请求,服务端这边验证没问题,就会把 id 为 1 的文章给删除了

这里黑客之所以可以发起 CSRF 攻击,有下面几个原因:

  • 用户:登录了正常网站且没有登出(Cookie 有效),之后访问了恶意网站
  • 黑客:知道执行请求的 URL 和所有的参数
  • 服务端:只使用 Cookie 进行权限验证,没有任何针对 CSRF 的防御措施
  • img 是支持跨域请求的。其实黑客也可以直接发送一个 AJAX 请求,不过由于同源策略和 CORS 的限制,http://evil.com 是无法向不同源的 http://article.com 发送请求的,所以黑客使用的是天然可以跨域的 img 标签

XSS + CSRF

上面的例子属于利用 Cookie 而不是窃取 Cookie,实际上,黑客可以先使用 XSS 拿到用户的 Cookie,接着再使用 CSRF 伪造发送请求。

防御措施

要制定防御措施,可以从 CSRF 产生的原因入手:

  • CSRF 大多来自第三方网站,若服务端能知道请求是谁发出的,并相应进行限制,那么可以在一定程度上规避攻击。与此相关的有 SameSite 属性、Origin 头部字段、Referer 头部字段
  • CSRF 发生的关键在于第三方网站也能携带 Cookie 发送请求,导致服务端并不知道请求来自恶意网站还是正常用户。那么,我们可以让正常用户发送请求时携带一个恶意网站无法获取到的 token,服务端通过校验请求是否携带正确的 token,来把正常的请求和攻击的请求区分开,也可以防范 CSRF 攻击。与此相关的就是 CSRF token

同站限制 —— SameSite

  • 第一方 Cookie:在 http://bank.com 下对 http://bank.com/xxx 发起请求,那么携带的 Cookie 是第一方 Cookie(由第一方携带的);
  • 第三方 Cookie:在 http://evil.com 下对 http://bank.com/xxx 发起请求,携带的则是第三方 Cookie(由第三方携带的)。

如果站点当初响应返回的 Set-Cookie 声明了 SameSite 属性,那么该 Cookie 就会成为一个同站 Cookie,这样的 Cookie 是不能作为第三方 Cookie 的 —— 换句话说,声明 SameSite 可以避免在 http://evil.com 下对 http://bank.com/xxx 发起请求时携带 Cookie 过去,因此可以规避 CSRF 攻击。

1)Set-Cookie: SameSite = Strict

这是最严格的模式,声明之后 Cookie 将不会在任何跨站请求中携带,也即完全禁用了第三方 Cookie,因此可以完全阻止 CSRF 攻击。但缺点是用户体验比较差 —— 比如当前网页有一个跳转到目标网站的链接,点击进入后往往已经是登录状态了,这是因为当前网页向目标网站发起的请求中携带了目标网站的 Cookie,而现在如果彻底禁用第三方 Cookie,就无法维持这个登录状态了,进入目标网站后需要重新登录。

2)Set-Cookie: SameSite = Lax

默认值。这种模式相对来说比较宽松,声明之后 Cookie 在大多数跨站请求的场景下还是不会携带的,因此保证了安全性;同时,导航到目标网站的 GET 请求是可以携带 Cookie 的,因此保证了可用性(比如说维持登录态)。具体情况如下:

请求类型示例正常情况Lax
链接<a href="..."></a>发送 Cookie发送 Cookie
预加载<link rel="prerender" href="..."/>发送 Cookie发送 Cookie
GET 表单<form method="GET" action="...">发送 Cookie发送 Cookie
POST 表单<form method="POST" action="...">发送 Cookie不发送
iframe<iframe src="..."></iframe>发送 Cookie不发送
AJAX$.get("...")发送 Cookie不发送
Image<img src="...">发送 Cookie不发送

表格的前三种都属于导航到目标网站的 GET 请求,这些请求虽然跨站但是可以携带 Cookie —— 尤其是第一种情况,允许我们通过外链到达目标网站后直接处于登录状态。

3)Set-Cookie: SameSite = None; Secure

这种模式可以关闭 SameSite 属性,跨站请求中第三方 Cookie 的携带不受限制。但与此同时,必须声明 Secure,让 Cookie 只能在 HTTPS 请求中携带。

为什么要设置 SameSite 之后再将其关闭,而不是一开始直接就不设置 SameSite 呢?因为 Chrome 默认设置 SameSite = Lax,所以必须通过显式设置 SameSite = None 的方式将其关闭。

PS:Chrome 将在 2022 年全面禁用第三方 Cookie

同源检测 —— OriginReferer

通常可以从请求报文的 Origin 或者 Referer 头部字段知道请求源,区别在于前者只给出服务器地址,而后者还会给出具体路径:

Origin: https://developer.mozilla.org

Referer: https://developer.mozilla.org/en-US/docs/Web/JavaScript

那么应该用哪一个呢?Origin 在 IE11 的 CORS 请求中不会携带,在 302 重定向的请求中也不会携带,所以更保险的是使用 Referer —— 但即便如此也要知道,在 HTTPS 页面跳转到 HTTP 页面的时候,出于安全考虑,不会携带 Referer

CSRF token

恶意网站进行 CSRF 攻击的一个必要条件是知道请求格式和参数,那么,如果让请求必须携带一个只有正常用户才知道的 token 作为参数,恶意网站就无法构造完整的请求了,也就无法进行攻击。

模式一:隐藏表单域 + session:

  • 服务端生成一个随机 CSRF token,存储在服务器的 session 中,同时下发 token 到用户的前端页面中。这里有两种情况

    一是将 token 注入到每个表单的隐藏的 input 域中:

<input type="hidden" name="csrf-token" value="CIwNZNlR4XbisJF39I8yWnWX9wX4WFoz">

​ 二是将 token 注入到 meta 标签中:

<meta name="csrf-token" content="CIwNZNlR4XbisJF39I8yWnWX9wX4WFoz">
  • 前端若要发起 GET 请求,则通过 JS 获取 meta 中的 token,并作为请求 URL 的参数,形如 http://test.com?csrftoken=CIwNZNlR4XbisJF39I8yWnWX9wX4WFoz ;若要发起 POST 请求,则直接提交表单即可,之前注入到表单中的 token 会自动作为请求体的参数
  • 服务端拿到 GET 或者 POST 请求的 token 参数,与保存在 session 中的 token 比较,若相同则认为此次请求来自合法用户,否则认为来自恶意网站(恶意网站是拿不到 token 的,无法构造完整的请求)

模式二:隐藏表单域 + cookie:

  • 服务端生成一对互相关联的 CSRF token,一个 token 通过隐藏表单域下发到用户的前端页面中,另一个 token 注入到 set-Cookie 字段中
  • 前端提交表单发起 POST 请求,隐藏表单域中的 token 自动成为请求体的参数,set-Cookie 中的 token 则被放到请求头部字段 Cookie 中
  • 服务端对收到的两个 token 进行校验,校验通过则说明此次请求来自合法用户

PS:这种模式不需要服务端通过 session 维护大量的 token。虽然恶意网站还是可以在请求中携带 Cookie(内含 token),但是由于它拿不到服务端返回给用户的隐藏表单域(内含 token) ,因此它的请求参数是缺失的,实际上无法通过服务端的校验。

验证码

像删除数据这类敏感操作,如果不进行任何验证就直接执行操作,会有很大的风险。因此可以考虑使用验证码,但验证码应该只用于关键的业务节点中,滥用将会影响用户体验 —— 从这个角度来说,验证码更适合作为一个防御 CSRF 攻击的辅助手段。

ClickJacking

ClickJacking 即点击劫持,指的是劫持用户的点击行为进行某些操作。

比如说有一个恶意网站 http://evil.com,有一个正常网站 http://funnyvideo.com,恶意网站的网页下面是一个透明的、引用了正常网站的 iframe。黑客诱导用户进入恶意网站并在网页中进行点击,看起来用户只是在点击恶意网站的网页,但实际上是在点击正常网站的网页。

防御措施

X-Frame-Options 实现:

上述点击劫持发生的本质原因是恶意网站可以通过 iframe 引用正常网站,如果我们设法禁止通过 iframe 去引用正常网站,或者限制只有某些信任网站可以通过 iframe 引用正常网站,那么就可以规避点击劫持。响应头部字段 X-Frame-Options 就是来做这个事的,它可以设置下面的值:

  • deny:禁止任何网站通过 iframe 引用正常网站
  • sameorigin:只允许同源网站通过 iframe 引用正常网站
  • allow-from:只允许特定网站通过 iframe 引用正常网站,比如 allow-from http://test.com,就表示 http://test.com 是受信任的,可以引用正常网站

JS 实现:

对于某些不支持设置 X-Frame-Options 头部字段的旧浏览器,可以使用 js 作为一种 callback 的方案。

  • 当 A 网站通过 iframe 引用 B 网站的时候,B 网站可以通过 self 获取自身 window 对象,通过 top 获取 A 网站的 window 对象,因此 B 网站可以使用 top == self 判断自身是否被其它网站通过 iframe 引用。
  • 同时,它还可以通过 top.location.href 获取引用自己的网站的 URL,据此可以通过模式匹配实现网站过滤,只允许信任的网站引用自己。
if(top != self){
    const style = document.createElement('style')
    style.innerHTML = 'html{display:none!important;}'
    document.head.appendChild(style)
    top.location = self.location
}

上面的代码处理方式比较粗糙,如果 top 不等于 self,说明有其它网站引用了自己,那么就隐藏自身的所有内容,同时让第三方网站跳转到自身。当然,可以修改代码实现类似于 X-Frame-Options 各个参数的效果。

中间人攻击

中间人攻击(Man-in-middle Attack)指的是,攻击者充当一个中间人的角色,与通信的两端分别创建独立的联系,对传输的数据进行劫持和篡改。整个会话被中间人操控,但通信的两端都以为是在和对方通信。中间人攻击发生的原因在于通信双方没有采用数字签名、数字证书等手段验证对方身份。

以前面讲过的混合加密过程为例,如果发生中间人攻击,过程大概如下:

  • 客户端发送请求,请求获取服务端的公钥。中间人拦截请求,并将请求转发给服务端
  • 服务端收到请求,生成一对公钥(服)和私钥(服),私钥(服)自己保管,公钥(服)发给中间人(服务端以为此时的中间人是客户端)
  • 中间人拿到公钥(服)。同时生成自己的一对公钥(中)和私钥(中),冒充服务端将公钥(中)发给客户端
  • 客户端收到公钥(中),生成会话密钥(客),用公钥(中)加密会话密钥(客),然后发给中间人(客户端以为此时的中间人是服务端)
  • 中间人用私钥(中)解密,得到会话密钥(客)。同时生成自己的会话密钥(中),用公钥(服)加密之后发给服务端
  • 服务端收到,用私钥(服)解密,拿到会话密钥(中),以为这是客户端发来的会话密钥(客),于是用这个会话密钥加密数据 XXX,发送给中间人
  • 中间人收到,用会话密钥(中)进行解密,拿到数据 XXX 后进行篡改,得到数据 YYY。接着用会话密钥(客)对数据 YYY 进行加密,发送给客户端
  • 客户端收到数据 YYY,用会话密钥(客)加密数据 ZZZ,发给中间人
  • 中间人收到,用会话密钥(客)进行解密,拿到数据 ZZZ 后进行篡改,得到数据 WWW。接着用会话密钥(中)对数据 WWW 进行加密,发送给服务端
  • 服务端收到,用会话密钥(中)解密,拿到数据 WWW
  • ……

可以看到,中间人在客户端面前表现为服务端,在服务端面前又表现为客户端,两边来回劫持和篡改数据。下面的图更加清晰地展示了整个过程:

DNS 污染和劫持

DNS 劫持

DNS 劫持指的是劫持 DNS 服务器,获得对于某个域名的解析记录控制权,进而修改该域名的解析结果,返回一个错误的 IP 地址给客户端。DNS 劫持篡改的是 DNS 服务器上的数据,会导致用户无法访问某个网站,或者访问了一个假的克隆网站,从而导致个人信息泄露等。

案例:访问谷歌但是打开的是百度

解决方案:既然问题是出在 DNS 服务器,那么可以考虑手动更换 DNS 服务器为公共 DNS

DNS 污染

DNS 污染属于 DNS 缓存投毒攻击,它把自己伪装成 DNS 服务器,将用户访问的域名指向不正确的 IP 地址然后返回。

案例: GFW 导致无法访问国外网站

**解决方案:**VPN 等

HTTP 劫持

发起 HTTP 劫持的可能是第三方运营商、局域网或者免费公共 Wi-Fi 等,发生的原因在于流量必须经过运营商、局域网、Wi-Fi 等,而 HTTP 本身又是明文传输的,这就给了它们对数据进行劫持和篡改的机会。

我们有时候在浏览某些网站的时候,经常会看到右下角有弹窗广告,实际上这不一定是网站本身投放的广告,往往是运营商进行 HTTP 劫持之后投放的。解决的方法也很简单,就是使用加密的 HTTPS。