图片 24

打造双剑合璧的,前端安全

打造双剑合璧的 XSS 前端防火墙

2015/09/30 · HTML5 ·
XSS

原文出处: 林子杰(@Zack__lin)   

作为前端,一直以来都知道HTTP劫持XSS跨站脚本(Cross-site
scripting)、CSRF跨站请求伪造(Cross-site request
forgery)。但是一直都没有深入研究过,前些日子同事的分享会偶然提及,我也对这一块很感兴趣,便深入研究了一番。

JavaScript 防 http 劫持与 XSS

2016/08/17 · JavaScript
· 1 评论 ·
http劫持, X
DNS劫持,
XSS,
安全

本文作者: 伯乐在线 –
chokcoco
。未经作者许可,禁止转载!
欢迎加入伯乐在线 专栏作者。

作为前端,一直以来都知道HTTP劫持XSS跨站脚本(Cross-site
scripting)、CSRF跨站请求伪造(Cross-site request
forgery)。但是一直都没有深入研究过,前些日子同事的分享会偶然提及,我也对这一块很感兴趣,便深入研究了一番。

最近用 JavaScript 写了一个组件,可以在前端层面防御部分 HTTP 劫持与 XSS。

当然,防御这些劫持最好的方法还是从后端入手,前端能做的实在太少。而且由于源码的暴露,攻击者很容易绕过我们的防御手段。但是这不代表我们去了解这块的相关知识是没意义的,本文的许多方法,用在其他方面也是大有作用。

已上传到 Github
– httphijack.js ,欢迎感兴趣看看顺手点个
star ,本文示例代码,防范方法在组件源码中皆可找到。

接下来进入正文。

前言

深入接触 xss 注入是从排查业务的广告注入开始,以前对 xss
注入片面认为是页面输入的安全校验漏洞导致一系列的问题,通过对 zjcqoo
的《XSS 前端防火墙》系列文章,认识到自己其实对 XSS
注入的认识还真是半桶水。

最近用
JavaScript 写了一个组件,可以在前端层面防御部分 HTTP 劫持与 XSS。

HTTP劫持、DNS劫持与XSS

先简单讲讲什么是 HTTP 劫持与 DNS 劫持。

捣蛋的运营商

由于 xss 注入的范围太广,本文仅对网关劫持这一方面的 XSS 注入进行讨论。
这里读者有个小小的疑问,为什么我要选网关劫持进行讨论?因为网关劫持可以大面积范围进行有效控制。

曾经,有这样一道风靡前端的面试题(当然我也现场笔试过):当你在浏览器地址栏输入一个URL后回车,将会发生的事情?其实本文不关心请求发到服务端的具体过程,但是我关心的时,服务端响应输出的文档,可能会在哪些环节被注入广告?手机、路由器网关、网络代理,还有一级运营商网关等等。所以,无论如何,任何网页都得经过运营商网关,而且最调(zui)皮(da)捣(e)蛋(ji)的,就是通过运营商网关。

另外,
也提醒大家,如果手机安装了一些上网加速软件、网络代理软件或设置网络代理
IP,会有安全风险,也包括公共场所/商家的免费 WIFI。

当然,防御这些劫持最好的方法还是从后端入手,前端能做的实在太少。而且由于源码的暴露,攻击者很容易绕过我们的防御手段。但是这不代表我们去了解这块的相关知识是没意义的,本文的许多方法,用在其他方面也是大有作用。

HTTP劫持

什么是HTTP劫持呢,大多数情况是运营商HTTP劫持,当我们使用HTTP请求请求一个网站页面的时候,网络运营商会在正常的数据流中插入精心设计的网络数据报文,让客户端(通常是浏览器)展示“错误”的数据,通常是一些弹窗,宣传性广告或者直接显示某网站的内容,大家应该都有遇到过。

前端防火墙的实践

经过近一段时间通过对 zjcqoo 的《XSS
前端防火墙》六板斧的反复琢磨理解,基本上防御措施可以归为两大类:一种是从协议上屏蔽,一种是从前端代码层面进行拦截移除。通过
zjcqoo
提出的几种注入防御方式,进行几个月的实践观察,对广告注入方式大概可以归为两种:完全静态注入、先静态注入后动态修改(创建)。

  1. 完全静态注入
    完全内联 js、css、和 dom,不管是 body
    内外,甚是恶心,而且如果是在监控脚本前面注入的,还可以抢先执行,造成防御不起作用。注入的
    DOM 也无法清除。
  2. 先静态注入后动态修改
    这种可以分为几种:一种是异步请求接口数据再生成 DOM 注入,一种是修改
    iframe 源地址进行引入,另外一种是修改 script 源地址,请求执行 js
    再异步获取数据或生成 DOM。

已上传到
Github
– httphijack.js ,欢迎感兴趣看看顺手点个
star ,本文示例代码,防范方法在组件源码中皆可找到。

DNS劫持

DNS劫持就是通过劫持了DNS服务器,通过某些手段取得某域名的解析记录控制权,进而修改此域名的解析结果,导致对该域名的访问由原IP地址转入到修改后的指定IP,其结果就是对特定的网址不能访问或访问的是假网址,从而实现窃取资料或者破坏原有正常服务的目的。

DNS 劫持就更过分了,简单说就是我们请求的是 
,直接被重定向了
,本文不会过多讨论这种情况。

监控数据观察分析

对 zjcqoo
提出的几种防御方式的实践,前一个月主要是花在优化检测脚本和增加白名单过滤脏数据方面,因为这块事情只能利用业余时间来搞,所以拖的时间有点久。白名单这块的确是比较繁琐,很多人以为分析下已知的域名就
ok 了,其实不然,云龙在这篇 iframe
黑魔法就提到移动端 Native 与 web
的通信机制,所以在各种 APP 上,会有各种 iframe
的注入,而且是各种五花八门的协议地址,也包括 chrome。

监控拿到的数据很多,但是,由于对整个广告注入黑产行业的不熟悉,所以,有必要借助
google
进行查找研究,发现,运营商大大地狡猾,他们自己只会注入自己业务的广告,如
4G
免费换卡/送流量/送话费,但是商业广告这块蛋糕他们会拱手让人?答案是不可能,他们会勾结其他广告代理公司,利用他们的广告分发平台(运营商被美名为广告系统平台提供商)进行广告投放然后分成…

对于用户投诉,他们一般都是认错,然后对这个用户加白名单,但是他们对其他用户还是继续作恶。对于企业方面的投诉,如果影响到他们的域名,如果你没有确凿的证据,他们就会用各种借口摆脱自己的责任,如用户手机中毒等等,如果你有确凿的证据,还得是他们运营商自己的域名或者
IP,否则他们也无法处理。他们还是一样的借口,用户手机中毒等等。

除非你把运营商的域名或 IP
监控数据列给他看,他才转变态度认错,但是这仅仅也是之前我们提到的流量话费广告,对于第三方广告代理商的广告,还是没法解决,这些第三方广告代理商有广告家、花生米、XX
传媒等等中小型广告商,当然也不排除,有的是“个体户广告商”。

从另一方面来看,由于使用的是古老的 http 协议,这种明文传输的协议,html
内容可以被运营商一清二楚地记录下来,页面关键字、访问时间、地域等用户标签都可以进行采集,说到这,你可能已经明白了一个事(隐私侵犯已经见怪不怪了)——大数据分析+个性化推荐,在
google 一查,运营商还真有部署类似于 iPush
网络广告定向直投这样的系统,而且广告点击率也出奇的高,不排除会定向推送一些偏黄色的图片或游戏。

另外,数据分析中发现一些百度统计的接口请求,也在一些 js
样本中发现百度统计地址,猜测很有可能是这种广告平台利用百度统计系统做数据分析,如定向投放用户
PV 统计,广告效果统计等等。
监控数据分析也扯这么多了,我们还是回来看怎么做防御措施吧!

接下来进入正文。

XSS跨站脚本

XSS指的是攻击者漏洞,向 Web
页面中注入恶意代码,当用户浏览该页之时,注入的代码会被执行,从而达到攻击的特殊目的。

关于这些攻击如何生成,攻击者如何注入恶意代码到页面中本文不做讨论,只要知道如
HTTP 劫持 和 XSS
最终都是恶意代码在客户端,通常也就是用户浏览器端执行,本文将讨论的就是假设注入已经存在,如何利用
Javascript 进行行之有效的前端防护。

防御措施介绍

 

页面被嵌入 iframe 中,重定向 iframe

先来说说我们的页面被嵌入了 iframe
的情况。也就是,网络运营商为了尽可能地减少植入广告对原有网站页面的影响,通常会通过把原有网站页面放置到一个和原页面相同大小的
iframe 里面去,那么就可以通过这个 iframe
来隔离广告代码对原有页面的影响。
图片 1

这种情况还比较好处理,我们只需要知道我们的页面是否被嵌套在 iframe
中,如果是,则重定向外层页面到我们的正常页面即可。

那么有没有方法知道我们的页面当前存在于 iframe
中呢?有的,就是 window.self 与 window.top 。

全站 HTTPS + HSTS

开启 HTTPS,可以加强数据保密性、完整性、和身份校验,而 HSTS (全称 HTTP
Strict Transport Security)可以保证浏览器在很长时间里都会只用 HTTPS
访问站点,这是该防御方式的优点。但是,缺点和缺陷也不可忽略。

互联网全站HTTPS的时代已经到来 一文已有详细的分析,加密解密的性能损耗在服务端的损耗和网络交互的损耗,但是移动端浏览器和
webview 的兼容性支持却是个问题,比如 Android webview
需要固件4.4以上才支持,iOS safari 8 以上也才支持,而 UC
浏览器目前还不支持。

而目前推动团队所有业务支持 HTTPS 难度也是相当高,部分 302
重定向也有可能存在 SSLStrip,更何况 UC
浏览器还不支持这个协议,很容易通过 SSLStrip
进行劫持利用,虽然运营商大部分情况下不会这么干,但是我还是坚定怀疑他们的节操。由于我国宽带网络的基本国情,短时间指望速度提升基本上不可能的,就算总理一句话,但哪个运营商不想赚钱?所以,业务性能的下降和业务安全,需要进行权衡利弊。

HTTP劫持、DNS劫持与XSS

先简单讲讲什么是
HTTP 劫持与 DNS 劫持。

window.self

返回一个指向当前 window 对象的引用。

Content Security Policy(简称 CSP)

