XSS演示与防范

557 阅读10分钟

xss-test

测试代码

为了代码简洁,直接使用 CDN 的方式引入Vue3, 假设输入框里输入的是后端传过来的数据,这个数据就是要使用 v-html 渲染的模板字符串

<!DOCTYPE html> 
<head>
    <title>xss测试</title>
</head>
<body>
    <div id="app">
        <p v-html="inputStr"></p> 
        <input type="text" v-model="inputStr">
    </div>
    <script src="https://unpkg.com/vue@next"></script> 
    <script> 
        const { createApp } = Vue; 
        createApp({ data() { return { inputStr: "" }; }, }).mount('#app'); 
    </script>
</body> 
</html> 

第一个测试

我们先测试一个正常(无xss攻击)的例子

<div>Hello World</div>

效果: image.png

结论:测试代码按照预期正常显示节点

第二个测试

我们再测试一个有带有 script 脚本标签的例子,看他是否会执行该脚本。

<script>alert(1)</script>

效果: image.png

结论:节点上存在 script 标签,但是脚本没有被执行。

思考:既然脚本没有被执行,那是不是就能说明 v-html 自动转义的功劳或者是浏览器可以对 XSS 进行有效防御呢? 我们不妨在多测一些其他更高级的 XSS 攻击。

第三个测试

利用 a 标签的 onclick 属性嵌入 XSS。

<a href="#" onclick="alert(1)">点击我</a>

效果:

image.png

点击之后:

image.png

结论:这里形成了一个a链接,当我们点击的时候触发了alert,这足以说明 v-html 并不能过滤掉 XSS 攻击;我们再测试一种。

第四个测试

利用 img 标签的 onerror 属性嵌入 XSS。

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

效果:

image.png

结论:这里直接触发 alert ,再次说明,如果后端传过来的模板字符串不进行 XSS 过滤,将会存在被恶意脚本攻击的隐患。

思考:那为什么第一种不行呢?

查阅后发现,这个不是vue的限制,而是html5中的规定。 html5 中为了安全起见,不会执行 innerHTML 中插入的 <script> 的代码。

所以,如果想给 v-html 赋值 <script> 的代码,虽然能看到在 dom 中成功展示,但却不会执行 <script> 中的 js 代码的。

那万一我确实有这种需求那该怎么办呢? 虽然这种需求少之又少。

在不使用任何库的情况下有一个方法:

// 大概是先写段代码,创建script标签,然后append到指定标签即可。

// html
<h2 ref="testxss"></h2>
// js 
const script = document.createElement('script') 
script.innerHTML = document.querySelect('input').value 
this.$refs.testxss.append(script)

什么是 XSS

到这里,相信大家不了解 XSS 的都大概知道了什么是 XSS 。下面我在简简简单的介绍一下 XSS 。

XSS 全称是 Cross Site Scripting(跨站脚本), 为了与 “CSS” 区分开,故简称 XSS 。XSS 攻击是页面被注入了恶意的代码,从而让用户浏览页面时利用恶意脚本对用户实施攻击的一种手段。

XSS 不是仅仅是我这次演示的这些这么简单。如今脚本语言几乎无所不能。

  • 如果脚本执行的不只是 alert(1);
  • 而是通过“document.cookie”盗取 Cookie ,然后通过 XMLHttpRequest 或者 Fetch 加上 CORS 功能将数据发送给恶意服务器,恶意服务器拿到用户的 Cookie 信息之后就可以模拟用户登录,然后进行其他操作了;
  • 而是使用 “addEventListener" 来监听键盘事件,比如可以获取用户输入的个人信息,将其发送到恶意服务器,黑客掌握这些信息之后,又可以进行其他操作了;
  • 而是通过修改 DOM 伪造假的登录窗口,用来欺骗用户输入用户名和密码等个人信息;
  • 还可以再页面内生成浮窗广告,影响用户体验。
  • .........

只有更清楚的认识到 XSS 的危害,才会有意识地去防范它! --- wzk

XSS 的种类

