流量劫持的方式主要分为两种:
我们国内用户,一般是在家用路由器后面,要访问一个网站的话,会有三个步骤:
流量劫持就是在这些环节当中,对数据进行 偷窃、篡改,甚至转发流量进行攻击的这样一类行为。
域名解析劫持(DNS 劫持)是针对传统 DNS 解析的常见劫持方式。用户在浏览器输入网址,即发出一个 HTTP 请求,首先需要进行域名解析,得到业务服务器的 IP 地址。使用传统 DNS 解析时,会通过当地网络运营商提供的本地域名服务器解析得到结果。在请求本地域名服务器解析域名时会出现问题,目标域名被恶意地解析到其他 IP 地址,造成用户无法正常使用服务。
那么如何才能够污染 DNS 以达成流量劫持的目的呢?粗略来说,一共有三种途径:
hosts
文件,影响用户的搜索引擎工作。这三种途径当中,第一种和第三种的实施成本都比较高,但污染链路设备,在 Wi-Fi 普及而安全意识尚未普及的今天,是最容易得手的一种途径。
目前针对 DNS 投毒,对抗中间人攻击的研究比较多。DNS 协议本身的安全性较差,而改造 DNS 协议又比较困难,因此现在主要的防御手段,集中在替换 UDP 协议上。
目前,三种常见的替代方式比较流行:
比较遗憾的是,由于浏览器没有暴露 DNS 相关的接口,这三种较为安全的 DNS 查询方式,都无法在前端当中得以使用。而 iOS 和 Android 开发者有机会使用其中的技术进行加强,但需要单独编写一些代码。
打工信部电话(12300)投诉也不失是个好办法。
HTTP 协议属于明文协议,中间链路上的任意设备,都可以篡改内容,导致流量劫持。
CSP 原本是为了和 XSS 对抗而产生的一种技术方案,其原理是在 HTML 加载的时候,指定每种资源的 URL 白名单规则,防止 XSS 的运行和数据外送。但如果巧妙利用规则,也可以让所有的资源强制走 HTTPS ,这样就可以降低流量劫持的可能性。
具体的 CSP 规则比较复杂,大家可以在 CSP 专属网站上自己查看。
SRI 是专门用来校验资源的一种方案,它读取资源标签中的 integrity
属性,将其中的信息摘要值,和资源实际的信息摘要值进行对比,如果发现无法匹配,那么浏览器就会拒绝执行资源。对于 <script>
标签来说,就是拒绝执行其中的代码,对于 CSS 来说则是不加载其中的样式。
通过给 link
标签或者 script
标签增加 integrity
属性即可开启 SRI 功能,比如:
<scripttype="text/javascript"src="//s.url.cn/xxx/aaa.js"integrity="sha156-xxx sha384-yyy"crossorigin="anonymous"></script>
integrity
值分成两个部分,第一部分指定哈希值的生成算法(sha256、sha384 及 sha512),第二部分是经过 base64
编码的实际哈希值,两者之间通过一个短横(-
)分割。integrity
值可以包含多个由空格分隔的哈希值,只要文件匹配其中任意一个哈希值,就可以通过校验并加载该资源。上述例子中我使用了 sha256
和 sha384
两种 hash
方案。
备注:crossorigin="anonymous" 的作用是引入跨域脚本,在 HTML5 中有一种方式可以获取到跨域脚本的错误信息,首先跨域脚本的服务器必须通过 Access-Controll-Allow-Origin 头信息允许当前域名可以获取错误信息,然后是当前域名的
script
标签也必须声明支持跨域,也就是crossorigin
属性。link
、img
等标签均支持跨域脚本。如果上述两个条件无法满足的话, 可以使用try catch
方案。
通过使用 webpack 的 html-webpack-plugin
和 webpack-subresource-integrity
可以生成包含 integrity
属性 script
标签。
import SriPlugin from 'webpack-subresource-integrity';const compiler = webpack({output: {crossOriginLoading: 'anonymous',},plugins: [new SriPlugin({hashFuncNames: ['sha256', 'sha384'],enabled: process.env.NODE_ENV === 'production',}),],});
那么当 script
或者 link
资源 SRI 校验失败的时候应该怎么做呢?
比较好的方式是通过 script
的 onerror
事件,当遇到 onerror
的时候重新 load
静态文件服务器之间的资源:
<scripttype="text/javascript"src="//11.url.cn/aaa.js"integrity="sha256-xxx sha384-yyy"crossorigin="anonymous"onerror="loadScriptError.call(this, event)"onsuccess="loadScriptSuccess"></script>
在此之前注入以下代码:
(function () {function loadScriptError (event) {// 上报...// 重新加载 jsreturn new Promise(function (resolve, reject) {var script = document.createElement('script')script.src = this.src.replace(/\/\/11.src.cn/, 'https://x.y.z') // 替换 cdn 地址为静态文件服务器地址script.onload = resolvescript.onerror = rejectscript.crossOrigin = 'anonymous'document.getElementsByTagName('head')[0].appendChild(script)})}function loadScriptSuccess () {// 上报...}window.loadScriptError = loadScriptErrorwindow.loadScriptSuccess = loadScriptSuccess})()
比较痛苦的是 onerror
中的 event
中无法区分究竟是什么原因导致的错误,可能是资源不存在,也可能是 SRI 校验失败,当然出现最多的还是请求超时,不过目前来看,除非有统计需求,无差别对待并没有多大问题。
注入 onerror
事件
当然,由于项目中的 script
标签是由 webpack 打包进去的,所以我们要使用 script-ext-html-webpack-plugin
将 onerror
事件和 onsuccess
事件注入进去:
const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin');module.exports = {//...plugins: [new HtmlWebpackPlugin(),new SriPlugin({hashFuncNames: ['sha256', 'sha384'],}),new ScriptExtHtmlWebpackPlugin({custom: {test: /\/*_[A-Za-z0-9]{8}.js/,attribute: 'onerror',value: 'loadScriptError.call(this, event)',},}),new ScriptExtHtmlWebpackPlugin({custom: {test: /\/*_[A-Za-z0-9]{8}.js/,attribute: 'onsuccess',value: 'loadScriptSuccess.call(this, event)',},}),],};
然后将 loadScriptError
和 loadScriptSuccess
两个方法注入到 HTML 中,可以使用 inline
的方式。
前面说到 script 加载失败可能是由于多种原因造成的,那如何是否判断发生了 CDN 劫持呢?
方法就是再请求一次数据,比较两次得到文件的内容(当然不必全部比较),如果内容不一致,就可以得出结论了。
function loadScript(url) {return fetch(url).then((res) => {if (res.ok) {return res;}return Promise.reject(new Error());}).then((res) => {return res.text();}).catch((e) => {return '';});}
比较两次加载的 script
是否相同
function checkScriptDiff(src, srcNew) {return Promise.all([loadScript(src), loadScript(srcNew)]).then((data) => {var res1 = data[0].slice(0, 1000);var res2 = data[1].slice(0, 1000);if (!!res1 && !!res2 && res1 !== res2) {// CDN劫持事件发生}}).catch((e) => {// ...});}
这里为什么只比较前 1000 个字符?因为通常 CDN 劫持者会在 js 文件最前面注入一些代码来达到他们的目的,注入中间代码需要 AST 解析,成本较高,所以比较全部字符串没有意义。如果你还是有顾虑的话,可以加上后 n 个字符的比较。