CSP
内容安全策略,属于一种浏览器安全策略,以可信白名单作机制,来限制网站中是否可以包含某来源内容。兼容性支持同样是个问题,比如
Android webview 需要固件4.4以上才支持,iOS safari 6 以上支持,幸运的是
UC 浏览器目前支持 1.0
策略版本,具体可以到 CANIUSE 了解。目前对
CSP 的使用仅有不到两周的经验而已,下面简单说说其优缺点。

缺点:

  1. CSP
    规范也比较累赘,每种类型需要重新配置一份,默认配置不能继承,只能替换,这样会导致整个
    header 内容会大大增加。
  2. 如果业务中有爬虫是抓取了外部图片的话,那么 img
    配置要么需要枚举各种域名,要么就信任所有域名。
    1. 移动端 web app 页面,如果有存在 Native 与 web 的通信,那么 iframe
      配置只能信任所有域名和协议了。
    1. 一些业务场景导致无法排除内联 script 的情况,所以只能开启
      unsafe-inline
    1. 一些库仍在使用 eval,所以避免误伤,也只能开启 unsafe-eval
    1. 由于 iframe 信任所有域名和协议,而 unsafe-inline
      开启,使得整个防御效果大大降低

优点:

  1. 通过 connect/script 配置,我们可以控制哪些
    外部域名异步请求可以发出,这无疑是大大的福音,即使内联 script
    被注入,异步请求仍然发不出,这样一来,除非攻击者把所有的 js
    都内联进来,否则注入的功能也运行不了,也无法统计效果如何。
  2. 通过 reportUri 可以统计到攻击类型和
    PV,只不过这个接口的设计不能自定义,上报的内容大部分都是鸡肋。
  3. object/media
    配置可以屏蔽一些外部多媒体的加载,不过这对于视频播放类的业务,也会误伤到。
  4. 目前 UC 浏览器 Android 版本的客户端和 web 端通信机制都是采用标准的
    addJavascriptInterface 注入方式,而 iPhone 版本已将 iframe
    通信方式改成 ajax 方式(与页面同域,10.5
    全部改造完成),如果是只依赖 UC
    浏览器的业务,可以大胆放心使用,如果是需要依赖于第三方平台,建议先开启
    reportOnly,将一些本地协议加入白名单,再完全开启防御。

总的来说吧,单靠 CSP
单打独斗显然是不行,即使完全开启所有策略,也不能完成消除注入攻击,但是作为纵深防御体系中的一道封锁防线,价值也是相当有用的。

HTTP劫持

什么是HTTP劫持呢,大多数情况是运营商HTTP劫持,当我们使用HTTP请求请求一个网站页面的时候,网络运营商会在正常的数据流中插入精心设计的网络数据报文,让客户端(通常是浏览器)展示“错误”的数据,通常是一些弹窗,宣传性广告或者直接显示某网站的内容,大家应该都有遇到过。

window.top

返回窗口体系中的最顶层窗口的引用。

对于非同源的域名,iframe 子页面无法通过 parent.location 或者
top.location 拿到具体的页面地址,但是可以写入 top.location
,也就是可以控制父页面的跳转。

两个属性分别可以又简写为 self 与 top,所以当发现我们的页面被嵌套在
iframe 时,可以重定向父级页面:

JavaScript

if (self != top) { // 我们的正常页面 var url = location.href; //
父级页面重定向 top.location = url; }

1
2
3
4
5
6
if (self != top) {
  // 我们的正常页面
  var url = location.href;
  // 父级页面重定向
  top.location = url;
}

 

前端防火墙拦截

前端防火墙显然适合作为第一道防线进行设计,可以预先对一些注入的内联 js
代码、script/iframe 源引用进行移除,同时对 script/iframe
源地址修改做监控移除。
基本设计逻辑大概如下:

图片 2

详细的实现逻辑,参考zjcqoo 的《XSS 前端防火墙》系列文章。

缺点:

  1. 如果是在监控脚本执行前,注入的脚本已经执行,显然后知后觉无法起防御作用了。
  2. 一些 DOM 的注入显然无能为力。

优点:

  1. 可以针对 iframe 做一些自定义的过滤规则,防止对本地通信误伤。
  2. 可以收集到一些注入行为数据进行分析。

DNS劫持

DNS
劫持就是通过劫持了 DNS
服务器,通过某些手段取得某域名的解析记录控制权,进而修改此域名的解析结果,导致对该域名的访问由原IP地址转入到修改后的指定IP,其结果就是对特定的网址不能访问或访问的是假网址,从而实现窃取资料或者破坏原有正常服务的目的。

DNS
劫持比之 HTTP 劫持
更加过分,简单说就是我们请求的是 
,直接被重定向了
,本文不会过多讨论这种情况。

使用白名单放行正常 iframe 嵌套

当然很多时候,也许运营需要,我们的页面会被以各种方式推广,也有可能是正常业务需要被嵌套在
iframe 中,这个时候我们需要一个白名单或者黑名单,当我们的页面被嵌套在
iframe 中且父级页面域名存在白名单中,则不做重定向操作。

上面也说了,使用 top.location.href 是没办法拿到父级页面的 URL
的,这时候,需要使用document.referrer

通过 document.referrer 可以拿到跨域 iframe 父页面的URL。

JavaScript

