CVE-2026-115560: el vector de PHP que se cierra con una línea de configuración
Dos días después de los parches de emergencia de PHP2Shell, el proyecto publicó un aviso nuevo, y esta vez no venía con parche. El 23 de octubre PHP decidió que CVE-2026-115560 no merecía un release de emergencia y lo mandaría al ciclo regular. Lo que sí publicó fue una instrucción de una línea: session.upload_progress.enabled=0.
Esa línea parece trivial, y el riesgo parece acotado, y por eso es exactamente el tipo de cosa que se aplica a ciegas o se pospone. El detalle que me hizo parar a leer dos veces fue otro: la directiva que hay que apagar y la directiva que abre la puerta no se pueden cambiar con la misma herramienta. Una se cambia en tiempo de ejecución desde el propio código de la aplicación, la otra no. Esa asimetría es la que define si tu mitigación realmente se sostiene, y es justo lo que se hace mal en la mayoría de las guías que circulan sobre este vector.
TL;DR — Lo que vas a tener al final
- Tiempo de setup: 20 minutos para confirmar en qué SAPI te sirve el tráfico, revisar si tu código cumple la precondición y aplicar la mitigación en el nivel correcto
- Requisitos: acceso al pool de PHP-FPM o al VirtualHost de Apache, y permiso para reiniciar los workers
- Resultado:
session.upload_progress.enableden Off, aplicado antes del parseo de la petición y sin que la aplicación pueda revertirlo en runtime - Troubleshooting: si
php -idice una cosa y tu sitio dice otra, estás mirando el CLI y no el SAPI; y si usas hosting gestionado, la mitigación la aplica el proveedor, no tú
Qué es exactamente CVE-2026-115560
El 23 de octubre de 2026 PHP publicó un aviso informativo en su archivo de noticias, justo dos días después del release de emergencia del 21 de octubre que arreglaba las dos RCE de PHP2Shell (CVE-2026-109618 y CVE-2026-109619) en PHP 8.5.12, 8.4.27, 8.3.36 y 8.2.35. El vector nuevo no tiene parche todavía: PHP lo trata en el ciclo regular de releases y publicará el advisory completo de CVE-2026-115560 junto con la próxima versión de corrección.
Esto no es un tercer PHP2Shell. La diferencia es de fondo, no de número:
| Vulnerabilidad | Mecanismo | Estado |
|---|---|---|
| CVE-2026-109618 | Corrupción por agotamiento de memoria: una interrupción deja datos de la petición parcialmente inicializados y el cleanup los toma por válidos | Parcheada el 21-oct-2026 (8.5.12 / 8.4.27 / 8.3.36 / 8.2.35), CVSS v4.0 9.2 |
| CVE-2026-109619 | Estado global obsoleto: un worker conserva punteros a memoria de una petición ya liberada y los reutiliza en la siguiente | Parcheada el 21-oct-2026, CVSS v4.0 9.2 |
| CVE-2026-115560 | Vector de ejecución remota en la ruta de sesión con progreso de subida. PHP aún no publicó el detalle técnico: solo la precondición y la mitigación | Sin parche. Se resuelve en el ciclo regular. La mitigación es una directiva de configuración |
Conviene ser preciso con lo que se sabe y lo que no: PHP todavía no publicó el detalle técnico de CVE-2026-115560. La ficha de CERT-EU 2026-018, en su versión v1.2 del 23 de octubre, lo dice sin rodeos: no hay detalle disponible sobre esta vulnerabilidad y el advisory completo llegará con la próxima release. Al momento de escribir esto, la API de NVD devuelve cero resultados para CVE-2026-115560, así que tampoco hay puntuación CVSS publicada: si alguien te da un número de severidad para este CVE, te lo está inventando.
Lo que sí es público, y es lo que permite trabajar hoy, es la precondición exacta y la mitigación.
Las cuatro condiciones que abren la puerta
Del aviso de PHP, textual: una aplicación debe tener session.serialize_handler en php_binary o php_serialize en su configuración, y luego cambiarlo a php en tiempo de ejecución con ini_set() antes de llamar a session_start(). El progreso de subida de sesión debe seguir además habilitado.
- El servidor arranca con
session.serialize_handler = php_binaryophp_serializeen la configuración. - El código de la aplicación llama a
ini_set('session.serialize_handler', 'php'). - Ese
ini_set()ocurre antes desession_start(). session.upload_progress.enabledsigue en 1.
Las cuatro tienen que cumplirse a la vez, en la misma petición. Por eso PHP lo califica de escenario poco común y por eso no hay release de emergencia.
Hay un detalle que no es trivial y que explica por qué la condición 1 no es tan rara como parece: el valor por defecto de session.serialize_handler es php, pero el propio manual de PHP recomienda usar php_serialize porque los handlers antiguos no pueden guardar índices numéricos ni claves con caracteres especiales en $_SESSION. Configurar php_serialize es una decisión de hardening razonable y frecuente. La condición 2, que la aplicación revierta ese handler en runtime, es la rara: normalmente la hace un framework, un shim de compatibilidad o código heredado que alguien copió sin revisar qué hacía.
Mi hipótesis sobre el mecanismo, y la digo como hipótesis porque PHP aún no lo documentó: el progreso de subida escribe datos de estado dentro de $_SESSION durante el procesamiento de la petición. Si el formato de serialización con el que se escribió no coincide con el formato con el que se relee, el deserializador interpreta bytes con una gramática distinta a la que espera, y ahí aparece la confusión de tipos que se convierte en ejecución de código. Esa sería la clase de fallo, no el detalle exacto, que sigue reservado para el advisory completo. Cuando PHP publique el advisory completo de CVE-2026-115560, este párrafo es el primero que hay que volver a contrastar.
Por qué tu .user.ini llega tarde y la directiva va en el VirtualHost o en el pool
Aquí está el meollo, y es la razón por la que la mayoría de las guías que se copian de internet no sirven. La mitigación no se puede poner donde te parezca: tiene que estar aplicada antes del parseo de la petición, y el mecanismo que la aplica tiene que ser de nivel administrador.
La razón está en los modos INI, y el manual de PHP los publica de forma explícita:
| Directiva | Modo INI | Consecuencia |
|---|---|---|
session.serialize_handler |
INI_ALL | La aplicación puede cambiarla en runtime con ini_set(). Ese es el lado peligroso. |
session.upload_progress.enabled |
INI_PERDIR | Solo se cambia en php.ini, httpd.conf, .htaccess o .user.ini. ini_set() no la toca. |
Lee esa tabla otra vez, porque es la que resuelve el problema: la directiva que abre el vector se puede pisar desde el código de la aplicación, y la que lo cierra no se puede pisar desde el código de la aplicación. Por eso la mitigación tiene que ir como directiva administrativa (php_admin_flag o php_admin_value) y no como ini_set(). Si la pones con ini_set() en un archivo de arranque de tu framework, no solo es que la aplicación pueda revertirla: es que el runtime no la va a poder aplicar en el momento en el que importa.
El segundo matiz es de momento, no de permisos. En Apache con mod_php, el .htaccess con php_value o php_flag y las directivas php_admin_* del VirtualHost se aplican antes de php_request_startup(), así que ahí sí alcanzan a tiempo. En FPM el orden es otro: los valores del pool se aplican cuando arranca el worker, y el .user.ini se aplica después de la inicialización normal. Por eso PHP y CERT-EU dicen, en la misma línea, que no hay que confiar en el .user.ini de FPM. No es que esté prohibido, es que llega después del punto en el que el vector se decide.
El bloque de configuración de Apache tiene una limitación adicional que conviene tener clara: php_admin_value y php_admin_flag no se pueden usar en .htaccess. Si usas mod_php, la directiva administrativa va en el VirtualHost o en el httpd.conf, no en un .htaccess dejado en el directorio del sitio.
Detectar si tu aplicación está en el caso expuesto
Antes de tocar la configuración del servidor vale la pena saber si estás siquiera en la población afectada, porque la respuesta cambia lo que haces. La condición 2 es código tuyo, y el código se busca.
# La aplicacion cambia el serialize_handler en runtime?
grep -rn "serialize_handler" /var/www/html --include="*.php"
# Que valor tiene configurado el servidor para el handler
grep -rn "serialize_handler" /etc/php/8.3/fpm/php.ini /etc/php/8.3/fpm/pool.d/
# Y el estado real de la directiva que hay que apagar
grep -rn "upload_progress" /etc/php/8.3/fpm/php.ini /etc/php/8.3/fpm/pool.d/
Si el primer grep no devuelve nada, tu aplicación no cambia el handler en runtime y la precondición no se cumple. Eso no significa que puedas ignorar el aviso: la mitigación cuesta cero y cubre el caso en el que alguien más, un plugin o una actualización futura, introduce ese ini_set(). Pero sí cambia la urgencia, y para un hosting compartido donde la mitigación implica un ticket, cambia por completo la conversación con el proveedor.
El segundo paso es confirmar en qué SAPI te sirve el tráfico. Esto es lo que más se falla, porque php -v y php -i en tu terminal te muestran el intérprete de línea de comandos, que en un servidor con varios roles puede no ser el mismo que atiende las peticiones web:
# Que SAPI atiende las peticiones web (php -v solo muestra el CLI)
php -v
php -r 'echo "SAPI del CLI: ", php_sapi_name(), PHP_EOL;'
ps -eo pid,cmd | grep -E 'php-fpm|php-cgi' | grep -v grep
apache2ctl -M 2>/dev/null | grep -i php
Lo que vea el visitante de tu sitio es lo que importa. Si tienes FPM detrás de nginx, el php.ini que importa es el del SAPI FPM y el pool es el que define los valores administrativos. Y para leer el valor final tal como lo recibe la aplicación, el camino corto es pasar por HTTP:
# Crear una pagina de verificacion en el docroot (ajusta la ruta)
echo '<?php phpinfo();' > /var/www/html/info.php
curl -s http://127.0.0.1/info.php | grep -i 'session.upload_progress.enabled'
rm -f /var/www/html/info.php
Esa página debe quedar fuera del docroot o protegida con autenticación, porque phpinfo() expone rutas del sistema y versiones exactas de cada módulo. Créala, mírala y bórrala en el mismo minuto.
Aplicar la mitigación en PHP-FPM
En el pool de FPM la directiva va como php_admin_flag, que es la forma de valor booleano. Un valor numérico equivalente sería php_admin_value[session.upload_progress.enabled] = 0:
; /etc/php/8.3/fpm/pool.d/www.conf
php_admin_flag[session.upload_progress.enabled] = off
Lo que define a php_admin_flag frente a php_flag es que la aplicación no puede sobreescribirla con ini_set(), y el valor del pool se aplica cuando arranca el worker, o sea antes de que se atienda la primera petición de ese worker. Después, el reinicio. Un reload del pool deja workers vivos con la configuración anterior, y el propio advisory insiste en reiniciar cada worker:
# Validar la configuracion del pool antes de reiniciar
sudo php-fpm8.3 -t
# Reiniciar TODOS los workers (reload no es suficiente)
sudo systemctl restart php8.3-fpm
# Confirmar que el valor quedo aplicado en el pool
sudo php-fpm8.3 -tt 2>&1 | grep -i upload_progress
El -t valida la sintaxis del archivo de configuración y -tt además vuelca la configuración final antes de salir, que es la forma barata de confirmar que tu línea quedó donde pensabas y no en otro pool. Ajusta el nombre del binario a tu versión instalada: en derivados de Debian el servicio suele llamarse php8.3-fpm y el binario php-fpm8.3.
Aplicar la mitigación en Apache con mod_php
Si tu servidor sirve con mod_php, la directiva administrativa va en el VirtualHost o en el httpd.conf, con la sintaxis de Apache, que no usa corchetes:
# /etc/apache2/sites-available/tu-sitio.conf
<VirtualHost *:80>
ServerName tu-sitio.example.com
DocumentRoot /var/www/html
php_admin_flag session.upload_progress.enabled off
</VirtualHost>
Y después, un reinicio completo. Un reload en Apache es un graceful: los procesos hijos que ya atienden conexiones siguen vivos con la configuración anterior, y con mod_php el advisory pide reiniciar cada worker. Con restart todos los hijos leen la configuración nueva de una vez:
# Confirmar que el modulo PHP es el que esta cargando
apache2ctl -M 2>/dev/null | grep -i php
# Validar y reiniciar todos los hijos
sudo apache2ctl configtest
sudo systemctl restart apache2
Verificar que quedó aplicado donde cuenta
La verificación final se hace en el SAPI que sirve el tráfico, nunca en el CLI. Y tiene dos partes: el valor que pide PHP y el valor que la aplicación ve en $_SESSION.
# 1. El valor que aplica el pool de FPM (-tt vuelca la config del pool, no php.ini)
sudo php-fpm8.3 -tt 2>&1 | grep -i upload_progress
# 2. La comprobacion final, por HTTP, contra el sitio real
curl -s https://tu-sitio.example.com/info.php | grep -i 'session.upload_progress.enabled'
# 3. Y que php.ini de verdad esta cargado en el servidor web
curl -s https://tu-sitio.example.com/info.php | grep -i 'Loaded Configuration File'
El paso 2 tiene una lectura que conviene tener clara, porque es donde se confunden la mitad de los casos. En el phpinfo() la columna de valor local es el que ve la petición actual y la de valor maestro es el que tenía la directiva en la configuración base. Con php_admin_flag en el pool de FPM las dos columnas aparecen como Off, porque el valor administrativo reemplaza al de php.ini: si lees Off en las dos, la mitigación está activa y no hay nada más que buscar. Si el maestro dice 1 y el local dice Off, el override por directorio está aplicando y también sirve. Si las dos dicen 1, el cambio no llegó a esa petición: revisa que esté en el pool o en el VirtualHost que realmente atiende ese sitio y que los workers se hayan reiniciado.
Hosting gestionado: qué pedir exactamente
En cPanel, Plesk o un hosting compartido no tienes acceso al pool de FPM. Puedes tocar tu php.ini desde el panel, y ahí es donde se esconde la confusión: el panel te deja escribir session.upload_progress.enabled = 0 y te muestra un phpinfo con el valor en Off, y aun así la mitigación no sirve, porque el panel lo aplica como un .user.ini o como un valor de por-sitio que llega después de la inicialización.
Cuando abras el ticket, la redacción importa. Pide la directiva a nivel administrativo del pool y pide confirmación de dónde se aplicó, no solo que el valor se vea:
# Lo que tu aplicacion ve ahora mismo, a traves del servidor web
curl -s https://tu-sitio.example.com/info.php | grep -i 'session.upload_progress.enabled'
# Y que php.ini esta usando el sitio
curl -s https://tu-sitio.example.com/info.php | grep -i 'Loaded Configuration File'
El texto que funciona en el ticket es concreto: que apliquen session.upload_progress.enabled=0 como php_admin_flag en el pool de PHP-FPM del hosting, que reinicien los workers y que confirmen qué SAPI atiende el sitio. Pide también que te digan con qué SAPI atienden el sitio, porque esa respuesta define la mitad de tu plan de mitigación.
Qué se rompe al apagar la función
Vale la pena ser honesto sobre el costo, porque la función no es decorativa. session.upload_progress.enabled es lo que permite que una aplicación muestre una barra de progreso de subida sin backend adicional: PHP escribe el estado en $_SESSION bajo una clave que concatena session.upload_progress.prefix con el valor que el cliente envía en el campo oculto session.upload_progress.name (por ejemplo, upload_progress_123), y el frontend lo lee por polling con un POST a un endpoint aparte.
Si tu aplicación hace eso, al apagar la directiva la barra de progreso deja de funcionar. La salida es conocida y no es traumática: el navegador emite eventos de progreso de subida en XMLHttpRequest, a través de xhr.upload, así que la barra se reconstruye en el cliente sin servidor de por medio, con la ventaja de que deja de depender del estado de la sesión. Con fetch() el asunto es distinto: la especificación todavía no expone progreso de subida, solo de respuesta, así que si tu frontend usa fetch la vía para tener barra sigue siendo XMLHttpRequest. Lo que no se puede hacer es dejar la mitigación a medias esperando a esa migración: o la aplicas y migras la barra, o esperas a que salga el parche y asumes el riesgo de la ventana.
Qué sigue después de la mitigación
La mitigación es un puente, no un destino. PHP va a publicar el advisory completo de CVE-2026-115560 junto con la próxima release de corrección, y en ese momento vas a querer tres cosas: las versiones afectadas confirmadas, el vector completo documentado y saber si la release que instalas realmente lo cubre. Hasta ese momento, el registro en NVD va a seguir vacío y el CVSS sigue sin publicarse.
Dos fechas del contexto de PHP que conviene tener presentes mientras tanto. PHP 8.2 llega a su fin de vida el 31 de diciembre de 2026 y deja de recibir correcciones de seguridad después de esa fecha, así que si sigues en 8.2 el plan de actualización es parte de este mismo trabajo y no un tema aparte. Y las ramas 8.1 y anteriores ya no reciben actualizaciones del proyecto: hay backports de código fuente para 7.4, 8.0 y 8.1, pero si construyes desde el código o no tienes un proveedor de LTS, la actualización es tu único camino para las dos RCE que ya traían parche de emergencia.
Mi recomendación concreta es dejar la mitigación puesta después del parche, como hizo PHP en su propio aviso del 21 de octubre para CVE-2026-109619, donde la mitigación ya figuraba en el advisory GHSA-6wmf-4rv5-p3rp. El motivo está escrito en ese advisory: la función es de las que apenas se usan y apenas se prueban, y los atacantes van a buscarle fallos precisamente ahí. Reactivarla más adelante es cambiar una línea en el pool, pero conviene hacerlo con la certeza de que la necesitas y no por inercia.
Vale la pena, y para quién
Para un servidor propio con FPM o mod_php, esto es veinte minutos de trabajo y una línea de configuración: aplícala hoy. No hay debate. El aviso es informativo y el vector es poco común, pero la propia PHP recomienda dejar la función apagada incluso después del parche si no la usas, y eso ya dice algo sobre el tamaño del problema.
El caso distinto es el hosting compartido y el de las plataformas administradas, donde tú no controlas el pool. Ahí el vector es raro de entrada y, si además tu código no cambia el serialize handler en runtime, la probabilidad real es baja. Aun así, el ticket vale la pena porque el mismo trabajo te deja el PHP al día en las dos RCE que sí tenían parche de emergencia, y esa parte no depende de nadie más. No lo plantées como una urgencia de RCE: plantéalo como el ítem que saca a tu hosting del limbo de versiones antiguas sin parches.
Y hay un tercer escenario, el de las plataformas de aplicación tipo PaaS o los paneles de WordPress administrados, donde la configuración de PHP la fija el proveedor y tú no ves ni el php.ini ni el pool. Ahí lo único que puedes hacer es documentar la pregunta: qué SAPI atienden, dónde se aplica session.upload_progress.enabled y si esa aplicación se aplica antes o después de la inicialización. Preguntarlo cuesta un mensaje y te evita meses de operar con un SAPI que nadie ha mirado.
Preguntas frecuentes
¿Estoy expuesto si mi aplicación no usa la barra de progreso de subida?
El riesgo no depende de si usas la función, sino de si session.upload_progress.enabled está en 1 y de si tu código cambia session.serialize_handler en runtime. La función no necesita que tu aplicación llame a session_start(): PHP escribe la información de progreso en la sesión antes de ejecutar ningún script. Lo que sí depende del cliente es que envíe el campo oculto con el nombre configurado en session.upload_progress.name, porque sin ese POST PHP no registra nada. Si tu aplicación nunca cambia el handler, la precondición no se cumple y la mitigación es una precaución barata, no una necesidad inmediata.
¿Por qué en mi hosting la directiva no sirve si la pongo en el .user.ini?
Por momento de aplicación, no por permisos. En FPM los valores del pool se aplican al arrancar el worker y el .user.ini se aplica después de la inicialización normal, demasiado tarde: PHP y CERT-EU dicen literalmente que no se confíe en él. Por eso el hosting debe aplicarla a nivel de pool. En Apache con mod_php es distinto: el .htaccess se procesa antes de php_request_startup() y sí alcanza a tiempo, pero php_admin_flag y php_admin_value no están permitidas en .htaccess: la forma administrativa va en el VirtualHost.
¿Cómo compruebo que la mitigación quedó activa de verdad?
Por HTTP, contra tu sitio, nunca con php -i en la terminal, que solo muestra el CLI. Crea una página con phpinfo() en el docroot, pídela por curl y revisa session.upload_progress.enabled en las columnas de valor local y valor maestro. Si el maestro dice 1 y el local Off, tu override está aplicando y la mitigación funciona. Si ambos dicen 1, el cambio no llegó a esa petición: revisa el pool o VirtualHost correcto y que los workers se hayan reiniciado.
¿Qué hago si mi hosting compartido no me deja tocar el pool de FPM?
Abre un ticket pidiendo la directiva a nivel administrativo del pool, el reinicio de los workers y la confirmación del SAPI que sirve el tráfico. Mientras esperas, aplica lo que sí puedas desde el panel y verifica por HTTP que el valor se vea en Off, sabiendo que puede estar llegando tarde. Acompaña el ticket con la actualización de PHP a 8.5.12, 8.4.27, 8.3.36 u 8.2.35, que no depende de que te den acceso al pool.
¿Qué pasa con mi barra de progreso de subida si desactivo la función?
Deja de funcionar, porque es la función que escribe el estado de progreso en $_SESSION para que el frontend lo lea por polling. La migración es directa: XMLHttpRequest ya emite eventos de progreso de subida en el navegador, a través de xhr.upload, así que la barra se reconstruye en el cliente sin backend. Con fetch() hay que tener cuidado: la especificación todavía no expone progreso de subida, solo de respuesta. Hazlo antes de apagar la directiva, no después, o te queda una ventana con la función rota y la mitigación sin aplicar.
