言語Sandbox環境の脆弱性とその真因の考察 - RestrictedPythonを題材に
ご挨拶
Python Advent Calendar 2024 の17日目の記事です。
JTCでセキュリティ・プライバシー・データ基盤領域の研究開発をしている @laysakura です。
この記事で扱うのは、信頼できないユーザーから与えられたコードを実行するための「言語Sandbox環境」です。特に、Pythonの言語Sandbox環境であるRestrictedPythonを取り上げます。
言語Sandbox環境の理念は素晴らしく、応用先も色々と考えられるものですが、初手の設計を誤ると攻撃者とのいたちごっこになってしまうということをこの記事を通してお伝えできればと思います。
それではお楽しみください(ここからは常体で失礼します)。
目次
導入
「◯◯言語を安全に動作させる環境」のことを、言語Sandbox環境と呼ぶこととする。その言語が本来持つ、例えばファイルアクセスやコマンド呼び出しのような機能を「危険な機能」として使えないようにしたものが言語Sandbox環境の典型的な姿である。
Pythonの言語Sandbox環境として、RestrictedPythonというものがある。
本稿では、RestrictedPythonを言語Sandbox環境の例として取り上げる。RestrictedPythonで報告されたCVEの詳説・デモを通じ、言語Sandbox環境に脆弱性をもたらす真因を考察する。
込み入った話も多いが、時間のない方は是非最後のTakeawayだけでも読んでいただくことを願う。
用語
- Sandbox環境
- ホスト環境と隔離された環境。Sandbox環境で実行したプログラムは、Sandbox環境のみを環境(入力)とし、Sandbox環境にのみ影響を与えるのが理想とされる
- Sandbox bypass; Jailbreak
- Sandbox環境で実行される悪意のあるプログラムにより、Sandbox環境外部(ホスト環境)と入出力すること
- CVE (Common Vulnerabilities and Exposures)
- 個別製品の脆弱性に割り当てられる識別子
- PoC (Proof of Concept)
- CVEの文脈では、CVEを突いた攻撃のコード
- RCE (Remote Code Execution)
- 攻撃(脆弱性の悪用)カテゴリの一つ
- 攻撃対象プロセスを実行するリモートマシンで、攻撃対象プロセスの実行ユーザーとして任意のコードを実行できるもの
- 攻撃カテゴリの中で影響は最大レベル
- Reverse-shell
- RCEを応用した典型的な攻撃。攻撃者がサーバーのシェルアクセスを得る
- 通常のsshなどでは「クライアントからサーバーに接続し、サーバーのシェルを得る」方向だが、reverse-shellは逆に「サーバーからクライアントに接続し、サーバーのシェルをクライアントに明け渡す」方向
- Reverse-shellの(攻撃者にとっての)利点:
- RCEが成立していれば、サーバー側にさらなるポート開放を求めずに済む
- サーバー側の侵入検知システム等ではincoming通信には厳しくoutbound通信には相対的に緩いことが多く、サーバーからのoutboundで成立する攻撃は成功率が比較的高い
デモ環境セットアップ
RestrictedPython のCVEを再現するためのdockerイメージ(上述の「設定」も含む)を https://github.com/laysakura/RestrictedPython-CVE-PoC に用意した。
構成図
各自の「ホストマシン」において、Dockerコンテナを立てる。「ホストマシン」がクライアント、Dockerコンテナがサーバーとなる。
サーバーのTCP 6000番ポートをホストマシンの6000番とマッピングしているため、 localhost 6000
にてサーバーと通信できる。
サーバー起動〜動作確認
ホストマシンでのコマンド
1 | clone & cd |
コンテナ内でのコマンド
1 | Python実行サーバー起動 |
ホストマシンでのコマンド
1 | cd RestrictedPython-CVE-PoC |
「危険」なコードは実行できない
フィボナッチ数列は計算できたが、次のような「危険」な(サーバーの情報を漏洩したりRCEにつながるような)コードはエラーとなることを確認する。
ホストマシンでのコマンド
1 | cd RestrictedPython-CVE-PoC |
RestrictedPythonの使い方・動作原理
使い方
RestrictedPythonを使ったsandbox環境の設定例を、 restricted_python_cve/run_server.py から、抜粋。
続く小節で説明をするのでまずは流し読みで良い。
1 | def execute_restricted_code(code): |
動作原理
何が起きているかを解説する。適宜 公式ドキュメント も参照のこと。
compile_restricted()
により、code
(クライアントから与えられたPythonプログラム文字列)をトランスパイル(コンパイルよりも単純な、プログラム文字列から別のプログラム文字列への変換)し、その後Pythonのバイトコードに変換- トランスパイルの内容:
- 危険なメソッド呼び出しを、独自のメソッド呼び出しに変換(一例):
a.b
→_getattr_(a, 'b')
getattr(a, 'b')
→_getattr_(a, 'b')
- 危険なメソッド呼び出しを、独自のメソッド呼び出しに変換(一例):
- バイトコードというのは .pyc の中身と同じもの
- トランスパイルの内容:
- バイトコードを実行する環境として、グローバル関数・変数(以下、グローバル)を設定
- 例1:
policy_globals = {**safe_globals,**utility_builtins}
により、標準的なPythonよりも限られたグローバルを設定 - 例2:
policy_globals["__builtins__"]["__import__"] = no_import
により、import
呼び出し時に例外が発生するようにしている - 例3:
policy_globals["_getattr_"] = Guards.safer_getattr
の行により、Python標準のgetattr
ではなくRestrictedPythonのsafer_getattr
をグローバルに設定しているsafer_getattr
は_
で始まるアトリビュート(__init__
など)へのアクセスを禁止し、攻撃者が言語機能を利用する余地を低減している
- 例1:
exec
関数(これはRestrictedPythonではなくPython標準の関数)に、バイトコードと上述の設定済みグローバルを与えるexec
に設定した my_locals に、バイトコード実行の結果セットされたローカル関数・変数が格納される- それによりローカル関数としてセットされた
run()
関数を実行(クライアントにrun()
関数を書いてもらう制約あり)
- それによりローカル関数としてセットされた
動作原理を動的に確認
上記で実行した ./example/open_to_info_leak.py
を改めて確認する。
コンテナ内でのコマンド
1 | Python実行サーバー起動 |
ホストマシンでのコマンド
1 | `open("/etc/passwd").read()` するプログラムをサーバーに投入し、結果を得ようとする |
open()
はPython標準のビルトイン関数であるが、 policy_globals = {**safe_globals,**utility_builtins}
の行において "open"
が policy_globals
dictのキーにセットされないため、sandbox環境 ( exec()
内) におけるグローバルとして open
は存在せずエラーとなっている。
RestrictedPythonのCVE
RestrictedPythonに関する脆弱性は https://github.com/zopefoundation/RestrictedPython/security にて情報開示されている3つである。
そのうち Severity (重大度) が High である、下記2点について詳説する。
- Arbitrary code execution via stack frame sandbox escape (CVE-2023-37271)
- Sandbox escape via various forms of “format”. (CVE-2023-41039)
この2つはどちらもRCE攻撃が可能である。
CVE-2023-37271: ジェネレーターオブジェクトからスタックフレームを遡上することによるsandbox escape(筆者命名)
原理
Pythonインタプリタはスタックフレームを持つ。例外等が発生した場合にも表示される。
スタックフレーム中の各要素は、その関数呼び出し時点での環境情報を内包する。環境情報にはグローバル空間のビルトイン関数なども含まれる。
Sandbox環境 ( exec()
内) のグローバル空間にはRCEに繋がるようなオブジェクトが存在しない場合を考える。
この前提で、sandbox環境からスタックフレームを遡り、ホスト環境 ( exec()
呼び出し前) のグローバル空間を参照することで、RCEに繋がるオブジェクトへのアクセスを得るというのが本脆弱性の核心である。
Sandbox環境の中からスタックフレームを得る道筋を塞ぐのがRestrictedPython側の意図であったが、ジェネレーター関数から作成できるジェネレーターオブジェクトからはスタックフレームへの参照が生えていたため、そこが攻撃ベクトルとなった。
PoCコード
example/cve_2023_37271.py
の中身を記載する。コメントを読み、上述の原理と照らし合わせてほしい。
Sandbox escapeが成功したあとは、ホストのグローバル環境のフレームから import
ビルトイン関数を取得し、 import os; os.system('任意コード')
相当のことをしている。
「任意コード」部分は、dockerコンテナ内からホストへ通信するためのドメイン host.docker.internal
を使って、ホストの9999番ポートにreverse-shellを張りに行っている。
1 | def run(): |
デモ動画とPoC実行手順
コンテナ内でのコマンド
1 | Python実行サーバー起動(攻撃が再現するバージョンを指定) |
ホストマシンでのコマンド
1 | 別シェルで、reverse-shell待機 |
CVE-2023-41039: string.Formatter() の書式文字列内でのアトリビュートアクセスのサニタイズ漏れ(筆者命名)
原理
TL;DR
_
の付いたアトリビュートへのアクセス、RestrictedPythonで禁止していたはずだが、string.Formatter().format()
を使うことでバイパスできた- →
random.Random.__init__
を経由し、os.system
にたどり着ける
- →
os.system
を引数付きで呼び出すために、string.Formatter
を継承したクラスを作って一工夫
RestrictedPythonに与えるユーザーコードでは a.b
のような直接的なアトリビュートアクセスや、 _
から始まるアトリビュートアクセスが軒並み禁じられており、従ってsandbox escapeに繋がるようなコードが書きづらくなっている。
しかし、 string.Formatter().format()
を巧みに使うとこれをバイパスできる。string.Formatter().format("{0._someUnderscoredAttr_}", obj0)
は、 str(obj0._someUnderscoredAttr_)
と同様の意味になる。後者のコードはRestrictedPythonで禁止されているが、前者は禁じられていない。
この挙動を利用し、なんとか os.system('reverse-shellコマンド')
を実行したい。
os のオブジェクトさえ獲得できれば、 string.Formatter().format("{0.system}", os)
とすることで str(os.system)
までは至る。まずはここを目指す。
ユーザーコードの中では random
モジュールが使える。これは restricted_python_cve/run_server.py
の中で policy_globals = {**safe_globals,**utility_builtins}
としているのが肝で、この utility_builtins
の中に random
モジュールが含まれているためである。
random
モジュールのコードの中に、 import os as _os
としている箇所がある。
従って、 random
モジュール内から _os
オブジェクトをたどることができる。具体的には:
1 | format("{0.Random.__init__.__globals__[_os]}", random) string.Formatter(). |
ここまでで os
モジュールまで手に入れたので、あとは os.system('cat /etc/passwd')
でも試したくなる。しかしここまでの方法では、 system
関数にアクセスはできても関数呼び出しができない。
1 | format("{0.Random.__init__.__globals__[_os].system}", random) string.Formatter(). |
そこで、 string.Formatter
クラスを継承し、クラス内のメソッドを定義することで引数( 'cat /etc/passwd'
)付きの関数呼び出しを実現する。string.Formatter
の定義を以下に抜粋:
1 | class Formatter: |
obj
が、 string.Formatter().format("{0.Random.__init__.__globals__[_os].system}", random)
における system
となる。
ということで、継承したクラスでその obj
を引数付きで呼び出せば目的達成。この方針で書いたコードが次のPoCとなる。
PoCコード
example/cve_2023_41039.py
の中身を記載する。コメントを読み、上述の原理と照らし合わせてほしい。os.system
の呼び出しで行っているRCEによるReverse-shellは、上述の CVE-2023-37271 のPoCコードと全く同じ考えなので、そちらも参照。
1 | def run(): |
デモ動画とPoC実行手順
コンテナ内でのコマンド
1 | Python実行サーバー起動(攻撃が再現するバージョンを指定) |
ホストマシンでのコマンド
1 | 別シェルで、reverse-shell待機 |
Takeaway
何が起きていたか?
RestrictedPythoonの2つのCVEについて詳説し、RCEを悪用してReverse-shellを獲得するデモを提供した。
それぞれのCVEの原因を振り返る:
- CVE-2023-37271
- 前提: スタックフレームにアクセスされると、遡上してsandbox escapeできてしまう
- 開発者の意図: スタックフレームへのアクセスを塞ぐ
- 攻撃の切り口: ジェネレーターオブジェクトからスタックフレームにアクセスできた
- CVE-2023-41039
- 前提: init などの関数オブジェクトを辿れると os などの危険なビルトインモジュールにアクセスされてしまう
- 開発者の意図: _ で始まるアトリビュートへのアクセスを塞ぐ
- 攻撃の切り口: string.Formatter.format() の書式文字列を使って _ で始まるアトリビュートへアクセスできた
非常に大雑把に言うと、
- 防御側: <大事な資産> にアクセスされないように <資産への道筋> を塞ぐ
- 攻撃側: <大事な資産> にアクセスするため、塞がれていない別の <資産への道筋> を探す
という構図と言える。全ての脆弱性がこういう構図なわけではないにせよ、頻繁に見受けられる構図である。
更に歴史に学ぶ
RestrictedPythonよりも昔に、同じようにPythonのsandbox環境を志向したpysandboxというOSSがあった。
そしてpysandboxは、LWN.netの投稿 で総括されているように、 「デザインから壊れていた」 ことを認めている。この投稿での指摘を抽出すると以下になる。
- セキュリティ上の根本的な問題
- Pythonの言語機能(特にintrospection)により、サンドボックスから脱出する方法が多数存在する
- CPythonの巨大なコードベース(126,000行以上)全体がセキュリティリスクとなる
- 単一のバグでサンドボックス全体が破られる可能性がある
- 実用性の喪失
- セキュリティ制限により、単純な計算以外ほぼ何もできなくなった
- 多くの基本的な言語機能を削除せざるを得なかった
RestrictedPythonの脆弱性は出尽くしたか?
ここまでの議論を読めば、「まだ報告されてないだけで穴はあるはず」「攻撃者は1つでも穴を見つければ良い」という考えになるかと思う(筆者も同じ考え)。
攻撃者は時に「どうしてそんなの思いつくの…」と途方に暮れるような攻撃を考えるものである。Pythonのsandbox escapeのテクニックをまとめたページを紹介するので、是非ご一読いただきたい。
Bypass Python sandboxes - HackTricks
セキュアな言語Sandboxの作り方に関する総括
セキュリティを志向した言語Sandbox環境の作り方の大方針として、筆者の考えをまとめる。
- [無理筋] 大きな言語機能の上に、小さなサブセットとしてsandboxを作る
- 大きな言語機能のたった一つでもsandbox escapeに使われたらアウト
- しかも言語機能側は勝手に拡張されていくので追従は事実上不可
- [進むべき道] 小さな言語機能(sandboxとして動作)の上に、大きな言語機能を乗せて利便を拡張