// 建立白名单 var whiteList = [ ‘www.aaa.com’, ‘res.bbb.com’ ]; if
(self != top) { var // 使用 document.referrer 可以拿到跨域 iframe
父页面的 URL parentUrl = document.referrer, length = whiteList.length, i
= 0; for(; i<length; i++){ // 建立白名单正则 var reg = new
RegExp(whiteList[i],’i’); // 存在白名单中,放行
if(reg.test(parentUrl)){ return; } } // 我们的正常页面 var url =
location.href; // 父级页面重定向 top.location = url; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 建立白名单
var whiteList = [
  ‘www.aaa.com’,
  ‘res.bbb.com’
];
if (self != top) {
  var
    // 使用 document.referrer 可以拿到跨域 iframe 父页面的 URL
    parentUrl = document.referrer,
    length = whiteList.length,
    i = 0;
  for(; i<length; i++){
    // 建立白名单正则
    var reg = new RegExp(whiteList[i],’i’);
    // 存在白名单中,放行
    if(reg.test(parentUrl)){
      return;
    }
  }
  // 我们的正常页面
  var url = location.href;
  // 父级页面重定向
  top.location = url;
}

 

双剑合璧

即使是单纯的 DOM
注入,显然无法满足更高级功能的使用,也会使运营商的广告分发平台效果大打折扣。如果单独其中一种方式进行使用,也只是发挥了一招一式的半成功力,如果是双手互搏,那也可以发挥成倍的功力。

而前端防火墙再加上 CSP
安全策略,双剑合璧,则可以大大降低广告注入带来的负面效果,重则造成广告代码严重瘫痪无法运行:在监控脚本后注入广告脚本,基本上可以被前端防火墙封杀殆尽,即使有漏网之鱼,也会被
CSP 进行追杀,不死也残。

即使在监控脚本运行前注入,通过 CSP content-src
策略,可以拦截白名单域名列表外的接口请求,使得广告代码的异步请求能力被封杀,script-src
策略,也可以封杀脚本外链的一些外部请求,进一步封杀异步脚本引用,frame-src
策略无论先后创建的 iframe,一律照杀。

侥幸者躲过了初一,却躲不过十五,前端防火墙拍马赶到,照样封杀无误,唯一的路径只有注入
DOM 这一方式,别忘了,只要开启 img-src
策略配置,广告代码只剩下文字链。虽然是一个文字链广告,但点击率又能高到哪去呢?

如果你是 node
派系,小弟附上《辟邪剑谱》 helmet 一本,如果你的业务有涉及到
UCBrowser,更有《辟邪剑谱之 UC
版》helmet-csp-uc 。

所谓道高一尺魔高一丈,既然我们有高效的防御措施,相信他们不久也会探索出反防御方式,如此,我们也需要和这帮人斗智斗勇,一直等到
HTTP/2 规范的正式落地。

1 赞 3 收藏
评论

图片 3

XSS跨站脚本

XSS指的是攻击者利用漏洞,向
Web
页面中注入恶意代码,当用户浏览该页之时,注入的代码会被执行,从而达到攻击的特殊目的。

关于这些攻击如何生成,攻击者如何注入恶意代码到页面中本文不做讨论,只要知道如
HTTP 劫持 和 XSS
最终都是恶意代码在客户端,通常也就是用户浏览器端执行,本文将讨论的就是假设注入已经存在,如何利用
Javascript 进行行之有效的前端防护。

更改 URL 参数绕过运营商标记

这样就完了吗?没有,我们虽然重定向了父页面,但是在重定向的过程中,既然第一次可以嵌套,那么这一次重定向的过程中页面也许又被
iframe 嵌套了,真尼玛蛋疼。

当然运营商这种劫持通常也是有迹可循,最常规的手段是在页面 URL
中设置一个参数,例如
 ,其中 iframe_hijack_redirected=1 表示页面已经被劫持过了,就不再嵌套
iframe 了。所以根据这个特性,我们可以改写我们的 URL
,使之看上去已经被劫持了:

JavaScript

var flag = ‘iframe_hijack_redirected’; // 当前页面存在于一个 iframe 中
// 此处需要建立一个白名单匹配规则,白名单默认放行 if (self != top) { var
// 使用 document.referrer 可以拿到跨域 iframe 父页面的 URL parentUrl =
document.referrer, length = whiteList.length, i = 0; for(; i<length;
i++){ // 建立白名单正则 var reg = new RegExp(whiteList[i],’i’); //
存在白名单中,放行 if(reg.test(parentUrl)){ return; } } var url =
location.href; var parts = url.split(‘#’); if (location.search) {
parts[0] += ‘&’ + flag + ‘=1’; } else { parts[0] += ‘?’ + flag +
‘=1’; } try { console.log(‘页面被嵌入iframe中:’, url); top.location.href
= parts.join(‘#’); } catch (e) {} }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
var flag = ‘iframe_hijack_redirected’;
// 当前页面存在于一个 iframe 中
// 此处需要建立一个白名单匹配规则,白名单默认放行
if (self != top) {
  var
    // 使用 document.referrer 可以拿到跨域 iframe 父页面的 URL
    parentUrl = document.referrer,
    length = whiteList.length,
    i = 0;
  for(; i<length; i++){
    // 建立白名单正则
    var reg = new RegExp(whiteList[i],’i’);
    // 存在白名单中,放行
    if(reg.test(parentUrl)){
      return;
    }
  }
  var url = location.href;
  var parts = url.split(‘#’);
  if (location.search) {
    parts[0] += ‘&’ + flag + ‘=1’;
  } else {
    parts[0] += ‘?’ + flag + ‘=1’;
  }
  try {
    console.log(‘页面被嵌入iframe中:’, url);
    top.location.href = parts.join(‘#’);
  } catch (e) {}
}

当然,如果这个参数一改,防嵌套的代码就失效了。所以我们还需要建立一个上报系统,当发现页面被嵌套时,发送一个拦截上报,即便重定向失败,也可以知道页面嵌入
iframe 中的 URL,根据分析这些 URL
,不断增强我们的防护手段,这个后文会提及。

 

内联事件及内联脚本拦截

在 XSS 中,其实可以注入脚本的方式非常的多,尤其是 HTML5
出来之后,一不留神,许多的新标签都可以用于注入可执行脚本。

列出一些比较常见的注入方式:

  1. <a href="javascript:alert(1)" ></a>
  2. <iframe src="javascript:alert(1)" />
  3. <img src='x' onerror="alert(1)" />
  4. <video src='x' onerror="alert(1)" ></video>
  5. <div onclick="alert(1)" onmouseover="alert(2)" ><div>

除去一些未列出来的非常少见生僻的注入方式,大部分都是 javascript:... 及内联事件 on*

我们假设注入已经发生,那么有没有办法拦截这些内联事件与内联脚本的执行呢?

对于上面列出的 (1) (5)
,这种需要用户点击或者执行某种事件之后才执行的脚本,我们是有办法进行防御的。

页面被嵌入 iframe 中,重定向 iframe

先来说说我们的页面被嵌入了
iframe
的情况。也就是,网络运营商为了尽可能地减少植入广告对原有网站页面的影响,通常会通过把原有网站页面放置到一个和原页面相同大小的
iframe 里面去,那么就可以通过这个 iframe
来隔离广告代码对原有页面的影响。
图片 4

这种情况还比较好处理,我们只需要知道我们的页面是否被嵌套在
iframe 中,如果是,则重定向外层页面到我们的正常页面即可。

那么有没有方法知道我们的页面当前存在于
iframe 中呢?有的,就是 window.self 与 window.top 。

浏览器事件模型

这里说能够拦截,涉及到了事件模型相关的原理。

我们都知道,标准浏览器事件模型存在三个阶段:

  • 捕获阶段
  • 目标阶段
  • 冒泡阶段

对于一个这样 <a href="javascript:alert(222)" ></a> 的 a
标签而言,真正触发元素 alert(222) 是处于点击事件的目标阶段。

See the Pen EyrjkG by Chokcoco
(@Chokcoco) on
CodePen.

点击上面的 click me ,先弹出 111 ,后弹出 222。

那么,我们只需要在点击事件模型的捕获阶段对标签内 javascript:... 的内容建立关键字黑名单,进行过滤审查,就可以做到我们想要的拦截效果。

对于 on*
类内联事件也是同理,只是对于这类事件太多,我们没办法手动枚举,可以利用代码自动枚举,完成对内联事件及内联脚本的拦截。

以拦截 a 标签内的 href="javascript:... 为例,我们可以这样写:

JavaScript

// 建立关键词黑名单 var keywordBlackList = [ ‘xss’,
‘BAIDU_SSP__wrapper’, ‘BAIDU_DSPUI_FLOWBAR’ ];
document.addEventListener(‘click’, function(e) { var code = “”; // 扫描
<a href=”javascript:”> 的脚本 if (elem.tagName == ‘A’ &&
elem.protocol == ‘javascript:’) { var code = elem.href.substr(11); if
(blackListMatch(keywordBlackList, code)) { // 注销代码 elem.href =
‘javascript:void(0)’; console.log(‘拦截可疑事件:’ + code); } } }, true);
/** * [黑名单匹配] * @param {[Array]} blackList [黑名单] *
@param {[String]} value [需要验证的字符串] * @return {[Boolean]}
[false — 验证不通过,true — 验证通过] */ function
blackListMatch(blackList, value) { var length = blackList.length, i = 0;
for (; i < length; i++) { // 建立黑名单正则 var reg = new
RegExp(whiteList[i], ‘i’); // 存在黑名单中,拦截 if (reg.test(value))
{ return true; } } return false; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 建立关键词黑名单
var keywordBlackList = [
  ‘xss’,
  ‘BAIDU_SSP__wrapper’,
  ‘BAIDU_DSPUI_FLOWBAR’
];
  
document.addEventListener(‘click’, function(e) {
  var code = "";
  // 扫描 <a href="javascript:"> 的脚本
  if (elem.tagName == ‘A’ && elem.protocol == ‘javascript:’) {
    var code = elem.href.substr(11);
    if (blackListMatch(keywordBlackList, code)) {
      // 注销代码
      elem.href = ‘javascript:void(0)’;
      console.log(‘拦截可疑事件:’ + code);
    }
  }
}, true);
/**
* [黑名单匹配]
* @param  {[Array]} blackList [黑名单]
* @param  {[String]} value    [需要验证的字符串]
* @return {[Boolean]}         [false — 验证不通过,true — 验证通过]
*/
function blackListMatch(blackList, value) {
  var length = blackList.length,
    i = 0;
  for (; i < length; i++) {
    // 建立黑名单正则
    var reg = new RegExp(whiteList[i], ‘i’);
    // 存在黑名单中,拦截
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}

可以戳我查看DEMO。(打开页面后打开控制台查看
console.log)

点击图中这几个按钮,可以看到如下:

图片 5

这里我们用到了黑名单匹配,下文还会细说。

 

window.self

返回一个指向当前
window 对象的引用。

静态脚本拦截

XSS 跨站脚本的精髓不在于“跨站”,在于“脚本”。

通常而言,攻击者或者运营商会向页面中注入一个<script>脚本,具体操作都在脚本中实现,这种劫持方式只需要注入一次,有改动的话不需要每次都重新注入。

我们假定现在页面上被注入了一个 <script src="http://attack.com/xss.js"> 脚本,我们的目标就是拦截这个脚本的执行。

听起来很困难啊,什么意思呢。就是在脚本执行前发现这个可疑脚本,并且销毁它使之不能执行内部代码。

所以我们需要用到一些高级 API ,能够在页面加载时对生成的节点进行检测。

 

window.top

返回窗口体系中的最顶层窗口的引用。

对于非同源的域名,iframe
子页面无法通过 parent.location 或者 top.location
拿到具体的页面地址,但是可以写入 top.location
,也就是可以控制父页面的跳转。

两个属性分别可以又简写为 self 与 top,所以当发现我们的页面被嵌套在
iframe 时,可以重定向父级页面:

if (self != top) {
  // 我们的正常页面
  var url = location.href;
  // 父级页面重定向
  top.location = url;
}

  

MutationObserver

MutationObserver 是 HTML5 新增的
API,功能很强大,给开发者们提供了一种能在某个范围内的 DOM
树发生变化时作出适当反应的能力。

说的很玄乎,大概的意思就是能够监测到页面 DOM 树的变换,并作出反应。

MutationObserver() 该构造函数用来实例化一个新的Mutation观察者对象。

JavaScript

MutationObserver( function callback );

1
2
3
MutationObserver(
  function callback
);

目瞪狗呆,这一大段又是啥?意思就是 MutationObserver
在观测时并非发现一个新元素就立即回调,而是将一个时间片段里出现的所有元素,一起传过来。所以在回调中我们需要进行批量处理。而且,其中的 callback 会在指定的
DOM
节点(目标节点)发生变化时被调用。在调用时,观察者对象会传给该函数两个参数,第一个参数是个包含了若干个
MutationRecord 对象的数组,第二个参数则是这个观察者对象本身。

所以,使用 MutationObserver
,我们可以对页面加载的每个静态脚本文件,进行监控:

JavaScript

// MutationObserver 的不同兼容性写法 var MutationObserver =
window.MutationObserver || window.WebKitMutationObserver ||
window.MozMutationObserver; // 该构造函数用来实例化一个新的 Mutation
观察者对象 // Mutation 观察者对象能监听在某个范围内的 DOM 树变化 var
observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) { // 返回被添加的节点,或者为null.
var nodes = mutation.addedNodes; for (var i = 0; i < nodes.length;
i++) { var node = nodes[i]; if (/xss/i.test(node.src))) { try {
node.parentNode.removeChild(node); console.log(‘拦截可疑静态脚本:’,
node.src); } catch (e) {} } } }); }); // 传入目标节点和观察选项 // 如果
target 为 document 或者 document.documentElement //
则当前文档中所有的节点添加与删除操作都会被观察到
observer.observe(document, { subtree: true, childList: true });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// MutationObserver 的不同兼容性写法
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver ||
window.MozMutationObserver;
// 该构造函数用来实例化一个新的 Mutation 观察者对象
// Mutation 观察者对象能监听在某个范围内的 DOM 树变化
var observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    // 返回被添加的节点,或者为null.
    var nodes = mutation.addedNodes;
    for (var i = 0; i < nodes.length; i++) {
      var node = nodes[i];
      if (/xss/i.test(node.src))) {
        try {
          node.parentNode.removeChild(node);
          console.log(‘拦截可疑静态脚本:’, node.src);
        } catch (e) {}
      }
    }
  });
});
// 传入目标节点和观察选项
// 如果 target 为 document 或者 document.documentElement
// 则当前文档中所有的节点添加与删除操作都会被观察到
observer.observe(document, {
  subtree: true,
  childList: true
});

可以看到如下:可以戳我查看DEMO。(打开页面后打开控制台查看
console.log)

图片 6

<script type="text/javascript" src="./xss/a.js"></script> 是页面加载一开始就存在的静态脚本(查看页面结构),我们使用
MutationObserver
可以在脚本加载之后,执行之前这个时间段对其内容做正则匹配,发现恶意代码则 removeChild() 掉,使之无法执行。

使用白名单放行正常 iframe 嵌套

当然很多时候,也许运营需要,我们的页面会被以各种方式推广,也有可能是正常业务需要被嵌套在
iframe 中,这个时候我们需要一个白名单或者黑名单,当我们的页面被嵌套在
iframe 中且父级页面域名存在白名单中,则不做重定向操作。

