ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent 工具调用安全加固:文件访问、SSRF 拦截与响应脱敏实践

Agent 工具调用安全加固:文件访问、SSRF 拦截与响应脱敏实践

花了不少时间调 Agent 的工具调用权限,然后发现真正麻烦的不是模型,而是模型能碰到的那些东西。一个能帮你读文件、拉网页、调内部 API 的 Agent,本质上就是一个没有安全意识但拥有大量凭证的实习生。你让它去查一下日志,它可能顺手把日志里某个用户的 token 回给了调用方;你让它抓取一篇文章,它可能因为提示注入去请求云元数据地址,把临时凭证吐出来。

最近我把一个内部 Agent 项目重做了一遍安全加固,核心就是三件事:文件访问做了三级控制、外发请求加了 SSRF 拦截、响应出口统一做脱敏并附带校验位。这套方案不依赖特定框架,只要你的 Agent 有工具调用(Function Calling / Tool Use)能力就能套用。

1. Agent 安全的第一性问题:你没法控制模型的“意图”

传统应用安全的核心是控制用户的输入。Agent 场景反过来了,危险的是模型根据输入产生的“工具动作”。你很难在 Prompt 层杜绝模型被诱导,所以必须在模型和真实资源之间加一层强制约束。

1.1 为什么 Prompt 防护不可靠

早期我试过在 system prompt 里写“不得访问内网地址”“不得读取非授权文件”,实测效果约等于零。

举一个真实经过:某次测试,用户输入一篇包含 Markdown 图片链接的文章,模型识别出“这是一张远程图片”,自动调用 fetch 工具去获取图片。如果在 Prompt 里做了图片链接拼接,攻击者把链接写成http://169.254.169.254/latest/meta-data/,模型很可能就把云厂商的元数据抓回来了。

Prompt 注入是目前 Agent 领域公认的难题,业界最好的做法也不是靠模型自觉,而是靠“工具边界”。你的工具能访问什么资源、能发出什么请求、能返回什么数据,必须在模型不可控制的层面写死。

1.2 三项护栏的职责划分

我给这套护栏定了三个目标,对应标题里的三项能力:

  • 文件访问:控制 Agent 能读哪些文件、写哪些文件。这里最容易出问题的是路径穿越(../../etc/passwd)和符号链接指向敏感目录。

  • 外发请求:控制 Agent 能访问哪些网络目标。不仅要防误访问内网 IP,还要防 DNS 重绑定、IPv6 映射、重定向跟随这类绕过。

  • 响应出口:控制 Agent 生成的内容里不包含敏感数据。要意识到,即便工具层面做了访问控制,LLM 也可能把上下文里的信息拼接进回答,所以需要在出口统一脱敏。

所谓三级文件访问,就是纵向把“能接触文件”的能力拆成三个可独立配置的层次,任何一层不通过就不放行。

层级控制粒度典型手段
系统级进程能访问哪些路径OS 账号权限、chroot、容器只读层
Agent 级具体某个工具能读哪些目录工具注册时声明资源范围
指令级单次调用的路径是否合法路径规范化 + 白名单前缀校验

2. 文件三级访问:从操作系统到单次调用的逐层拦截

这层我踩过不少坑,最关键的一点是:不要用一个拦截函数打天下。单层校验一旦有漏洞,整个文件系统就暴露了。三级各有各的防线,哪怕前两级被绕过,第三级也能兜住。

2.1 第一级:系统级最小权限

这一级很多人会忽略,因为觉得后面有路径校验就够了。但路径校验函数难免有 bug,最稳妥的做法是让 Agent 进程本身就碰不到敏感目录。

我在生产环境用的是容器隔离:Agent 执行代码跑在独立容器里,文件系统只有/workspace可写,整个/etc、/root、/home都是只读或者干脆不可见。

几个细节:

  • 容器以非 root 用户运行(UID 必须显式指定,不能省略)
  • /workspace挂载为独立数据卷,宿主机其他目录都隔离
  • 敏感文件如果必须让 Agent 访问,用单独挂载而不是放进 workspace
