据摘录所述,AWS Loom 1.6.1 之前的版本在未配置身份提供方时,允许网络客户端取得 Agent 控制面的完整管理权限。权限涉及注册工具服务器、读取集成凭据及修改托管 Agent 的 IAM 策略,暴露了认证缺省时放行的设计问题。
AWS Loom 发布时,如果没有配置 identity provider(身份提供方),任何网络客户端都能成为管理员。这个漏洞很具体,但同样的模式很可能也存在于你的代码里。
你大概也交付过这样的配置状态:认证集成是可选的,部署指南把它列为一个可以稍后完成的步骤,服务在没有它的情况下也能正常启动。没有崩溃,没有警告。健康检查显示正常,演示也能跑起来,而那个本该决定谁能进入系统的控制机制,已经悄悄决定让所有人都进来。
这不是假设。2026 年 10 月 2 日,AWS 发布了 Security Bulletin 2026-124,披露其开源 AI Agent 编排平台 Loom 中的三个问题。第一个是 CVE-2026-103956,值得仔细看看。在 1.6.1 之前的版本中,如果部署没有配置 identity provider,任何网络客户端都可以通过对应用 API 发起任意请求,获得 Agent 控制平面的完整管理权限。完整管理权限意味着可以注册工具服务器、读取已存储的集成凭据,以及改写托管 Agent 角色所关联的 IAM 角色策略。不需要 token,不需要密码,也不需要漏洞利用链。缺少一项配置,本身就构成了漏洞。
另外两个问题补全了这次事件的全貌。CVE-2026-103957 允许已认证用户把 OAuth2 discovery URL 指向攻击者的端点,从而获取 client secrets 或其他用户的 access token。CVE-2026-103958 允许已认证用户把工具服务器和远程 Agent 的连接请求指向任意内部地址,包括容器凭据端点,并读取响应。AWS 在 Loom 1.7.0 中修复了这三个问题,并建议运维人员轮换 OAuth2 client secrets、撤销仍然有效的 access tokens;如果容器角色凭据可能已被访问,还应检查 CloudTrail。
大多数关于这份公告的文章会止步于“升级到 1.7.0”。对 Loom 运维人员来说,这确实是正确的第一步。但各种解读通常都会跳过这些漏洞背后的共同模式。Loom 只是一个代码库。认证采用 fail-open、未经过 allowlist 校验就抓取用户提供的 URL,以及本地开发的绕过开关被带进生产环境,这些都是会反复出现的模式,甚至会出现在从未听说过 Loom 的代码库里。本文会准确指出这三种模式,把它们分别简化成可运行的代码,并给出一个下午就能完成的检查,帮你堵住这些缺口。
CVE 记录为 CVE-2026-103956 标注了 CWE-306(关键功能缺少认证)和 CWE-1188(资源初始化采用不安全的默认值)。第二个标签更准确地揭示了问题。漏洞并不是登录页面坏了,也不是 token 检查可以被绕过,而是初始化的默认行为错了:当 identity provider 缺失时,系统进入了权限最宽松的状态,而不是权限最严格的状态。
想一想,这样的默认行为为什么会出现,因为你的代码库也承受着同样的压力。认证会增加开发过程中的阻力。刚克隆仓库的新工程师希望控制平面能直接启动,销售电话中的演示希望顺利跑起来,集成测试希望省掉获取和传递 token 的那套流程。于是,有人把认证改成了有条件启用。在笔记本电脑上,这个条件分支很合理。问题在于,代码没有区分开发者的笔记本电脑和实际部署环境,这个条件分支便跟着构建产物一起进入了部署。AWS 自己给出的临时缓解措施直接点名了这个绕过开关:确认所有已部署环境中都没有设置 LOOM_ALLOW_UNAUTHENTICATED_LOCAL_DEV 环境变量。一个原本只为本地开发提供便利的开关,最终成了安全公告中必须专门提醒的生产配置。
一个在未配置时就自行关闭的控制机制,并不只是“带有配置选项的控制机制”。它实际上是两个不同的系统共用一个二进制文件,而你无法自主决定部署运行的是哪一个。
防止这种问题的规则很短,足以放进代码审查清单:缺少配置是停止运行的条件,绝不是授予权限的理由。如果没有配置 identity provider,服务就应该拒绝启动,或者只通过 loopback 提供服务,并在启动日志中明确说明这一限制。无论怎样组合开关,都绝不能向未经认证的网络调用方开放管理端点。
来看一个例子。下面用一个只依赖 Python stdlib 的文件,把有漏洞的实现和修复后的实现放在一起。这是对问题的简化,不是重新实现 Loom,但每一行都对应着真实事件中的一个设计决策。
# fail_closed.py -- stdlib only: the fail-open bug, and the fixed version
import ipaddress
from urllib.parse import urlparse
ADMIN_ACTIONS = {"register_tool_server", "read_credentials", "rewrite_iam_policy"}
class Config:
def __init__(self, idp_issuer=None, allow_unauth_local_dev=False, bind="0.0.0.0"):
self.idp_issuer = idp_issuer # None means: nobody configured auth
self.allow_unauth_local_dev = allow_unauth_local_dev
self.bind = bind
# ---------- the vulnerable version, reduced to its shape ----------
def vulnerable_authorize(cfg, token):
if cfg.idp_issuer is None:
return {"principal": "anonymous", "scopes": ["admin"]} # auth skipped entirely
if token is None:
return None
return {"principal": "user", "scopes": ["read"]}
# ---------- the fixed version ----------
def fixed_startup_check(cfg):
local_dev = cfg.allow_unauth_local_dev and cfg.bind in ("127.0.0.1", "::1")
if cfg.idp_issuer is None and not local_dev:
raise SystemExit("refusing to start: no identity provider configured")
def fixed_authorize(cfg, token, valid_tokens):
if token is None or token not in valid_tokens:
return None # no token, no principal
return valid_tokens[token]
def check_outbound_url(url, allowed_hosts):
parsed = urlparse(url)
if parsed.scheme != "https":
raise ValueError("outbound URL must be https")
host = parsed.hostname or ""
try:
ip = ipaddress.ip_address(host)
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError(f"outbound URL points at a non-public address: {host}")
except ValueError as e:
if "points at" in str(e):
raise
if host not in allowed_hosts:
raise ValueError(f"outbound host is not on the allowlist: {host}")
return url
运行文件底部的场景,你会得到以下结果:
vulnerable, no token -> {'principal': 'anonymous', 'scopes': ['admin']}
vulnerable, can register a tool server: True
fixed, same config -> startup refused: refusing to start: no identity provider configured
fixed, no token -> None
fixed, reader token -> {'principal': 'anusha', 'scopes': ['read']}
outbound blocked: https://169.254.169.254/latest/meta-data/ - outbound URL points at a non-public address: 169.254.169.254
outbound blocked: https://attacker.example/oauth-discovery - outbound host is not on the allowlist: attacker.example
outbound allowed: https://mcp.example.com/mcp
这个例子有几处值得注意。首先,有漏洞的函数看起来并不恶意。它看起来只是某人在开发第二周加上的一个便利分支,这也正是它能通过代码审查的原因。其次,修复并不是在 authorize 中写一个更好的分支,而是把决策前移到启动阶段:配置错误的部署会在启动时,当着运维人员的面明确失败,而不是等到攻击者发来请求时悄悄失守。第三,注意修复后的 authorize 对有效的只读 token 返回了什么:只有 read scope。有漏洞版本中的匿名 principal 却拿到了 admin,这意味着 fail-open 路径不仅不需要认证,还拥有最高权限。审计自己的代码时,就要重点找这种组合:某个免认证路径直接授予了宽泛的 scopes,因为写这个分支时,开发者需要所有功能都能用。
Loom 的第二个和第三个问题有着相同的机制:服务器抓取了用户提供的 URL,而响应内容或连接本身成了攻击载荷。discovery 文档告诉后端把 secrets 发到哪里。工具服务器的连接请求访问了位于 169.254.169.254 的容器凭据端点,并读取了返回内容。这就是披着 Agent 外衣的 server-side request forgery(SSRF)。Agent 平台会不断遇到这种风险,因为连接用户指定的工具服务器和远程 Agent,本来就是产品的功能。
典型 Agent 平台中的处理链路如下:
user configures a URL --> server fetches it --> response is parsed as trusted config
| | |
discovery doc, no allowlist, secrets sent to the
MCP server, private IPs endpoint named in
remote agent reachable the document
每个阶段都可以介入,而且至少要在其中两个阶段设置防护,因为各阶段失守的方式并不相同。在配置阶段,对照主机 allowlist 校验 URL,并要求使用 https。在抓取阶段,解析主机地址,拒绝 private、loopback 和 link-local 地址,并在每次重定向后重新检查,因为让公开 URL 重定向到 169.254.169.254,是这种攻击最老的一种玩法。在处理响应时,把抓取到的文档视为不可信输入:discovery 文档可以声明端点,但你的 client secrets 只能发送到 allowlist 中的端点,绝不能仅凭文档提供了某个地址就发过去。上面的 check_outbound_url 函数以简化形式覆盖了前两个阶段。它本身无法阻止 DNS rebinding 攻击,这也是为什么真正承担防护责任的是 allowlist,而不是 IP 检查。
既然已经开始检查,就趁别人动手之前,先对自己的部署运行这些审计命令:
# Is any unauthenticated mode enabled in the deployed environment?
env | grep -i "ALLOW_UNAUTHENTICATED\|AUTH_DISABLED\|DEV_MODE"
grep -rn "allow_unauthenticated\|auth.*optional\|skip_auth" --include="*.py" --include="*.ts" .
# Does the control plane answer an unauthenticated request? It should say 401.
curl -s -o /dev/null -w "%{http_code}\n" https://your-control-plane.example/api/admin/tool-servers
# If you run Loom itself: upgrade, then rotate what the bulletin names.
# Loom 1.7.0 fixes all three issues; 1.6.1 fixed only CVE-2026-103956.
这条 curl 是整篇文章里成本最低的安全测试。把它作为集成测试断言加入 CI:使用接近生产环境的配置启动服务,向一个管理端点发送一条未经认证的请求,只要响应不是 401 或 403,就让构建失败。这样,fail-open 回归会变成一次构建失败,而不是一个 CVE。
拒绝启动也可能让你无法进入系统。如果 identity provider 宕机,而控制平面在缺少它时拒绝启动,那么 IdP 故障就会变成整个平台的故障。要清楚地接受这一取舍:正确的应对方式是建立一套指定负责人、限定有效时间并保留审计记录的 break-glass 应急访问流程,而不是悄悄启用免认证模式。
IP 检查并不充分。主机名需要解析,重定向会改变目标,DNS 响应也可能在检查和抓取之间发生变化。allowlist 才是主要控制措施;地址检查只能挡住最简单的攻击方式。
维护 allowlist 确实需要投入。每增加一个合法工具服务器,都需要发起变更请求。如果这个过程太麻烦,运维人员就会要求使用通配符,而通配符会悄悄把原来的漏洞带回来。
三个 CVE 中有两个需要拥有 write scopes 的用户才能触发。CVE-2026-103957 和 CVE-2026-103958 都要求攻击者是已认证且拥有 write scopes 的用户。即便认证采用 fail-closed,也无法保护部署免受这两个漏洞的影响。出站请求校验和最小权限 scope 分配是两项独立的控制措施,必须分别落实。
fork 不会自动继承修复。AWS 建议所有使用 Loom fork 或衍生代码的人自行打补丁。你曾经复制到项目内部的每一份认证 middleware,也都适用同样的原则。
如果你运维的控制平面会为 Agent 代管凭据、处理工具注册或管理角色策略,就应该实施完整方案,尤其是当它能被单个开发者笔记本电脑之外的客户端访问时。拒绝启动、在 CI 中测试未经认证的请求、为出站请求设置 allowlist,以及形成文档的 break-glass 应急访问流程,这四项构成完整方案,而且没有哪一项需要做成一个平台级项目。
如果你的 Agent 工具只是一个单进程本地脚本,没有网络监听,也不存储凭据,就可以省掉复杂的方案。它面临的是另一类风险。为一个根本不会抓取 URL 的进程花整个周末维护 URL allowlist,是白费时间。
最小可行方案一个下午就能完成:添加一条启动断言,在 loopback 之外运行却没有配置 identity provider 时拒绝启动;添加一个 CI 测试,断言未经认证的管理请求返回 401;再用一次 grep 检查部署环境,找出名称中带有 UNAUTHENTICATED 的任何开关。三个小改动,其中任何一个都足以让 Loom 的故事从一份安全公告,变成一次失败的部署。
先从那条 curl 开始。对你今天运维的控制平面运行它,把状态码大声念出来,然后根据这个数字反映的实际行为修复部署,而不是相信配置文件给你的承诺。当请求完全不带 token 时,你自己的控制平面会返回什么?
AWS Security Bulletin 2026-124:Loom 的相关问题、受影响版本和凭据轮换指导
CVE-2026-103956 记录:未配置 identity provider 时可绕过认证
CVE-2026-103958 记录:工具服务器和远程 Agent 连接中的出站请求处理问题
你的 Agent 不应该借用你的 API key:为每个 Agent 配置限定 scope、会过期的凭据
你的 Agent 执行了你未曾打算让它执行的操作:在工具边界设置策略关卡
如需采取进一步行动,你可以考虑屏蔽此人和/或举报滥用行为。