上面也说了,使用
top.location.href 是没办法拿到父级页面的 URL
的,这时候,需要使用document.referrer

通过
document.referrer 可以拿到跨域 iframe 父页面的URL。

// 建立白名单
var whiteList = [
  'www.aaa.com',
  'res.bbb.com'
];

if (self != top) {
  var
    // 使用 document.referrer 可以拿到跨域 iframe 父页面的 URL
    parentUrl = document.referrer,
    length = whiteList.length,
    i = 0;

  for(; i<length; i++){
    // 建立白名单正则
    var reg = new RegExp(whiteList[i],'i');

    // 存在白名单中,放行
    if(reg.test(parentUrl)){
      return;
    }
  }

  // 我们的正常页面
  var url = location.href;
  // 父级页面重定向
  top.location = url;
}

 

使用白名单对 src 进行匹配过滤

上面的代码中,我们判断一个js脚本是否是恶意的,用的是这一句:

JavaScript

if (/xss/i.test(node.src)) {}

1
if (/xss/i.test(node.src)) {}

当然实际当中,注入恶意代码者不会那么傻,把名字改成 XSS
。所以,我们很有必要使用白名单进行过滤和建立一个拦截上报系统。

JavaScript

// 建立白名单 var whiteList = [ ‘www.aaa.com’, ‘res.bbb.com’ ]; /**
* [白名单匹配] * @param {[Array]} whileList [白名单] * @param
{[String]} value [需要验证的字符串] * @return {[Boolean]} [false
— 验证不通过,true — 验证通过] */ function whileListMatch(whileList,
value) { var length = whileList.length, i = 0; for (; i < length;
i++) { // 建立白名单正则 var reg = new RegExp(whiteList[i], ‘i’); //
存在白名单中,放行 if (reg.test(value)) { return true; } } return false;
} // 只放行白名单 if (!whileListMatch(blackList, node.src)) {
node.parentNode.removeChild(node); }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// 建立白名单
var whiteList = [
  ‘www.aaa.com’,
  ‘res.bbb.com’
];
/**
* [白名单匹配]
* @param  {[Array]} whileList [白名单]
* @param  {[String]} value    [需要验证的字符串]
* @return {[Boolean]}         [false — 验证不通过,true — 验证通过]
*/
function whileListMatch(whileList, value) {
  var length = whileList.length,
    i = 0;
  for (; i < length; i++) {
    // 建立白名单正则
    var reg = new RegExp(whiteList[i], ‘i’);
    // 存在白名单中,放行
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}
// 只放行白名单
if (!whileListMatch(blackList, node.src)) {
  node.parentNode.removeChild(node);
}

这里我们已经多次提到白名单匹配了,下文还会用到,所以可以这里把它简单封装成一个方法调用。

更改 URL 参数绕过运营商标记

这样就完了吗?没有,我们虽然重定向了父页面,但是在重定向的过程中,既然第一次可以嵌套,那么这一次重定向的过程中页面也许又被
iframe 嵌套了,真尼玛蛋疼。

当然运营商这种劫持通常也是有迹可循,最常规的手段是在页面
URL 中设置一个参数,例如
 ,其中 iframe_hijack_redirected=1 表示页面已经被劫持过了,就不再嵌套
iframe 了。所以根据这个特性,我们可以改写我们的 URL
,使之看上去已经被劫持了:

var flag = 'iframe_hijack_redirected';
// 当前页面存在于一个 iframe 中
// 此处需要建立一个白名单匹配规则,白名单默认放行
if (self != top) {
  var
    // 使用 document.referrer 可以拿到跨域 iframe 父页面的 URL
    parentUrl = document.referrer,
    length = whiteList.length,
    i = 0;

  for(; i<length; i++){
    // 建立白名单正则
    var reg = new RegExp(whiteList[i],'i');

    // 存在白名单中,放行
    if(reg.test(parentUrl)){
      return;
    }
  }

  var url = location.href;
  var parts = url.split('#');
  if (location.search) {
    parts[0] += '&' + flag + '=1';
  } else {
    parts[0] += '?' + flag + '=1';
  }
  try {
    console.log('页面被嵌入iframe中:', url);
    top.location.href = parts.join('#');
  } catch (e) {}
}

当然,如果这个参数一改,防嵌套的代码就失效了。所以我们还需要建立一个上报系统,当发现页面被嵌套时,发送一个拦截上报,即便重定向失败,也可以知道页面嵌入
iframe 中的 URL,根据分析这些 URL
,不断增强我们的防护手段,这个后文会提及。

动态脚本拦截

上面使用 MutationObserver
拦截静态脚本,除了静态脚本,与之对应的就是动态生成的脚本。

JavaScript

var script = document.createElement(‘script’); script.type =
‘text/javascript’; script.src = ”;
document.getElementsByTagName(‘body’)[0].appendChild(script);

1
2
3
4
5
var script = document.createElement(‘script’);
script.type = ‘text/javascript’;
script.src = ‘http://www.example.com/xss/b.js’;
document.getElementsByTagName(‘body’)[0].appendChild(script);

要拦截这类动态生成的脚本,且拦截时机要在它插入 DOM
树中,执行之前,本来是可以监听 Mutation Events 中的 DOMNodeInserted 事件的。

 

内联事件及内联脚本拦截

在 XSS
中,其实可以注入脚本的方式非常的多,尤其是 HTML5
出来之后,一不留神,许多的新标签都可以用于注入可执行脚本。

列出一些比较常见的注入方式:

  1. <a href="javascript:alert(1)" ></a>
  2. <iframe src="javascript:alert(1)" />
  3. <img src='x' onerror="alert(1)" />
  4. <video src='x' onerror="alert(1)" ></video>
  5. <div onclick="alert(1)" onmouseover="alert(2)" ><div>

除去一些未列出来的非常少见生僻的注入方式,大部分都是 javascript:... 及内联事件 on*

我们假设注入已经发生,那么有没有办法拦截这些内联事件与内联脚本的执行呢?

对于上面列出的
(1) (5)
,这种需要用户点击或者执行某种事件之后才执行的脚本,我们是有办法进行防御的。

Mutation Events 与 DOMNodeInserted

打开 MDN ,第一句就是:

该特性已经从 Web
标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。

虽然不能用,也可以了解一下:

JavaScript

document.addEventListener(‘DOMNodeInserted’, function(e) { var node =
e.target; if (/xss/i.test(node.src) || /xss/i.test(node.innerHTML)) {
node.parentNode.removeChild(node); console.log(‘拦截可疑动态脚本:’,
node); } }, true);

1
2
3
4
5
6
7
document.addEventListener(‘DOMNodeInserted’, function(e) {
  var node = e.target;
  if (/xss/i.test(node.src) || /xss/i.test(node.innerHTML)) {
    node.parentNode.removeChild(node);
    console.log(‘拦截可疑动态脚本:’, node);
  }
}, true);

然而可惜的是,使用上面的代码拦截动态生成的脚本,可以拦截到,但是代码也执行了:DOMNodeInserted 顾名思义,可以监听某个
DOM 范围内的结构变化,与 MutationObserver 相比,它的执行时机更早。

图片 7

但是 DOMNodeInserted 不再建议使用,所以监听动态脚本的任务也要交给 MutationObserver

可惜的是,在实际实践过程中,使用 MutationObserver 的结果和 DOMNodeInserted 一样,可以监听拦截到动态脚本的生成,但是无法在脚本执行之前,使用 removeChild 将其移除,所以我们还需要想想其他办法。

浏览器事件模型

这里说能够拦截,涉及到了事件模型相关的原理。

我们都知道,标准浏览器事件模型存在三个阶段:

  • 捕获阶段
  • 目标阶段
  • 冒泡阶段

对于一个这样 <a href="javascript:alert(222)" ></a> 的
a 标签而言,真正触发元素 alert(222) 是处于点击事件的目标阶段。

点击上面的 click me ,先弹出
111 ,后弹出 222。

那么,我们只需要在点击事件模型的捕获阶段对标签内 javascript:... 的内容建立关键字黑名单,进行过滤审查,就可以做到我们想要的拦截效果。

对于 on*
类内联事件也是同理,只是对于这类事件太多,我们没办法手动枚举,可以利用代码自动枚举,完成对内联事件及内联脚本的拦截。

以拦截 a
标签内的 href="javascript:... 为例,我们可以这样写:

// 建立关键词黑名单
var keywordBlackList = [
  'xss',
  'BAIDU_SSP__wrapper',
  'BAIDU_DSPUI_FLOWBAR'
];

document.addEventListener('click', function(e) {
  var code = "";

  // 扫描 <a href="javascript:"> 的脚本
  if (elem.tagName == 'A' && elem.protocol == 'javascript:') {
    var code = elem.href.substr(11);

    if (blackListMatch(keywordBlackList, code)) {
      // 注销代码
      elem.href = 'javascript:void(0)';
      console.log('拦截可疑事件:' + code);
    }
  }
}, true);

/**
 * [黑名单匹配]
 * @param  {[Array]} blackList [黑名单]
 * @param  {[String]} value    [需要验证的字符串]
 * @return {[Boolean]}         [false -- 验证不通过,true -- 验证通过]
 */
function blackListMatch(blackList, value) {
  var length = blackList.length,
    i = 0;

  for (; i < length; i++) {
    // 建立黑名单正则
    var reg = new RegExp(whiteList[i], 'i');

    // 存在黑名单中,拦截
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}

可以戳我查看DEMO。(打开页面后打开控制台查看
console.log) 

点击图中这几个按钮,可以看到如下:

图片 8

这里我们用到了黑名单匹配,下文还会细说。

 

重写 setAttribute 与 document.write

静态脚本拦截

XSS
跨站脚本的精髓不在于“跨站”,在于“脚本”。

通常而言,攻击者或者运营商会向页面中注入一个<script>脚本,具体操作都在脚本中实现,这种劫持方式只需要注入一次,有改动的话不需要每次都重新注入。

我们假定现在页面上被注入了一个 <script src="http://attack.com/xss.js"> 脚本,我们的目标就是拦截这个脚本的执行。

听起来很困难啊,什么意思呢。就是在脚本执行前发现这个可疑脚本,并且销毁它使之不能执行内部代码。

所以我们需要用到一些高级
API ,能够在页面加载时对生成的节点进行检测。

 

重写原生 Element.prototype.setAttribute 方法

在动态脚本插入执行前,监听 DOM 树的变化拦截它行不通,脚本仍然会执行。

那么我们需要向上寻找,在脚本插入 DOM
树前的捕获它,那就是创建脚本时这个时机。

假设现在有一个动态脚本是这样创建的:

JavaScript

var script = document.createElement(‘script’);
script.setAttribute(‘type’, ‘text/javascript’);
script.setAttribute(‘src’, ”);
document.getElementsByTagName(‘body’)[0].appendChild(script);

1
2
3
4
5
var script = document.createElement(‘script’);
script.setAttribute(‘type’, ‘text/javascript’);
script.setAttribute(‘src’, ‘http://www.example.com/xss/c.js’);
document.getElementsByTagName(‘body’)[0].appendChild(script);

而重写 Element.prototype.setAttribute 也是可行的:我们发现这里用到了
setAttribute
方法,如果我们能够改写这个原生方法,监听设置 src 属性时的值,通过黑名单或者白名单判断它,就可以判断该标签的合法性了。

JavaScript

// 保存原有接口 var old_setAttribute = Element.prototype.setAttribute;
// 重写 setAttribute 接口 Element.prototype.setAttribute =
function(name, value) { // 匹配到 <script src=’xxx’ > 类型 if
(this.tagName == ‘SCRIPT’ && /^src$/i.test(name)) { // 白名单匹配 if
(!whileListMatch(whiteList, value)) { console.log(‘拦截可疑模块:’,
value); return; } } // 调用原始接口 old_setAttribute.apply(this,
arguments); }; // 建立白名单 var whiteList = [ ‘www.yy.com’,
‘res.cont.yy.com’ ]; /** * [白名单匹配] * @param {[Array]}
whileList [白名单] * @param {[String]} value [需要验证的字符串]
* @return {[Boolean]} [false — 验证不通过,true — 验证通过] */
function whileListMatch(whileList, value) { var length =
whileList.length, i = 0; for (; i < length; i++) { // 建立白名单正则
var reg = new RegExp(whiteList[i], ‘i’); // 存在白名单中,放行 if
(reg.test(value)) { return true; } } return false; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// 保存原有接口
var old_setAttribute = Element.prototype.setAttribute;
// 重写 setAttribute 接口
Element.prototype.setAttribute = function(name, value) {
  // 匹配到 <script src=’xxx’ > 类型
  if (this.tagName == ‘SCRIPT’ && /^src$/i.test(name)) {
    // 白名单匹配
    if (!whileListMatch(whiteList, value)) {
      console.log(‘拦截可疑模块:’, value);
      return;
    }
  }
  
  // 调用原始接口
  old_setAttribute.apply(this, arguments);
};
// 建立白名单
var whiteList = [
‘www.yy.com’,
‘res.cont.yy.com’
];
/**
* [白名单匹配]
* @param  {[Array]} whileList [白名单]
* @param  {[String]} value    [需要验证的字符串]
* @return {[Boolean]}         [false — 验证不通过,true — 验证通过]
*/
function whileListMatch(whileList, value) {
  var length = whileList.length,
    i = 0;
  for (; i < length; i++) {
    // 建立白名单正则
    var reg = new RegExp(whiteList[i], ‘i’);
    // 存在白名单中,放行
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}

可以看到如下结果:可以戳我查看DEMO。(打开页面后打开控制台查看
console.log)

图片 9

重写 Element.prototype.setAttribute ,就是首先保存原有接口,然后当有元素调用
setAttribute 时,检查传入的 src
是否存在于白名单中,存在则放行,不存在则视为可疑元素,进行上报并不予以执行。最后对放行的元素执行原生的 setAttribute ,也就是 old_setAttribute.apply(this, arguments);

上述的白名单匹配也可以换成黑名单匹配。

MutationObserver

MutationObserver
是 HTML5 新增的 API,功能很强大,给开发者们提供了一种能在某个范围内的
DOM 树发生变化时作出适当反应的能力。

说的很玄乎,大概的意思就是能够监测到页面
DOM 树的变换,并作出反应。

MutationObserver() 该构造函数用来实例化一个新的Mutation观察者对象。

MutationObserver(
  function callback
);

目瞪狗呆,这一大段又是啥?意思就是
MutationObserver
在观测时并非发现一个新元素就立即回调,而是将一个时间片段里出现的所有元素,一起传过来。所以在回调中我们需要进行批量处理。而且,其中的 callback 会在指定的
DOM
节点(目标节点)发生变化时被调用。在调用时,观察者对象会传给该函数两个参数,第一个参数是个包含了若干个
MutationRecord
对象的数组,第二个参数则是这个观察者对象本身。

所以,使用
MutationObserver
,我们可以对页面加载的每个静态脚本文件,进行监控:

// MutationObserver 的不同兼容性写法
var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || 
window.MozMutationObserver;
// 该构造函数用来实例化一个新的 Mutation 观察者对象
// Mutation 观察者对象能监听在某个范围内的 DOM 树变化
var observer = new MutationObserver(function(mutations) {
  mutations.forEach(function(mutation) {
    // 返回被添加的节点,或者为null.
    var nodes = mutation.addedNodes;

    for (var i = 0; i < nodes.length; i++) {
      var node = nodes[i];
      if (/xss/i.test(node.src))) {
        try {
          node.parentNode.removeChild(node);
          console.log('拦截可疑静态脚本:', node.src);
        } catch (e) {}
      }
    }
  });
});

// 传入目标节点和观察选项
// 如果 target 为 document 或者 document.documentElement
// 则当前文档中所有的节点添加与删除操作都会被观察到
observer.observe(document, {
  subtree: true,
  childList: true
});

可以看到如下:可以戳我查看DEMO。(打开页面后打开控制台查看
console.log)

图片 10

<script type="text/javascript" src="./xss/a.js"></script> 是页面加载一开始就存在的静态脚本(查看页面结构),我们使用
MutationObserver
可以在脚本加载之后,执行之前这个时间段对其内容做正则匹配,发现恶意代码则 removeChild() 掉,使之无法执行。

 

重写嵌套 iframe 内的 Element.prototype.setAttribute

当然,上面的写法如果 old_setAttribute = Element.prototype.setAttribute 暴露给攻击者的话,直接使用old_setAttribute 就可以绕过我们重写的方法了,所以这段代码必须包在一个闭包内。

当然这样也不保险,虽然当前窗口下的 Element.prototype.setAttribute 已经被重写了。但是还是有手段可以拿到原生的 Element.prototype.setAttribute ,只需要一个新的
iframe 。

JavaScript

var newIframe = document.createElement(‘iframe’);
document.body.appendChild(newIframe); Element.prototype.setAttribute =
newIframe.contentWindow.Element.prototype.setAttribute;

1
2
3
4
var newIframe = document.createElement(‘iframe’);
document.body.appendChild(newIframe);
Element.prototype.setAttribute = newIframe.contentWindow.Element.prototype.setAttribute;

通过这个方法,可以重新拿到原生的 Element.prototype.setAttribute ,因为
iframe 内的环境和外层 window 是完全隔离的。wtf?

图片 11

怎么办?我们看到创建 iframe
用到了 createElement,那么是否可以重写原生 createElement 呢?但是除了createElement 还有 createElementNS ,还有可能是页面上已经存在
iframe,所以不合适。

那就在每当新创建一个新 iframe
时,对 setAttribute 进行保护重写,这里又有用到 MutationObserver :

JavaScript

/** * 使用 MutationObserver 对生成的 iframe 页面进行监控, *
防止调用内部原生 setAttribute 及 document.write * @return {[type]}
[description] */ function defenseIframe() { // 先保护当前页面
installHook(window); } /** * 实现单个 window 窗口的 setAttribute保护
* @param {[BOM]} window [浏览器window对象] * @return {[type]}
[description] */ function installHook(window) { // 重写单个 window
窗口的 setAttribute 属性 resetSetAttribute(window); // MutationObserver
的不同兼容性写法 var MutationObserver = window.MutationObserver ||
window.WebKitMutationObserver || window.MozMutationObserver; //
该构造函数用来实例化一个新的 Mutation 观察者对象 // Mutation
观察者对象能监听在某个范围内的 DOM 树变化 var observer = new
MutationObserver(function(mutations) {
mutations.forEach(function(mutation) { // 返回被添加的节点,或者为null.
var nodes = mutation.addedNodes; // 逐个遍历 for (var i = 0; i <
nodes.length; i++) { var node = nodes[i]; // 给生成的 iframe
里环境也装上重写的钩子 if (node.tagName == ‘IFRAME’) {
installHook(node.contentWindow); } } }); }); observer.observe(document,
{ subtree: true, childList: true }); } /** * 重写单个 window 窗口的
setAttribute 属性 * @param {[BOM]} window [浏览器window对象] *
@return {[type]} [description] */ function
resetSetAttribute(window) { // 保存原有接口 var old_setAttribute =
window.Element.prototype.setAttribute; // 重写 setAttribute 接口
window.Element.prototype.setAttribute = function(name, value) { … }; }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 使用 MutationObserver 对生成的 iframe 页面进行监控,
* 防止调用内部原生 setAttribute 及 document.write
* @return {[type]} [description]
*/
function defenseIframe() {
  // 先保护当前页面
  installHook(window);
}
/**
* 实现单个 window 窗口的 setAttribute保护
* @param  {[BOM]} window [浏览器window对象]
* @return {[type]}       [description]
*/
function installHook(window) {
  // 重写单个 window 窗口的 setAttribute 属性
  resetSetAttribute(window);
  // MutationObserver 的不同兼容性写法
  var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;
  // 该构造函数用来实例化一个新的 Mutation 观察者对象
  // Mutation 观察者对象能监听在某个范围内的 DOM 树变化
  var observer = new MutationObserver(function(mutations) {
    mutations.forEach(function(mutation) {
      // 返回被添加的节点,或者为null.
      var nodes = mutation.addedNodes;
      // 逐个遍历
      for (var i = 0; i < nodes.length; i++) {
        var node = nodes[i];
        // 给生成的 iframe 里环境也装上重写的钩子
        if (node.tagName == ‘IFRAME’) {
          installHook(node.contentWindow);
        }
      }
    });
  });
  observer.observe(document, {
    subtree: true,
    childList: true
  });
}
/**
* 重写单个 window 窗口的 setAttribute 属性
* @param  {[BOM]} window [浏览器window对象]
* @return {[type]} [description]
*/
function resetSetAttribute(window) {
  // 保存原有接口
  var old_setAttribute = window.Element.prototype.setAttribute;
  // 重写 setAttribute 接口
  window.Element.prototype.setAttribute = function(name, value) {
    …
  };
}

我们定义了一个 installHook 方法,参数是一个 window ,在这个方法里,我们将重写传入的 window 下的
setAttribute
,并且安装一个 MutationObserver ,并对此窗口下未来可能创建的 iframe 进行监听,如果未来在此 window 下创建了一个
iframe ,则对新的 iframe 也装上 installHook 方法,以此进行层层保护。

使用白名单对 src 进行匹配过滤

上面的代码中,我们判断一个js脚本是否是恶意的,用的是这一句:

if (/xss/i.test(node.src)) {}

当然实际当中,注入恶意代码者不会那么傻,把名字改成
XSS
。所以,我们很有必要使用白名单进行过滤和建立一个拦截上报系统。 

// 建立白名单
var whiteList = [
  'www.aaa.com',
  'res.bbb.com'
];

/**
 * [白名单匹配]
 * @param  {[Array]} whileList [白名单]
 * @param  {[String]} value    [需要验证的字符串]
 * @return {[Boolean]}         [false -- 验证不通过,true -- 验证通过]
 */
function whileListMatch(whileList, value) {
  var length = whileList.length,
    i = 0;

  for (; i < length; i++) {
    // 建立白名单正则
    var reg = new RegExp(whiteList[i], 'i');

    // 存在白名单中,放行
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}

// 只放行白名单
if (!whileListMatch(blackList, node.src)) {
  node.parentNode.removeChild(node);
} 

这里我们已经多次提到白名单匹配了,下文还会用到,所以可以这里把它简单封装成一个方法调用。

 

重写 document.write

根据上述的方法,我们可以继续挖掘一下,还有什么方法可以重写,以便对页面进行更好的保护。

document.write 是一个很不错选择,注入攻击者,通常会使用这个方法,往页面上注入一些弹窗广告。

我们可以重写 document.write ,使用关键词黑名单对内容进行匹配。

什么比较适合当黑名单的关键字呢?我们可以看看一些广告很多的页面:

图片 12

这里在页面最底部嵌入了一个 iframe ,里面装了广告代码,这里的最外层的 id
id="BAIDU_SSP__wrapper_u2444091_0" 就很适合成为我们判断是否是恶意代码的一个标志,假设我们已经根据拦截上报收集到了一批黑名单列表:

JavaScript

// 建立正则拦截关键词 var keywordBlackList = [ ‘xss’,
‘BAIDU_SSP__wrapper’, ‘BAIDU_DSPUI_FLOWBAR’ ];

1
2
3
4
5
6
// 建立正则拦截关键词
var keywordBlackList = [
‘xss’,
‘BAIDU_SSP__wrapper’,
‘BAIDU_DSPUI_FLOWBAR’
];

接下来我们只需要利用这些关键字,对 document.write 传入的内容进行正则判断,就能确定是否要拦截document.write 这段代码。

JavaScript

“`javascript // 建立关键词黑名单 var keywordBlackList = [ ‘xss’,
‘BAIDU_SSP__wrapper’, ‘BAIDU_DSPUI_FLOWBAR’ ]; /** * 重写单个
window 窗口的 document.write 属性 * @param {[BOM]} window
[浏览器window对象] * @return {[type]} [description] */ function
resetDocumentWrite(window) { var old_write = window.document.write;
window.document.write = function(string) { if
(blackListMatch(keywordBlackList, string)) {
console.log(‘拦截可疑模块:’, string); return; } // 调用原始接口
old_write.apply(document, arguments); } } /** * [黑名单匹配] *
@param {[Array]} blackList [黑名单] * @param {[String]} value
[需要验证的字符串] * @return {[Boolean]} [false —
验证不通过,true — 验证通过] */ function blackListMatch(blackList,
value) { var length = blackList.length, i = 0; for (; i < length;
i++) { // 建立黑名单正则 var reg = new RegExp(whiteList[i], ‘i’); //
存在黑名单中,拦截 if (reg.test(value)) { return true; } } return false;
}<span style=”font-family: verdana, geneva;”> </span>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
“`javascript
// 建立关键词黑名单
var keywordBlackList = [
  ‘xss’,
  ‘BAIDU_SSP__wrapper’,
  ‘BAIDU_DSPUI_FLOWBAR’
];
/**
* 重写单个 window 窗口的 document.write 属性
* @param  {[BOM]} window [浏览器window对象]
* @return {[type]}       [description]
*/
function resetDocumentWrite(window) {
  var old_write = window.document.write;
  window.document.write = function(string) {
    if (blackListMatch(keywordBlackList, string)) {
      console.log(‘拦截可疑模块:’, string);
      return;
    }
    // 调用原始接口
    old_write.apply(document, arguments);
  }
}
/**
* [黑名单匹配]
* @param  {[Array]} blackList [黑名单]
* @param  {[String]} value    [需要验证的字符串]
* @return {[Boolean]}         [false — 验证不通过,true — 验证通过]
*/
function blackListMatch(blackList, value) {
  var length = blackList.length,
    i = 0;
  for (; i < length; i++) {
    // 建立黑名单正则
    var reg = new RegExp(whiteList[i], ‘i’);
    // 存在黑名单中,拦截
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}<span style="font-family: verdana, geneva;"> </span>

我们可以把 resetDocumentWrite 放入上文的 installHook 方法中,就能对当前
window 及所有生成的 iframe 环境内的 document.write 进行重写了。

动态脚本拦截

上面使用
MutationObserver
拦截静态脚本,除了静态脚本,与之对应的就是动态生成的脚本。

var script = document.createElement('script');
script.type = 'text/javascript';
script.src = 'http://www.example.com/xss/b.js';

document.getElementsByTagName('body')[0].appendChild(script); 

要拦截这类动态生成的脚本,且拦截时机要在它插入
DOM
树中,执行之前,本来是可以监听 Mutation Events 中的 DOMNodeInserted 事件的。

锁死 apply 和 call

接下来要介绍的这个是锁住原生的 Function.prototype.apply 和
Function.prototype.call 方法,锁住的意思就是使之无法被重写。

这里要用到 Object.defineProperty ,用于锁死 apply 和 call。

 

Mutation Events 与 DOMNodeInserted

打开 MDN ,第一句就是:

该特性已经从 Web
标准中删除,虽然一些浏览器目前仍然支持它,但也许会在未来的某个时间停止支持,请尽量不要使用该特性。

虽然不能用,也可以了解一下:

document.addEventListener('DOMNodeInserted', function(e) {
  var node = e.target;
  if (/xss/i.test(node.src) || /xss/i.test(node.innerHTML)) {
    node.parentNode.removeChild(node);
    console.log('拦截可疑动态脚本:', node);
  }
}, true);

然而可惜的是,使用上面的代码拦截动态生成的脚本,可以拦截到,但是代码也执行了:DOMNodeInserted 顾名思义,可以监听某个
DOM
范围内的结构变化,与 MutationObserver 相比,它的执行时机更早。

图片 13

但是 DOMNodeInserted 不再建议使用,所以监听动态脚本的任务也要交给 MutationObserver

可惜的是,在实际实践过程中,使用 MutationObserver 的结果和 DOMNodeInserted 一样,可以监听拦截到动态脚本的生成,但是无法在脚本执行之前,使用 removeChild 将其移除,所以我们还需要想想其他办法。

Object.defineProperty

Object.defineProperty()
方法直接在一个对象上定义一个新属性,或者修改一个已经存在的属性,
并返回这个对象。

JavaScript

Object.defineProperty(obj, prop, descriptor)

1
Object.defineProperty(obj, prop, descriptor)

其中:

  • obj – 需要定义属性的对象
  • prop – 需被定义或修改的属性名
  • descriptor – 需被定义或修改的属性的描述符

我们可以使用如下的代码,让 call 和 apply 无法被重写。

JavaScript

// 锁住 call Object.defineProperty(Function.prototype, ‘call’, { value:
Function.prototype.call, // 当且仅当仅当该属性的 writable 为 true
时,该属性才能被赋值运算符改变 writable: false, // 当且仅当该属性的
configurable 为 true 时,该属性才能够被改变,也能够被删除 configurable:
false, enumerable: true }); // 锁住 apply
Object.defineProperty(Function.prototype, ‘apply’, { value:
Function.prototype.apply, writable: false, configurable: false,
enumerable: true });

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 锁住 call
Object.defineProperty(Function.prototype, ‘call’, {
  value: Function.prototype.call,
  // 当且仅当仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变
  writable: false,
  // 当且仅当该属性的 configurable 为 true 时,该属性才能够被改变,也能够被删除
  configurable: false,
  enumerable: true
});
// 锁住 apply
Object.defineProperty(Function.prototype, ‘apply’, {
  value: Function.prototype.apply,
  writable: false,
  configurable: false,
  enumerable: true
});

