CDN劫持与SRI解决方案

1,861 阅读10分钟

这是我参与更文挑战的第21天,活动详情查看: 更文挑战

SRI是什么

Subresource Integrity 简称sri,具体的可以去看文档,我简单来说就是可以通过生成一个文件的唯一hash值,然后帮你对文件的内容做检查,如果服务端返回的静态资源和生成的hash不一致,则报错不加载。就和大家去下载站下zip包都会有md5校验一样的原理。

CDN劫持

SRI 全称 Subresource Integrity - 子资源完整性,是指浏览器通过验证资源的完整性(通常从 CDN 获取)来判断其是否被篡改的安全特性。 通过给 link 标签或者 script 标签增加 integrity 属性即可开启 SRI 功能,比如:

<script type="text/javascript" src="//s.url.cn/xxxx/xxx.js?_offline=1" integrity="sha256-mY9nzNMPPf8oL3CJss7THIEoXAC2ToW1tEX0NBhMvuw= sha384-ncIKElSEk2OR3YfjNLRSY35mzt0CUwrpNDVS//iD3dF9vxrWeZ7WPlAPJTqGkSai" crossorigin="anonymous"></script>

integrity 值分成两个部分,第一部分指定哈希值的生成算法(sha256、sha384 及 sha512),第二部分是经过 base64 编码的实际哈希值,两者之间通过一个短横(-)分割。integrity 值可以包含多个由空格分隔的哈希值,只要文件匹配其中任意一个哈希值,就可以通过校验并加载该资源。上述例子中我使用了 sha256sha384 两张 hash方案。

备注:crossorigin="anonymous" 的作用是引入跨域脚本,在 HTML5 中有一种方式可以获取到跨域脚本的错误信息,首先跨域脚本的服务器必须通过 Access-Controll-Allow-Origin 头信息允许当前域名可以获取错误信息,然后是当前域名的 script 标签也必须声明支持跨域,也就是 crossorigin 属性。link、img 等标签均支持跨域脚本。如果上述两个条件无法满足的话, 可以使用 try catch 方案。

为什么要使用 SRI

在 Web 开发中,使用 CDN 资源可以有效减少网络请求时间,但是使用 CDN 资源也存在一个问题,CDN 资源存在于第三方服务器,在安全性上并不完全可控。

CDN 劫持是一种非常难以定位的问题,首先劫持者会利用某种算法或者随机的方式进行劫持(狡猾大大滴),所以非常难以复现,很多用户出现后刷新页面就不再出现了。之前公司有同事做游戏的下载器就遇到这个问题,用户下载游戏后解压不能玩,后面通过文件逐一对比找到原因,原来是 CDN 劫持导致的。怎么解决的呢?听说是找 xx 交了保护费,后面也是利用文件 hash 的方式,想必原理上也是跟 SRI 相同的。 所幸的是,目前大多数的 CDN 劫持只是为了做一些夹带,比如通过 iframe 插入一些贴片广告,如果劫持者别有用心,比如 xss 注入之类的,还是非常危险的。

开启 SRI 能有效保证页面引用资源的完整性,避免恶意代码执行。

浏览器如何处理 SRI

当浏览器在 script 或者 link 标签中遇到 integrity 属性之后,会在执行脚本或者应用样式表之前对比所加载文件的哈希值和期望的哈希值。当脚本或者样式表的哈希值和期望的不一致时,浏览器必须拒绝执行脚本或者应用样式表,并且必须返回一个网络错误说明获得脚本或样式表失败。

使用 SRI

通过使用 webpack 的 html-webpack-pluginwebpack-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 静态文件服务器之间的资源:

<script type="text/javascript" src="//11.url.cn/aaa.js"
        integrity="sha256-xxx sha384-yyy"
        crossorigin="anonymous" onerror="loadjs.call(this, event)"></script>

// loadjs:
function loadjs (event) {
  // 上报
  ...
  // 重新加载 js
  return 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 = resolve
    script.onerror = reject
    document.getElementsByTagName('head')[0].appendChild(script);
  })
}

这种方式的缺点是目前 onerror 中的 event 参数无法区分究竟是什么原因导致的错误,可能是资源不存在,也可能是 SRI 校验失败,不过目前来看,除非有统计需求,无差别对待并没有多大问题。 除此之外,我们还需要使用 script-ext-html-webpack-pluginonerror 事件注入进去:

const ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin');

