使用jQuery绕过DOMPurify

Auther: \u2400@Syclover

前言

由于jQuery的 jQuery.fn.html() 函数 和 document.innerHTML() 函数对于html渲染的差异, 从而导致
DOMPurify库在默认配置下使用jQuery库可能导致XSS (官方文档已经指出了这一点), 在这里通过jQuery库的源码来分析为何 jQuery.fn.html() 函数用于html渲染时可能与别的方式产生差异.
jQuery源码较为复杂, 阅读起来部分代码的含义仍然不能完全理解,限于水平, 文章中错漏片面之处在所难免, 望各位师傅们批评指正.

jQuery.fn.html()函数源码分析

分析html函数自然先从html函数的源码入手, 里面是access函数的调用, 代码如下
jQuery.fn.html:

html: function( value ) {
        return access( this, function( value ) {
            var elem = this[ 0 ] || {},
      //... 与分析无关的代码, 例如不会进入的if, 不会使用的变量等, 在这里使用这种方式代替
            if ( elem ) {
        //这里先通过empty清空原先的element, 再通过append添加新的html
                this.empty().append( value ); 
            }
        }, null, value, arguments.length );
    }

如果使用静态调试, 会首先进入 access() 函数, 但是里面的内容与我们无关, 所以直接阅读回掉函数的代码, function传入的参数 value 就是我们在 $("xxx").html(<value>) 在这里输入的value
这里首先会进入 this.empty() 函数跟入查看函数的逻辑

JQuery.fn.empty

empty: function() {
        var elem,
            i = 0;

        for ( ; ( elem = this[ i ] ) != null; i++ ) {
            if ( elem.nodeType === 1 ) {

                // Prevent memory leaks
                jQuery.cleanData( getAll( elem, false ) );

                // Remove any remaining nodes
                elem.textContent = "";
            }
        }
        return this;
    }

函数逻辑并不复杂, 大体的意思是删除节点中所有的子节点, 和所关联的事件接听器, 由于和主题关联不大所以函数不再具体跟入, 关键在于15行 return this 这里返回了当前函数的上下文, 这里的上下文就是 $("xxx").html(<value>) 中的这个 xxx 也就是说 append() 接受了当前的上下文继续执行, 那么继续跟入 append() 函数

jQuery.fn.append

append: function() {
        return domManip( this, arguments, function( elem ) {
            if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) {
                var target = manipulationTarget( this, elem );
                target.appendChild( elem );
            }
        } );
    },


append函数较为简短, 这里占据了大量篇幅的这个回调函数的关键代码只有第5行的添加DOM对象, 这里就是将我们输入的字符串写入DOM树中的函数, 这个回调由 domManip() 函数调用, 查看一下这个函数的内容.

jQuery - domManip

function domManip( collection, args, callback, ignored ) {

    // Flatten any nested arrays
    args = concat.apply( [], args );

    var fragment, first, scripts, hasScripts, node, doc,
        i = 0,
        l = collection.length,
        iNoClone = l - 1,
        value = args[ 0 ],
        valueIsFunction = isFunction( value );

    //...

    if ( l ) {
        fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored );
        first = fragment.firstChild;

        if ( fragment.childNodes.length === 1 ) {
            fragment = first;
        }

        // Require either new content or an interest in ignored elements to invoke the callback
        if ( first || ignored ) {
            scripts = jQuery.map( getAll( fragment, "script" ), disableScript );
            hasScripts = scripts.length;

            // Use the original fragment for the last item
            // instead of the first because it can end up
            // being emptied incorrectly in certain situations (#8070).
            for ( ; i < l; i++ ) {
                node = fragment;

                if ( i !== iNoClone ) {
                    node = jQuery.clone( node, true, true );

                    // Keep references to cloned scripts for later restoration
                    if ( hasScripts ) {

                        // Support: Android <=4.0 only, PhantomJS 1 only
                        // push.apply(_, arraylike) throws on ancient WebKit
                        jQuery.merge( scripts, getAll( node, "script" ) );
                    }
                }
                callback.call( collection[ i ], node, i );
            }
      //...
        }
    }

    return collection;
}


第16行又调用了 buildFragment() 函数, 继续跟入.


jQuery - buildFragment

