基于Esprima的JS语义分析

Esprima是什么

Esprima是一个javascript解释器,遵循ECMAScript,目前支持到ES6的语法解析。
通过Esprima提供的API可以将一段javascript代码解析成节点语法树。

Source code

var name = 'xray'

Syntax tree

[
  {
    "type": "VariableDeclaration",
    "declarations": [
      {
        "type": "VariableDeclarator",
        "id": {
          "type": "Identifier",
          "name": "name"
        },
        "init": {
          "type": "Literal",
          "value": "xray",
          "raw": "'xray'"
        }
      }
    ],
    "kind": "var"
  }
]

为什么要对JS进行语义分析

之前挖过一些DOM-XSS,觉得通过人工审计js的方式来挖掘漏洞,准确性较高,但效率较低。
也尝试过使用chrome extension做过一些挂钩分析的事情,但是效果都不太理想。

总结了一些之前挖过的XSS漏洞,大多数漏洞中,漏洞点都出现在赋值表达式危险函数调用内。

赋值表达式造成的DOM-XSS

//    标签
document.getElementById('id').innerHTML = code
//    属性
document.getElementById('id').src = code
//    伪协议
window.location.href = code

危险函数调用造成的XSS

//    eval
eval(code)
//    document.write
document.write(code)

以上两类,都是一眼就能看出这是存在问题的代码。
但是随着前端技术不断进步,自从引入了webpack技术之后,审计JS变得没有那么容易了,代码压缩、变量名混淆等,让代码审计变得十分头疼,例如以下这段代码

Source code

function getQueryString(name) {
    var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
    var r = window.location.search.substr(1).match(reg);
    if (r != null) return unescape(r[2]);
    return null;
}

var url = getQueryString("url")
window.location.href = url

webpack

!function (e) {
    var n = {};

    function t(r) {
        if (n[r]) return n[r].exports;
        var o = n[r] = {i: r, l: !1, exports: {}};
        return e[r].call(o.exports, o, o.exports, t), o.l = !0, o.exports
    }

    t.m = e, t.c = n, t.d = function (e, n, r) {
        t.o(e, n) || Object.defineProperty(e, n, {enumerable: !0, get: r})
    }, t.r = function (e) {
        "undefined" != typeof Symbol && Symbol.toStringTag && Object.defineProperty(e, Symbol.toStringTag, {value: "Module"}), Object.defineProperty(e, "__esModule", {value: !0})
    }, t.t = function (e, n) {
        if (1 & n && (e = t(e)), 8 & n) return e;
        if (4 & n && "object" == typeof e && e && e.__esModule) return e;
        var r = Object.create(null);
        if (t.r(r), Object.defineProperty(r, "default", {
            enumerable: !0,
            value: e
        }), 2 & n && "string" != typeof e) for (var o in e) t.d(r, o, function (n) {
            return e[n]
        }.bind(null, o));
        return r
    }, t.n = function (e) {
        var n = e && e.__esModule ? function () {
            return e.default
        } : function () {
            return e
        };
        return t.d(n, "a", n), n
    }, t.o = function (e, n) {
        return Object.prototype.hasOwnProperty.call(e, n)
    }, t.p = "", t(t.s = 0)
}([function (e, n) {
    var t, r,
        o = (t = new RegExp("(^|&)" + "url" + "=([^&]*)(&|$)", "i"), null != (r = window.location.search.substr(1).match(t)) ? unescape(r[2]) : null);
    window.location.href = o
}]);

一个DOM-XSS的demo被打包后,不光是对初学者,即使是对常年阅读JS的人来说,也是一件非常头疼的事情。如果再加上一些逻辑判断、加密算法之后,让代码审计变得难上加难。

这个方案可行吗?

冷静下来仔细阅读代码,我们就会发现,即使代码再怎么混淆,它都是有源可溯的。最终触发XSS的核心代码就是这一段:

var t, r,
    o = (t = new RegExp("(^|&)" + "url" + "=([^&]*)(&|$)", "i"), null != (r = window.location.search.substr(1).match(t)) ? unescape(r[2]) : null);
window.location.href = o

最核心的就是变量o这个值,往上可以追溯到location.search.substr(1).match(t)t可以拆分成:

最终的触发点为:
window.location.href = o

我们只需要将语法树进行解析,最终判断出o是从哪里来的即可。由于condition中的条件判断起来难度较大(我菜),所以暂时不做考虑,只需要知道它这个参数是从URL中过来的即可。

于是借助MDN Web 文档,查询了一些关于字符串处理的方法:

'split', 'replace', 'concat', 'match', 'matchAll', 'sub', 'substr', 'substring', 'toLocaleLowerCase', 'toLocaleUpperCase', 'tirm', 'toLowerCase', 'toUpperCase', 'toString', 'trimEnd', 'trimStart', 'valueOf', 'raw'

如果一个字符串的property是这些方法的话,那么说明该字符串大部分情况下,还是在可控范围内的。
如果一个字符串的property是length的话,那么紧接着后面的一段表达式也没有必要继续跟踪下去了。

所以通过以上这个简单的小例子,我们可以确定,通过语法树来解析DOM-XSS是可行的。

方案的难点和坑在哪里?

方法固然可行,那么在尝试解析的过程中,我遇到了哪些坑呢?

  • 脚本一时爽,重构火葬场

最初使用Esprima时,直接从拉了一段代码进去,尝试进行解析,解析了一段时间后发现,js表达式远比我想象中复杂的多。

一个赋值操作,就可能需要考虑许多因素。
var a = b;

  • 茴香豆的“茴”字有几种写法?

js 的语法十分灵活,这也导致了解析难度很大,我们常见的赋值表达式,大概有以下这些

//    AssignmentExpression expression
a = parent.top.window
b = parent.top.window.location
c = parent.top.window.location.href
d = parent.top.window.document
e = parent.top.window.document.cookie
f = parent.top.window.document.URL
g = window.name
h = parent.top.window.name
i = location
j = window
k = parent
l = document
m = document['cookie']
n = top.parent.window.parent['name']
o = top.parent.window['parent']['name']
p = 'location'
q = window[p]

其中有一些对象是可控的,比如window.location.href、window.name这种,还有一些对象是部分可控,比如document.URL、document.cookie可控,而document.domain不可控,我们要做的就是从这些对象中区分出哪些可控,哪些不可控。

b -> location
c -> location.href
e -> document.cookie
f -> document.cookie
g -> window.name
h -> window.name
i -> location
m -> document.cookie
n -> window.name
o -> window.name
q -> location 

经过解析,我们得到了可控的值。当表达式左边为单个变量名时,较为简单,如果表达式为以下形式

a.b.c = d.e.f

就需要拆分出每个变量的原始值、作用域,并且分析出该对象是否可控。

这里抛砖引玉,希望有兴趣的同学一起来研究JS语法树,让简单的、表层的DOM-XSS变得更加易于挖掘,这样就能有更多的精力和时间去学习更深层的语法特性。