0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Pythonでユーザが入力した式を安全に実行できるようにする方法の検討

Last updated at Posted at 2025-06-15

eval, exec は十分に対策したつもりでも危険が残ります。あくまで参考としてください。

この記事の方式を採用したことによる損害や不利益に対して私は責任を負わず、保障しません。

Python には他のスクリプト言語と同様に、文字列として与えた式や文をソースコードとして実行する機能 (eval(), exec()) があります。この機能をうまく使えればユーザに任意のプログラムを入力させられるので、柔軟性に富んだアプリケーションを作成できそうですが、想定していないプログラムをも実行可能にする脆弱性を生みかねません。そこで、ユーザにプログラムを入力させつつ望まない動作を禁止して安全なアプリケーションにするための方法を検討します。

この記事では目的のために eval()exec() が必要であることを前提にしています。実際にプロダクトを作る際は、ast.literal_eval() などのより安全な方法で実現できないかをまず検討しましょう。

結論

  • eval, exec の第二引数 globals には {"__builtins__":{ここに許可するビルトイン関数・変数}, ここに許可するその他の関数・変数} を指定し、第三引数 locals には {ここに許可するその他の関数・変数} を指定する。
  • 上記「許可する関数・変数」に、副作用がある関数は含めない
  • あらゆる例外が発生しうることを前提に、必要であればキャッチする

前提

  • Python 3.13

脆弱性の例

たとえば、次のコードではユーザがブラウザ(フロントエンド)で入力した文字列が式としてバックエンドで実行されます。

下記のコードはHTTPアクセスできる任意の人間がシステムを破壊できる脆弱性を含んでいます。

この記事のコードを実行したことによる損害や不利益に対して私は責任を負わず、保障しません。

app.py 例:安全でない動的コード実行(試す際は自己責任で)
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_globalsdef_locals の内容が更新されます。その影響を引き継ぎたくない場合、evalexec の引数とする辞書を毎回生成するようにしてください。

ちなみに、この措置でビルトイン関数を原則禁止することによって classimport も禁止できます。なぜなら、これらは内部でビルトイン関数の __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.parsecompile の引数の "eval" は、コード実行の関数に合わせます。つまり、exec() で実行するつもりであれば ast.parsecompile の引数は "exec" にします。

この例では特定の要素以外をすべて禁止していて、禁止する要素を含むコードだった場合、コードが実行される前に例外を発して終了します。特に minmax 以外の関数を禁止していますし、属性参照 (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 は十分に対策したつもりでも危険が残ります。あくまで参考としてください。

この記事の方式を採用したことによる損害や不利益に対して私は責任を負わず、保障しません。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?