# docker-compose 片断,注意 user 和 read_only services: agent-worker: image: agent-runner:latest user: "10001:10001" read_only: true tmpfs: - /tmp volumes: - agent-workspace:/workspace:rw

这层最大的意义是“失陷兜底”。假设 Agent 被完全攻破,攻击者也只能在这个容器里折腾,拿不到宿主机和整个内网的控制权。

2.2 第二级:工具注册表

到了 Agent 级,你要做的是在工具注册时声明它的资源边界。每个文件类工具都应该带上一个allowed_paths清单,路径加载器只能从这个清单里选目录。

我用的工具声明格式类似:

FileToolSet = { "read_file": { "name": "read_file", "allowed_paths": ["/workspace/data", "/workspace/config"], "blocked_paths": ["/workspace/data/private"], }, "write_file": { "name": "write_file", "allowed_paths": ["/workspace/output"], }, }

工具装载时,如果发现某个工具有资源请求但没有allowed_paths,直接拒绝注册。宁可少一个功能,也要保证每个工具都能说清楚自己能碰哪里。

2.3 第三级:指令级路径校验

第三级是真正干活的地方,也是绕过手法最多的环节。这里别自己写路径解析,一定用标准库的规范化函数。

核心逻辑:

  1. 先将路径转换为绝对路径
  2. 对软链接执行 realpath 解析(防止软链接指向白名单外)
  3. 校验解析后的路径是否以允许的目录前缀开头
  4. 对不存在的文件,需要先校验父目录
import os from pathlib import Path def check_path_access(tool_path: str, allowed_prefixes: list[str]) -> bool: # 1. 转绝对路径并规范化 candidate = Path(tool_path).expanduser().resolve() # 2. 如果路径存在,解析所有软链接 if candidate.exists() or candidate.parent.exists(): candidate = candidate.resolve(strict=False) # 3. 前缀校验 for prefix in map(Path, allowed_prefixes): resolved_prefix = prefix.resolve(strict=False) try: candidate.relative_to(resolved_prefix) return True except ValueError: continue return False

一个很容易漏掉的细节:校验前缀时不能只判断“路径字符串以/workspace/data/开头”,因为resolve()已经做了规范化,能防掉绝大多数路径穿越。

但如果想偷懒用字符串前缀判断,我建议也别拦着,只是会收到一堆奇怪的 bug 报告。

2.4 soft link 和 hard link 的坑

这是三级校验里最阴间的环节。某个目录下存在一个软链接,比如/workspace/data/link_to_logs -> /var/log,如果不做 realpath 解析,你校验的是/workspace/data/link_to_logs/security.log,看似在白名单前缀内,实际读的却是/var/log/security.log。

更阴间的是 hard link。在同一个文件系统内,你可以让一个目录里的硬链接直接指向/etc/passwd,从路径上看完全正常。防 hard link 的办法是系统级配置加protected_hardlinks,或者校验 inode 是否是常规文件,而不是在应用层死磕。

3. SSRF 拦截:Agent 发起的请求同样需要目标校验

SSRF(Server Side Request Forgery,服务端请求伪造)的核心问题是:服务端发起了攻击者期望的请求。Agent 场景里,攻击者通过提示注入让模型发起请求,本质和传统 SSRF 一样,但因为模型行为不可预知,拦截难度更高。

3.1 目标校验的第一步:URL 标准化

拿到一个 URL 之后,别急着发起请求。先要把它拆开,看清楚真正要连接的主机是谁。

http://2130706433/ -> 127.0.0.1(十进制 IP) http://0x7f000001/ -> 127.0.0.1(十六进制 IP) http://0177.0.0.1/ -> 127.0.0.1(八进制 IP) http://[::ffff:127.0.0.1]/ -> 127.0.0.1(IPv4 映射地址) http://localtest.me/ -> 127.0.0.1(DNS 解析)

