Guilgo Blog

Notas de mi quehacer diario con las técnologias.

php.net publicó PHP 8.4.27 el mismo día en que dos RCE sin autenticación (el aviso que NCSC-NL agrupa como PHP2Shell) dejaron claro el riesgo máximo: Apache con mod_php. En ese modelo, y con configuración por defecto, se ha demostrado explotación antes de ejecutar el código de la aplicación. PHP-FPM y otros frontends web también están afectados; mod_php es el escenario de mayor riesgo, no el único. En el homelab, cuatro contenedores web con CMS propios ya corrían así, con tags flotantes desfasados.

En corto: subimos directo a 8.4.27, pinamos el tag semver en el Dockerfile, reconstruimos solo el servicio web (--no-deps, sin tumbar MariaDB) y verificamos en runtime y en el borde. De cara al siguiente release, el rebuild queda documentado con build --pull.

Este post no es un changelog de php.net. Es la crónica de un parche real: qué bug salió, por qué nos tocaba de lleno, cómo se actualizaron las imágenes, qué falló por el camino y qué quedó aprendido.

El bug: CVE-2026-109618 y CVE-2026-109619

El 2026-10-21, el aviso de seguridad de php.net y el changelog 8.4.27 documentan dos fallos:

CVE Problema técnico Por qué duele en mod_php
CVE-2026-109618 Estado de petición parcialmente inicializado tras un error de memoria (OOM), tratado incorrectamente durante la limpieza Un worker Apache reutiliza procesos; un fallo de memoria en el runtime no necesita login de la app
CVE-2026-109619 Estado asociado a una petición que persiste después de liberar su memoria (use-after-free entre requests del mismo worker) Entre dos peticiones del mismo proceso PHP, el estado residual se puede abusar

CVSS v4.0: 9.2. No es “un CVE del escáner que no nos aplica”. El aviso marca explícitamente mod_php como el caso de mayor riesgo (PHP-FPM, php-cgi, FrankenPHP y LiteSpeed también entran).

No hay mitigación completa. CERT-EU y el propio aviso listan medidas temporales (memory_limit=-1 no sobreescribible, session.upload_progress.enabled=0, límites de post_max_size, etc.) que reducen vías concretas si no puedes actualizar ya. En CVE-2026-109619, una de las rutas confirmadas pasa por session.upload_progress: desactivarla acota esa superficie, no elimina la vulnerabilidad. Ninguna de esas medidas hace la instalación segura. Aquí no se usaron como sustituto: el arreglo es la versión parcheada.

Cronología: de P3 a RCE en un día

Momento Qué ocurrió
2026-10-20 Nota interna: los CMS en raam estaban por debajo de 8.4.26. Criterio P3: deuda de parche, sin ruta de explotación confirmada ese día para los CVE de la 8.4.26.
2026-10-21 (mañana) php.net publica 8.4.27 por dos RCE que sí aplican a Apache + mod_php.
Mismo día Inventario real: cuatro contenedores en 8.4.20, 8.4.25, 8.4.24 y 8.4.23 (tres en raam, uno en un segundo host).
Mismo día Pin de tag fijo vía mirror pull-through, rebuild solo del servicio web (--no-deps), MariaDB intacta.
Mismo día Verificación: php -v, X-Powered-By, HTTP 200 local y en borde.
Después Commit del pin en git y fast-forward en el otro host que reconstruye la misma imagen.

Decisión consciente: no quedarse en 8.4.26. Si el día siguiente ya existía 8.4.27 con RCE reales para nuestro modelo de despliegue, subir directo a esa release.

Por qué nos tocaba de lleno

Los CMS de este entorno no son “PHP-FPM detrás de un reverse proxy con separación clara”. Son imágenes oficiales php:…-apache con mod_php embebido:

  • Un sitio público expuesto vía Cloudflare (bonsai.guilgo.es).
  • Dos sitios adicionales en raam (LAN / acceso interno).
  • Una réplica del mismo stack en un segundo host (expuesto a Internet).

En ese modelo, un fallo del motor PHP es un fallo del perímetro de la petición, no un bug de un módulo concreto del CMS. Parchear la aplicación no basta.

