PHP2Shell, el RCE crítico de PHP (CVE-2026-109618 y CVE-2026-109619)
Tabla de contenido
Hoy, 21 de octubre de 2026, PHP ha sacado parches de emergencia para una pareja de fallos que llevaban catorce años en el código y que mucha gente va a recordar una temporada. Se llaman PHP2Shell, son CVE-2026-109618 y CVE-2026-109619, y permiten ejecución remota de código sin autenticarse contra un montón de servidores PHP. No es un bug de un plugin ni de un framework, está en el propio núcleo de PHP, así que la superficie es enorme.
Te voy a contar qué son, por qué ocurren y, sobre todo, cómo protegerte, que es lo urgente. Monté el laboratorio del investigador en local para enseñarte el fallo con mis propios ojos, pero aviso desde ya de que esto no es una receta para ir reventando servidores ajenos. Esto ya se está explotando de forma masiva en internet, así que la jugada seria es entenderlo y blindarse, no repartir el arma.
⚠️ Si administras cualquier cosa con PHP de cara a internet, vete directo a la sección de mitigación y parchea antes de seguir leyendo. Lo demás te lo cuento con calma después.
🧨 Qué es PHP2Shell
Son dos vulnerabilidades distintas que comparten apellido y gravedad.
- CVE-2026-109618, RCE por corrupción de memoria en una condición de falta de memoria (OOM). PHP se queda sin memoria durante el arranque de la petición y, al abortar, deja el estado interno roto.
- CVE-2026-109619, RCE por estado global rancio. El proceso que atiende peticiones se queda con restos de memoria de una petición anterior que ya debería estar liberada.
Las dos tienen una puntuación CVSS de 9.2 (crítica), y algunos medios hablan incluso de 10.0. Lo grave de verdad es la palabra preautenticación. Un atacante no necesita usuario, ni contraseña, ni tocar la lógica de tu aplicación. Con una simple petición HTTP malintencionada puede llegar a ejecutar comandos con los permisos del proceso web, normalmente www-data.
Para que te hagas una idea del tamaño del problema, VirusTotal estima más de 5 millones de servidores PHP expuestos, y Cloudflare ya confirmó que estaba viendo ataques reales incluso antes de que salieran los parches. El NCSC británico ha pedido a las organizaciones actualizar de inmediato o apagar lo afectado. Vamos, que no es para dejarlo de deberes para el lunes.
🎯 A quién afecta
Aquí es donde se te ponen los pelos de punta. El código vulnerable es idéntico desde PHP 5.4.0, que salió en 2012, hasta la 8.5.11. Son catorce años de versiones con el mismo fallo.
- Versiones vulnerables, de la 5.4.0 a la 8.5.11, incluidas todas las ramas 7.x y 8.0, 8.1, etc.
- Versiones parcheadas, salidas hoy mismo, 8.2.35, 8.3.36, 8.4.27 y 8.5.12. PHP también publicó backports mínimos de código fuente para 7.4, 8.0 y 8.1, que ya están fuera de soporte.
- Formas de ejecución afectadas (SAPIs), prácticamente todas las que sirven web. PHP-FPM, Apache con mod_php, php-cgi, FrankenPHP y LiteSpeed.
El propio equipo de seguridad de PHP avisa de que el riesgo más alto es con mod_php, porque ahí una petición sin autenticar puede disparar el fallo antes de que se ejecute una sola línea de tu aplicación. Con PHP-FPM el impacto es algo distinto porque el hijo del pool muere en el arranque fallido, pero no te relajes, que las otras cadenas siguen aplicando.
🧩 Por qué pasa, las cuatro cadenas
El investigador que publicó el análisis describe cuatro caminos distintos para llegar al mismo sitio. Te los resumo a nivel conceptual, que es lo que de verdad ayuda a entender el riesgo y a decidir qué apagar. El punto en común de casi todos es una función que casi nadie mira, session.upload_progress, que viene activada por defecto.
Cadena A, inyección en el fichero de sesión más LFI
session.upload_progress existe para que una web pueda mostrarte una barra de progreso mientras subes un fichero. Para eso, PHP va guardando en el fichero de sesión datos de la subida, y entre ellos el nombre del fichero que subes, tal cual, sin sanitizar. Si ese nombre contiene código PHP, ese código acaba escrito en disco dentro del fichero de sesión.
Por sí solo eso no ejecuta nada. Pero si la aplicación tiene además un Local File Inclusion, es decir, si en algún sitio hace un include() de una ruta que tú controlas, entonces puedes incluir el fichero de sesión y ejecutar ese código. El autor la marca como la cadena más fiable, del 100 por cien, porque no depende de la suerte con la memoria. Lo único que necesita es un LFI en la aplicación, que por desgracia no es raro.
Cadena B, use-after-free en var_destroy
Esta vive en el deserializador, en ext/standard/var_unserializer.re. Cuando unserialize() destruye los objetos de un payload, el bucle de destructores llama al __destruct de un objeto sin activar el cerrojo de serialización (BG(serialize_lock)). Si ese destructor vuelve a llamar a unserialize() por dentro, se reutiliza una tabla de memoria que ya se había liberado, y tienes un use-after-free. El autor lo confirmó con AddressSanitizer en var_unserializer.c:851. Para aprovecharla necesitas una aplicación que llame a unserialize() con datos que tú controles.
Cadena C, descuadre de handlers de sesión
PHP puede guardar las sesiones con distintos formatos (serialize_handler). Si una carpeta escribe la sesión con el formato php_serialize y otra la lee con el formato php, el segundo parte el contenido por el carácter | y deserializa lo que venga detrás como un objeto. Combinado con el nombre de fichero sin sanitizar de la cadena A, puedes colar un objeto serializado en la sesión y hacer que su __destruct se dispare, incluso cuando la decodificación de la sesión devuelve error. Si en el código hay una clase con un __destruct jugoso, ahí tienes la ejecución.
Cadena D, corrupción por OOM sin depender de la aplicación
Esta es la más fina y la que da más miedo, porque no necesita ni LFI ni unserialize ni nada de tu código. Un POST multipart bien construido fuerza a PHP a quedarse sin memoria justo durante el arranque de la petición. Al abortar por OOM, un longjmp() se salta la inicialización del motor (init_executor), y en Apache con mod_php el cierre de la petición se ejecuta igualmente sobre un estado global medio inicializado. Ese estado rancio es el que se convierte en ejecución con el moldeado de heap adecuado. Funciona en mod_php y en FrankenPHP. En PHP-FPM el hijo muere, por eso ahí el riesgo es menor.
El parche, por cierto, va a la raíz. Cambia ese longjmp() por un aborto suave y retardado y un _exit() duro, para que nunca se siga ejecutando sobre memoria inválida. Son 300 ficheros tocados en el commit, lo que te dice lo profundo que estaba metido el problema.
🧪 El laboratorio
El investigador publicó un repositorio con un Docker para reproducirlo en local. Lo levanté en mi Kali para verlo de primera mano. Clonamos y preparamos los ficheros.
git clone https://github.com/ikow/php2shell-poc.git
cd php2shell-poc/lab
bash setup.sh
Aquí hay un detalle importante que me encontré y que conviene que sepas. El docker-compose.yml del repo usa el tag flotante php:8.2-fpm, y como los parches salieron hoy, ese tag ya te baja la 8.2.35 parcheada, así que el lab tal cual ya no es vulnerable. Para ver el fallo de verdad hay que fijar la versión vulnerable, la 8.2.34.
docker run -d --name php2shell-fpm --network-alias php \
-v $PWD/webroot:/var/www/html -v $PWD/php.ini:/usr/local/etc/php/php.ini \
php:8.2.34-fpm php-fpm
Levantamos también un nginx por delante en el puerto 18080 y comprobamos el entorno con una página inofensiva.
curl -s http://127.0.0.1:18080/index.php
PHP 8.2.34
SAPI fpm-fcgi
upload_progress 1
OK
Ahí lo tienes, PHP 8.2.34 y upload_progress a 1, es decir, activado. El terreno perfecto para entender la cadena A.
🔎 El primitivo en acción
No voy a montar aquí la cadena completa hasta la shell, entre otras cosas porque el propio autor tampoco libera esa parte y porque esto se está explotando ahora mismo. Pero sí te voy a enseñar el primitivo, que es lo que de verdad explica el peligro y motiva la mitigación.
La idea es mandar un POST multipart con tres piezas. El PHPSESSID en el cuerpo (lo que ya rompe el aislamiento de cookies y te deja fijar la sesión sin autenticarte), el campo mágico PHP_SESSION_UPLOAD_PROGRESS y un fichero cuyo nombre lleva código PHP. El repo trae un script de verificación que hace exactamente eso.
python3 poc/verify_session_injection.py --target http://127.0.0.1:18080
Lo interesante no es el script, sino lo que queda escrito en disco. Leemos el fichero de sesión que se ha creado con el SID que eligió el atacante.
docker exec php2shell-fpm cat /tmp/sessions/sess_verifysession00000000000000b
upload_progress_x|a:5:{s:10:"start_time";i:1792606038;s:14:"content_length";i:289;s:15:"bytes_processed";i:289;s:4:"done";b:1;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:1:"f";s:4:"name";s:32:"<?php system('id');echo 'RCE';?>";s:8:"tmp_name";s:14:"/tmp/phptOmaek";s:5:"error";i:0;s:4:"done";b:1;s:10:"start_time";i:1792606038;s:15:"bytes_processed";i:1;}}}
Fíjate en el s:32:"<?php system('id');echo 'RCE';?>". Ese es el nombre del fichero que mandé, guardado crudo, sin sanitizar, en un fichero de disco con una ruta predecible (/tmp/sessions/sess_<SID>) y un SID que yo mismo elegí, todo sin usuario ni contraseña. A partir de ahí, lo único que le falta al atacante para convertir esto en ejecución es un include() de esa ruta en algún punto de la aplicación. Por eso la cadena A es tan fiable, porque la parte difícil (dejar código atacante en una ruta conocida) la pone PHP de serie.
El mismo script deja preparada la cadena C, metiendo un objeto serializado detrás de un | en el nombre, que es lo que un lector con el handler php deserializaría.
🛡️ Detección
Mientras parcheas, que es lo que de verdad cierra esto, conviene saber qué buscar.
- En logs de acceso, peticiones POST con
Content-Type: multipart/form-dataque incluyan el campoPHP_SESSION_UPLOAD_PROGRESS, sobre todo si llevan nombres de fichero raros con<?php,|O:o similares. - Peticiones de inclusión sospechosas que apunten a
/tmp/sessions/sess_*o a rutas de sesión, señal de que alguien intenta cerrar la cadena A. - Picos de errores 500 y caídas de workers con cuerpos multipart enormes o sin
Content-Length, que es la pinta de la cadena D. - WAF. Cloudflare ya desplegó reglas contra los métodos conocidos, aunque su propio CTO deja claro que es un parche temporal. Si tienes WAF, actívalas, pero sin confiarte, porque no sustituyen al parche.
🔧 Mitigación y parcheo
Aquí está lo importante de verdad. Te lo ordeno de lo definitivo a lo provisional.
Lo que lo arregla de verdad, actualizar PHP
El único cierre permanente es poner una versión parcheada. Si estás en una rama con soporte, actualiza ya.
# Debian / Ubuntu
sudo apt update && sudo apt upgrade php*
# comprueba la version despues
php -v
Las versiones a las que llegar son 8.2.35, 8.3.36, 8.4.27 o 8.5.12 o superiores. Si estás en PHP 7 o en 8.0 y 8.1, que ya no tienen soporte oficial, migra a una rama parcheada o aplica el backport de tu distribución. Y acuérdate de reiniciar los workers después, que si no el proceso viejo sigue vivo con el código vulnerable cargado.
Mitigaciones provisionales mientras actualizas
Si no puedes parchear al instante, el equipo de PHP recomienda estas medidas de contención. Ninguna es un sustituto del parche, pero bajan mucho el riesgo.
Para la parte de sesiones, cerramos la cadena A y buena parte de la C.
session.upload_progress.enabled = Off
session.use_only_cookies = On
session.use_strict_mode = On
Un aviso que te ahorra un disgusto. Apagar esto con ini_set() desde el código no vale, ni tampoco con un .user.ini, porque el fallo actúa antes de que eso se aplique. Tiene que ir en el php.ini del sistema o en la configuración del pool de FPM con php_admin_value, y hay que reiniciar los workers.
Para la parte de OOM, la cadena D.
memory_limit = -1
post_max_size = 8M
Ponlo con php_admin_value en el pool de FPM o en la configuración de Apache para que la aplicación no lo pueda pisar. El memory_limit = -1 suena contraintuitivo, pero la idea es que es precisamente el límite de memoria lo que dispara el OOM corrupto, así que quitarlo evita esa vía, y el control de memoria lo trasladas al contenedor o al cgroup. El post_max_size bájalo a 8M si no subes ficheros, o a 32M si los necesitas. Y solo en PHP 8.5, hay una mitigación extra.
fatal_error_backtraces = Off
La mitigación, en vivo
Para que veas que esto no es palabrería, apliqué session.upload_progress.enabled = Off en el mismo laboratorio, reinicié y repetí el POST de antes. El resultado es que el fichero de sesión se queda a cero bytes, el nombre con código PHP ya no se escribe en ningún sitio.
docker exec php2shell-fpm ls -la /tmp/sessions/
-rw------- 1 www-data www-data 0 sess_mitigado0000000000000000000b
Cero bytes. El primitivo ha desaparecido. Esa es la diferencia entre un servidor que regala la primera mitad del ataque y uno que no. Aun así, insisto, esto tapa las cadenas de sesión, pero la corrupción por OOM del motor solo se cierra del todo con el parche.
🏁 Cierre
PHP2Shell es de esos fallos que recordaremos, catorce años escondido en una función que casi nadie mira y activada por defecto, en un lenguaje que mueve medio internet. La parte buena es que la defensa está clara. Actualiza a una versión parcheada, reinicia los workers y, mientras tanto, apaga session.upload_progress y ata la memoria. Si administras PHP de cara al público, hazlo hoy, que esto ya anda suelto.
Yo lo monté en local solo para entenderlo y enseñártelo de forma responsable. Como siempre, conocer el fallo es lo que te permite defenderte de él. Nos vemos en la próxima.
📚 Referencias
- Aviso de seguridad oficial de PHP
- GHSA-w8hw-w9fx-j79w (CVE-2026-109618)
- GHSA-6wmf-4rv5-p3rp (CVE-2026-109619)
- BleepingComputer, PHP ships emergency patches for critical PHP2Shell flaws
- SecurityWeek, PHP2Shell, Mass Exploitation Looms for Leaked Critical PHP Flaws
- Help Net Security, PHP2Shell critical PHP vulnerabilities patched
- Repositorio de investigación de Qi Deng (ikow)
