PHP2Shell: cinco días atacando un fallo que nadie podía parchear
Si te tocó parchear ayer, o te toca ahora mismo, esto es para ti. No voy a repetirte el aviso de seguridad, eso ya lo has leído. Voy a contarte lo que hay debajo, hasta el longjmp, porque bajé al barro a entender cómo diablos se encadena todo esto y no quiero que tengas que hacerlo tú.
Por qué es importante
Lo primero que hice fue mirar si tenía PHP en algún sitio. No tenía: esta máquina no ejecuta una línea de PHP. Respiré.
Conviene decir sin eufemismos qué significa «ejecución remota de código», porque en los avisos suena a categoría y no a consecuencia. Significa que alguien ejecuta lo que quiera como el usuario del servidor web. Desde ahí lee la configuración de la base de datos, se lleva las credenciales que encuentre, escribe en el docroot, deja un minero o usa la máquina como trampolín. Y no hace falta que tu aplicación tenga un solo fallo: el que se explota está por debajo, así que actualizar el CMS no te salva.
La fuga de memoria es un IDOR si lo piensas: manipulas un identificador y la aplicación te devuelve datos que no son tuyos. Aquí el identificador es el de tu sesión, y lo que vuelve es memoria del proceso con contraseñas, tokens y claves de otras peticiones12. El efecto para la víctima es el de un control de acceso roto. El mecanismo es otro: el puntero que PHP tenía para esa sesión apunta al heap de la petición anterior y nadie comprueba que siga siendo suyo.
Y eso importa para el remedio. Un IDOR se arregla añadiendo la comprobación de permisos que falta. Esto se arregla arreglando el tiempo de vida de la memoria (CWE-416), que es bastante más difícil, y por eso tardó veintidós años.
El detalle que me hizo grabar el nombre del CVE no fue la severidad. Fue contar las veces que este fallo ya estaba escrito y nadie lo cerró. Van tres.
La primera es de 2004. Stefan Esser publicó la advisory 11/2004, PHP memory_limit remote vulnerability, para PHP 4.3.7 y anteriores. Y no era un aviso vago: decía que la terminación por memory_limit ocurre «en sitios donde interrumpir no es seguro», ponía como ejemplo las HashTable de Zend, que se inicializan en dos pasos, y nombraba el código de subida de ficheros y la extensión de sesión. Añadía que con POST «es bastante fácil consumir 100 veces y más los bytes enviados». Eso es la amplificación, descrita en 20043. Esser no publicó el exploit, y PHP arregló los sitios concretos, no el mecanismo.
La segunda es de 2019. El bug #78876, enviado en noviembre por jr at coredu dot mp: variables largas en multipart/form-data provocan OOM y dejan ficheros temporales sin limpiar. Se cerró en mayo de 2020 con CVE-2019-11048. La primera respuesta del mantenimiento lo rebajó a denegación de servicio: «un fallo de asignación no es exactamente un crash, pero no limpiar el temporal sí puede ser malo y causar DoS»4. Se arregló el temporal. La corrupción de memoria que había debajo siguió ahí.
La tercera es de 2024. La auditoría de OSTIF y Quarkslab lo vuelve a encontrar y tampoco se arregla, esta vez con otro argumento: no hay prueba de explotación. El commit de 2020 lleva la etiqueta security-audit-2024, así que el rastro estaba en el propio repositorio5.
Y entre esa tercera y octubre de 2026 hay dos años de arreglos que no llegaron. La PHP Foundation lo cuenta sin adornos en su nota del 21: hubo varias propuestas, cada una con su coste en compatibilidad, en nuevas vías de DoS o en cambios que arrastraban a extensiones de terceros, y el equipo no lograba decidir. «La demora fue un fallo estructural», escribieron6.
Tres avisos y dos años de empate. El exploit no salió porque nadie hubiera mirado antes. Salió porque por fin alguien pudo permitirse el trabajo de encadenarlo.
El empate se rompió en agosto. En 2026, con modelos frontier, alguien pasó de «esto debería ser explotable» a un exploit que funciona contra WordPress, Drupal y Joomla en cuestión de días7. El código no empeoró: lo caro era entenderlo, y eso ya no es caro.
Y por si faltaba, la gestión de esos días merece su propio apartado. Esto es lo que pasó, en orden:
- 11 de agosto. Calif reporta un RCE encadenado; OpenAI reporta la misma causa raíz ese mismo día, por su cuenta6.
- 19 de agosto. Wiz encuentra los fallos.
- 24 de agosto. Wiz los reporta al equipo de seguridad de PHP.
- 17 de octubre. AWS detecta intentos contra MadPot, su honeypot, desde una IP de una red de anonimización. En pleno embargo, cuatro días antes de que existiera parche8.
- 20 de octubre, 15:00 UTC. Los dos identificadores empiezan a circular por foros, con una puntuación inventada de 10.0 y una hora de release equivocada.
- 20 de octubre, 19:30 UTC. PHP adelanta el preaviso. El aviso llega antes que los paquetes.
- 21 de octubre, 07:41 UTC. Chainguard publica imágenes con el parche, seis horas antes del anuncio coordinado6.
- 21 de octubre, 14:00 UTC. Release oficial: 8.2.35, 8.3.36, 8.4.27 y 8.5.12.
- 21 de octubre, 45 minutos después. Decenas de miles de intentos en Cloudflare. A las dos horas, cientos de miles. Ahora, millones9.
Lo del 17 no fue un caso aislado, y esa es la parte que cambia el cuadro: quien mira el tráfico ya lo estaba viendo antes de que hubiera anuncio. Cloudflare vio miles de intentos en las diez horas previas10. Fastly dice que hay evidencia de ataques activos en la calle antes del disclosure11.
No todos vieron lo mismo, y eso también cuenta: Imperva afirma que no registró ninguna petición con los patrones que seguía durante ese periodo12. Que un proveedor no lo vea no significa que no pasara, significa que su red no lo vio. El honeypot de AWS sí lo vio.
Y los WAF llegaron antes que el parche: Cloudflare tenía reglas activas desde las 03:02 UTC del 21, unas nueve horas antes del anuncio10. Aun así todos repiten lo mismo, y con razón: la regla compra tiempo, el parche es el arreglo.
Ahí te toca el traje de bombero a las horas que no son: comprobando si el PHP que ni recordabas que tenías está afectado, con un preaviso por delante y ningún paquete que instalar. Y con el detalle que AWS puso encima de la mesa el miércoles: los intentos empezaron el 17, así que cuando por fin llegó el parche, parte del trabajo ya estaba hecho. Eso sí que cambia la frase de «mal día» por la de «íbamos tarde desde antes de empezar».
Y encima el tono. OpenAI publicó su post de Patch the Planet como si fuera el lanzamiento de una plataforma: la «Defender’s Window», mil millones en créditos, un marco de cinco fases para coordinar disclosures y un verificador de versiones que, según el texto del anuncio, «se lanzará»13. El verificador existe desde ese mismo día: openai/php2shell-checker, dos skills de Codex, se instala en un minuto14. El anuncio iba por detrás de su propio artefacto.
Lo que hace el verificador es lo que importa. Lee X-Powered-By y Server, manda un HEAD y compara la versión con unos umbrales del 20 de octubre. Si el servidor no expone la versión, contesta UNKNOWN. Y UNKNOWN no significa parcheado: significa que no lo sabe. Da la casualidad de que los servidores mejor configurados, los que llevan expose_php en Off, son exactamente los que van a recibir UNKNOWN.
Con la herramienta en la mano, el resultado es que contesta «no sé» y, con prisa, eso se lee como «estás bien». Eso es peor que un disclosure mal coordinado: es la sensación de haber comprobado algo que nadie ha comprobado.
Y una corrección al relato que más ha circulado, porque también es parte del mismo ruido: el fallo lo encontró una persona. Calif acredita a LCFR, y OpenAI encontró después fallos adicionales que completaron el cuadro9. La IA aceleró la investigación, no la descubrió. Que no es lo mismo, aunque quepa en un titular más corto.
En qué te afecta
Si tu web es estática o generada, deja de leer. Esto no te toca.
Si sirves PHP de cualquier forma (FPM, mod_php, php-cgi, FrankenPHP, LiteSpeed), sigue.
Con mod_php es donde peor se pone. El proceso sobrevive al fallo, y la explotación ocurre antes de resolver el script. Da igual qué aplicación haya detrás, porque la aplicación todavía no ha entrado en juego. Tampoco hace falta que el fichero exista: un 404 vale.
Con PHP-FPM el worker muere si el fallo llega durante el arranque, así que hace falta una ruta de aplicación que use la sesión: un session_start(), un unserialize(), el maybe_unserialize() de WordPress. Sigue siendo explotable, pero pide más.
La pregunta útil es qué de lo que sirvo usa PHP, con qué versión y con qué SAPI. Contestarla es más aburrido de lo que parece, porque la respuesta suele estar en tres sitios distintos y uno de ellos es un contenedor que nadie reconstruye desde 2023.
Cómo funciona