为啥要这样写呢?其实还是与上文的 重写 setAttribute 有关。

虽然我们将原始 Element.prototype.setAttribute
保存在了一个闭包当中,但是还有奇技淫巧可以把它从闭包中给“偷出来”。

试一下:

JavaScript

(function() {})( // 保存原有接口 var old_setAttribute =
Element.prototype.setAttribute; // 重写 setAttribute 接口
Element.prototype.setAttribute = function(name, value) { // 具体细节 if
(this.tagName == ‘SCRIPT’ && /^src$/i.test(name)) {} // 调用原始接口
old_setAttribute.apply(this, arguments); }; )(); // 重写 apply
Function.prototype.apply = function(){ console.log(this); } // 调用
setAttribute
document.getElementsByTagName(‘body’)[0].setAttribute(‘data-test’,’123′);

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(function() {})(
    // 保存原有接口
    var old_setAttribute = Element.prototype.setAttribute;
    // 重写 setAttribute 接口
    Element.prototype.setAttribute = function(name, value) {
        // 具体细节
        if (this.tagName == ‘SCRIPT’ && /^src$/i.test(name)) {}
        // 调用原始接口
        old_setAttribute.apply(this, arguments);
    };
)();
// 重写 apply
Function.prototype.apply = function(){
    console.log(this);
}
// 调用 setAttribute
document.getElementsByTagName(‘body’)[0].setAttribute(‘data-test’,’123′);

