发现智能体被攻陷

9 分钟读完

S18
运营 · 安全、对齐与智能体安全

发现智能体被攻陷:破绽在轨迹的形状里,不在文本里。

你买的每一个控制项都在检查内容——入口处的注入分类器、出口处的输出扫描器——而它们每一个都会「失败即放行」,这意味着有一次绕过它们的那天,正是你完全没有检测能力的那天。挺过这一点的是行为层面的东西:被攻陷的智能体,其工具调用序列会不再像该任务类型通常产生的那些序列,而且它通常会在这个过程中丢下用户的任务。麻烦在于,一个健康的智能体按普通 SOC 的标准衡量本来就极度异常,所以通用的用户行为基线只会产出噪声。按任务类型建基线、对轨迹形状告警,你就得到了一个在分类器漏检之后仍然有效的控制项。

STEP 1

内容检查属于预防,而预防不带任何检测故事。

标准配置是:一个给检索到的文本打注入分数的输入分类器,加一个寻找外泄模式的输出过滤器。两者都值得有。两者都不会告诉你某个智能体已经被攻陷了,原因是结构性的,而不是模型质量问题。

  • 分类器的漏检是静默的。召回率 98% 的检测器是个好检测器,可它仍会每五十次尝试放过一次,并且不产生任何事件。这里没有第二个信号,因为「这是不是一次攻击」和「刚才有没有发生攻击」是由同一个组件裁定的。
  • 事后来看,那条指令与上下文无从区分。被注入的文本一旦进了对话记录,它就只是模型读到的一些令牌,而其后每一步看上去都像模型在对自己的上下文做推理——事实也正是如此。到后面的步骤里去扫那条注入什么也找不到,因为注入早已生效,也没有任何理由继续存在。
  • 输出过滤器位于那些真正有意思的外泄路径的下游。一个由客户端去取的 markdown 图片 URL,根本不会以文本形式经过你的文本过滤器;参见安全地渲染智能体输出

有用的表述是:内容类控制降低被攻陷的频率,行为类控制降低被攻陷的时长。这两个数你都得有。大多数团队只有前者,还把它的召回率当成覆盖度在报。

STEP 2

你的智能体天生就是异常的,所以通用基线一文不值。

把一款现成的用户行为分析产品对准一个恰好是智能体的服务账号,它会永久性地亮红灯。这个智能体每小时读几百份文档、连着调十几个 API、凌晨三点在干活,而且它的量会随着被派的任务变动一个数量级。上面每一条都是教科书级的内部威胁指标,而上面每一条又都是正确行为。团队的反应是不断放宽阈值直到告警不再出现——到那一刻,这个控制项就已经关掉了。

解法是换掉建基线的单位。真正有区分度的比较不是这个身份与它自己的历史相比,而是这次运行与同一任务类型的其他运行相比。一次 summarise-ticket 的运行有其特征形状:大致这么多步、这些工具大致按这个顺序、读取面这么宽、出站到这些主机。这个分布是紧的,因为任务是固定的;而一次跑出这个分布的运行是值得看的,哪怕该身份的滚动均值一切正常。

  • 在派发时就给每次运行打上任务类型标签。做不到这一点,你就没有分母,下面的一切都不成立。
  • 基线要用生产环境中成功的运行来建,按滚动窗口刷新,而不是用一份「智能体应该做什么」的设计文档来建。
  • 当各租户的数据形状差异大到有影响时,就按租户分开维护各任务类型的基线;否则「正常」的定义会由你最大的那个客户来写。
STEP 3

五个真正有区分度的信号,按性价比排序。

这里按实战中的信噪比排序,而不是按听起来有多高级。第一个几乎是免费的,并且能抓住成功注入中的大多数——因为一个被劫持的智能体,必须把它剩下的步数花在攻击者的目标上,而不是用户的目标上。

  • 任务遗弃。拿这次运行的终态去比对它被派发时的任务:它有没有产出被要求的那件产物?被注入的智能体通常会返回一个看着像模像样的非答案,或者在一件相邻的事情上取得了成功。计算便宜,而且攻击者出奇地难以规避——把用户的任务完成掉,要额外花掉攻击者的步数和上下文。
  • 工具序列新颖度。把本次运行的工具名序列,对照该任务类型上观测到的 n-gram 打分。一个 read → read → summarise 的任务里突然出现 read → list_keys → http_post,就会被打成「未见过」。工具名上的二元组就能抓住其中大部分;你不需要一个序列模型。
  • 出站目的地新颖度。首次接触某台主机、首次使用某个能通向外部的工具、出站字节与入站字节的比例反常。这是严重度最高、同时量最低的信号,它该紧挨着出站管控放——允许清单负责拦,检测器负责记录谁试过。
  • 读取面膨胀。相对该任务类型的分布,本次触及的文档、行或代码仓的数量与多样性。外泄尝试之前那种分阶段的数据收集,最先就出现在这里;一个检索权限缺陷也是这样自报家门的。
  • 授权摩擦。被拒的调用、作用域错误、被拒绝的工具、对同一个禁止端点的反复重试。一个健康的智能体很少为同一件事被拒绝两次;而在试探边界的东西会。把拒绝本身当作独立事件记下来——大多数技术栈在策略层就把它们丢掉了。

