eval, exec は十分に対策したつもりでも危険が残ります。あくまで参考としてください。
この記事の方式を採用したことによる損害や不利益に対して私は責任を負わず、保障しません。
Python には他のスクリプト言語と同様に、文字列として与えた式や文をソースコードとして実行する機能 (eval(), exec()) があります。この機能をうまく使えればユーザに任意のプログラムを入力させられるので、柔軟性に富んだアプリケーションを作成できそうですが、想定していないプログラムをも実行可能にする脆弱性を生みかねません。そこで、ユーザにプログラムを入力させつつ望まない動作を禁止して安全なアプリケーションにするための方法を検討します。
この記事では目的のために eval() や exec() が必要であることを前提にしています。実際にプロダクトを作る際は、ast.literal_eval() などのより安全な方法で実現できないかをまず検討しましょう。
結論
-
eval,execの第二引数globalsには{"__builtins__":{ここに許可するビルトイン関数・変数}, ここに許可するその他の関数・変数}を指定し、第三引数localsには{ここに許可するその他の関数・変数}を指定する。 - 上記「許可する関数・変数」に、副作用がある関数は含めない
- あらゆる例外が発生しうることを前提に、必要であればキャッチする
前提
- Python 3.13
脆弱性の例
たとえば、次のコードではユーザがブラウザ(フロントエンド)で入力した文字列が式としてバックエンドで実行されます。
下記のコードはHTTPアクセスできる任意の人間がシステムを破壊できる脆弱性を含んでいます。
この記事のコードを実行したことによる損害や不利益に対して私は責任を負わず、保障しません。
from flask import Flask, request
import os
import pandas as pd
app = Flask(__name__)
# http://○○○/point?code=○○ へのアクセスを受けたとき、○○を式として実行する
@app.route("/point")
def point():
math = 90 # 数学の点数
english = 50 # 英語の点数
science = 60 # 理科の点数
code = request.args.get("code")
point = eval(code) # code の通りに総合得点を計算
return {"point": point}
このアプリは python コードで書けるあらゆる数式を指定して総合得点を算出できるようになっています。たとえば、code が "2*english + math + science" であれば、point は英語を2重に足した合計点である 250 になります。ほかにも割り算、max() などを使えて便利そうなうえに、それらをユーザに自由に組成させる機能はPythonの文法解析・関数を流用して省力で実装できたので、とても頭の良い方法に見えます。
しかし、このWEBアプリは何も警戒・対策せずに受け取ったコードを実行しているので、システムに干渉するコードをも実行できる脆弱性を持ちます。たとえば、次のような攻撃が可能です。
-
code="os.mkdir('abc')"であれば (osをインポートしているので) サーバ内にabcという名前のディレクトリが作成されます -
code="open('/home/user/.ssh/id_rsa', mode='w')"であれば、秘密鍵が削除されます (ユーザ名やファイル名がわからなくても何度でもチャレンジできるので、総当たり攻撃が成立します) -
code="open('/home/user/.ssh/id_rsa', mode='r').readlines()"であれば秘密鍵の内容を見ることができます。(上と同じく、ユーザ名やファイル名は総当たり攻撃)
これらは設計段階で想定していない、大きな力をユーザに与えてしまっていて、場合によっては情報の窃取や破壊も可能な致命的な脆弱性になりえます。
このような問題を回避しつつ、Pythonの文法解析や関数を流用する方針は維持することで、柔軟で高度な動作を簡単に実現することがこの記事の目的となります。
根本原因と対処方法
先述の例の脆弱性の原因は、使える関数や機能を制限していないことにあります。先述の例も含めて、制限を加えない場合は以下の機能が使用可能になってしまいます。
-
ビルトインの関数・変数等
- 例:
open(),exit()
- 例:
- app.py でインポートしたモジュール等
- 例:
os,Flask,request
- 例:
- app.py で定義した関数・変数等
- 例:
app
- 例:
- (
execで)import,class - 例外の発生
これにより、目的よりはるかに大きな権能をユーザに与える結果となりました。そのため、上にあげたような関数等を使用できないようにすることを考え、次の3つの対策を取ります。
対処① スコープの明示
実は exec() や eval() には、使える関数等を指定する機能がすでに備わっています。先述の例ではそれを指定しなかったのでデフォルト通り、eval() を実行する時点で使えるすべての関数等が使用可能になってしまったのです。
最も使える関数等を減らすと、次のようになります。
def_globals = {
"__builtins__": {},
}
def_locals = {}
eval(code, def_globals, def_locals)
eval() や exec() には、第二引数としてグローバルスコープに定義された関数等、第三引数としてローカルスコープに定義された関数等を辞書 (ローカルの方はMappingなら非辞書でもよい) で指定できます。そのため、そこで空の辞書を指定することで何も定義していない状態でコードを実行できます。
ただし、ビルトインの関数・変数等についてはデフォルトで使用可能になってしまうため、注意が必要です。ビルトインの機能を制限するには、「ビルトインとしてはこの辞書の中身だけを使って」という明示的な指定として "__builtins__": 辞書 の指定が必要になります。
あとは、必要に応じて関数や変数をスコープに追加していくことになります。どの関数なら使用可能にして問題ないかは後述しますが、たとえば以下のようになります。
def_globals = {
"__builtins__": {
"min": min,
"max": max,
},
}
def_locals = {
"math": 90,
"english": 50,
"science": 60,
}
eval(code, def_globals, def_locals)
実行する code によっては、新たに変数が宣言されることで def_globals や def_locals の内容が更新されます。その影響を引き継ぎたくない場合、eval や exec の引数とする辞書を毎回生成するようにしてください。
ちなみに、この措置でビルトイン関数を原則禁止することによって class と import も禁止できます。なぜなら、これらは内部でビルトイン関数の __build_class__() と __import__() を使用するためです。
対処② 例外を捕捉する
ユーザが式や文を自由に入力できる場合、例外はたやすく起こせます。exec でありかつスコープの制限をしていなければ任意のビルトイン例外を直接 raise できますし、制限があってもたとえば code="'a'>1" なら異なる型の大小比較なので TypeError が発生します。そのため、基本的にはあらゆる例外が想定されることを前提としたコーディングが必要になります。
たとえば、上述の例ではキャッチしない場合は 500 エラーになるだけなので、それでよければキャッチは不要ですが、400 エラー (Bad Request) にしたいのであれば下記のように try-except で囲みます。
try:
eval(code, def_globals, def_locals)
except Exception:
abort(400)
対処③ 構文解析をして最低限のノード以外があったらエラーとする
これだけの対策をしても、下記の記事の通りビルトイン機能や他のモジュールを使用できてしまう抜け穴があります。
そこで、ast モジュールを用いた構文解析を実施し、不要なはずのノードがあれば例外を発して拒絶するようにします。
たとえば、次のようなコードになります。
import ast
def validate_code(value: ast.AST):
"""value の内容をチェックする (問題があれば validate_code() 内で例外を投げる)"""
if isinstance(value, ast.Module):
for node in value.body:
validate_code(node)
return
elif isinstance(value, ast.Expression):
validate_code(value.body)
return
elif isinstance(value, ast.BinOp):
# 二項演算は許可する
validate_code(value.left)
validate_code(value.right)
return
elif isinstance(value, ast.Call):
if isinstance(value.func, ast.Name) and value.func.id in ("min", "max"):
# 関数 min, max の呼び出しは許可する
for arg in value.args:
validate_code(arg)
for kw in value.keywords:
validate_code(kw.value)
return
elif isinstance(value, (ast.Name, ast.Constant)):
# 変数や関数等の参照と、定数の参照は許可する
return
raise ValueError(f"Unexpected syntax node: {value}")
# コードを抽象構文木 (AST) に変換
parsed_code = ast.parse(code, mode="eval")
# 抽象構文木 (AST) をチェック
validate_code(parsed_code)
# 抽象構文木をコンパイルして実行可能にする
compiled = compile(parsed_code, "<code>", "eval")
# コードを実行する
result = eval(compiled, def_globals, def_locals)
ast.parse や compile の引数の "eval" は、コード実行の関数に合わせます。つまり、exec() で実行するつもりであれば ast.parse や compile の引数は "exec" にします。
この例では特定の要素以外をすべて禁止していて、禁止する要素を含むコードだった場合、コードが実行される前に例外を発して終了します。特に min と max 以外の関数を禁止していますし、属性参照 (obj.attr のドット) も禁止しているため、ほとんどの動作ができなくなっています。そのおかげで、先述の記事の抜け穴も防止できています。
※ 単項演算子 - など数値計算に使いそうな機能も制限しているため、検討の余地あり
使用可能にする関数等の選定
ここからは対処①をより具体的に検討します。関数等を制限し、こちらが許可したものだけを使用可能にしましょう。そのために、どの関数等を使用可とするかを選定します。
基準
悪影響がなさそうな関数等だけを許可するというのがやりたいことですが、漠然としていて判定しづらいので、指標としては副作用がない関数を許可する方針とするとよいかと思います。また、それを順守するためモジュール丸ごとはNGです(モジュール内のすべてを熟知していれば別ですが、将来のアップデートまで加味すると現実的でないと思うので)。これらを守ることで、想定外の動作を防ぎます。
許可・禁止候補の一覧
ビルトインの関数等については下記にまとめたので、参考にどうぞ。
下記の表で採用可となっていても、採用は自己責任で判断してください。
下記の表で採用可となっていても、必要ない関数等は不採用にすることをおすすめします。勘違いによる脆弱性の作りこみを防止するためです。
| 名称 | 機能 | 副作用 | 採用可 | 危険性・補足 |
|---|---|---|---|---|
__build_class__ |
クラス作成 | - | × | 無限イテレータ、重い __str__ など他の関数と組み合わせることで凶悪になるクラスを作れるためNG。他の行もカスタムクラスを作成できないことを前提に採用可・不可を評価している。 |
__import__ |
モジュールインポート | - | × | インポートを許すと os を含めなんでも使用できてしまうのでNG |
abs |
絶対値 | 無し | ○ | |
all |
and の関数版 |
無し | ○ | |
any |
or の関数版 |
無し | ○ | |
ascii |
repr + ASCII化 |
無し | ○ | |
bin |
二進表記 | 無し | ○ | |
breakpoint |
デバッガ起動 | あり | × | |
callable |
呼び出し可否判定 | 無し | ○ | |
chr |
1文字のデコード | 無し | ○ | |
compile |
コードコンパイル | 無し | △ | 現状、関数自体も戻り値のインスタンスのメソッドも副作用はないと思われるが、将来は不明。目的がないなら不採用がよさそう。 |
delattr |
属性削除 | あり | × |
exec, eval の外と共有のインスタンスがある場合、その属性が削除されると影響が出る。無い場合でも delattr を使わせたいことはないと思われるため不採用でよさそう。 |
dir |
属性取得 | 無し | ○ | |
divmod |
余りがある除算 | 無し | ○ | |
eval |
コード実行 | あり | × | スコープは引き継ぐので許可していないコードを実行される心配はないと思われるが、メリットが無いため不採用。 |
exec |
コード実行 | あり | × | スコープは引き継ぐので許可していないコードを実行される心配はないと思われるが、メリットが無いため不採用。 |
format |
文字列生成 | 無し | ○ | |
getattr |
属性値取得 | 無し | ○ | |
globals |
スコープ取得 | 無し | ○ | |
hasattr |
属性の有無の判定 | 無し | ○ | |
hash |
ハッシュ化 | 無し | ○ | |
hex |
16進表記 | 無し | ○ | |
id |
オブジェクトid取得 | 無し | ○ | |
input |
標準入力の使用 | あり | △ | ユーザが標準入力を使えるなら、採用もあり。逆に input() 実行でスタックする恐れがあるなら不採用。 |
isinstance |
型の判定 | 無し | ○ | |
issubclass |
型継承の判定 | 無し | ○ | |
iter |
イテレータ作成 | 無し | ○ | |
aiter |
イテレータ作成 | 無し | ○ | |
len |
リスト長 | 無し | ○ | |
locals |
スコープ取得 | 無し | ○ | |
max |
最大値 | 無し | ○ | |
min |
最小値 | 無し | ○ | |
next |
イテレート | 無し | ○ | |
anext |
イテレート | 無し | ○ | |
oct |
8進表記 | 無し | ○ | |
ord |
1文字のエンコード | 無し | ○ | |
pow |
べき乗 | 無し | ○ | |
print |
テキスト出力 | あり | △ | ユーザが標準出力・標準エラー出力を使えるなら、採用もあり。 |
repr |
文字列化 | 無し | ○ | |
round |
丸め | 無し | ○ | |
setattr |
属性値の設定 | あり | × |
exec, eval の外と共有のインスタンスがある場合、その属性が編集されると影響が出る。無い場合でも setattr を使わせたいことはないと思われるため不採用でよさそう。 |
sorted |
ソート | 無し | ○ | |
sum |
合計 | 無し | ○ | |
vars |
属性取得 | 無し | ○ | |
None |
定数 | - | - |
__builtins__ に含めなくても使用可 |
Ellipsis |
定数 | - | - |
__builtins__ に含めなくても使用可 |
NotImplemented |
定数 | - | ○ | |
False |
定数 | - | - |
__builtins__ に含めなくても使用可 |
True |
定数 | - | - |
__builtins__ に含めなくても使用可 |
bool |
型 | - | ○ | |
memoryview |
内部データアクセス | あり | × | メモリ操作は意図しない操作が可能になりがち |
bytearray |
型 | - | ○ | |
bytes |
型 | - | ○ | |
classmethod |
デコレータ | - | × | クラス作成を許さないため不要 |
complex |
型 | - | ○ | |
dict |
型 | - | ○ | |
enumerate |
イテレータ | 無し | ○ | |
filter |
イテレータ | 無し | ○ | |
float |
型 | - | ○ | |
frozenset |
型 | - | ○ | |
property |
デコレータ | - | × | クラス作成を許さないため不要 |
int |
型 | - | ○ | |
list |
型 | - | ○ | |
map |
イテレータ | 無し | ○ | |
object |
型 | - | ○ | |
range |
イテレータ | 無し | ○ | |
reversed |
イテレータ | 無し | ○ | |
set |
型 | - | ○ | |
slice |
型 | - | ○ | |
staticmethod |
デコレータ | - | × | クラス作成を許さないため不要 |
str |
型 | - | ○ | |
super |
親クラスメソッド | - | ○ | |
tuple |
型 | - | ○ | |
type |
型 | - | ○ | |
zip |
イテレータ | 無し | ○ | |
__debug__ |
定数 | - | - |
__builtins__ に含めなくても使用可 |
open |
ファイルオープン | あり | × | (プロセスのユーザ権限の中で) 任意のファイルを操作できてしまう |
quit |
終了 | あり | × | プロセスを終了させられてしまう |
exit |
終了 | あり | × | プロセスを終了させられてしまう |
copyright |
著作権表示 | 無し | ○ | |
credits |
クレジット表示 | 無し | ○ | |
license |
ライセンス表示 | あり | × | 単なる出力ではなくページャになり、スタックの恐れがあるため |
help |
対話ヘルプ | あり | × | 対話モードに入り、スタックの恐れがあるため |
修正したコード例
上述の方針で、冒頭のコードを書き直したものが次のコードです。
from flask import Flask, abort, request
import ast
import os
import pandas as pd
app = Flask(__name__)
def validate_code(value: ast.AST):
"""value の内容をチェックする (問題があれば validate_code() 内で例外を投げる)"""
if isinstance(value, ast.Module):
for node in value.body:
validate_code(node)
return
elif isinstance(value, ast.Expression):
validate_code(value.body)
return
elif isinstance(value, ast.BinOp):
return
elif isinstance(value, ast.Call):
if isinstance(value.func, ast.Name) and value.func.id in ("min", "max"):
return
elif isinstance(value, (ast.Name, ast.Constant)):
return
raise ValueError(f"Unexpected syntax node: {value}")
# http://○○○/point?code=○○ へのアクセスを受けたとき、○○を式として実行する
@app.route("/point")
def point():
# コードの内容次第では実行時に def_globals, def_locals の内容が更新されるため、
# 前回のコード実行を引き継がないように毎回 def_globals, def_locals を作成する。
def_globals = {
"__builtins__": {
"min": min,
"max": max,
},
}
def_locals = {
"math": 90,
"english": 50,
"science": 60,
}
code = request.args.get("code")
try:
# code の通りに総合得点を計算
parsed_code = ast.parse(code, mode="eval")
validate_code(parsed_code)
compiled = compile(parsed_code, "<code>", "eval")
point = eval(compiled, def_globals, def_locals)
except Exception:
abort(400)
return {"point": point}
(再掲)eval, exec は十分に対策したつもりでも危険が残ります。あくまで参考としてください。
この記事の方式を採用したことによる損害や不利益に対して私は責任を負わず、保障しません。