module.exports = {
  //...
  plugins: [
    new HtmlWebpackPlugin(),
    new SriPlugin({
      hashFuncNames: ['sha256', 'sha384']
    }),
    new ScriptExtHtmlWebpackPlugin({
      custom: {
        test: /.js*/,
        attribute: 'onerror="loadjs.call(this, event)" onsuccess="loadSuccess.call(this)"'
      }
    })
  ]
}

然后将 loadjs 和 loadSuccess 两个方法注入到 html 中,可以使用 inline 的方式。

SRI解决CDN劫持案例

CDN 劫持也可以通过 jsonp 的方式解决。这种方式目前能够完美应对 CDN 劫持的主要原因是运营商通过文件名匹配的方式进行劫持,主要通过 onerror 检测拦截,并且去掉资源文件的 js 后缀以应对 CDN 劫持。

知乎上有个作者写了一个简单的webpack插件,其实有现成的webpack-subresource-integrity这个插件,但是这个只是负责生成hash,你还要替换模板什么的,就比较费劲了,而且如何生成srihash不是核心问题,问题是如何利用这个特性来对付js文件劫持!

发布的js文件都是可以生成一个srihash值的,如果我们的文件被劫持了,会触发这个script标签的onerror事件回调(本地多个移动端浏览器测试成功),然后就利用这个特性在onerror回调里做点事。

1,复现场景。

我们首先监控到onerror被触发,然后我们会自己再fetch一次这个script资源,当然这次fetch我们不加sri校验,那么我就可以得到这次报错的文件的真实内容了,然后我们通过一个日志接口上报这段js内容。

2,多次对比。

我们在fetch请求第一次的时候一般情况下访问到的是用户本地的浏览器缓存,而我们其实是想查看远程的cdn真实文件是否被改变了。如何穿透缓存呢?很简单,我们发第二次fetch,这次加一个时间戳就可以了,保证拿到的是无缓存的js文件,我们在本地进行对比,对比方法很简单就是size的diff。

3,收集客户端信息。

我们可以简单的收集到报错的页面信息,js url,js文件内容,因为我们是fetch请求,其实我们可以获取一些response header的,但是response header的获取有一些限制,不少头是拿不到的,可以去参考文档,但是自定义头如果设置了origin跨域是可以拿到的,这里需要在CDN源站来设置,然后我们就可以拿到报错的js的cdn节点地址了,这非常关键,用来甩锅和排查问题,甚至清cdn缓存。

4,运用大数据思维

可以把onerror收回来的这一条条信息入库,然后分析出报错的运营商,报错的代码类型有多少种,客户分布情况等等。然后再结合页面的pv,就可以看到劫持的流量趋势了。

var SriPlugin = require('webpack-subresource-integrity');
var HtmlWebpackPlugin = require('html-webpack-plugin');
var ScriptExtInlineHtmlWebpackPlugin = require('script-ext-inline-html-webpack-plugin');
var ScriptExtHtmlWebpackPlugin = require('script-ext-html-webpack-plugin');
var path = require('path');
var WebpackAssetsManifest = require('webpack-assets-manifest');
var writeJson = require('write-json');

var attackCatch = `
    (function(){
    function log(url, ret) {
        return fetch(url, {
            method: 'post',
            body: encodeURIComponent(JSON.stringify({
        sizes:ret.sizes,
        diff:ret.diff,
                jscontent: ret.context,
                cdn: ret.cdn,
                edge: ret.edge,
                url: ret.url,
                protocol: ret.protocol
            })),
            headers: {
                "Content-type": "application/x-www-form-urlencoded"
            }
        });
    }
        function fetchError(res){
            return Promise.resolve({
                text:function(){
                    return res.status;
                },
                headers:res.headers || {},
                status:res.status
            });
        }
        function loadscript(url){
            return fetch(url).then(function(res){
                if(res.ok){
                    return res;
                }
                return fetchError(res);
            }).catch(function(err){
                return fetchError({
                    status:err
                });
            });
        }
        function getHeader(res1,res2,key){
            if(res1.headers.get){
                return res1.headers.get(key);
            }else if(res2.headers.get){
                return res2.headers.get(key);
            }else{
                return '';
            }
        }
        window.attackCatch = function(ele){
            var src = ele.src;
            var protocol = location.protocol;
            function getSourceData (res1,res2,len1,len2,context1){
                return Promise.resolve({
                    diff:(len1 === len2) ? 0 : 1,
                    sizes:[len1,len2].join(','),
                    cdn:getHeader(res1,res2,'X-Via-CDN'),
                    edge:getHeader(res1,res2,'X-via-Edge'),
                    context:context1 ? context1 :  res1.status + ',' + res2.status,
                    url:src,
                    protocol:protocol
                });
            }
            //如果不支持fetch,可能就是404或者cdn超时了,就不发log了。
            if(window.fetch){
                //加载2次,对比有缓存无缓存的size
                Promise.all([loadscript(src),loadscript(src+'?vt='+(new Date().valueOf()))]).then(function(values){
                    var res1 = values[0],res2 = values[1];
                    //如果支持fetch,我们二次获取时根据http.status来判断,只有200才回报。
                    if(res1.status == '200' && res2.status == '200'){
                        var cdn = res1.headers.get('X-Via-CDN');
                        var edge = res1.headers.get('X-Via-Edge');
                        return Promise.all([res1.text(),res2.text()]).then(function(contexts){
                            var context1 = contexts[0];
                            var len1 = context1.length,len2 = contexts[1].length;
                            return getSourceData(res1,res2,len1,len2,context1);
                        });
                    }else if(res1.status == '200'){
                            return res1.text().then(function(context){
                                var len1 = context.length;
                                return getSourceData(res1,res2,len1,-1);
                            });
                    }else if(res2.status == '200'){
                            return res2.text().then(function(context){
                                var len2 = context.length;
                                return getSourceData(res1,res2,-1,len2);
                            });
                    }else{
                            return getSourceData(res1,res2,-1,-1);
                    }
                }).then(function(ret){
                    if(ret && ret.context) log('日志服务接口,',ret);
                })
            }
        }
    })();
`;