这些变形看起来低级,但实际自测的时候能拦住大半,因为很多第三方 SSRF 库本身就不处理这些。

推荐的写法是先解析 URL,再提取 host/port,最后把各种格式统一转成标准 IP 格式去判断。

import ipaddress from urllib.parse import urlparse def validate_url_target(url: str) -> tuple[bool, str]: parsed = urlparse(url) host = parsed.hostname port = parsed.port or (443 if parsed.scheme == "https" else 80) if not host: return False, "missing-host" return validate_host(host, port)

算法的核心:

  1. 如果 host 本身是 IP 字面量,直接解析成标准 IP 判断私网/保留地址。
  2. 如果 host 是域名,先做 DNS A/AAAA 解析,拿到所有解析结果后逐个判断,任何一个解析结果是内网地址就拒绝。
  3. 重点是解析结果要转化成标准 IP 形式,比如::ffff:10.0.0.1要识别出真实 IP 是 IPv4 的10.0.0.1。

3.2 禁止用域名直接建连:聊聊 DNS Rebinding

这是我这一节最想强调的一点。校验完解析结果是公网 IP,不等于安全,因为你校验时解析的 IP 和实际建连时解析的 IP 可能不一样。

这就是 DNS Rebinding。攻击者控制一个域名,第一次解析返回公网 IP(比如你自己的博客服务器),校验通过;随后攻击者切换 DNS 解析到内网地址(比如 172.16.0.1)。如果你的代码是“校验解析结果->放行->再次用域名发请求”,第二次解析就可能拿到内网 IP。

正确做法是:校验通过后,用解析出来的 IP 作为连接目标,而不是再用域名去建连。也就是校验和建连必须使用同一次解析结果,并且建连时禁用自动 DNS 解析。

如果把安全策略放到 HTTP 客户端层,要注意重定向跟随。默认 Pythonrequests会自动跟随重定向,攻击者可以利用一个公网重定向跳转到内网地址。所以禁掉自动跟随,手动检查每个跳转身的 URL,或者干脆不允许跨域跳转。

3.3 网络层的兜底:出口限制

应用层校验再严格,也怕有遗漏。我的建议是网络层加一层兜底:Agent 容器的网络出口只允许访问固定的公网 IP 段,或者干脆走 HTTP 代理,由正向代理层做黑白名单。

做法参考:

  • Agent 容器只允许访问指定的 HTTP 代理地址,不允许直连外网
  • 代理层维护一份“允许访问的公网域名/网段”名单
  • 代理层对远程地址再做一次目标校验,防止应用层被绕过

这样即使应用层出现了一个逻辑漏洞让请求发出去了,网络层也会兜住。

4. 响应脱敏与带校验位的防篡改机制

文件访问和 SSRF 拦的是“Agent 能看到什么、能触达哪里”,响应脱敏管的是“Agent 返回的答案里有没有夹带私货”。

4.1 为什么要在出口统一脱敏

有人觉得文件访问都拦干净了,Agent 返回的内容自然是安全的。这个想法太乐观。LLM 的回答不一定是某个工具的直接输出,它可能把工具返回内容、系统提示、历史会话拼接在一起重新组织语言。模型不可控,所以出口必须有一道独立防线。

脱敏要在“Agent 返回内容”这个出口统一处理,不能在工具层做。工具层脱敏会导致模型在上下文里拿到的是脱敏后的内容,影响推理,但出口脱敏不会。

4.2 脱敏位点与敏感数据扫描

我实现的脱敏位点:

  • 工具结果返回给模型之前(可选,防止敏感数据进上下文)
  • 模型最终响应返回给调用方之前(必须做)

敏感数据的类型要覆盖常见的高价值泄露项:手机号、邮箱、身份证号、各类 API Key / Token、数据库连接串、内网 IP 地址、云厂商临时凭证、带 Metadata 的 URL 链接。

逻辑比较简单,本质是正则匹配 + 替换。区分度依赖于正则质量,下面给一个例子:

import re SENSITIVE_RULES = [ # 云厂商 AK 前缀示例 (re.compile(r"AKID[0-9A-Za-z]{16,}"), "<AK/>"), (re.compile(r"sk-[A-Za-z0-9]{20,}"), "<SK/>"), # 典型 access_token 形式 (re.compile(r"Bearer\s+[A-Za-z0-9\-_.~+/]+=*"), "Bearer <TOKEN>"), # 内网 IP (re.compile(r"\b(?:10|127|172\.(?:1[6-9]|2\d|3[01])|192\.168)\.\d{1,3}\.\d{1,3}\b"), "<PRIVATE_IP/>"), # 邮箱 (re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "<EMAIL/>"), ] def sanitize_text(text: str) -> str: for pattern, repl in SENSITIVE_RULES: text = pattern.sub(repl, text) return text

这里有个区分——规则不能一刀切把所有 IP 都盖掉,否则 Agent 想返回“服务部署在 10.10.10.10”这类正常信息也被拦,影响功能。所以“内网 IP”的正则里只匹配私网段,公网 IP 不管。

4.3 校验位:让调用方确认“这份响应是脱敏过的”

只做替换有个问题:调用方(比如前端、另一个服务)如何确认自己拿到的响应确实经过了脱敏网关?如果攻击者绕过网关直接调用后端接口,拿到的就是未脱敏响应。如果网关和调用方之间被中间人篡改,调用方也无从察觉。

我引入了校验位机制,做法比较直接:对脱敏后的响应内容计算一个 HMAC,附加在响应头里。

import hashlib import hmac SANITIZE_SECRET = b"set-a-different-secret-in-production" def compute_checksum(body: bytes) -> str: return hmac.new(SANITIZE_SECRET, body, hashlib.sha256).hexdigest()

调用方拿到响应后,先取X-Sanitize-Checksum头,对响应体做同样的 HMAC 计算并比对。如果一致,说明这份响应在网关脱敏之后没有被篡改。

这里校验位的意义:

  1. 防绕过:调用方可以通过校验位判断响应是否真的经过网关。
  2. 防篡改:一旦响应被中间人替换,校验位就对不上。
  3. 审计依据:网关可以在日志里记录每个响应的校验位,事后排查非常方便。

个人建议校验位只对文本类响应(application/json、text/plain 等)生效,二进制文件流不要去算 HMAC,因为性能开销大,而且文件类响应通常走独立接口,不属于模型生成的文本响应。

4.4 脱敏失败时的降级策略

正则替换不是百分百可靠,总有漏网之鱼。我的做法是加一道“疑似敏感内容”检测:如果某个响应在替换后仍包含大量疑似密钥结构(比如连续 32 位以上的 base64 字符串),网关直接返回一个标准的 refuse 报文,而不是冒险把未脱敏内容放出去。

HTTP/1.1 200 OK Content-Type: application/json X-Sanitize-Checksum: 7f9c...(比对失败时客户端应扔掉整个响应) {"code": 403, "message": "response blocked: potential sensitive data"}

注意这时校验位也要照常计算。如果调用方发现校验失败/报文内容是 refuse,就知道这次请求有问题,可以把 payload 存下来给安全组排查。

5. 落地的完整链路与排错笔记

三个模块单独实现都不难,真正花时间的是把它们串进 Agent 框架里,并且在真实场景里不自相矛盾。

5.1 编排:请求在护栏间的流转顺序

以一个带工具调用的 Agent 为例,完整链路如下:

  1. 用户输入先到达 Agent API 网关。
  2. Agent 框架启动会话,LLM 生成工具调用请求。
  3. 工具调用请求进入工具网关,先做鉴权和参数白名单校验。
  4. 文件类工具走文件三级访问;HTTP 类工具走 SSRF 拦截;数据库类工具走是否允许该会话调用的校验。
  5. 工具执行完成后,响应回到工具网关,如果包含敏感信息则在工具层打脱敏标记(可用)。
  6. LLM 根据工具结果生成最终回复,回复在出口网关做强制脱敏 + HMAC 签名。
  7. 调用方拿到响应,校验X-Sanitize-Checksum,一致则接受,不一致则丢弃。

5.2 常见坑清单

这套系统上线以后,我记录了几个高频踩坑点,写在这里供参考:

1. 路径校验的绝对路径和软链接问题

很多同学只判断Path(tool_path).resolve()之后的前缀,而忘了校验“父目录是否存在”。一个路径resolve(strict=False)和strict=True的行为不同,对不存在的文件会抛出异常,导致工具调用直接失败。

建议所有文件访问统一走一个safe_open函数,在函数内部做完整校验,而不是靠每个工具自己拼路径。

2. SSRF 校验里对“重定向跟随”的处理

HTTP 客户端库默认跟随重定向,攻击者可以把http://your-public-server配成 302 跳转到http://172.16.0.1。我一开始没管这个,到内网扫描测试时才发现。

正确做法是禁用跟随,或者像前面说的,手动逐个校验跳转目标。

requests.get(url, allow_redirects=False)

3. 脱敏的顺序问题

脱敏规则必须放在最后执行,不能在 LLM 生成回复之前就全局替换一遍,否则模型看到的脱敏内容可能影响语义。工具层可以打标记,但最终面向调用方之前必须做最后一遍脱敏。

4. HMAC 校验位的 payload 类型判断

如果响应是二进制(比如 Agent 下载文件),HMAC 算出来调用方也很难校验,反而成为性能瓶颈。我的策略是只在文本响应上启用校验位,二进制接口单独走访问控制。

5.3 验证方法:用攻击样例做回归测试

护栏做完了不能只靠想象,我强烈建议建一套回归测试集,包含了攻击样例:

攻击样例清单: - 路径穿越:read_file("../../etc/passwd") - 软链接绕过:/workspace/data/link_to_etc/passwd - 混合场景:write_file 到 /workspace/../workspace2/evil.txt - SSRF 私网直连:http://169.254.169.254/、http://10.0.0.1/ - SSRF 变形:十进制 IP、IPv6 映射、DNS Rebinding - 提示注入 + 外发:让模型抓取公网恶意 URL,URL 里重定向到内网 - 敏感数据透传:让模型在回答中返回自定义 token,确认出口被盖掉 - 校验位防篡改:模拟中间人替换 json 内容,确认 checksum 失败

每次更新 Agent 框架或某个工具实现,都跑一遍这个清单。实测下来这是性价比最高的安全投入,花半天时间建好,后面能省下大量沟通成本。

6. 收尾:安全护栏和 Agent 能力的平衡

做完这套方案之后我的体会是,Agent 安全最难的其实不是某一个技术点,而是别让安全把 Agent 变傻。三级文件访问如果配置太死,Agent 经常读不到它应该读的配置;SSRF 如果只允许少数白名单域名,Agent 拉资料的能力就废了一半。

我现在的调参思路是:文件访问默认从严,只有在工具声明里明确写清楚“读 /workspace/public”这类需求才放开;SSRF 默认放行公网 IP,内网一律拦截,特殊情况走独立的内网专用工具(这样便于审计);脱敏规则保持“宁可错杀,不放过密钥”。

另外建议给每个 Agent 实例分配独立凭证和独立审计 ID。这样一旦某个 Agent 出了安全问题,你能快速定位到是哪个实例、哪个配置、哪个时间点出的问题。

最后分享一个小技巧:安全网关的日志别只记“拦截了什么”,要把“放行了什么”也记录下来,特别是针对 SSRF 和文件访问的放行记录。很多时候你排查一个 Agent 的诡异行为,最后发现是某个被放行的请求在搞鬼。

这套护栏方案我在内部跑了两个多月,从最初每天都有告警,到后面基本稳定。它能拦住的不是所有攻击,但可以把最常见的、危害最大的几类风险堵住。如果你的 Agent 也接了文件工具和 HTTP 工具,尽早把它加上,别等到上线了再补。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进