Zenity Labs 披露 Salesforce AI 代理平台三处漏洞,其中两处可实现零点击数据外传,攻击者仅需通过表单填写恶意内容即可触发。厂商已于 6 月私下知悉后约两周修复。
SalesBleed 揭示了一个最可怕的提示词注入并非出现在聊天窗口,而是安静地躺在 CRM 的某一行数据里。
你的线索表单向陌生人开放——这本来就是线索表单存在的意义。2026 年 9 月 24 日,Zenity Labs 发布了 SalesBleed,并展示了当某个陌生人留下一条备注、你的 AI Agent 后续将其作为指令读取时会发生什么。
没有员工点击任何东西。没有人向 Agent 输入任何内容。攻击者只是填写了一个表单。
Zenity Labs 于 2026 年 9 月 24 日披露了 Salesforce Agentforce 中的三个漏洞。Salesforce 已于 2026 年 6 月 1 日私下收到通知,并在约两周内修复了报告的绕过方式。这个时间线值得关注,因为这是漏洞披露应有的运作方式:研究员发现严重问题,私下报告,供应商修复。
三个漏洞中有两个实现了零点击数据泄露。敏感 CRM 数据可以在无需员工点击或批准任何内容的情况下被传输到攻击者控制的基础设施。第三个漏洞允许攻击者利用与 Agentforce 关联的 Slack Agent 的可信身份,向员工发送来自组织内部的钓鱼消息。
入口是一个几乎所有 Salesforce 客户都在使用的功能。Web-to-Lead 让外部用户通过公开表单提交数据,数据直接流入 CRM 记录。它的设计初衷就是接收来自陌生人的输入。Zenity 发现,攻击者可以在这些提交内容中植入隐藏的提示词注入载荷。当可信的 Agentforce Agent 后续处理该记录时,它将攻击者的指令当作合法内容来读取并执行。
出口是 Agentforce 的 Trusted URLs 机制——一个本应控制 Agent 可以向何处发送数据的白名单。Zenity 发现该机制未能正确识别顶级域名,且某些字符序列可以干扰 URL 解析,使数据绕过白名单到达攻击者控制的目的地。
模型的行为完全符合设计预期:它读取文本并遵循指令。问题出在假设了线索表单的文本是数据而非指令。
大多数提示词注入的解读都把战场设在聊天框里。攻击者输入一些巧妙的话,Agent 听从指令,大家点头认可。SalesBleed 的战场发生在存储层。载荷静默地躺在数据库行中,看似普通平常,直到一个可信的 Agent 在一个普通的工作日将其读取。载体不是对话,而是 CRM。
这改变了防御的形态。如果你把提示词注入想象成攻击者与 Agent 之间的对决,你会在聊天窗口处构建过滤器并宣布胜利。但这里没有聊天窗口。指令通过与所有其他线索相同的渠道到达,在被读取前可能已经过了数小时甚至数天,攻击者与你的数据之间唯一的屏障就是 Agent 区分指令来源的能力。语言模型并不能天然画出这条线。
持久的教训不是"加强过滤"。它有两点,本文将使之具体化:标记每个字段的来源,并对出口通道执行严格、正确解析的标准。大多数对 SalesBleed 的报道止步于注入本身。真正有趣的部分是泄露,因为白名单在解析层失败了,而解析层正是你的防御静默死亡的地方。
安全研究员将这种危险组合描述为致命三要素(lethal trifecta),这是 Simon Willison 在 2025 年 6 月 16 日提出的框架。具有私有数据访问权限、暴露于不可信内容且有外部通信手段的 Agent,在设计上就是可利用的。三者之一单独存在没有问题。三者同时存在意味着埋在不可信内容中的提示词注入可以将 Agent 自身的能力反噬其主人。打断任意一条腿就能削弱攻击。
我理解为什么三者同时存在,这值得在说教之前先讲清楚。失去 CRM 访问权限的线索丰富 Agent 就是一个搜索框。失去联系外部目的地的能力,它就无法发送后续通知。没有线索摄入,它就没有任何工作。这三者的组合就是产品本身。没有人会为了安抚安全审查而移除一条腿,所以修复必须在组合周围建立边界,而不是减去组合。
以下是将攻击路径作为管道来展示,因为顺序很关键。载荷先到达,稍后才引爆——这正是无人注意的原因:
[public lead form: anyone may write]
|
v
+---------------------------+ +-----------------------------+
| CRM record |----->| trusted agent reads the row |
| attacker text sits here, | | "enrich this lead" |
| quiet and ordinary | +-----------------------------+
+---------------------------+ |
v
+-------------------------------+
| Trusted URLs allowlist |
| mis-parses TLDs; character |
| sequences tamper with parsing |
+-------------------------------+
|
+-----------------------+------------------------+
| |
v v
exfil to attacker-controlled Slack agent sends phishing
infrastructure "from" a trusted identity
这个图中需要关注三个要点。第一,从顶部到中间的时间差:攻击者无需在 Agent 行动时在场。第二,白名单处的失败是解析失败,而非策略失败。策略说的是对的。执行它的代码无法将 evil-example.com 与它认为允许的域名区分开来。第三,最右侧的分支对攻击者没有任何额外成本。一旦副官被混淆,它持有的每项能力都成为攻击者的能力,包括可信的 Slack 身份。
这就是混乱副官问题(confused-deputy problem)穿了新衣,这种形态我在 9 月份讨论插件供应链时详细分析过。副官是可信的、已连接的、已授权的。攻击者永远不需要自己的访问权限。他们只需要把指令放到一个无法区分指令来源的副官面前。
根本原因是模型混淆了指令和数据,而没有任何过滤器能可靠地将它们分开。持久的修复是停止依赖模型的判断,在边界处记录血缘关系:Agent 读取的每个字段都应该携带一个标签说明是谁写的。以下是一个小型、可运行的 Python 标准库版本。它封装了 Agent 使用的记录,使信任边界显式化而非隐含。
from dataclasses import dataclass
from urllib.parse import urlsplit
@dataclass(frozen=True)
class Field:
"""A value plus its provenance: who put it here."""
value: str
trusted: bool # False if it came from outside your organization
def load_lead_record(raw: dict) -> dict[str, Field]:
"""Tag every field at ingestion. Untrusted until proven otherwise."""
return {
key: Field(value=str(value), trusted=False)
for key, value in raw.items()
}
def mark_verified(record: dict[str, Field], key: str) -> dict[str, Field]:
"""Only a human review or an internal system may flip a field to trusted."""
record = dict(record)
record[key] = Field(value=record[key].value, trusted=True)
return record
def agent_reads(field: Field, action: str) -> str:
"""
The tool boundary. Untrusted fields are data, never instructions.
If a field contains instruction-like text and is untrusted,
the agent reports it instead of obeying it.
"""
lowered = field.value.lower()
looks_like_instruction = any(
phrase in lowered
for phrase in ("ignore previous", "disregard", "send to",
"http://", "https://", "exfiltrate")
)
if not field.trusted and looks_like_instruction:
return f"FLAGGED for review: untrusted field attempted '{action}'"
return f"proceeding with '{action}' on trusted content"
这个示例中有几点值得注意。首先,默认值是不可信的。字段不会因为看起来无害就获得信任;它们必须通过已验证的渠道——人工审核或内部系统——才能获得信任。这个单一的默认值承担了大部分安全防护工作。其次,检查位于工具边界,而非模型内部。模型可以尽可能顺从,但门禁决定什么算作指令。第三,标记路径是报告而非静默丢弃,因为你永远听不到的已阻止攻击是一份正在被丢弃的威胁情报。
让我坦诚地说这是什么。关键词检查是有意为之的粗糙实现。在生产环境中,你需要的是一个分类器、嵌入距离检查或结构化输出模式——它根本不接受命令所在的自由文本。这个示例的重点是形态:血缘标记加上一个拒绝将数据提升为指令的边界。换掉检测启发式方法,换成你的团队信任的任何方式。保留这个形态。
SalesBleed 的第二个教训得到的关注较少:泄露之所以成功,是因为 URL 解析——白名单的执行层——是错误的。关于白名单这件事:每个人都在策略上达成一致。几乎没有人审查解析器。如果你从本文只带走一个机械性的任务,那就应该是这一个:像对待你的认证代码一样怀疑你的出口 URL 检查。
def egress_ok(url: str, allowlist: set[str]) -> bool:
"""Strict egress check. Exact hostname match, https only."""
try:
parts = urlsplit(url)
except ValueError:
return False
if parts.scheme != "https":
return False
if parts.username or parts.password:
# userinfo tricks: https://allowed.com@evil.com/
return False
host = (parts.hostname or "")
if host != host.lower() or host.endswith("."):
# demand canonical spelling: your own agent writes these URLs,
# so it has no excuse for creative capitalization
return False
if host != host.encode("idna").decode("ascii"):
# non-ASCII hostnames need explicit review, not silent acceptance
return False
return host in allowlist
# The part most teams skip: a test file of hostile URLs.
HOSTILE = [
"https://allowed.com@evil.com/", # userinfo trick
"http://allowed.com/", # wrong scheme
"https://allowed.com.evil.com/", # subdomain of attacker
"https://ALLOWED.COM./", # trailing dot, case games
"https://xn--allowed-9nf.com/", # lookalike punycode
]
SAFE = ["https://allowed.com/report"]
for url in HOSTILE:
assert not egress_ok(url, {"allowed.com"}), f"leaked: {url}"
for url in SAFE:
assert egress_ok(url, {"allowed.com"}), f"blocked legit: {url}"
print("egress gate holds")
这里需要注意的是:检查对任何无法理解的内容默认拒绝。解析失败就是拒绝,而非耸肩。用户信息拒绝捕获了经典的 allowed.com@evil.com 结构。规范拼写要求(精确小写、无尾部点)消除了整类规范化不匹配问题,而不是试图一一列举。IDNA 行强制 punycode 相似域名显式露出,而非静默规范化。测试列表才是真正的交付物。没有恶意 URL 测试套件的白名单只是一种希望,而 SalesBleed Trusted URLs 绕过正是希望在对抗压力下的样子。Zenity 在同一披露周期中用混淆载荷击败了其他过滤器,这告诉你测试需要达到的标准:你的团队像攻击者一样思考,在别人替你做之前。
关于这些防御,不做任何保留。如果你部署了上述两个门禁,以下内容仍然会漏进来。
血缘不能阻止可信人类粘贴恶意文本。被破坏的合作伙伴、恶意员工或被污染的供应商源都会戴着可信标签到达。血缘的质量取决于来源的清洁度。
坚定的泄露可以利用你允许的目的地。DNS 查询到已批准解析器、隐藏在错误消息或图像像素中的数据、时序通道:白名单缩小了出口,但没有封死它。
上述 IDNA 检查是一把钝器。合法的国际化域名会被标记为人工审核,这是一个迟早要处理的支持工单。根据你的实际流量调整规则,否则就要接受工单。
工具边界处的关键词检测是一个绊线,不是城墙。它捕获笨拙的载荷。耐心的攻击者会改写表述。
对 consequential 行动的人工批准消灭了零点击属性,但审批疲劳会把"批准"按钮变成反射动作。如果你的审批者一路点过去,你增加的是延迟,不是安全。
值得构建:你的 Agent 读取来自你信任边界之外的人写的行、工单、邮件或文档,且它可以访问网络。这就是穿着你们公司 logo 的致命三要素,SalesBleed 就是收据。
跳过:如果你的 Agent 只在检索上下文上回答问题,没有工具、没有写访问权限、没有网络路径。那你的问题是答案质量,不是泄露,这些门禁是,没有威胁要对付的机械装置。
最小可行版本一个下午就能完成。一个出口函数配精确主机名白名单、一份在 CI 中运行的恶意 URL 测试文件,以及每次阻止尝试时的一条日志行。观察那些日志一周。你会了解到你的 Agent 实际尝试联系什么,这是你曾经构建的最便宜的威胁模型。血缘标记排第二位,等日志告诉你哪些字段值得标记之后。
SalesBleed 值得你花一个下午,因为它不是 Salesforce 的故事。它是每个接入真实业务数据的 Agent 的教科书级失败模式:不可信内容流向可信副官,出口由无人审查的解析器守卫。供应商快速修复了漏洞,研究员负责任地披露了情况,而这个领域仍然敞开着。
构建出口门禁。用恶意 URL 喂它。在摄入时标记你的字段,并在工具边界拒绝将数据提升为指令。然后读日志,衡量你的 Agent 实际尝试了什么,根据数据决定你面对的是过滤问题还是架构问题。
你的 Agent 曾尝试联系过的最奇怪的目的地是什么,有什么阻止了它吗?
进一步行动,你可以考虑屏蔽此人或举报滥用