Encaja con lo que ya se ha escrito aquí sobre superficie de contenedores y deuda de imágenes: el SOC proactivo con Trivy avisa de CVEs en lo expuesto; el docker-listener de Wazuh ve el ciclo de vida del contenedor. Ninguno de los dos sustituye al pin + rebuild cuando el runtime es el vector.

Cómo se parcheó PHP 8.4.27 en Docker

1. Medir antes de tocar

Versión real con docker exec … php -v, Debian base (bookworm en unos, trixie en otro), extensiones (mysqli, gd, curl) y si php_module seguía cargado en Apache. Sin eso, un rebuild “a ciegas” mezcla bases y rompe lo que ya funcionaba.

2. Tag fijo, misma rama, misma Debian

Dockerfile sobre bookworm:

FROM dockerproxy.net/library/php:8.4.27-apache-bookworm

Dockerfile sobre trixie:

FROM dockerproxy.net/library/php:8.4.27-apache-trixie

Son dos ficheros distintos, no un multi-stage. Misma rama 8.4 (sin saltar a 8.5), misma variante Debian que ya tenía cada contenedor, y prefijo de mirror pull-through: en este host, Docker Hub directo no es fiable. Sin mirror, el “actualiza PHP” se queda a medias.

Un tag semver fijo (8.4.27-…) controla la versión de PHP. No es reproducibilidad criptográfica: el tag sigue siendo mutable. El digest (@sha256:…) fija la imagen exacta, pero congelarlo sin proceso de actualización también puede dejar fuera parches de Debian de la base. Aquí el pin de versión fue el compromiso operativo; el digest queda como siguiente endurecimiento si hace falta auditar bit a bit.

3. Rebuild solo del web

En el cierre del 21 se usó:

docker compose up -d --no-deps --build web

Para el siguiente security release, mejor separar pull de base y recreación (si el tag ya está en caché, un --build a secas puede quedarse con lo viejo):

docker compose build --pull web
docker compose up -d --no-deps web

--no-deps evita recrear MariaDB; no evita reiniciar el propio web. Un upgrade de PHP no exige tumbar la base de datos; tumbarla sí introduce riesgo innecesario.

4. Verificar de verdad

No basta con que el build termine en exit 0. Lo que se hizo:

  • php -v → 8.4.27 (cli, built el mismo día del release).
  • Apache arranca anunciando PHP/8.4.27.
  • Extensiones críticas cargadas; php_module shared.
  • HTTP 200 en los puertos locales de cada sitio.
  • En el borde, X-Powered-By: PHP/8.4.27 sirvió como evidencia circunstancial del despliegue (donde la cabecera aún se exponía).

Lo que un 200 no demuestra: que el CMS funcione de punta a punta. Tras un upgrade de runtime conviene, además, una operación representativa (login, consulta a MariaDB, sesión). La verificación definitiva del parche es el runtime en el contenedor y el pin en el Dockerfile — no la cabecera pública. expose_php = Off es lo razonable en régimen normal; no conviene dejar la versión de PHP como mecanismo habitual de comprobación en el borde.

Contenedor Host Antes Después
web-a raam 8.4.20 8.4.27
web-b raam 8.4.25 8.4.27
web-c raam 8.4.24 8.4.27
web-d segundo host 8.4.23 8.4.27

Qué falló (o casi): la parte útil

1. El tag flotante mentía

Los Dockerfile pedían cosas del estilo php:8.4-apache / php:8.4-apache-bookworm. El tag flotante puede cambiar en el registro; el contenedor no se actualiza solo. La imagen que corre depende de cuándo se descargó, de la caché local y de la política de pull del build — no de “la última 8.4 segura del día”. Resultado medido: cuatro versiones de parche distintas dentro de PHP 8.4 (20, 23, 24, 25), todas por debajo del aviso.

Lección: para runtime de seguridad, tag semver fijo (8.4.27-…) —mejor que la rama flotante—, rebuild con --pull cuando php.net (o Diun/Trivy) diga que hay release de seguridad, y no confundir “versión pinada” con “digest inmutable”.

2. Docker Hub en raam no es un detalle

Sin mirror, el pull de la imagen oficial falla o se atasca. Un “actualiza PHP” que asume Hub abierto se queda a medias. El mirror no es optimización: es precondición de este host.

