はじめに
前回の記事「プリザンターのサーバスクリプトで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
ビルトインモジュールの問題
ここで重要な注意点があります。os や sys のようなビルトインモジュール(C# で実装されたモジュール)は、ファイルシステムを経由せずに直接ロードされます。カスタム __import__ はこれらもインターセプトできるため最も効果的な防御手段ですが、念のため Layer 4 の sys.modules ポイズニングと組み合わせることで安全性を高めます。
Layer 3: SearchPaths の空化
考え方
IronPython はモジュールをインポートする際、SearchPaths に設定されたディレクトリから .py ファイルを検索します。この検索パスを空にすることで、外部の Python ファイルの読み込みを遮断します。
// SearchPaths を空に設定
engine.SetSearchPaths(new List<string>());
これにより、サーバ上に悪意のある .py ファイルが配置されていたとしても、インポートされることはありません。
SearchPaths の空化だけでは os や sys のようなビルトインモジュール(C# 実装)のブロックには効果がありません。ビルトインモジュールはファイルシステムを経由せず直接ロードされるためです。必ず Layer 2 のカスタム __import__ と組み合わせてください。
Layer 4: .NET interop ブロック
IronPython 最大のリスク
IronPython は DLR/.NET 上で動作する特性上、import clr から .NET の全名前空間にアクセスできてしまいます。これが IronPython サンドボックスにおける最大の脅威です。
対策:モジュールポイズニング
sys.modules に None を設定することで、該当モジュールのインポートを無効化します。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つのレイヤーを統合したエンジンクラスの全体像を見てみましょう。
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 対応を追加できます