如果这里只做一个,就做任务遗弃。它不需要调度器已知信息之外的任何新遥测,触发时是可解释的,而且它是攻击者为了压制而付出代价最大的那个信号。

STEP 4

循环不发出正确的字段,上面的一切都算不出来。

大多数智能体链路追踪是为调试而建的:提示词、补全、延迟。检测需要的是同一次运行的另一种投影——一条紧凑的、逐步骤的记录,其字段可以跨运行比较。要从工具调用路径内部发出它,而不是从一个会被智能体自身错误跳过的外层包装里发出。

# detect/step_record.py — emitted on every tool call, before dispatch
def record(run, step, tool, args, result):
    emit({
        "run_id":      run.id,
        "task_type":   run.task_type,      # the baseline key
        "principal":   run.on_behalf_of,   # human, not service account
        "step":        step,
        "tool":        tool,               # name only — the n-gram unit
        "dest":        egress_host(args),  # None for local tools
        "read_ids":    hashed(result.ids), # breadth, never content
        "bytes_out":   size(args),
        "denied":      result.denied,      # keep policy refusals
        "ctx_source":  args.provenance,    # which retrieved doc drove this
    })

有两个字段的价值远超其体量。principal 保留了这次运行是替哪个人在行动,于是一次攻陷可以按受影响的人来划定范围,而不是按大家共用的那一个服务账号——这正是智能体身份里的论点。ctx_source 保留了这一步之前那段文本的来源,正是它把「这次运行跑偏了」变成「这份文档会策反智能体」,而后者才是保护其余所有租户的那条发现。请对标识符做哈希而不要存内容;检测器要的是宽度和形状,而把文档本身留下来,会把你的检测存储变成全网最诱人的目标。留存策略遵循链路采样与留存——并请注意,在这里按比例采样运行恰恰是错的,因为你丢掉的那次运行正是你需要的那次。为控成本可以对 span 采样,但逐步骤记录要对每一次运行都保留。

STEP 5

只会开工单的检测器,什么时长也没缩短。

检测在被接到某个跑得过人的动作上之前,什么也换不来。智能体在几秒内就走完剩下的步骤;一条十五分钟后到达的呼叫,是在运行结束之后到的。搭一个阶梯,并把便宜的那几级放进循环本身。

  • 降级。分数中等时,把这次运行降到只读工具并让它跑完。大多数误报止步于此,代价是一次被降级的运行——正是这一点让激进的阈值负担得起。
  • 设关。出现出站类或授权类信号时,要求下一次改变状态的调用必须经过审批。如果你已经有人在环,审批界面就已经存在;这里是把流量改道进去,而不是新建一套。
  • 叫停。分数高时,停掉这次运行并做检查点。这就是运行粒度上的终止开关,而它必须能由检测器直接调用,路径上不夹人。
  • 隔离来源。ctx_source 指向某份具体文档时,在复核完成前对所有运行封禁它。一个被投毒的页面会触达每一个检索到它的智能体,所以遏制的单位是文档,不是运行。

阈值应当按每一级上误报的代价来定,而不是按一个固定分位数。降级承受得起在百分之几的运行上触发;叫停承受不起。把确认的命中转入事件响应,并预期第一次清理是不彻底的——在工具层关掉的一条通道,往往会在某个不那么显眼的地方被重新打开。

STEP 6

要么测出平均发现时间,要么你只是在猜。

一个从未被演练过的检测器,只是一种信念。测量很便宜:埋无害的哨兵,然后数步数,不是数分钟。

  • 把一条哨兵指令放进智能体会检索到的文档里——让它去调用一个你自己掌控的、无害的内部端点。每周在预发布租户里、按生产配置跑一次。你的指标是:从智能体读到它,到检测器触发,中间隔了多少步。
  • 记录是在哪一级被抓住的。是哪一级拦下的,还是这次运行没被抓住就跑完了。「在叫停这级发现」和「运行结束之后才发现」是两种不同的产品。
  • 换着花样做载荷。一个做外泄、一个悄悄丢下任务、一个抬升作用域。每一种演练的是不同的信号,而「任务遗弃」这一种,是大多数检测器会栽的那一种。
  • 两个数都要报。来自分类器的被攻陷频率,来自行为栈的持续时长。一个只报前者的项目,对「漏检之后会发生什么」毫无证据。

这个季度按顺序做这三件事:给每次运行打上任务类型标签、拿终态去比对派发的任务、把策略拒绝当事件记下来。这大约是一天的工作量,却能把你的智能体集群从「不可观测」变成「有基线」。工具序列打分和出站新颖度值得接着建,而这两者都要靠任务类型标签才有意义——所以要先落地的依赖是那个标签,不是模型。

延伸:提示词注入防御讲预防的那一半,数据外泄风险讲出站信号在保护什么,失败分类与分诊讲怎样把「被攻陷」与普通失败区分开,决策回执讲你事后解释经过时会想要的那份记录。