はじめに
Pythonの組み込み関数eval
・exec
は、文字列をPythonコードとして評価・実行してくれる便利な関数です。
例えばユーザが入力したコードをインタラクティブに実行するWEBサービスなんかを作ったりすることにも使えそうなように思えます。
が、この機能、気をつけて使わないと場合によってはシステムの重大な脆弱性になり得ます。
少なくとも、信頼できない入力を受け取ってeval
・exec
に投げるのはご法度である、という話です。
※本記事の内容は以下の内容を噛み砕いてまとめたものです。
Eval really is dangerous
Redditの関連する議論
eval
についてもexec
についてもほぼ同様の議論になるので、以下では特にevel
について述べます。
実験環境
- Python 3.6.8
何が危険なのか
eval
は、次のように文字列を式として評価します。
assert eval("2 + 3 * len('hello')") == 17
仮にos
モジュールが外側のソースコード内でimportされている場合、以下のような文字列を与えられるとこれはそのまま評価され、害を及ぼすシステムコマンドも実行できてしまいます。
eval("os.remove('file.txt')")
# 'file.txt'が削除されてしまう
これに対して、eval
内で参照できる名前空間を制限するという対策が考えられます。
eval
の第2引数として辞書を与えると、評価時のグローバル名前空間がその辞書の中身に置き換えられます。よって、空の辞書を与えれば上記の「os」モジュールを使用できなくなります。
eval("os.remove('file.txt')", {})
# NameError: name 'os' is not defined
ただし、この第2引数に与えた辞書に__builtins__
というキーが含まれていない場合は例外的に、キー__builtins__
に対して組み込みモジュールbuiltinsの辞書が値として自動的に挿入される仕様になっています。
(公式ドキュメント)
__builtins__
というのは通常組み込みオブジェクトにアクセスするために自動的に定義されるグローバル変数です。
ここにbuiltinsモジュールの中身が自動的にセットされるとはつまり、組み込み関数については通常時と同様に使用できるということを意味します。
import
文を呼び出したときに内部的に呼び出される組み込み関数__import__()
にもアクセスできることになり、これによって結局組み込み関数以外にも任意の関数を以下のように実行できるということになります。
eval("__import__('os').remove('file.txt')", {})
# 削除は実行される
これを防ぐ方法は、__builtins__
キーに対して明示的に値を設定してこの穴を塞ぐことです。こうすれば、通常の方法では組み込みオブジェクトにアクセスすることができなくなります。
eval("__import__('os').remove('file.txt')", {'__builtins__': {}})
# NameError: name '__import__' is not defined
これで一安心、かと思いきや、そうではありません。
直接組み込みオブジェクトにアクセスできなくても、実は例えば以下のように穴を突くと同様のことができてしまいます。
eval("""
[
c for c in ().__class__.__base__.__subclasses__()
if c.__name__ == 'catch_warnings'
][0]()._module.__builtins__['__import__']('os').remove('file.txt')
""",
{'__builtins__': {}})
# 削除は実行される
一見なんのことやらわかりませんが、中身を順に見てみましょう。
まず().__class__.__base__
の部分に注目します。ここでやりたいことは要は「objectクラスへのアクセス」です。
組み込みオブジェクトへのアクセスができないために、直接「object」を呼ぶのではなく().__class__
でtupleクラスを呼んでその基底クラスにアクセスする、という回りくどい手順を踏んでいます。
object
へアクセスできたら、そこから__subclasses__()
を呼ぶことで全サブクラスのリストを取得できます。objectはpythonの全クラスの基底クラスですから、これはつまりこの時点で存在するすべてのクラスへアクセスできるということを意味します。
これができれば、[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == 'xxxxx'][0]
で、任意のクラスの名前を指定して取得できます。
ここでは、catch_warnings
クラスのオブジェクトを生成して_module
を呼ぶと標準ライブラリwarnings
のモジュール自体にアクセスでき、ここから__builtins__
を復元して、任意の組み込み関数を呼び出すことを達成しています。
(このあたりはwarnings特有のことなのかどうかは正直よくわかりません)
これに対して、ではどうすればいいのか……について上記記事では、例えば入力されたソースに__
(ダブルアンダースコア)が含まれていた場合は実行を拒否するなどとすれば__おそらく__安全になるのではないか、という提案で締められています。
ただしこういったブラックリストを作って穴を塞いでいくような対策では、思わぬ穴が発見されてすり抜けられる可能性をゼロにすることはできず、真に安全になっているとは言えない、というのがいったんの結論です。
どうしたらいいか
調べた限りでは、不特定多数からの信頼できないソースについてeval
・exec
を安全に使用することは難しそうです(良い方法があれば教えて下さい)。
代替案として、できることは制限されますが
- ast.literal_eval()
- pyparsing
あたりで所望の動作を実現できないかを検討するのが現実的なようです。
一方で、入力が信頼できるソースからのものに限られると断言できる場合であれば、eval
・exec
を便利に使えばよいと思います。
まとめ
-
eval
・exec
の標準機能による名前空間の制限は、セキュリティ上の防御にはなり得ない - 不特定多数からの信頼できない入力を受け付けるシステムでは、
eval
・exec
を使うべきでない - ブラックリストによるセキュリティ対策では確実な安全性は獲得できない