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が使えるか調べてみる[後編:サンドボックス化編]

0
Posted at

はじめに

前回の記事「プリザンターのサーバスクリプトでPythonが使えるか調べてみる」では、ClearScript(V8)が採用されている理由と IronPython による Python 対応の実現可能性を調べました。

今回は後編として、IronPython でV8 と同等の安全性を実現するサンドボックスの具体的な設計を見ていきます。

記事 内容
前編 ClearScript とは何か、なぜ採用されているのか、IronPython で Python 対応できるのか
後編(この記事) IronPython のサンドボックス実装の具体的な設計

バージョン 1.5.1.0 を対象にしています

おさらい:なぜサンドボックスが必要なのか

前回触れた通り、ClearScript(V8)は「API が存在しないから安全」という原理的保証があります。一方で IronPython は .NET の DLR 上で動作するため、何も制限しなければ .NET の全機能にアクセスできてしまいます。

# 制限なしだとこんなことが出来てしまう
import clr
clr.AddReference("System")
from System.Diagnostics import Process
Process.Start("cmd", "/c del C:\\important_data")

サーバスクリプトの設計原則は「ホストオブジェクト経由の値操作のみ許可」です。この原則を IronPython でもアプリケーションレベルで実現する必要があります。

4層防御の全体像

サンドボックスは 多層防御(Defense in Depth) の考え方で構築します。単一の防御では抜け道が生じるため、4つのレイヤーを重ねます。

Layer 1: __builtins__ 制限

考え方

Python の組み込み関数(open, exec, eval など)は __builtins__ というモジュール経由で提供されています。この __builtins__安全な関数のみを含む辞書に差し替えることで、危険な関数へのアクセスを遮断します。

ブロックすべき組み込み関数

関数 危険性
open ファイル I/O
exec 任意のコード実行
eval 任意の式評価
compile コードオブジェクト生成
__import__ モジュールインポート(Layer 2 で別途制御)
exit / quit プロセス終了
globals / locals スコープの辞書取得(制限回避の可能性)
breakpoint デバッガ起動
input 標準入力の読み取り(ブロッキング)

許可する組み込み関数

安全な組み込み関数のホワイトリストです。副作用がなく、値の変換や集計に使うものだけを残します。

ALLOWED_BUILTINS = [
    # 型変換
    "int", "float", "str", "bool", "bytes",
    "list", "tuple", "dict", "set", "frozenset",
    # 数値操作
    "abs", "round", "pow", "divmod", "min", "max", "sum",
    # イテレーション
    "range", "len", "enumerate", "zip", "map", "filter",
    "sorted", "reversed", "iter", "next", "all", "any",
    # 文字列
    "chr", "ord", "repr", "format", "ascii", "hex", "oct", "bin",
    # 型判定
    "isinstance", "issubclass", "id", "hash", "callable", "hasattr",
    # 例外クラス
    "ValueError", "TypeError", "KeyError", "IndexError",
    "Exception", "RuntimeError",
    # 定数
    "True", "False", "None",
]

C# での実装

public static void RestrictBuiltins(ScriptEngine engine, ScriptScope scope)
{
    var builtinsModule = engine.GetBuiltinModule();
    var safeBuiltins = new PythonDictionary();

    foreach (var name in AllowedBuiltins)
    {
        if (builtinsModule.TryGetVariable(name, out dynamic value))
        {
            safeBuiltins[name] = value;
        }
    }

    // 危険な関数 (open, exec, eval, compile, exit 等) は
    // safeBuiltins に含まれないため自動的に使用不可になる
    scope.SetVariable("__builtins__", safeBuiltins);
}

適用後のスクリプトの挙動はこうなります。

# サンドボックス適用後
len([1, 2, 3])     # → 3(許可されている)
sorted([3, 1, 2])  # → [1, 2, 3](許可されている)
int("42")          # → 42(許可されている)

open("/etc/passwd") # → NameError: name 'open' is not defined
exec("import os")   # → NameError: name 'exec' is not defined
eval("1+1")         # → NameError: name 'eval' is not defined

IronPython 3(Python 3 互換)では exec は関数として扱われるため、__builtins__ からの除去で確実にブロックできます。Python 2 では exec がステートメントだったため事情が違いましたが、IronPython 3 ではこの問題は解消されています。

Layer 2: モジュールインポート制御

考え方

Python の import 文は内部で __import__ 関数を呼び出します。この __import__カスタム関数に差し替え、ホワイトリストに含まれるモジュールのみインポートを許可します。

許可するモジュール

サーバスクリプトで実用的かつ安全なモジュールのみ許可します。

モジュール 用途
math 数学関数(sqrt, floor, ceil 等)
json JSON のパース・生成
datetime 日付・時刻の操作
re 正規表現
decimal 高精度小数計算
string 文字列定数・テンプレート
collections 特殊なコンテナ型
itertools イテレータ操作
functools 高階関数
copy オブジェクトのコピー

ブロックするモジュール(一部抜粋)