猜猜上面一段会输出什么?看看:
图片 14

居然返回了原生 setAttribute 方法!

这是因为我们在重写 Element.prototype.setAttribute 时最后有 old_setAttribute.apply(this, arguments);这一句,使用到了
apply 方法,所以我们再重写 apply ,输出 this ,当调用被重写后的
setAttribute
就可以从中反向拿到原生的被保存起来的 old_setAttribute 了。

这样我们上面所做的嵌套 iframe 重写 setAttribute 就毫无意义了。

使用上面的 Object.defineProperty 可以锁死 apply 和 类似用法的 call
。使之无法被重写,那么也就无法从闭包中将我们的原生接口偷出来。这个时候才算真正意义上的成功重写了我们想重写的属性。

 

建立拦截上报

防御的手段也有一些了,接下来我们要建立一个上报系统,替换上文中的
console.log() 日志。

上报系统有什么用呢?因为我们用到了白名单,关键字黑名单,这些数据都需要不断的丰富,靠的就是上报系统,将每次拦截的信息传到服务器,不仅可以让我们程序员第一时间得知攻击的发生,更可以让我们不断收集这类相关信息以便更好的应对。

这里的示例我用 nodejs 搭一个十分简易的服务器接受 http 上报请求。

先定义一个上报函数:

