Webアプリでユーザーのコードを安全に受け入れて実行するにはどうすればよいですか?

2020-07-30 python python-3.x django docker google-kubernetes-engine

私はいくつかの関数を含む入力としてpythonファイルを使用するdjangoベースのWebアプリに取り組んでいます。バックエンドでは、ユーザーの関数を介してパラメーターとして渡されるいくつかのリストがあり、単一の値の出力が生成されます。生成される結果はいくつかのさらなる計算に使用されます。

ユーザーのファイル内の関数は次のようになります。

def somefunctionname(list):

    ''' some computation performed on list'''

    return float value

現在私が使用しているアプローチは、ユーザーのファイルを通常のファイル入力として使用することです。次に、私のviews.pyで、ファイルをモジュールとして実行し、eval関数でパラメータを渡します。以下にスニペットを示します。

ここでmodulenameは、ユーザーから取得してモジュールとしてインポートしたpythonファイル名です

exec("import "+modulename)

result = eval(f"{modulename}.{somefunctionname}(arguments)")

まったく問題なく動作しています。しかし、私はこれが安全なアプローチではないことを知っています。

私の質問、使用している方法が安全ではないため、ユーザーのファイルを安全に実行できる他の方法はありますか?私は提案されたソリューションが完全な証明ではないことを知っていますが、これを実行できる他の方法は何ですか? または、可能であれば、これまたは簡単に私を助けることができるチュートリアルをサンドボックス化する方法を誰かに教えてもらえますか?

参考資料やリソースが参考になります。

Answers

それは重要な質問です。 Pythonでは、サンドボックス化は簡単ではありません。

使用しているpythonインタープリターのバージョンが問題になる数少ないケースの1つです。たとえば、JytonはJavaバイトコードを生成し、JVMはコードを安全に実行する独自のメカニズムを備えています。

デフォルトのインタープリターであるCPythonの場合、元々は制限付き実行モードを作成しようとする試みがいくつかありましたが、かなり前に放棄されました。

現在、非公式のプロジェクト、 RestrictedPythonがあり、必要なものが提供される可能性があります。これは完全なサンドボックスではありません 。つまり、ファイルシステムへのアクセス制限などはありませんが、必要な場合はそれで十分です。

基本的に、そこにいる人たちはPythonコンパイルをより制限された方法で書き直しただけです。

それができることは、コードの一部をコンパイルしてから、すべて制限付きモードで実行することです。例えば:

from RestrictedPython import safe_builtins, compile_restricted

source_code = """
print('Hello world, but secure')
"""

byte_code = compile_restricted(
    source_code,
    filename='<string>',
    mode='exec'
)
exec(byte_code, {__builtins__ = safe_builtins})

>>> Hello world, but secure

builtins = safe_builtinsで実行すると、ファイルを開く、インポートなどの危険な機能が無効になります。 ビルトインやその他のオプションには他にもバリエーションがあります。ドキュメントを読むのに少し時間がかかりますが、かなり良いものです。

編集:

ここにあなたのユースケースの例があります

from RestrictedPython import safe_builtins, compile_restricted
from RestrictedPython.Eval import default_guarded_getitem


def execute_user_code(user_code, user_func, *args, **kwargs):
    """ Executed user code in restricted env
        Args:
            user_code(str) - String containing the unsafe code
            user_func(str) - Function inside user_code to execute and return value
            *args, **kwargs - arguments passed to the user function
        Return:
            Return value of the user_func
    """

    def _apply(f, *a, **kw):
        return f(*a, **kw)

    try:
        # This is the variables we allow user code to see. @result will contain return value.
        restricted_locals = {
            "result": None,
            "args": args,
            "kwargs": kwargs,
        }

        # If you want the user to be able to use some of your functions inside his code,
        # you should add this function to this dictionary.
        # By default many standard actions are disabled. Here I add _apply_ to be able to access
        # args and kwargs and _getitem_ to be able to use arrays. Just think before you add
        # something else. I am not saying you shouldn't do it. You should understand what you
        # are doing thats all.
        restricted_globals = {
            "__builtins__": safe_builtins,
            "_getitem_": default_guarded_getitem,
            "_apply_": _apply,
        }

        # Add another line to user code that executes @user_func
        user_code += "\nresult = {0}(*args, **kwargs)".format(user_func)

        # Compile the user code
        byte_code = compile_restricted(user_code, filename="<user_code>", mode="exec")

        # Run it
        exec(byte_code, restricted_globals, restricted_locals)

        # User code has modified result inside restricted_locals. Return it.
        return restricted_locals["result"]

    except SyntaxError as e:
        # Do whaever you want if the user has code that does not compile
        raise
    except Exception as e:
        # The code did something that is not allowed. Add some nasty punishment to the user here.
        raise

これで、関数execute_user_code 。これは、安全でないコードを文字列として受け取り、このコードからの関数の名前、引数を受け取り、指定された引数を持つ関数の戻り値を返します。

ここにいくつかのユーザーコードの非常に愚かな例があります:

example = """
def test(x, name="Johny"):
    return name + " likes " + str(x*x)
"""
# Lets see how this works
print(execute_user_code(example, "test", 5))
# Result: Johny likes 25

しかし、ユーザーコードが安全でない何かを行おうとすると、次のようになります。

