はじめに
Microsoft Semantic Kernel は Microsoft が公開する GitHub スター27,000超(2026年5月時点)のAIエージェント開発SDKです。Python/.NET両対応で、Anthropic・Bedrock・Gemini・Ollama向けのconnectorを拡張パッケージとして提供しています。この Semantic Kernel に、AIモデルが生成した文字列を eval() 相当で実行してしまう脆弱性(CVE-2026-26030)が報告されました。対象読者は、AIエージェントフレームワークのガードレール設計を検証・実装するエンジニア向けです。
対策コードには「許可リスト方式なので安全」という説明がついていました。筆者はこの許可リストを実際にPythonで動かし、迂回できるかどうかをその場で確かめました。
TL;DR
- Semantic Kernel の
InMemoryCollectionはフィルター文字列(lambda x: x.key == 1のような式)をastモジュールでパースし、許可された関数名・AST ノード型だけを通す設計になっている - 1.39.3 で実際に検証すると、属性アクセス(
.attr)自体の名前検証が抜けており、x.__class__のような dunder 属性を自由にたどれた - さらに
obj[key](...)という Subscript経由の関数呼び出しでは、関数名チェックのロジックが素通り することを確認した - 2つを組み合わせ、
str.__subclasses__()を許可リストに載っていない状態で実行できることを実機で確認した - CVE-2026-26030 の公式修正版である 1.39.4 では既に
blocked_filter_attributesという属性名ブロックリストが実装されており、同じペイロードが REJECT されることを実機で確認した。最新の 1.44.1 でも同様に防がれている
背景・課題
2026年5月、Microsoftのセキュリティチームが「Prompts Become Shells」という記事で、Semantic Kernel を含む複数のAIエージェントフレームワークにおける RCE(リモートコード実行)系脆弱性を報告しました1。同時期には Cursor にも CVE-2026-22708(Terminal Tool の Allowlist Bypass)が報告されています2。Cursor 側は GUI アプリケーションの内部実装で、Auto-Run モード・Allowlist有効時に export や unset のようなシェル組み込みコマンドが検証をすり抜け、環境変数書き換え経由で「信頼済みコマンド」の挙動をハイジャックできる、という内容でした。GUIアプリのためクラウド環境からの直接再現は困難です。
一方 Semantic Kernel の脆弱性(CVE-2026-26030)は semantic-kernel という pip パッケージそのものに存在します。クラウド環境でも pip install すれば実機で挙動を確かめられるため、今回はこちらを実際に手を動かして検証しました。
やったこと
ステップ1: 脆弱性のある旧バージョンを venv に入れる
CVE-2026-26030 の対象は「Semantic Kernel Python版 1.39.4未満」とMicrosoftの記事に書かれていたため、その直前の 1.39.3 を venv にインストールしました。
cd /tmp && python3 -m venv sk-venv
/tmp/sk-venv/bin/pip install semantic-kernel==1.39.3
/tmp/sk-venv/bin/python3 -c "import semantic_kernel; print(semantic_kernel.__version__)"
# → 1.39.3
ステップ2: フィルター検証ロジックを読む
該当コードは semantic_kernel/connectors/in_memory.py の InMemoryCollection._parse_and_validate_filter にありました。ドキュメント文字列には「許可リスト方式(allowlist approach)」と明記されています。
def _parse_and_validate_filter(self, filter_str: str) -> Callable:
tree = ast.parse(filter_str, mode="eval")
# ... ラムダ式であることの確認 ...
for node in ast.walk(tree):
node_type = type(node)
if node_type not in self.allowed_filter_ast_nodes:
raise VectorStoreOperationException(...)
if isinstance(node, ast.Name) and node.id not in lambda_param_names:
raise VectorStoreOperationException(...)
if isinstance(node, ast.Call):
func_name = None
if isinstance(node.func, ast.Name):
func_name = node.func.id
elif isinstance(node.func, ast.Attribute):
func_name = node.func.attr
if func_name and func_name not in self.allowed_filter_functions:
raise VectorStoreOperationException(...)
code = compile(tree, filename="<filter>", mode="eval")
func = eval(code, {"__builtins__": {}}, {}) # nosec
return func
読んで気づいた点が2つありました。
1つ目は、ast.Attribute ノード自体は allowed_filter_ast_nodes に含まれているものの、属性名(node.attr)を検証している行が存在しない ことです。ast.Name ノードでは node.id(変数名)をラムダ引数と照合していますが、.__class__ や .__mro__ のような属性アクセスにはこのチェックが及んでいませんでした。
2つ目は、ast.Call の検証部分です。呼び出し対象(node.func)が ast.Name(foo())か ast.Attribute(x.foo())のどちらかである前提でしか func_name を取得しておらず、node.func が ast.Subscript(x[key](...) という形の呼び出し)の場合は func_name が None のままになります。すると if func_name and func_name not in self.allowed_filter_functions: の条件が False になり、チェックそのものがスキップ されます。
ステップ3: 実際にペイロードを投げる
読んだだけでは確信が持てなかったので、_parse_and_validate_filter を直接呼び出して試しました。
from semantic_kernel.connectors.in_memory import InMemoryCollection
class Dummy:
allowed_filter_ast_nodes = InMemoryCollection.allowed_filter_ast_nodes
allowed_filter_functions = InMemoryCollection.allowed_filter_functions
_parse_and_validate_filter = InMemoryCollection._parse_and_validate_filter
d = Dummy()
# 通常のCall形式(x.foo())は許可リストでブロックされる
p1 = "lambda x: x.__class__.__bases__[0].__subclasses__()[0]"
# → REJECTED: Function '__subclasses__' is not allowed in filter expressions.
# Subscript経由のCall(obj[key](...))は関数名チェックが素通りする
p2 = "lambda x: x.__class__.__class__.__dict__['__subclasses__'](x.__class__)"
func = d._parse_and_validate_filter(p2)
print("ACCEPTED:", p2)
result = func(x="dummy")
print("EXECUTED:", type(result), len(result))
実行結果です。
ACCEPTED: lambda x: x.__class__.__class__.__dict__['__subclasses__'](x.__class__)
EXECUTED: <class 'list'> 20
str.__subclasses__() が実際に実行され、str を継承する20個のクラス(StrEnum や FoldedCase など、この venv にインストールされている依存ライブラリ由来)が返ってきました。__subclasses__ は allowed_filter_functions の許可リストに含まれていない名前です。それにもかかわらず実行できたのは、x.__class__.__class__.__dict__['__subclasses__'] という Subscript経由で関数オブジェクトを取り出し、それを (x.__class__) で呼び出す という形にしたことで、ast.Call の node.func が ast.Attribute ではなく ast.Subscript になり、関数名チェックの対象から外れたためです。
Microsoft のブログ記事は、Python のクラス階層をたどって BuiltinImporter のようなビルトイン型を経由し、os モジュールを動的にロードして system() を呼び出すことで calc.exe の起動に至る、と概念的に説明しています^ms-blog。ブログが説明していたのは許可リストを一切通さない「素の eval()」を前提にしたケースでした。今回実機で確認できたのは、1.39.3 の許可リスト方式に対しては通常の x.foo() 形式では防がれ、obj[key](...) 形式の呼び出しでのみ迂回が成立する、という別の攻撃経路です。os.system のような破壊的な呼び出しまでは検証せず、__subclasses__() の実行確認までに留めています。
ステップ4: 公式修正版(1.39.4)と最新版(1.44.1)で同じペイロードを試す
CVE-2026-26030 の公式アドバイザリには「1.39.4 未満が対象」と書かれていたため、まず修正版である 1.39.4 に同じペイロードを投げました。
cd /tmp && python3 -m venv sk394
/tmp/sk394/bin/pip install semantic-kernel==1.39.4
func = d._parse_and_validate_filter(
"lambda x: x.__class__.__class__.__dict__['__subclasses__'](x.__class__)"
)
REJECTED (1.39.4): VectorStoreOperationException: Access to attribute '__class__' is
not allowed in filter expressions. This attribute could be used to escape the filter sandbox.
1.39.4 の時点で既にREJECTされていました。 in_memory.py を読むと、この修正版には次のロジックが追加されています。
# For Attribute nodes, validate that dangerous dunder attributes are not accessed
if isinstance(node, ast.Attribute) and node.attr in self.blocked_filter_attributes:
raise VectorStoreOperationException(...)
blocked_filter_attributes には __class__ / __globals__ / __subclasses__ / __mro__ など48個のdunder属性名が列挙されており、ast.Attribute ノードごとに属性名(node.attr)を検証するようになっています。1.39.3 で欠けていた「属性名そのものの検証」が、公式修正版でそのまま塞がれていました。
さらに最新の 1.44.1 でも同じペイロードを試すと、こちらもREJECTされることを確認しました。
REJECTED (1.44.1): VectorStoreOperationException: Call target node type 'Subscript' is
not allowed in filter expressions. Only direct function and method calls are supported.
拒否の理由が1.39.4とは異なります。1.44.1 の ast.Call 検証には、呼び出し対象が ast.Name でも ast.Attribute でもない場合(=ast.Subscript を含む)に 明示的に例外を投げる 分岐が追加されていました。
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name):
func_name = node.func.id
elif isinstance(node.func, ast.Attribute):
func_name = node.func.attr
else:
raise VectorStoreOperationException(
f"Call target node type '{type(node.func).__name__}' is not allowed..."
)
1.39.3 では「該当しなければスキップ」だった箇所が、1.44.1 では「該当しなければ拒否」に反転しています。ast.walk は深さ優先で外側のノードから訪れるため、今回のペイロードでは外側の Call ノードが先に検査され、この拒否ロジックが先に発火しました(その内側にある ast.Attribute の __class__ も、1.39.4以降ならどのみち別途拒否されます)。属性名ブロックリストとCall形式チェックの強化は同じバージョンでまとめて入ったものではなく、属性名チェックは1.39.4(CVE公式修正版)、Call形式の明示的拒否はその後のマイナーバージョンで別々に追加 されていました。
ハマりポイント
ポイント1: object.__mro__ はインデックス [1] を持たない
最初 lambda x: x.__class__.__mro__[1].__subclasses__ を x=object() で試したところ、AST検証自体はACCEPTEDされたのに実行時に IndexError: tuple index out of range になりました。object.__mro__ は (object,) の1要素タプルのため [1] が存在しないのが原因です。x="dummy" のように __mro__ が2要素以上ある型を渡すことで解決しました。
ポイント2: マイナス記号(ast.USub)は許可リストに無い
__mro__[-1] で末尾要素を取ろうとしたところ AST node type 'USub' is not allowed で拒否されました。単項マイナス演算子が許可リストの ast.USub に無かったためで、代わりに正のインデックス([1])を使いました。意図せぬAST型が漏れなく拒否される設計であることの裏付けにもなっています。
まとめ
- Semantic Kernel の許可リスト方式のフィルター検証(1.39.3)は、
ast.Attributeの属性名自体を検証していなかった -
ast.Callの関数名チェックはnode.funcがast.Name/ast.Attributeの場合にしか働かず、ast.Subscript経由の呼び出しはノーチェックで通過した - この2つを組み合わせることで、許可リストに載っていない
__subclasses__を実際に呼び出せることを実機で確認した - CVE-2026-26030の公式修正版(1.39.4)では属性名ブロックリストが既に実装されており、同じペイロードがREJECTされることを確認した。最新の1.44.1では「未知のCall形式は拒否」という反転ロジックも加わっている
- 許可リスト方式のサンドボックスを自作するときは、「チェックに引っかからなかったら通す(fail-open)」ではなく「想定外の形は明示的に拒否する(fail-closed)」設計にしないと、検証ロジックの想定外の組み合わせをすり抜けられる、という教訓が実装差分から読み取れる
参考リンク
- Prompts Become Shells: RCE Vulnerabilities in AI Agent Frameworks(Microsoft公式・CVE-2026-26030/CVE-2026-25592の詳細)
- CVE-2026-22708: Cursor AI Code Editor RCE Vulnerability(SentinelOne脆弱性データベース)
- semantic-kernel · PyPI
関連記事
- Amazon Qのmcp.json自動実行、Claude Codeにも同じ穴があった
- Claude Code Deny Rules Bypass 完全解説 — 50サブコマンドで無効化される脆弱性と対策
- Claude Code Action、[bot]を名乗るだけで書き込み権限が通っていた