危険度 モジュール 理由
Critical os OS コマンド実行・環境変数アクセス
Critical subprocess プロセス起動
Critical socket ネットワーク通信
Critical ctypes ネイティブコード実行
Critical importlib __import__ 制限のバイパス
Critical io ファイル I/O
High multiprocessing プロセス生成
High threading スレッド生成(リソース枯渇)
High pickle デシリアライズによる任意コード実行
High pathlib ファイルシステム操作
Medium http / urllib HTTP 通信
Medium sqlite3 データベースアクセス

C# での実装

// Python コードでカスタム __import__ を注入
var importScript = @"
import builtins as _builtins
_original_import = _builtins.__import__

_allowed_modules = {
    'math', 'json', 'datetime', 're', 'decimal',
    'string', 'collections', 'itertools', 'functools',
    'copy', 'enum', 'typing',
}

def _sandboxed_import(name, *args, **kwargs):
    top_level = name.split('.')[0]
    if name not in _allowed_modules and top_level not in _allowed_modules:
        raise ImportError(
            f""Module '{name}' is not allowed in ServerScript"")
    return _original_import(name, *args, **kwargs)

_builtins.__import__ = _sandboxed_import
del _builtins, _original_import
";
engine.Execute(importScript, scope);

適用後の挙動を見てみましょう。

import math
math.sqrt(16)        # → 4.0(許可されている)

import json
json.dumps({"a": 1}) # → '{"a": 1}'(許可されている)

import os             # → ImportError: Module 'os' is not allowed
import subprocess     # → ImportError: Module 'subprocess' is not allowed
import socket         # → ImportError: Module 'socket' is not allowed

ビルトインモジュールの問題