Cuando una asignación no cabe en memory_limit, PHP no lanza una excepción ni devuelve un error. Salta. longjmp se lleva la ejecución a otro punto de la pila sin pasar por el final de la función: no limpia, no cierra, no avisa5.
De ahí sale todo el patrón, y merece la pena quedarse con él porque se repite en cuatro sitios del motor. El código publica un estado, hace una asignación que puede fallar, y solo después lo confirma. Si el salto cae entre publicar y confirmar, ese estado queda a medias, con punteros a memoria que ya no es suya, y alguien lo lee como si fuera válido.
En la práctica son tres peticiones sobre el mismo proceso.
La primera rompe. Un multipart troceado se salta post_max_size, agota la memoria justo donde interesa y muere dejando el estado de sesión publicado y el heap liberado. El proceso sigue vivo, que es lo importante.
La segunda lee. Reclama esa memoria y el motor le devuelve una cookie PHPSESSID con trozos del heap dentro. Dos pasadas: una para la base del heap, otra para la de libphp. Con eso el ASLR deja de estorbar.
La tercera escribe. Coloca un zval falso en la posición que ha calculado y el motor lo ejecuta como si fuera suyo.
Hay un detalle que casi nadie cuenta y que explica por qué esto no es «un curl y ya». El estado es por worker. Si las tres peticiones caen en hijos distintos de Apache, no hay cadena; el toolkit público resuelve eso manteniendo N-1 workers ocupados con conexiones keep-alive15. Junto con la dependencia del layout de memoria de cada build, ahí están los veintidós años.
Cómo mitigar
Lo único que funciona del todo: actualizar y reiniciar. Y con reiniciar me refiero a los procesos, no al servidor: PHP-FPM, Apache y cualquier proceso PHP que lleve meses en pie.
Insisto en lo del reinicio porque es el error más común. Instalar el paquete no reinicia nada. Si el proceso sigue vivo con el código viejo cargado, sigues expuesto, y el gestor de paquetes te dirá que estás al día.
Versiones corregidas: 8.2.35, 8.3.36, 8.4.27 y 8.5.1216. Las distros y los LTS ya tienen paquetes.
Si no puedes parchear hoy, esto es lo que hay:
memory_limita-1administrado de verdad, en el pool, en el directorio y en la aplicación. Quita el OOM controlable, que es la condición de todo lo demás.session.upload_progress.enabled=0en FPM.post_max_sizeconservador y un límite del cuerpo ya decodificado en el borde.- Exigir
content-lengthen los multipart y acotar partes, cabeceras y anidamiento. pm.max_requests = 50en el pool de FPM: reinicia el worker cada cincuenta peticiones y acorta la ventana en la que el estado de una alcanza a la siguiente. No sirve contra el fallo de la tabla hash, que ocurre dentro de una sola petición, y a un valor de 1 te cargas el rendimiento11.
Un aviso, porque es contraintuitivo y lo he visto recomendado: no uses RLIMIT_AS ni RLIMIT_DATA. Provocan justo el fallo de mmap() que reabre la ruta vulnerable.
Y si tienes margen para algo estructural: pasar de mod_php a PHP-FPM ya te quita el caso «cualquier script, incluso inexistente». El parche, por su parte, quita longjmp para el OOM y lo sustituye por un OOM diferido en el siguiente safe point, con _exit() si se cruza un segundo umbral1718. Corrige el patrón, no los cuatro caminos que se publicaron.
Cómo saber si ya han entrado
Buscar firmas sirve de poco: en un servidor vivo hay miles de líneas que parecen un ataque y el ruido te come antes de encontrar nada. Lo que funciona es agrupar por lo que el atacante intenta hacer19, porque cada paso deja rastro en un sitio distinto y se puede contrastar con otro.
Acceso inicial y ejecución (T1190, T1505.003). Errores de memoria o de multipart en el log de PHP que no tengan ninguna petición detrás en el access log: si no hay petición que lo explique, no fue un cliente torpe. En la misma ventana, caídas del proceso: segfaults, dobles frees, abortos de httpd o php-fpm, volcados en /var/crash. Fastly apunta a un patrón concreto, decenas de segfaults en minutos seguidos de un único 200 lento, que coincide con la fase de fuerza bruta para recuperar direcciones11. Y ficheros PHP cuyo propietario sea el usuario del servidor web, fuera de carpetas de caché: Apache no escribe PHP, así que eso no lo ha puesto tu despliegue.
Ejecución de comandos y shell (T1059.004, T1571). Procesos del usuario web que no son Apache, hijos de Apache que no son Apache, conexiones salientes a puertos que no pintan nada, binarios viviendo en /tmp. El más limpio de todos: un /bin/sh hijo de un worker de PHP. Con los exploits conocidos no corre código de la aplicación, así que si aparece ese shell, es el payload disparando11.
Acceso a credenciales (T1552.001, T1552.004). .env, .aws/credentials, .htpasswd, claves privadas y dumps de base de datos servidos con 200, y copias .bak con credenciales dentro. El contraste que evita el susto: comprobar que el fichero existe de verdad en disco, porque muchas aplicaciones devuelven 200 en su página de «no encontrado».
Persistencia (T1053.003, T1543.002, T1546.004, T1574.006, T1098.004, T1136.001, T1548.001). Claves SSH autorizadas en usuarios de sistema, cuentas con UID 0, cron que descarga y ejecuta, unidades systemd que no vienen de ningún paquete, ld.so.preload, SUID fuera de su sitio.
Evitación de defensas (T1027, T1140, T1562.001, T1564.001). Blobs base64, cadenas hex, chr() concatenados, error_reporting(0) junto a eval, ini_set tocando disable_functions u open_basedir, PHP escondido, auto_prepend_file cargando código en cada petición.
Reconocimiento (T1595.002). Ráfagas de escaneo, y esa petición suelta que solo sirve para leer tu memory_limit del mensaje de error.
Y un contraste más, que ahorra más falsos positivos que ningún otro: un cambio de configuración a las 04:12 suele ser certbot reescribiendo el vhost, no un intruso.
Mi regla, si tengo que automatizar algo: multipart sin longitud fiable, más filename="" anidado, más PHPSESSID de longitud anómala, todo dentro de la misma ventana de un minuto. Por separado es ruido de internet. Junto no tiene ningún uso legítimo.
Y si te sale algo de esto, asume lo peor: la memoria del worker salió. Rota tokens, claves de API, credenciales de base de datos y sesiones de esa máquina. No pierdas el tiempo buscando el payload en los logs de la aplicación. No está, porque nunca pasó por ahí.
Entre el martes y el miércoles hubo gente haciendo las cosas bien: PHP quitando el longjmp en todas las ramas, las distribuciones con paquetes listos el primer día, Wiz y Calif llegando al mismo sitio por separado. Y hubo gente corriendo: un CVE mal puntuado, una hora mal dicha, un vídeo de más, un anuncio de producto encima de un incendio, una herramienta que contesta «no sé» justo a los servidores mejor configurados. La diferencia entre los dos grupos no era el talento. Era si alguien había decidido antes qué hacer cuando el secreto se rompe. Y aun así, al día siguiente un millón largo seguía sin parchear20.
Suerte ahí afuera.
Referencias
CWE-639: Authorization Bypass Through User-Controlled Key · MITRE ↩︎
A01:2021 Broken Access Control · OWASP Top 10 ↩︎
Advisory 11/2004: PHP memory_limit remote vulnerability · Stefan Esser, e-matters, 14 de julio de 2004 (CAN-2004-0594). El advisory no incluye exploit: «e-matters is not going to release an exploit for this vulnerability to the public» ↩︎
Bug #78876: Long variables in multipart/form-data cause OOM and temp files are not cleaned · enviado el 28 de noviembre de 2019, cerrado el 11 de mayo de 2020 (CVE-2019-11048) ↩︎
A tale of two PHP remote vulnerabilities · Ryan Austin, Calif Research, 21 de octubre de 2026 ↩︎ ↩︎
Today’s PHP security release, and how we’re strengthening PHP security · PHP Foundation, 21 de octubre de 2026 ↩︎ ↩︎ ↩︎
PHP2Shell: AI uncovers critical RCE vulnerabilities affecting 70% of the Internet · Wiz Research, 21 de octubre de 2026 ↩︎
Amazon Threat Intelligence identifies PHP2Shell exploitation prior to public disclosure in MadPot · CJ Moses, AWS Security Blog, 21 de octubre de 2026 ↩︎
Amazon and Cloudflare report PHP2Shell cyberattack attempts as millions of websites race to patch · Cybernews, 22 de octubre de 2026 (AWS MadPot, curva de Cloudflare y autoría de LCFR) ↩︎ ↩︎
Cloudflare deploys protections against php2shell vulnerability · Daniele Molteni, Dan Jones y Kuber Nandwani, Cloudflare, 21 de octubre de 2026 (reglas activas desde las 03:02 UTC, nueve horas antes del anuncio). La cifra de miles de intentos en las diez horas previas es de Matthew Prince, recogida por SecurityWeek ↩︎ ↩︎
Fastly’s Proactive Protection for PHP2Shell · Kelly Shortridge, Fastly, 21 de octubre de 2026: «There is evidence of active attacks in-the-wild pre-disclosure», y las tres señales citadas (segfaults y 200 lento, shell hijo de un worker,
pm.max_requests) ↩︎ ↩︎ ↩︎ ↩︎PHP2Shell: When PHP Runs Out of Memory at the Wrong Moment · Imperva, octubre de 2026: durante el periodo de monitorización previo, no observó tráfico que casara con los patrones seguidos ↩︎
Patch the Planet: global response coordination as agents uncover critical vulnerabilities · OpenAI, 21 de octubre de 2026 ↩︎
openai/php2shell-checker · dos skills de Codex (
phpcheck-serveryphpcheck-url); leeX-Powered-ByyServery compara con umbrales del 20 de octubre, así queUNKNOWNno significa parcheado ↩︎devploit/php2shell · toolkit que documenta las tres etapas; uso exclusivo contra sistemas propios o con permiso escrito ↩︎
PHP October 2026 security release · php.net, 21 de octubre de 2026 ↩︎
GHSA-w8hw-w9fx-j79w: RCE via OOM corruption · aviso de seguridad de PHP ↩︎
GHSA-6wmf-4rv5-p3rp: RCE via stale state · aviso de seguridad de PHP ↩︎
MITRE ATT&CK · referencia de técnicas citada arriba por su identificador ↩︎
PHP2Shell Exploitation Started Days Before Patches · Ionut Arghire, SecurityWeek, 22 de octubre de 2026, con los recuentos de Censys, VulnCheck y VirusTotal sobre servidores sin parchear y en versiones sin soporte ↩︎