malicious_example = """
import sys
print("Now I have the access to your system, muhahahaha")
"""
# Lets see how this works
print(execute_user_code(malicious_example, "test", 5))
# Result - evil plan failed:
#    Traceback (most recent call last):
#  File "restr.py", line 69, in <module>
#    print(execute_user_code(malitious_example, "test", 5))
#  File "restr.py", line 45, in execute_user_code
#    exec(byte_code, restricted_globals, restricted_locals)
#  File "<user_code>", line 2, in <module>
#ImportError: __import__ not found

可能な拡張子:

ユーザーコードは関数の呼び出しごとにコンパイルされることに注意してください。ただし、ユーザーコードを一度コンパイルしてから、別のパラメーターで実行することもできます。したがって、必要なのは、 byte_codeどこかに保存してから、 byte_code restricted_localsた異なるbyte_codeセットをrestricted_localsしてexecを呼び出すことだけです。

EDIT2:

インポートを使用したい場合は、安全と思われるモジュールのみを使用できる独自のインポート関数を作成できます。例:

def _import(name, globals=None, locals=None, fromlist=(), level=0):
    safe_modules = ["math"]
    if name in safe_modules:
       globals[name] = __import__(name, globals, locals, fromlist, level)
    else:
        raise Exception("Don't you even think about it {0}".format(name))

safe_builtins['__import__'] = _import # Must be a part of builtins
restricted_globals = {
    "__builtins__": safe_builtins,
    "_getitem_": default_guarded_getitem,
    "_apply_": _apply,
}

....
i_example = """
import math
def myceil(x):
    return math.ceil(x)
"""
print(execute_user_code(i_example, "myceil", 1.5))

このサンプルのインポート関数は非常にプリミティブであり、 from x import yようなものでは機能しないことに注意してください。より複雑な実装については、 こちらご覧ください

EDIT3

pythonの多くは機能が組み込まれていることを注意は、RestrictedPythonでボックスの利用可能ない、それがすべてでは利用できないという意味ではありません。使用可能になるには、いくつかの機能を実装する必要がある場合があります。

sum+=演算子のようないくつかの明らかなことでさえ、制限された環境では明らかではありません。

たとえば、 forループは_getiter_関数を使用します。 _getiter_関数は、グローバルに実装して提供する必要があります。無限ループを回避したいので、許可される反復回数にいくつかの制限を設けることができます。以下は、反復数を100に制限する実装例です。

MAX_ITER_LEN = 100

class MaxCountIter:
    def __init__(self, dataset, max_count):
        self.i = iter(dataset)
        self.left = max_count

    def __iter__(self):
        return self

    def __next__(self):
        if self.left > 0:
            self.left -= 1
            return next(self.i)
        else:
            raise StopIteration()

def _getiter(ob):
    return MaxCountIter(ob, MAX_ITER_LEN)

....

restricted_globals = {
    "_getiter_": _getiter,

....

for_ex = """
def sum(x):
    y = 0
    for i in range(x):
        y = y + i
    return y
"""

print(execute_user_code(for_ex, "sum", 6))

ループ数を制限したくない場合は、アイデンティティ関数を_getiter_として使用し_getiter_

restricted_globals = {
    "_getiter_": labmda x: x,

ループ数を制限するだけではセキュリティが保証されないことに注意してください。まず、ループを入れ子にすることができます。次に、 whileループの実行回数を制限することはできません。安全にするために、安全でないコードをタイムアウトで実行する必要があります。

ドキュメントをお読みください。

すべてが文書化されているわけではないことに注意してください(多くのものが文書化されていますが)。より高度なことについては、プロジェクトのソースコードを読むことを学ぶ必要があります。学習する最良の方法は、コードを試して実行し、不足している関数の種類を確認してから、プロジェクトのソースコードを確認して、その実装方法を理解することです。

EDIT4

さらに別の問題があります-制限されたコードには無限ループがある可能性があります。これを回避するには、コードで何らかのタイムアウトが必要です。

残念ながら、djangoを使用しているため、明示的に指定しない限りマルチスレッドです。signealsを使用したタイムアウトの簡単なトリックはここでは機能せず、マルチプロセッシングを使用する必要があります。

私の意見では最も簡単な方法- このライブラリを使用しますexecute_user_codeデコレータを追加するだけで、次のようになります。

@timeout_decorator.timeout(5, use_signals=False)
def execute_user_code(user_code, user_func, *args, **kwargs):

これで完了です。コードは5秒以上実行されません。 use_signals = Falseに注意してください。これがないと、djangoで予期しない動作が発生する可能性があります。

また、これはリソースに比較的負荷がかかることにも注意してください(そして、これを克服する方法は本当にわかりません)。私は本当に狂ったように重いというわけではありませんが、それは追加のプロセススポーンです。あなたはあなたのウェブサーバー設定でそれを覚えておくべきです-任意のユーザーコードを実行することを許すAPIはddosに対してより脆弱です。

Dockerを使用すれば、注意して実行をサンドボックス化できます。 CPUサイクル、最大メモリを制限し、すべてのネットワークポートを閉じ、ファイルシステムへの読み取り専用アクセス権を持つユーザーとして実行できます。

それでも、これを正しく行うには非常に複雑になります。私にとっては、クライアントがそのような任意のコードを実行することを許可しないものとします。

プロダクション/ソリューションがまだ完了していないかどうかを確認し、それを使用します。サーバー上で実行されるコード(python、javaなど)を送信できるサイトがあると思っていました。

Related