function buildFragment( elems, context, scripts, selection, ignored ) {
    var elem, tmp, tag, wrap, contains, j,
        fragment = context.createDocumentFragment(),
        nodes = [],
        i = 0,
        l = elems.length;

    for ( ; i < l; i++ ) {
        elem = elems[ i ];

        if ( elem || elem === 0 ) {

            //...
      tmp = tmp || fragment.appendChild( context.createElement( "div" ) );

      // Deserialize a standard representation
      tag = ( rtagName.exec( elem ) || [ "", "" ] )[ 1 ].toLowerCase();
      wrap = wrapMap[ tag ] || wrapMap._default;
      tmp.innerHTML = wrap[ 1 ] + jQuery.htmlPrefilter( elem ) + wrap[ 2 ];

      // Descend through wrappers to the right content
      j = wrap[ 0 ];
      while ( j-- ) {
        tmp = tmp.lastChild;
      }

      // Support: Android <=4.0 only, PhantomJS 1 only
      // push.apply(_, arraylike) throws on ancient WebKit
      jQuery.merge( nodes, tmp.childNodes );

      // Remember the top-level container
      tmp = fragment.firstChild;

      // Ensure the created nodes are orphaned (#12392)
      tmp.textContent = "";
        }
    }

    // Remove wrapper from fragment
    fragment.textContent = "";

    i = 0;
    while ( ( elem = nodes[ i++ ] ) ) {

        // Skip elements already in the context collection (trac-4087)
        if ( selection && jQuery.inArray( elem, selection ) > -1 ) {
            if ( ignored ) {
                ignored.push( elem );
            }
            continue;
        }
    //...

    tmp = getAll( fragment.appendChild( elem ), "script" );
        //... 这里貌似是对包含了script标签的html做了特殊处理
    }
    return fragment;
}

过了这么多行代码, 终于到了这个关键的函数, 这里需要注意这么几个地方:

  1. 第17-19行的匹配它会匹配最外层的标签根据标签是否满足要求, 在最外层添加标签进行包围, 见下图,(前为标签名称)
  2. 第19行, 使用刚才的标签包围后使用 .innerHTML() 将html写入一个临时的dom对象中
  3. 第23-29行, 使用 lastChild 从2中写入的那个临时dom对象中取出它的子节点, 这也是我感觉最匪夷所思的地方, jQuery把DOM写入了一个临时对象, 又为了去掉最外面自己添加的标签, 使用了 lastChild 属性和 childNodes 这两个属性, 也因此导致了jQuery与其他的方式写入html的解析导致差异.
  4. 54行这里将node中的数据写入了 fragment 并获取渲染好的dom中所有的script标签(获取标签是为了后面对script标签做特殊处理, 这里的处理步骤我没太看懂)

最终在第57行返回了渲染的结果, 在后面jQuery会将这个节点添加到上下文环境当中.

利用jQuery解析差异绕过DOMPurify

这里通过利用jQuery会自动在外面没有select的option标签外添加select来产生解析的差异, 通过尝试在控制台中运行下面两段代码并略微思考便可以明白绕过的原理.


A:

document.getElementsByTagName("body")[0].innerHTML = "<option><style></option></select><abc><img src=xx onerror=alert(1)></style></option>";

B:

document.getElementsByTagName("body")[0].innerHTML = "<select><option><style></option></select><abc><img src=xx onerror=alert(1)></style></option>";

运行A的结果:

运行B的结果:

如果你运行代码B的页面没有CSP那么你在运行B的时候想必会得到一个弹窗, 所以对于HTML <option><style></option></select><abc><img src=xx onerror=alert(1)></style></option> DOMPurify将会得到结果A并认为这段html无害, 但是使用jQuery时将会产生结果B, 从而导致xss, 这是因为jQuery添加了select标签后进行了一次额外的解析, 就导致html标签闭合的优先级发生了变化
同时jQuery需要去掉自己添加的select标签, 但是由于去除的方法有误, 结果会只留下 <img src=xx onerror=alert(1)> 被拼接到了DOM树中.

如何防御?

DOMPurify的官方手册也有指出在配合jQuery使用时需要做出正确的配置, 但是还是有不少的DOMPurify配置依然使用默认的, 而DOMPurify对这个问题的解决办法就是将<style>标签的中的前&lt;转义为html实体, 如此<style>中的内容无论如何也不会再被当作html解析.<br />当然同理这种绕过方式适用于所有使用jQuery.fn.html()的环境绕过除了DOMPurify以外的过滤器或者其他的waf.</p> </style>

jquery.zip (0.074 MB) 下载附件
点击收藏 | 2 关注 | 2
登录 后跟帖