module.exports = {
  entry: {
    index: './index.js'
  },
  output: {
    path: __dirname + '/dist',
    filename: '[name].js',
    crossOriginLoading: 'anonymous'
  },
  plugins: [
    new HtmlWebpackPlugin(),
    new SriPlugin({
      hashFuncNames: ['sha256', 'sha384'],
      enabled: true
    }),
    new WebpackAssetsManifest({
      done: function(manifest, stats) {
        var mainAssetNames = stats.toJson().assetsByChunkName;
        var json = {};
        for (var name in mainAssetNames) {
          if (mainAssetNames.hasOwnProperty(name)) {
            var integrity = stats.compilation.assets[mainAssetNames[name]].integrity;
            //重新生成一次integrity的json文件,因为版本问题,webpack4才支持直接生成。
            json[mainAssetNames[name]] = integrity;
          }
        }
        writeJson.sync(__dirname + '/dist/integrity.json', json)
      }
    }),
    new ScriptExtHtmlWebpackPlugin({
      custom: {
        test: /.js$/,
        attribute: 'onerror="attackCatch(this)"'
      }
    }),
    new ScriptExtInlineHtmlWebpackPlugin({
      prepend: attackCatch
    }),
  ]
};

作者在上报日志的时候因为已经diff了,所以直接搜diff=1的日志基本90%都是被劫持的,10%是文件内容不全的(CDN有时候大文件会返回断的文件,这个是CDN已知问题);看到劫持方法都非常的恶心,直接把你的js换成iframe,或者在你的js后边再插入一个他自己的js,我们也分析过这些js,干什么的都有,基本把之前用户投诉的场景都给吻合住了。最后,因为我们有了劫持流量趋势和劫持代码详细日志信息,我们开始下一步操作,解决劫持问题:

1,保证cdn全链路https(回源一定也要用https,因为回源是访问站源,https对站点的压力会比较大,很多人不走全链路https,但是很多时候这个环节就会被劫持)
2,改文件名,观察劫持流量情况。(这个非常重要,因为之前很多人都会通过改文件名来缓解投诉情况,但是我们不知道真实到底有多大作用,但是我们有了监控手段后发现,改了名字,流量直线掉下来,但是。。。第二天又慢慢恢复了,只能治标不治本)
3,大家知道jsonp原理么?简单的复习下功课,jsonp是用来跨域获取数据用的,一般jsonp请求的都是一个服务端接口,然后服务端返回你一段代码,里面包含了一个可执行的callbackname,那么好了,我们是否可以请求js文件的时候不带 .js 尾缀呢?因为jsonp也不带,同样可以直接script啊,只需要在script上增加一个type就可以了,解析还是js代码。

通过数据监控,我们发现这个问题100%几乎解决了劫持情况,所以猜测运营商劫持是识别的网络下载的文件名尾缀,无差别劫持,哪怕你用了https;在webpack方面的处理主要是onerror里的代码,唯一需要替换的是上报日志的接口,需要支持post,因为需要上报劫持的js文件内容和正常的文件内容,上报日志量比较大。