3. Mitigaciones de configuración no cierran el hueco

La tentación del incidente es “mientras tanto, apaga X y sube Y”. El aviso (y CERT-EU) documentan mitigaciones temporales que acotan rutas concretas; ninguna deja la instalación segura. O se sube el binario, o el riesgo sigue abierto. Aquí no se usaron como sustituto del parche.

4. Parche en runtime sin commit = deuda inmediata

Los contenedores quedaron en 8.4.27 antes de que el pin estuviera en git. En el otro host el mismo cambio era local: un git pull iba a chocar. Eso se cerró con commit desde raam y fast-forward en el segundo host. Imagen en marcha ≠ configuración versionada. Los dos tienen que coincidir.

5. La deuda de “mañana” se caduca en un día

El 20 de octubre la nota pedía 8.4.26 como P3. El 21, el escenario cambió: RCE confirmados para mod_php. Quedarse en “ya lo miramos ayer, era P3” habría sido el error de proceso. La severidad la marca el aviso nuevo, no el ticket viejo.

Qué no se tocó (a propósito)

  • MariaDB de cada stack: uptime intacto.
  • Rama PHP: no se migró a 8.5.
  • Debian base: cada contenedor conservó la variante que ya tenía (bookworm o trixie).
  • Otros sitios PHP en hosting ajeno: fuera del alcance de estos contenedores; se tratan aparte.

Lecciones

  1. Con mod_php, un RCE del runtime es un incidente de borde, no de “módulo del CMS” (PHP-FPM también parchea; aquí el modelo era mod_php).
  2. Tag flotante + rebuild infrecuente = versiones divergentes y deuda silenciosa; tag semver ≠ digest.
  3. Si el pull de Hub depende de un mirror, sin mirror el parche no existe.
  4. build --pull + up --no-deps: fuerza base fresca y aísla MariaDB; el web sí se reinicia.
  5. “Parcheado” = php -v + pin en git en todos los clones que reconstruyen; HTTP/cabecera son indicios, no prueba funcional del CMS.
  6. Cuando sale un security release el día después de tu nota P3, no rehaces el análisis antiguo: subes.

Checklist reutilizable

# 1. Runtime (fuente de verdad)
for c in web-a web-b web-c; do echo "== $c"; docker exec "$c" php -v | head -1; done

# 2. Pin en el Dockerfile (FROM puede no ser la línea 1)
grep -nE '^[[:space:]]*FROM[[:space:]]' docker/php/Dockerfile

# 3. Imagen que corre de verdad (intención ≠ runtime)
# .Config.Image = referencia con la que se creó; .Image = ID local del contenedor.
# No prueba por sí solo coincidencia con un digest remoto concreto.
docker inspect --format '{{.Config.Image}} {{.Image}}' web-a

# 4. Disponibilidad (no sustituye una prueba funcional del CMS)
curl -sI https://ejemplo/ | head -1

Criterio de éxito: 8.4.27 en cli/Apache, FROM con el mismo tag, referencia/ID del contenedor coherentes con ese pin, y —si puedes— una operación PHP+DB representativa. La cabecera X-Powered-By solo si aún se expone; mejor apagarla en régimen normal.

Cierre

No fue un “upgrade rutinario de imagen”. Fue cerrar en el mismo día dos RCE sin autenticación en el runtime exacto que usan estos CMS (mod_php), después de meses con tags flotantes que habían dejado a cada contenedor en una versión de parche distinta dentro de 8.4.

El problema de fondo no era solo “PHP desactualizado”: faltaba un mecanismo que llevara de forma controlada un security release a todos los despliegues (inventario → aviso → rebuild con --pull → verificación → pin en git). Este incidente lo deja planteado; el siguiente paso es cerrar ese bucle, no solo este CVE.

¿Cubiertos ante el próximo aviso de PHP? Solo si ese bucle existe. ¿Ante este del 21 de octubre? Los cuatro contenedores quedaron actualizados a la versión que corrige ambos CVE (8.4.27), MariaDB intacta, borde comprobado — sin pretender haber auditado cada ruta de exposición ni una batería funcional completa del CMS.