ここで重要な注意点があります。ossys のようなビルトインモジュール(C# で実装されたモジュール)は、ファイルシステムを経由せずに直接ロードされます。カスタム __import__ はこれらもインターセプトできるため最も効果的な防御手段ですが、念のため Layer 4 の sys.modules ポイズニングと組み合わせることで安全性を高めます。

Layer 3: SearchPaths の空化

考え方

IronPython はモジュールをインポートする際、SearchPaths に設定されたディレクトリから .py ファイルを検索します。この検索パスを空にすることで、外部の Python ファイルの読み込みを遮断します。

// SearchPaths を空に設定
engine.SetSearchPaths(new List<string>());

これにより、サーバ上に悪意のある .py ファイルが配置されていたとしても、インポートされることはありません。

SearchPaths の空化だけでは ossys のようなビルトインモジュール(C# 実装)のブロックには効果がありません。ビルトインモジュールはファイルシステムを経由せず直接ロードされるためです。必ず Layer 2 のカスタム __import__ と組み合わせてください。

Layer 4: .NET interop ブロック

IronPython 最大のリスク

IronPython は DLR/.NET 上で動作する特性上、import clr から .NET の全名前空間にアクセスできてしまいます。これが IronPython サンドボックスにおける最大の脅威です。

対策:モジュールポイズニング

sys.modulesNone を設定することで、該当モジュールのインポートを無効化します。Layer 2 のカスタム __import__ と組み合わせることで二重の防御になります。

var poisonScript = @"
import sys

# .NET 関連モジュールを完全に封鎖
_blocked = [
    'clr', 'System', 'Microsoft',
    'System.IO', 'System.Diagnostics',
    'System.Net', 'System.Net.Http',
    'System.Reflection', 'System.Runtime',
    'System.Threading', 'System.Environment',
    'os', 'subprocess', 'socket', 'ctypes',
    'io', 'pathlib', 'shutil', 'importlib',
    'nt', 'posix',
]

for mod in _blocked:
    sys.modules[mod] = None

del _blocked
";
engine.Execute(poisonScript, scope);

sys.modules の操作だけでは、スクリプトが del sys.modules['os'] で削除してから再インポートを試みるという回避が可能です。sys モジュール自体へのアクセスも Layer 2 で制限する必要があります。

なぜ .NET CAS が使えないのか

.NET Framework 時代には Code Access Security (CAS) による AppDomain レベルのサンドボックスが利用可能でした。しかし .NET Core 以降(.NET 5+)では CAS は廃止されています。

.NET バージョン CAS サポート
.NET Framework 4.x 対応
.NET Core / 5 / 6 / 8 / 10 非対応

プリザンターは .NET 10 をターゲットにしているため、サンドボックスは全てアプリケーションレベルで実装する必要があります。

統合実装:SandboxedPythonEngine

4つのレイヤーを統合したエンジンクラスの全体像を見てみましょう。

SandboxedPythonEngine.cs
public class SandboxedPythonEngine : IDisposable
{
    private ScriptEngine _engine;
    private ScriptScope _scope;

    public SandboxedPythonEngine(bool debug = false)
    {
        var options = new Dictionary<string, object>
        {
            ["Debug"] = debug,
        };

        _engine = Python.CreateEngine(options);

        // Layer 3: 外部 .py ファイルの読み込みを遮断
        _engine.SetSearchPaths(new List<string>());

        _scope = _engine.CreateScope();

        // Layer 4: .NET interop モジュールをポイズニング
        PoisonBlockedModules();

        // Layer 1: __builtins__ を安全な辞書に差し替え
        RestrictBuiltins();

        // Layer 2: カスタム __import__ を注入
        InjectSafeImport();
    }

    /// <summary>ホストオブジェクトの注入</summary>
    public void SetVariable(string name, object value)
    {
        _scope.SetVariable(name, value);
    }

    /// <summary>Python コードの実行</summary>
    public void Execute(string code)
    {
        var source = _engine.CreateScriptSourceFromString(
            code, SourceCodeKind.Statements);
        source.Execute(_scope);
    }

    public void Dispose()
    {
        _scope = null;
        _engine?.Runtime?.Shutdown();
        _engine = null;
    }
}

使用イメージ

// サーバスクリプト実行時
using (var sandbox = new SandboxedPythonEngine())
{
    // ClearScript と同じホストオブジェクトを注入
    sandbox.SetVariable("context", model.Context);
    sandbox.SetVariable("model", model.Model);
    sandbox.SetVariable("items", model.Items);
    sandbox.SetVariable("columns", model.Columns);
    // ... その他のホストオブジェクト

    // Python スクリプトを実行
    sandbox.Execute(userScript);
}

タイムアウト制御

ClearScript では ContinuationCallback で実行中のタイムアウトチェックを行っています。IronPython では sys.settrace を使って同等の機能を実現します。

// タイムアウトチェック用のトレースコールバック
var deadline = DateTime.UtcNow.Add(timeout);
sandbox.SetVariable("_check_timeout",
    new Func<bool>(() => DateTime.UtcNow < deadline));

var traceSetup = @"
import sys
def _trace_func(frame, event, arg):
    if not _check_timeout():
        raise TimeoutError('ServerScript timeout')
    return _trace_func

sys.settrace(_trace_func)
del sys
";
sandbox.Execute(traceSetup);

sys.settrace は Python のステートメントが実行されるたびにコールバックを呼び出すため、無限ループの検出にも有効です。

サンドボックスの動作確認

各レイヤーが正しく動作するか、テストケースで確認します。

テスト 期待結果
import os ImportError
import subprocess ImportError
import clr ImportError
import System ImportError
open('/etc/passwd') NameError
exec('import os') NameError
eval('1+1') NameError
exit() NameError
import math; math.sqrt(16) 4.0
import json; json.dumps({"a": 1}) '{"a": 1}'
model.ClassA = 'test' 正常に設定される
items.Get(123) 正常にAPI呼び出し

注意が必要なエスケープ手法

完全に防げるものとは別に、注意が必要な攻撃パスもあります。

リスク 説明 対策
object.__subclasses__() 全サブクラスにアクセスし危険な型を探索 type の除外 + __subclasses__ アクセス制限
__class__.__mro__ メソッド解決順序を辿ってエスケープ メタクラス属性のブロック
ホストオブジェクトの .GetType() C# オブジェクトからリフレクション SafeHostObject ラッパーの検討
CPU 枯渇(while True: pass 無限ループ sys.settrace + タイムアウト
メモリ枯渇([0] * 10**9 大量メモリ割当 Docker メモリ制限

IronPython は DLR 上で動作するため、CPython とはオブジェクトモデルが一部異なります。__subclasses__() が返す型のセットも異なり、ファイルローダー等の CPython 内部クラスが含まれない場合がありますが、.NET 型がサブクラスとして列挙される可能性があるため対策は必要です。

段階的な導入プラン

実際にプリザンターに Python 対応を組み込む場合は、段階的に進めるのが現実的です。

Phase 内容
Phase 1 IScriptEngine インターフェースの抽出、既存コードのリファクタリング
Phase 2 PythonScriptEngine の実装(IronPython 統合 + サンドボックス)
Phase 3 UI 対応(言語選択ドロップダウン、コードエディタの切替)
Phase 4 テスト(既存 JS 回帰 + Python + サンドボックス突破テスト)

Phase 1 は純粋なリファクタリングで既存動作に影響がないため、最もリスクが低い段階です。Phase 2 でサンドボックスの POC(概念実証)を行い、全テストケースをパスしてから Phase 3 の UI 対応に進むのが安全です。

まとめ

  • IronPython のサンドボックスは 4層防御__builtins__ 制限 → Import 制御 → SearchPaths 空化 → .NET interop ブロック)で構築します
  • Layer 1 で open, exec, eval 等の危険な組み込み関数を除去
  • Layer 2 でカスタム __import__ によるホワイトリスト方式のインポート制御
  • Layer 3 で外部 .py ファイルの読み込みを遮断
  • Layer 4 で clr, System, Microsoft 等の .NET interop モジュールを封鎖
  • .NET 10 では CAS が利用不可のため、サンドボックスは全てアプリケーションレベルで実装します
  • V8 の「原理的保証」に対し IronPython は「実装的保証」になるため、継続的な脆弱性検証が重要です
  • 段階的な導入プランにより、既存環境に影響を与えずに Python 対応を追加できます
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?