Saltar al contenido
Luis ZambranaDevOps · Seguridad · Forense

Y no se olviden de Roundcube: PHP2Shell no perdona

Después de PHP2Shell, el webmail es el servicio que todos usan y nadie mira. Un caso supuesto: Roundcube 1.6.7 con PHP 8.1 a 1.6.20 con PHP 8.4.27, 15 segundos de corte, las trampas que aparecieron y cómo los encabezados pasaron de C a A.

Roundcube y PHP2Shell: de 1.6.7 con PHP 8.1 a 1.6.20 con PHP 8.4.27, encabezados de C a A

Después de PHP2Shell todos corrimos a actualizar PHP en los sitios. Bien. Pero hay un servicio que casi nadie mira porque "anda": el webmail. Y ahí suele haber un Roundcube instalado hace años, con un PHP de esa época, publicado en Internet y con miles de usuarios adentro.

Esta entrada cuenta cómo actualizar uno de esos sin romper nada, con un caso supuesto (los nombres, dominios y direcciones son inventados) pero armado con problemas bien reales.

El caso: un webmail que nadie tocaba

Supongamos un organismo con un webmail en webmail.ejemplo.gob.ar, que atiende varios dominios. La foto del día uno:

  • Roundcube 1.6.7 en Docker, sobre la imagen oficial, con PostgreSQL.
  • PHP 8.1.29, Apache 2.4.59.
  • Unos 9.000 usuarios y más de 2.000 sesiones abiertas.
  • Un skin propio con los logos del organismo, hecho sobre una versión vieja de elastic.
  • Un plugin de cambio de contraseña con un driver propio que llama a un script externo.
  • Todo detrás de un proxy inverso que hace el HTTPS.

Y los encabezados que devolvía:

Server: Apache/2.4.59 (Debian)
X-Powered-By: PHP/8.1.29
Set-Cookie: roundcube_sessid=...; path=/; HttpOnly

Sin HSTS, sin CSP, la cookie de sesión sin Secure ni SameSite y las versiones exactas a la vista. En securityheaders.com daba C.

Por qué no se puede esperar

Ese servidor juntaba tres problemas a la vez:

  1. PHP2Shell (CVE-2026-109618 y CVE-2026-109619, CVSS 9.2): ejecución remota sin login, antes de que corra una sola línea de Roundcube, con un pedido multipart/form-data. El parche salió el 21/10 para 8.2, 8.3, 8.4 y 8.5. PHP 8.1 ya no tiene versión oficial con el arreglo. Un webmail es justo el tipo de servicio que acepta formularios de cualquiera: la pantalla de login está ahí para eso.
  2. Roundcube 1.6.7 arrastra fallas propias conocidas. Entre otras, CVE-2024-42009 (XSS al abrir un correo, corregida en 1.6.8) y CVE-2025-49113 (ejecución remota para un usuario logueado, corregida en 1.6.11). Las dos se explotaron en ataques reales contra organismos públicos.
  3. La cara expuesta lo hace fácil. X-Powered-By: PHP/8.1.29 le dice a un escáner automático, sin esfuerzo, que vale la pena probar.

Con PHP2Shell se vio mucho de esto: los sitios que cayeron no eran los más grandes, eran los que nadie actualizaba. Muchos de gobierno. El atacante no busca a alguien en particular: barre Internet buscando versiones viejas.

A qué versión ir

La rama vigente de Roundcube es la 1.7, pero no hacía falta un salto tan grande en una ventana corta. Se eligió 1.6.20:

  • Es la última de la rama 1.6 (salió el 19/10) y trae unos 20 arreglos de seguridad respecto de 1.6.19.
  • Su imagen oficial, roundcube/roundcubemail:1.6.20-apache, ya viene con PHP 8.4.27, que tiene el parche de PHP2Shell. Un solo cambio arregla las dos cosas.
  • El esquema de la base es el mismo que en 1.6.7. Esto es oro: si hay que volver atrás, no hay que restaurar la base.