JavaScript

/** * 自定义上报 — 替换页面中的 console.log() * @param {[String]}
name [拦截类型] * @param {[String]} value [拦截值] */ function
hijackReport(name, value) { var img = document.createElement(‘img’),
hijackName = name, hijackValue = value.toString(), curDate = new
Date().getTime(); // 上报 img.src =
” + hijackName + ‘&value=’ +
hijackValue + ‘&time=’ + curDate;

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 自定义上报 — 替换页面中的 console.log()
* @param  {[String]} name  [拦截类型]
* @param  {[String]} value [拦截值]
*/
function hijackReport(name, value) {
  var img = document.createElement(‘img’),
    hijackName = name,
    hijackValue = value.toString(),
    curDate = new Date().getTime();
  // 上报
  img.src = ‘http://www.reportServer.com/report/?msg=’ + hijackName + ‘&value=’ + hijackValue + ‘&time=’ + curDate;

假定我们的服务器地址是 www.reportServer.com 这里,我们运用 img.src 发送一个
http
请求到服务器http://www.reportServer.com/report/ ,每次会带上我们自定义的拦截类型,拦截内容以及上报时间。

用 Express 搭 nodejs 服务器并写一个简单的接收路由:

JavaScript

var express = require(‘express’); var app = express();
app.get(‘/report/’, function(req, res) { var queryMsg = req.query.msg,
queryValue = req.query.value, queryTime = new
Date(parseInt(req.query.time)); if (queryMsg) { console.log(‘拦截类型:’

  • queryMsg); } if (queryValue) { console.log(‘拦截值:’ + queryValue); }
    if (queryTime) { console.log(‘拦截时间:’ + req.query.time); } });
    app.listen(3002, function() { console.log(‘HttpHijack Server listening
    on port 3002!’); });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
var express = require(‘express’);
var app = express();
app.get(‘/report/’, function(req, res) {
    var queryMsg = req.query.msg,
        queryValue = req.query.value,
        queryTime = new Date(parseInt(req.query.time));
    if (queryMsg) {
        console.log(‘拦截类型:’ + queryMsg);
    }
    if (queryValue) {
        console.log(‘拦截值:’ + queryValue);
    }
    if (queryTime) {
        console.log(‘拦截时间:’ + req.query.time);
    }
});
app.listen(3002, function() {
    console.log(‘HttpHijack Server listening on port 3002!’);
});

运行服务器,当有上报发生,我们将会接收到如下数据:

图片 15

好接下来就是数据入库,分析,添加黑名单,使用 nodejs 当然拦截发生时发送邮件通知程序员等等,这些就不再做展开。

重写 setAttribute 与 document.write

HTTPS 与 CSP

最后再简单谈谈 HTTPS 与
CSP。其实防御劫持最好的方法还是从后端入手,前端能做的实在太少。而且由于源码的暴露,攻击者很容易绕过我们的防御手段。

重写原生 Element.prototype.setAttribute 方法

在动态脚本插入执行前,监听
DOM 树的变化拦截它行不通,脚本仍然会执行。

那么我们需要向上寻找,在脚本插入
DOM 树前的捕获它,那就是创建脚本时这个时机。

假设现在有一个动态脚本是这样创建的:

var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.setAttribute('src', 'http://www.example.com/xss/c.js');

document.getElementsByTagName('body')[0].appendChild(script);

而重写 Element.prototype.setAttribute 也是可行的:我们发现这里用到了 setAttribute
方法,如果我们能够改写这个原生方法,监听设置 src 属性时的值,通过黑名单或者白名单判断它,就可以判断该标签的合法性了。

// 保存原有接口
var old_setAttribute = Element.prototype.setAttribute;

// 重写 setAttribute 接口
Element.prototype.setAttribute = function(name, value) {

  // 匹配到 <script src='xxx' > 类型
  if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) {
    // 白名单匹配
    if (!whileListMatch(whiteList, value)) {
      console.log('拦截可疑模块:', value);
      return;
    }
  }

  // 调用原始接口
  old_setAttribute.apply(this, arguments);
};

// 建立白名单
var whiteList = [
'www.yy.com',
'res.cont.yy.com'
];

/**
 * [白名单匹配]
 * @param  {[Array]} whileList [白名单]
 * @param  {[String]} value    [需要验证的字符串]
 * @return {[Boolean]}         [false -- 验证不通过,true -- 验证通过]
 */
function whileListMatch(whileList, value) {
  var length = whileList.length,
    i = 0;

  for (; i < length; i++) {
    // 建立白名单正则
    var reg = new RegExp(whiteList[i], 'i');

    // 存在白名单中,放行
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
}

可以看到如下结果:可以戳我查看DEMO。(打开页面后打开控制台查看
console.log)

图片 16

重写 Element.prototype.setAttribute ,就是首先保存原有接口,然后当有元素调用
setAttribute 时,检查传入的 src
是否存在于白名单中,存在则放行,不存在则视为可疑元素,进行上报并不予以执行。最后对放行的元素执行原生的 setAttribute ,也就是 old_setAttribute.apply(this, arguments);

上述的白名单匹配也可以换成黑名单匹配。

CSP

CSP 即是 Content Security
Policy,翻译为内容安全策略。这个规范与内容安全有关,主要是用来定义页面可以加载哪些资源,减少
XSS 的发生。

MDN
– CSP

 

HTTPS

能够实施 HTTP 劫持的根本原因,是 HTTP
协议没有办法对通信对方的身份进行校验以及对数据完整性进行校验。如果能解决这个问题,则劫持将无法轻易发生。

HTTPS,是 HTTP over SSL 的意思。SSL 协议是 Netscape 在 1995
年首次提出的用于解决传输层安全问题的网络协议,其核心是基于公钥密码学理论实现了对服务器身份认证、数据的私密性保护以及对数据完整性的校验等功能。

因为与本文主要内容关联性不大,关于更多 CSP 和 HTTPS 的内容可以自行谷歌。

 

本文到此结束,我也是涉猎前端安全这个方面不久,文章必然有所纰漏及错误,文章的方法也是众多防御方法中的一小部分,许多内容参考下面文章,都是精品文章,非常值得一读:

  • 《web前端黑客技术揭秘》
  • XSS
    前端防火墙系列1~3
  • 【HTTP劫持和DNS劫持】实际JS对抗
  • 浅谈DNS劫持
  • HTTP Request
    Hijacking

 

使用 Javascript 写的一个防劫持组件,已上传到 Github
– httphijack.js,欢迎感兴趣看看顺手点个
star ,本文示例代码,防范方法在组件源码中皆可找到。

另外组件处于测试修改阶段,未在生产环境使用,而且使用了很多 HTML5
才支持的 API,兼容性是个问题,仅供学习交流。

到此本文结束,如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

打赏支持我写出更多好文章,谢谢!

打赏作者

重写嵌套 iframe 内的 Element.prototype.setAttribute

当然,上面的写法如果 old_setAttribute = Element.prototype.setAttribute 暴露给攻击者的话,直接使用old_setAttribute 就可以绕过我们重写的方法了,所以这段代码必须包在一个闭包内。

当然这样也不保险,虽然当前窗口下的 Element.prototype.setAttribute 已经被重写了。但是还是有手段可以拿到原生的 Element.prototype.setAttribute ,只需要一个新的
iframe 。

var newIframe = document.createElement('iframe');
document.body.appendChild(newIframe);

Element.prototype.setAttribute = newIframe.contentWindow.Element.prototype.setAttribute;

通过这个方法,可以重新拿到原生的 Element.prototype.setAttribute ,因为
iframe 内的环境和外层 window 是完全隔离的。wtf?

图片 17

怎么办?我们看到创建
iframe
用到了 createElement,那么是否可以重写原生 createElement 呢?但是除了createElement 还有 createElementNS ,还有可能是页面上已经存在
iframe,所以不合适。

那就在每当新创建一个新
iframe
时,对 setAttribute 进行保护重写,这里又有用到 MutationObserver :

/**
 * 使用 MutationObserver 对生成的 iframe 页面进行监控,
 * 防止调用内部原生 setAttribute 及 document.write
 * @return {[type]} [description]
 */
function defenseIframe() {
  // 先保护当前页面
  installHook(window);
}

/**
 * 实现单个 window 窗口的 setAttribute保护
 * @param  {[BOM]} window [浏览器window对象]
 * @return {[type]}       [description]
 */
function installHook(window) {
  // 重写单个 window 窗口的 setAttribute 属性
  resetSetAttribute(window);

  // MutationObserver 的不同兼容性写法
  var MutationObserver = window.MutationObserver || window.WebKitMutationObserver || window.MozMutationObserver;

  // 该构造函数用来实例化一个新的 Mutation 观察者对象
  // Mutation 观察者对象能监听在某个范围内的 DOM 树变化
  var observer = new MutationObserver(function(mutations) {
    mutations.forEach(function(mutation) {
      // 返回被添加的节点,或者为null.
      var nodes = mutation.addedNodes;

      // 逐个遍历
      for (var i = 0; i < nodes.length; i++) {
        var node = nodes[i];

        // 给生成的 iframe 里环境也装上重写的钩子
        if (node.tagName == 'IFRAME') {
          installHook(node.contentWindow);
        }
      }
    });
  });

  observer.observe(document, {
    subtree: true,
    childList: true
  });
}

/**
 * 重写单个 window 窗口的 setAttribute 属性
 * @param  {[BOM]} window [浏览器window对象]
 * @return {[type]} [description]
 */
function resetSetAttribute(window) {
  // 保存原有接口
  var old_setAttribute = window.Element.prototype.setAttribute;

  // 重写 setAttribute 接口
  window.Element.prototype.setAttribute = function(name, value) {
    ...
  };
} 

我们定义了一个 installHook 方法,参数是一个 window ,在这个方法里,我们将重写传入的 window 下的
setAttribute
,并且安装一个 MutationObserver ,并对此窗口下未来可能创建的 iframe 进行监听,如果未来在此 window 下创建了一个
iframe
,则对新的 iframe 也装上 installHook 方法,以此进行层层保护。

打赏支持我写出更多好文章,谢谢!

任选一种支付方式

图片 18
图片 19

2 赞 10 收藏 1
评论

 

关于作者:chokcoco

图片 20

经不住流年似水,逃不过此间少年。

个人主页 ·
我的文章 ·
63 ·
   

图片 3

重写 document.write

根据上述的方法,我们可以继续挖掘一下,还有什么方法可以重写,以便对页面进行更好的保护。

document.write 是一个很不错选择,注入攻击者,通常会使用这个方法,往页面上注入一些弹窗广告。

我们可以重写 document.write ,使用关键词黑名单对内容进行匹配。

什么比较适合当黑名单的关键字呢?我们可以看看一些广告很多的页面:

图片 22

这里在页面最底部嵌入了一个
iframe ,里面装了广告代码,这里的最外层的 id
id="BAIDU_SSP__wrapper_u2444091_0" 就很适合成为我们判断是否是恶意代码的一个标志,假设我们已经根据拦截上报收集到了一批黑名单列表:

// 建立正则拦截关键词
var keywordBlackList = [
'xss',
'BAIDU_SSP__wrapper',
'BAIDU_DSPUI_FLOWBAR'
];

接下来我们只需要利用这些关键字,对 document.write 传入的内容进行正则判断,就能确定是否要拦截document.write 这段代码。 

```javascript
// 建立关键词黑名单
var keywordBlackList = [
  'xss',
  'BAIDU_SSP__wrapper',
  'BAIDU_DSPUI_FLOWBAR'
];

/**
 * 重写单个 window 窗口的 document.write 属性
 * @param  {[BOM]} window [浏览器window对象]
 * @return {[type]}       [description]
 */
function resetDocumentWrite(window) {
  var old_write = window.document.write;

  window.document.write = function(string) {
    if (blackListMatch(keywordBlackList, string)) {
      console.log('拦截可疑模块:', string);
      return;
    }

    // 调用原始接口
    old_write.apply(document, arguments);
  }
}

/**
 * [黑名单匹配]
 * @param  {[Array]} blackList [黑名单]
 * @param  {[String]} value    [需要验证的字符串]
 * @return {[Boolean]}         [false -- 验证不通过,true -- 验证通过]
 */
function blackListMatch(blackList, value) {
  var length = blackList.length,
    i = 0;

  for (; i < length; i++) {
    // 建立黑名单正则
    var reg = new RegExp(whiteList[i], 'i');

    // 存在黑名单中,拦截
    if (reg.test(value)) {
      return true;
    }
  }
  return false;
} 

我们可以把 resetDocumentWrite 放入上文的 installHook 方法中,就能对当前
window 及所有生成的 iframe 环境内的 document.write 进行重写了。

 

锁死 apply 和 call

接下来要介绍的这个是锁住原生的
Function.prototype.apply 和 Function.prototype.call
方法,锁住的意思就是使之无法被重写。

这里要用到 Object.defineProperty ,用于锁死
apply 和 call。

 

Object.defineProperty

Object.defineProperty()
方法直接在一个对象上定义一个新属性,或者修改一个已经存在的属性,
并返回这个对象。

Object.defineProperty(obj, prop, descriptor)

其中: 

  • obj –
    需要定义属性的对象
  • prop –
    需被定义或修改的属性名
  • descriptor –
    需被定义或修改的属性的描述符

我们可以使用如下的代码,让
call 和 apply 无法被重写。

// 锁住 call
Object.defineProperty(Function.prototype, 'call', {
  value: Function.prototype.call,
  // 当且仅当仅当该属性的 writable 为 true 时,该属性才能被赋值运算符改变
  writable: false,
  // 当且仅当该属性的 configurable 为 true 时,该属性才能够被改变,也能够被删除 
  configurable: false,
  enumerable: true
});
// 锁住 apply
Object.defineProperty(Function.prototype, 'apply', {
  value: Function.prototype.apply,
  writable: false,
  configurable: false,
  enumerable: true
}); 

为啥要这样写呢?其实还是与上文的 重写 setAttribute 有关。

虽然我们将原始
Element.prototype.setAttribute
保存在了一个闭包当中,但是还有奇技淫巧可以把它从闭包中给“偷出来”。

试一下:

(function() {})(
    // 保存原有接口
    var old_setAttribute = Element.prototype.setAttribute;
    // 重写 setAttribute 接口
    Element.prototype.setAttribute = function(name, value) {
        // 具体细节
        if (this.tagName == 'SCRIPT' && /^src$/i.test(name)) {}
        // 调用原始接口
        old_setAttribute.apply(this, arguments);
    };
)();
// 重写 apply
Function.prototype.apply = function(){
    console.log(this);
}
// 调用 setAttribute
document.getElementsByTagName('body')[0].setAttribute('data-test','123'); 

猜猜上面一段会输出什么?看看:
图片 23

居然返回了原生
setAttribute 方法!

这是因为我们在重写 Element.prototype.setAttribute 时最后有 old_setAttribute.apply(this, arguments);这一句,使用到了
apply 方法,所以我们再重写 apply ,输出 this ,当调用被重写后的
setAttribute
就可以从中反向拿到原生的被保存起来的 old_setAttribute 了。

这样我们上面所做的嵌套
iframe 重写 setAttribute 就毫无意义了。

使用上面的 Object.defineProperty 可以锁死
apply 和 类似用法的 call
。使之无法被重写,那么也就无法从闭包中将我们的原生接口偷出来。这个时候才算真正意义上的成功重写了我们想重写的属性。

 

建立拦截上报

防御的手段也有一些了,接下来我们要建立一个上报系统,替换上文中的
console.log() 日志。

上报系统有什么用呢?因为我们用到了白名单,关键字黑名单,这些数据都需要不断的丰富,靠的就是上报系统,将每次拦截的信息传到服务器,不仅可以让我们程序员第一时间得知攻击的发生,更可以让我们不断收集这类相关信息以便更好的应对。

这里的示例我用 nodejs 搭一个十分简易的服务器接受
http 上报请求。

先定义一个上报函数:

/**
 * 自定义上报 -- 替换页面中的 console.log()
 * @param  {[String]} name  [拦截类型]
 * @param  {[String]} value [拦截值]
 */
function hijackReport(name, value) {
  var img = document.createElement('img'),
    hijackName = name,
    hijackValue = value.toString(),
    curDate = new Date().getTime();

  // 上报
  img.src = 'http://www.reportServer.com/report/?msg=' + hijackName + '&value=' + hijackValue + '&time=' + curDate;
}

假定我们的服务器地址是 www.reportServer.com 这里,我们运用 img.src 发送一个
http
请求到服务器http://www.reportServer.com/report/ ,每次会带上我们自定义的拦截类型,拦截内容以及上报时间。

用 Express
搭 nodejs 服务器并写一个简单的接收路由:

var express = require('express');
var app = express();

app.get('/report/', function(req, res) {
    var queryMsg = req.query.msg,
        queryValue = req.query.value,
        queryTime = new Date(parseInt(req.query.time));

    if (queryMsg) {
        console.log('拦截类型:' + queryMsg);
    }

    if (queryValue) {
        console.log('拦截值:' + queryValue);
    }

    if (queryTime) {
        console.log('拦截时间:' + req.query.time);
    }
});

app.listen(3002, function() {
    console.log('HttpHijack Server listening on port 3002!');
});

运行服务器,当有上报发生,我们将会接收到如下数据:

图片 24

好接下来就是数据入库,分析,添加黑名单,使用 nodejs 当然拦截发生时发送邮件通知程序员等等,这些就不再做展开。

 

HTTPS 与 CSP

最后再简单谈谈
HTTPS 与
CSP。其实防御劫持最好的方法还是从后端入手,前端能做的实在太少。而且由于源码的暴露,攻击者很容易绕过我们的防御手段。

CSP

CSP 即是
Content Security
Policy,翻译为内容安全策略。这个规范与内容安全有关,主要是用来定义页面可以加载哪些资源,减少
XSS 的发生。

MDN
– CSP

HTTPS

能够实施
HTTP 劫持的根本原因,是 HTTP
协议没有办法对通信对方的身份进行校验以及对数据完整性进行校验。如果能解决这个问题,则劫持将无法轻易发生。

HTTPS,是
HTTP over SSL 的意思。SSL 协议是 Netscape 在 1995
年首次提出的用于解决传输层安全问题的网络协议,其核心是基于公钥密码学理论实现了对服务器身份认证、数据的私密性保护以及对数据完整性的校验等功能。

因为与本文主要内容关联性不大,关于更多
CSP 和 HTTPS 的内容可以自行谷歌。

 

本文到此结束,我也是涉猎前端安全这个方面不久,文章必然有所纰漏及错误,文章的方法也是众多防御方法中的一小部分,许多内容参考下面文章,都是精品文章,非常值得一读:

  • 《web前端黑客技术揭秘》
  • XSS
    前端防火墙系列1~3
  • 【HTTP劫持和DNS劫持】实际JS对抗
  • 浅谈DNS劫持
  • HTTP
    Request
    Hijacking

 

使用
Javascript 写的一个防劫持组件,已上传到 Github
– httphijack.js,欢迎感兴趣看看顺手点个
star ,本文示例代码,防范方法在组件源码中皆可找到。

另外组件处于测试修改阶段,未在生产环境使用,而且使用了很多
HTML5 才支持的 API,兼容性是个问题,仅供学习交流。

到此本文结束,如果还有什么疑问或者建议,可以多多交流,原创文章,文笔有限,才疏学浅,文中若有不正之处,万望告知。

发表评论