根据攻击的来源,XSS 攻击可分为存储型、反射型和 DOM 型三种。

存储型 XSS 的攻击步骤:

  1. 攻击者将恶意代码提交到目标网站的数据库中。
  2. 用户打开目标网站时,网站服务端将恶意代码从数据库取出,拼接在 HTML 中返回给浏览器。
  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。

这种攻击常见于带有用户保存数据的网站功能,如论坛发帖、商品评论、用户私信等。

反射型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码。
  2. 用户打开带有恶意代码的 URL 时,网站服务端将恶意代码从 URL 中取出,拼接在 HTML 中返回给浏览器。
  3. 用户浏览器接收到响应后解析执行,混在其中的恶意代码也被执行。
  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。

这种攻击常见攻击者通过 qq 群发 URL 链接诱导用户点击链接。

DOM 型 XSS 的攻击步骤:

  1. 攻击者构造出特殊的 URL,其中包含恶意代码。
  2. 用户打开带有恶意代码的 URL。
  3. 用户浏览器接收到响应后解析执行,前端 JavaScript 取出 URL 中的恶意代码并执行。
  4. 恶意代码窃取用户数据并发送到攻击者的网站,或者冒充用户的行为,调用目标网站接口执行攻击者指定的操作。

基于 DOM 的 XSS 攻击是不牵涉到服务器的。攻击者通过各种手段将恶意脚本注入到用户页面中,比如通过网络劫持( Wifi 路由器劫持、本地恶意软件)在页面传输过程中修改 HTML 页面的内容(例如广告)。

备注:https 只能增加攻击难度,不能防止所有场景的基于 DOM 的 XSS 攻击

XSS 攻击的预防

XSS 攻击有两大要素:

  1. 攻击者提交恶意代码。
  2. 浏览器执行恶意代码。

XSS 最终都要通过浏览器执行,所以前端工程师是最后的把关者,预防这两种漏洞,有两种常见做法:

  • 改成纯前端渲染,把代码和数据分隔开。
  • 对 HTML 做充分转义。

对于充分的转义:

错误做法:

所有要插入到页面上的数据,都要通过一个敏感字符过滤函数的转义,过滤掉通用的敏感字符后,就可以插入到页面中。

正确做法:

不同的上下文,如 HTML 属性、HTML 文字内容、HTML 注释、跳转链接、内联 JavaScript 字符串、内联 CSS 样式表等,所需要的转义规则不一致,针对不同的上下文调用不同的转义规则。

js-xss

js-xss是一个用于对用户输入的内容进行过滤,以避免遭受 XSS 攻击的模块。主要用于论坛、博客、网上商店等等一些可允许用户录入页面排版、格式控制相关的 HTML 的场景。

特性:

  • 可配置白名单控制允许的 HTML 标签及各标签的属性;
  • 通过自定义处理函数,可对任意标签及其属性进行处理;

由于未知原因使用在线引入的方法失效,所以进入官网直接下载其源码。当然 node 环境可以直接通过 npm 下载使用。

源码地址:github.com/leizongmin/…

image.png

基础用法

测试一:

// 直接使用filterXSS函数
filterXSS(this.inputStr)

输入框输入: <a onclick="alert('攻击你')">连接</a>

效果: image.png

结论:链接无法点击、a 节点的 onclick 直接干掉。

测试二( filterXSS() 方法可以自定义配置过滤规则 ):

// 设置白名单,只允许a标签,该标签只允许href, title, target这三个属性 
var options = { whiteList: { a: ["href", "title", "target"] } }; 
// 第二个形参填入自定义规则 
filterXSS(this.inputStr, options); 

输入框输入 <a href="#" onclick="alert('攻击你')"><i>大家好</i></a>

将被过滤为 : <a href="#">大家好</a>

更多使用方法请看官网:jsxss.com/zh/starter/…

js-xss 做了什么事

getDefaultWhiteList()

首先打开上面的源码地址我们首先看到 getDefaultWhiteList() 方法:

function getDefaultWhiteList() {

  return {

    a: ["target", "href", "title"],

    abbr: ["title"],

    address: [],

 	···

    ···

    ···

    tt: [],

    u: [],

    ul: [],

    video: ["autoplay", "controls", "loop", "preload", "src", "height", "width"]

  };

}

getDefaultWhiteList()方法return出默认的所有标签名,如果用户没有自定义options参数与配置,那xss()将默认处理所有的标签属性;

接下来的方法:


// 以下为函数方法的作用,FN:后面为函数方法名称

FN: onTag()                          // 自定义匹配到标签时的处理方法,默认不做处理;

FN: onIgnoreTag()                    // 自定义匹配到不在白名单上的标签时的处理方法,默认不做处理;

FN: onTagAttr()                      // 自定义匹配到标签的属性时的处理方法,默认不做处理;

FN: onIgnoreTagAttr()                // 自定义匹配到不在白名单上的标签时的处理方法,默认不做处理;

FN: escapeHtml()                     // 把所有‘< >’ 处理为 “&lt; "&gt;”

FN: safeAttrValue()	   				 // 处理 href、src、style、url等属性,如不规范则返回空

核心的正则表达式

接下来就是js-xss最核心的正则部分了,xss()过滤规则主要是靠下面13个正则表达式匹配之后进行处理。


// 匹配 尖括号

var REGEXP_LT = /</g;

var REGEXP_GT = />/g;

// 匹配 双引号

var REGEXP_QUOTE = /"/g;

var REGEXP_QUOTE_2 = /&quot;/g;



// 匹配 大小写&#数字 全局换行忽略大小写搜索

var REGEXP_ATTR_VALUE_1 = /&#([a-zA-Z0-9]*);?/gim;



// 匹配 &colon; &newline; 

var REGEXP_ATTR_VALUE_COLON = /&colon;?/gim;

var REGEXP_ATTR_VALUE_NEWLINE = /&newline;?/gim;



// 匹配 ‘/*’、‘*\’ 全局换行搜索

var REGEXP_DEFAULT_ON_TAG_ATTR_3 = /\/\*|\*\//gm;



// 匹配javascript和vscript和livescript

var REGEXP_DEFAULT_ON_TAG_ATTR_4 = /((j\s*a\s*v\s*a|v\s*b|l\s*i\s*v\s*e)\s*s\s*c\s*r\s*i\s*p\s*t\s*|m\s*o\s*c\s*h\s*a)\:/gi;



// 匹配 data