Para verificar lo último, comparar la versión del esquema en la tabla system con la de la versión nueva:

SELECT * FROM system WHERE name = 'roundcube-version';
-- 2022081200 en las dos

Pasar a 1.7 queda para otro momento, con tiempo para probar los plugins.

Antes de tocar nada: el backup de verdad

Lo primero fue un backup de la máquina virtual entera. La idea era clonarla, pero el clon en caliente falló por el disco EFI ("Source and target image have different sizes"). Se usó vzdump en modo snapshot, que con el agente de QEMU congela el sistema de archivos un instante y no corta el servicio. 37 GB, 16 minutos, y marcado como protegido para que la rotación no lo borre.

Y justo antes del cambio, un dump de la base:

docker exec roundcube_postgres_1 sh -c 'pg_dump -U $POSTGRES_USER $POSTGRES_DB' \
  | gzip > ~/rc-antes-1.6.20-$(date +%Y%m%d%H%M).sql.gz

Más una copia *.1.6.7 de cada archivo que se iba a reemplazar. Con eso, volver atrás es copiar cuatro archivos y levantar el contenedor de nuevo.

Las trampas que aparecieron revisando

Antes de la ventana se revisó todo lo que estaba montado en el contenedor. Salieron cosas que, si no se veían, rompían el webmail o lo dejaban igual de vulnerable.

1. Los skins tapaban la versión nueva

El docker-compose.yml montaba toda la carpeta skins/ local sobre la del contenedor. O sea: aunque la imagen fuera 1.6.20, el skin elastic seguía siendo el de 1.6.7. Se cambió para montar solo los skins propios.

Y los propios eran un fork de elastic de 2021. Se miró qué tenían de distinto: colores en _variables.less, algunas reglas en _styles.less, el logo en menu.html y las imágenes. Nada más. Se rearmaron sobre el elastic nuevo, se compilaron los .less y se compararon con producción hasta que se vieran igual.

2. La clave de cifrado vivía adentro del contenedor

La imagen oficial genera des_key (la clave con la que Roundcube cifra la contraseña IMAP dentro de la sesión) al arrancar y la deja en config.inc.php dentro del contenedor. Si se recrea el contenedor sin fijarla, sale una nueva y todas las sesiones quedan inservibles. Se sacó la actual y se pasó al .env:

K=$(docker exec roundcube sed -n "s/.*des_key'\] = '\(.*\)';/\1/p" /var/www/html/config/config.inc.php)
echo ${#K}            # tiene que dar 24
echo "ROUNDCUBEMAIL_DES_KEY=$K" >> .env

3. El driver de contraseñas permitía ejecutar comandos

El plugin password usaba un driver propio que hacía esto:

shell_exec("/ruta/chpass.py $usuario $vieja $nueva");

Sin escapeshellarg. Una contraseña con ;, $() o comillas no solo fallaba: ejecutaba lo que viniera después dentro del contenedor. Y el script mandaba las claves en la URL al panel interno, así que quedaban en los logs del otro lado. Arreglado:

$cmd = '/ruta/chpass.py ' . escapeshellarg($usuario) . ' '
     . escapeshellarg($vieja) . ' ' . escapeshellarg($nueva);

Y el script ahora manda los datos en el cuerpo del POST.

4. Basura que no tenía que estar

  • Un logs/1.php con un exec('echo hola') de alguna prueba vieja. Daba 403 desde afuera, pero no tenía nada que hacer ahí.
  • smtp_debug = true olvidado: smtp.log pesaba 24 GB.
  • Una libreta LDAP de ejemplo que venía del config original.

5. Dos docker compose distintos

En el servidor convivían docker-compose 1.21 (el viejo, en Python) y docker compose v2. Los contenedores los había creado el viejo. El v2 los nombra distinto, así que habría levantado contenedores nuevos al lado en vez de reemplazar los existentes, peleándose por el mismo puerto. Regla: usar el mismo que los creó.

Probar con datos reales, sin tocar producción

En el mismo servidor se levantó una copia aparte (otro nombre de proyecto en compose), escuchando solo en 127.0.0.1, con una copia de la base. Se entró por un túnel SSH y se probó con una cuenta de prueba:

ssh -L 9102:127.0.0.1:9002 usuario@servidor
# después, en el navegador: http://localhost:9102

Login, lectura, envío, filtros (sieve), cambio de contraseña y los skins de cada dominio. Al terminar, docker-compose -p prueba down -v para no dejar nada.

La ventana: 15 segundos de corte

Con todo probado, el cambio fue corto:

  1. Dump de la base.
  2. Copias *.1.6.7 de lo que se reemplaza.
  3. Archivos nuevos en su lugar y ROUNDCUBEMAIL_DES_KEY en el .env.
  4. Recrear solo Roundcube. PostgreSQL no se toca:
docker-compose up -d --no-deps roundcubemail
docker logs -f roundcube_roundcubemail_1   # hasta "resuming normal operations"
  1. Verificar:
docker exec roundcube_roundcubemail_1 php -v       # 8.4.27
curl -sI https://webmail.ejemplo.gob.ar/ | grep -iE 'server|powered|strict|cookie'

El webmail estuvo caído unos 15 segundos. Como la des_key era la misma, los usuarios ni se enteraron.

Cerrar todas las sesiones

¿Para qué cuidar las sesiones si después se cierran todas? Porque son dos cosas distintas: una cosa es que el cambio no rompa nada y otra es decidir echar a todos. Con un servidor que estuvo expuesto, lo prudente es invalidar cualquier sesión que alguien pudiera haber robado:

docker exec roundcube_postgres_1 sh -c \
  'psql -U $POSTGRES_USER -d $POSTGRES_DB -c "TRUNCATE session;"'

Más de 2.300 sesiones cerradas. La gente volvió a entrar sin problemas. Hubo un solo error de clave duplicada en el log, en el mismo segundo del TRUNCATE (un navegador que justo guardaba su sesión), y no se repitió.

De C a A en los encabezados

Actualizar arregla las fallas. Los encabezados hacen que el navegador ayude y que el servidor no regale información. Se hizo en dos partes.

En Roundcube

Un archivo zz-seguridad.inc.php en la carpeta de configuración. La imagen oficial incluye esos archivos en orden alfabético, así que el zz lo deja último y pisa lo anterior:

<?php
// Todo llega por HTTPS a través del proxy: cookie de sesión con Secure
$config['use_https'] = true;
$config['session_samesite'] = 'Lax';

// IP real del cliente en los logs (la que manda el proxy)
$config['proxy_whitelist'] = ['10.0.0.10'];

// Sin versión de Roundcube en los correos enviados
$config['useragent'] = 'Webmail';

// Nada de logs gigantes; sí registro de logins para auditar
$config['smtp_debug'] = false;
$config['log_logins'] = true;

Y expose_php = Off en el .ini de PHP, para que desaparezca X-Powered-By.

En Apache

Un .conf propio, montado en el contenedor como archivo suelto. Así se puede ajustar sin rearmar la imagen:

ServerTokens Prod
ServerSignature Off
TraceEnable Off

Header always set Strict-Transport-Security "max-age=31536000"
Header always set X-Content-Type-Options "nosniff"
Header always set Referrer-Policy "same-origin"
Header always set Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; font-src 'self' data:; frame-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'"

X-Frame-Options no hace falta agregarlo: Roundcube ya lo manda solo.

Por etapas, no todo de golpe

  • Primer paso: de C a B. Sin versiones, Permissions-Policy, HSTS de 1 semana y la CSP publicada como Content-Security-Policy-Report-Only: el navegador avisa lo que bloquearía, pero no bloquea nada.
  • Una hora de uso real. Se usó el webmail con la consola del navegador abierta: leer, escribir, adjuntar, filtros, cambiar la clave. Ni un aviso.
  • Segundo paso: de B a A. La CSP pasó a Content-Security-Policy (ahora sí bloquea) y HSTS a 1 año.
  • Para recargar Apache sin cortar: apache2ctl graceful.

Un detalle con el archivo montado: Docker monta el inodo, no el nombre. Si lo editás con algo que crea un archivo nuevo y lo renombra (mv, tar, algunos editores), el contenedor sigue viendo el viejo. Usá cat > archivo o un editor que escriba en el mismo archivo.

Y en la consola de Chrome puede aparecer Permissions policy violation: unload is not allowed. No es tu política: es Chrome retirando el evento unload, que usa jQuery. Es inofensivo.

¿Y la A+?

Para la A+ hay que sacar 'unsafe-inline' de la CSP, y Roundcube 1.6 usa scripts y estilos inline por todos lados. Se podría con nonces, pero eso es tocar el código de Roundcube y perder la imagen oficial. Una A con la imagen oficial, fácil de actualizar la próxima vez, vale más que una A+ con un fork que nadie va a mantener.

Cuidado con lo que activaste

HSTS de 1 año tiene una contracara: si el certificado vence, el navegador no deja "continuar de todos modos". El webmail queda inaccesible hasta que se renueve. Con certificados de renovación manual, anotá la fecha de vencimiento en un calendario compartido, con aviso un mes antes. Y no pongas includeSubDomains si el dominio tiene otros subdominios que no controlás.

La cookie con Secure hace que ya no se pueda entrar por HTTP directo al puerto del contenedor. Si alguien (o el monitoreo) entraba así, va a tener que pasar por el proxy.

Volver atrás, por las dudas

cp -p docker-compose.yml.1.6.7 docker-compose.yml
for f in config/php/custom.ini config/passconfig/drivers/driver.php config/rcconfig/password/chpass.py; do
  cp -p "$f.1.6.7" "$f"
done
rm config/rcconfig/zz-seguridad.inc.php
docker-compose up -d --no-deps roundcubemail

Sin restaurar la base, porque el esquema no cambió. Si algo más falla, están el dump y el backup de la VM. No hizo falta.

Lista corta para tu webmail

  • ¿Qué versión de Roundcube y de PHP tenés? docker exec ... php -v. Si es 8.1 o anterior, no hay parche oficial para PHP2Shell.
  • Backup de la VM (o del servidor) y dump de la base antes de empezar.
  • Revisá qué carpetas montás: un skins/ o un plugins/ montado entero tapa la versión nueva.
  • Fijá des_key en tu configuración.
  • Buscá shell_exec, exec y system en tus plugins y drivers propios.
  • Probá en una copia aparte, solo en 127.0.0.1, con túnel SSH.
  • Recreá solo el contenedor del webmail, con el mismo docker-compose que lo creó.
  • Cerrá todas las sesiones al terminar.
  • Encabezados: CSP en modo informe primero, HSTS por etapas, sin versiones a la vista.
  • Anotá cuándo vence el certificado.

Si querés armar uno desde cero, en Servidor de correo seguro en Docker está el paso a paso con docker-mailserver, Roundcube y Let's Encrypt.

PHP2Shell no perdona. Y el webmail es el servicio que todos usan y nadie mira. Andá a fijarte qué versión tenés.

Comentarios

Todavía no hay comentarios. ¡Sé el primero!

Seguí leyendo

Docker

Seguridad en Servidores de Emails

En otras oportunidades hablamos de spam, envios de mails masivos y otros tantos, pero se nos pasó un tema interezante que puso de moda (y exige) las empresas Hotmail y Gmail, hablamos de la Seguridad en Servidores de Emails implementando SPF – DMARC y DKIM (hay yapa al final)

11 min