var REGEXP_DEFAULT_ON_TAG_ATTR_5 = /^[\s"'`]*(d\s*a\s*t\s*a\s*)\:/gi;



//  匹配 "'` data  imge 

var REGEXP_DEFAULT_ON_TAG_ATTR_6 = /^[\s"'`]*(d\s*a\s*t\s*a\s*)\:\s*image\//gi;



// 匹配 expression( 

var REGEXP_DEFAULT_ON_TAG_ATTR_7 = /e\s*x\s*p\s*r\s*e\s*s\s*s\s*i\s*o\s*n\s*\(.*/gi;



// 匹配 url( 

var REGEXP_DEFAULT_ON_TAG_ATTR_8 = /u\s*r\s*l\s*\(.*/gi;

紧接着通过exports.将所有方法暴露至全局:


exports.whiteList = getDefaultWhiteList();

exports.getDefaultWhiteList = getDefaultWhiteList;

exports.onTag = onTag

···

···

···

exports.cssFilter = defaultCSSFilter;

exports.getDefaultCSSWhiteList = getDefaultCSSWhiteList;

FilterXSS()方法


function FilterXSS(options) {

  options = shallowCopyObject(options || {});



   // 判断用户是否传入配置如未传入则使用默认配置

  if (options.stripIgnoreTag) {

    if (options.onIgnoreTag) {

      console.error(

        'Notes: cannot use these two options "stripIgnoreTag" and "onIgnoreTag" at the same time'

      );

    }

    options.onIgnoreTag = DEFAULT.onIgnoreTagStripAll;

  }

  options.whiteList = options.whiteList || DEFAULT.whiteList;

  options.onTag = options.onTag || DEFAULT.onTag;

  options.onTagAttr = options.onTagAttr || DEFAULT.onTagAttr;

  options.onIgnoreTag = options.onIgnoreTag || DEFAULT.onIgnoreTag;

  options.onIgnoreTagAttr = options.onIgnoreTagAttr || DEFAULT.onIgnoreTagAttr;

  options.safeAttrValue = options.safeAttrValue || DEFAULT.safeAttrValue;

  options.escapeHtml = options.escapeHtml || DEFAULT.escapeHtml;

  this.options = options;



  if (options.css === false) {

    this.cssFilter = false;

  } else {

    options.css = options.css || {};

    this.cssFilter = new FilterCSS(options.css);

  }

}



/**

 * 启动进程,在FilterXSS.prototype注入方法

 *

 * @param {String} html

 * @return {String}

 */

FilterXSS.prototype.process = function(html) {

  // 兼容html内容

  html = html || "";

  html = html.toString();

  if (!html) return "";

  ···

  ···

  ···

  // 移除不可见字符

  if (options.stripBlankChar) {

    html = DEFAULT.stripBlankChar(html);

  }

  // 移除html注释

  if (!options.allowCommentTag) {

    html = DEFAULT.stripCommentTag(html);

  }



  // 是否过滤掉不在白名单中的标签

  var stripIgnoreTagBody = false;

  if (options.stripIgnoreTagBody) {

    var stripIgnoreTagBody = DEFAULT.StripTagBody(

      options.stripIgnoreTagBody,

      onIgnoreTag

    );

    onIgnoreTag = stripIgnoreTagBody.onIgnoreTag;

  }



  // 处理html内容

  var retHtml = parseTag(

    html,

    function(sourcePosition, position, tag, html, isClosing) {

     	···

     	···

     	···

        var attrs = getAttrs(html); // 获取去除标签名后的内容

        var whiteAttrList = whiteList[tag];

        // 解析输入属性并返回已处理的属性

        var attrsHtml = parseAttr(attrs.html, function(name, value) {

		  ···

          ···

          ···

        });



        // 把处理过的标签+属性重新组合起来创建新的html标签

        var html = "<" + tag;

        if (attrsHtml) html += " " + attrsHtml;

        if (attrs.closing) html += " /";

        html += ">";

        return html;

      } else {

        // call `onIgnoreTag()`

        var ret = onIgnoreTag(tag, html, info);

        if (!isNull(ret)) return ret;

        return escapeHtml(html);

      }

    },

    escapeHtml

  );



  // if enable stripIgnoreTagBody

  if (stripIgnoreTagBody) {

    retHtml = stripIgnoreTagBody.remove(retHtml);

  }



  return retHtml;

};

CSS过滤器


function FilterCSS (options) {

  // 判断用户是否传入配置如未传入则使用默认配置

  options = shallowCopyObject(options || {});

  options.whiteList = options.whiteList || DEFAULT.whiteList;

  options.onAttr = options.onAttr || DEFAULT.onAttr;

  options.onIgnoreAttr = options.onIgnoreAttr || DEFAULT.onIgnoreAttr;

  options.safeAttrValue = options.safeAttrValue || DEFAULT.safeAttrValue;

  this.options = options;

}

// FilterCSS.prototype注入方法

FilterCSS.prototype.process = function (css) {

  // 兼容各种奇葩输入

  css = css || '';

  css = css.toString();

  if (!css) return '';

  ···

  ···

  ···

  // 解析style并处理style样式

  var retCSS = parseStyle(css, function (sourcePosition, position, name, value, source) {



    var check = whiteList[name];

    var isWhite = false;

    if (check === true) isWhite = check;

    else if (typeof check === 'function') isWhite = check(value);

    else if (check instanceof RegExp) isWhite = check.test(value);

    if (isWhite !== true) isWhite = false;



    // 如果过滤后 value 为空则直接忽略

    value = safeAttrValue(name, value);

    if (!value) return;

    ···

    ···

    ···

  });

  return retCSS;

};