1. Descripción técnica
PHP2Shell es el nombre usado por la comunidad para dos vulnerabilidades críticas del núcleo de PHP: CVE-2026-109618, una corrupción de memoria provocada por el manejo de errores out of memory (OOM), y CVE-2026-109619, una reutilización de estado global obsoleto entre peticiones. Ambas pueden terminar en ejecución remota de código (RCE) sin autenticación y recibieron una puntuación CVSS v4.0 de 9.2. Aviso oficial de PHP.
Explicación corta Una petición provoca un error mientras PHP prepara datos internos y deja una estructura incompleta o una referencia hacia memoria liberada. Si el proceso vuelve a utilizarla, el resultado puede ser una fuga de memoria, una caída o RCE con los permisos del worker web. El fallo está en el motor de PHP; WordPress, Drupal y Joomla sólo fueron objetivos de demostración. Investigación de Wiz.
Antes de estudiar cada CVE conviene definir los términos principales:
- Worker: proceso que ejecuta PHP para atender una petición. Un mismo worker puede procesar varias peticiones una detrás de otra.
- SAPI: capa que conecta PHP con el servidor web.
mod_php, PHP-FPM y CGI son ejemplos. Cada SAPI decide cuándo inicia, limpia o termina un worker. - Heap de petición: memoria dinámica donde PHP guarda temporalmente strings, arrays, objetos y metadatos de una petición. Al terminar, puede reutilizarla.
- Puntero: número que indica dónde se encuentra un dato en memoria. Se parece a una dirección postal dentro del proceso.
- Estado global: información que permanece en el worker durante más tiempo que una petición. Puede incluir errores, sesiones y datos de extensiones.
- OOM (out of memory): condición que aparece cuando PHP no puede reservar más memoria o alcanza
memory_limit. - Use-after-free: uso de un puntero después de liberar el dato al que apuntaba. La misma dirección puede contener ahora información diferente.
- ASLR: protección que cambia las direcciones de memoria en cada ejecución para que un atacante no sepa de antemano dónde están los datos y funciones.
- Offset: distancia entre el comienzo de una zona de memoria y un dato o una función dentro de ella.
- ELF: formato de ejecutables y bibliotecas en Linux; su tabla de símbolos permite localizar funciones por su offset.
HashTable: estructura interna con la que PHP representa arrays y otras colecciones.- Heap grooming o heap spray: creación de muchos objetos o copias para influir en qué datos ocupan una dirección del heap.
- RCE (remote code execution): capacidad de ejecutar comandos en el servidor mediante una petición remota.
Los datos enviados por Internet deberían permanecer separados de las estructuras internas del motor. PHP2Shell rompe esa separación. El atacante consigue que bytes procedentes de una petición ocupen memoria que PHP vuelve a interpretar como un puntero, un array o una función interna.
1.1. Modelo de ejecución de una petición PHP
De forma simplificada, un worker persistente repite este ciclo:
- Prepara el estado de una petición.
- Analiza cabeceras, parámetros y cuerpos como
multipart/form-data. - Inicializa módulos y ejecuta el script PHP.
- Ejecuta las rutinas de cierre.
- Libera el heap de la petición.
- Conserva el proceso para atender otra petición.
Un worker persistente queda listo para otra petición después de responder. El orden exacto cambia según la SAPI: algunas analizan cargas o aplican configuración antes del script, y pueden conservar o terminar el worker tras un error. Esto determina si el estado dañado sobrevive.
// renderizando diagrama…
La petición A deja el problema y la B lo aprovecha. En una ruta de CVE-2026-109618, mod_php consume el estado inválido durante la limpieza de la misma petición.
1.2. Qué hacen memory_limit, OOM y longjmp()
memory_limit indica cuánta memoria puede consumir una ejecución de PHP. Si una reserva supera ese límite, PHP genera un OOM. En las versiones afectadas podía usar longjmp() para saltar directamente desde el lugar del fallo hasta un punto de recuperación. Configuración de memoria de PHP.
Una forma sencilla de imaginar longjmp() es una salida de emergencia. En una salida normal, cada función termina su trabajo, guarda el resultado y limpia sus datos temporales. longjmp() atraviesa varias funciones de una vez. Si alguna había actualizado sólo la mitad de una estructura, esa estructura puede quedar visible aunque todavía no sea válida.
La reserva de memoria que falla no tiene que pertenecer al objeto que termina corrupto. Sólo debe ocurrir en el momento preciso: después de que PHP haya publicado una referencia y antes de que termine de construir o retirar la estructura relacionada.
2. CVE-2026-109618: RCE mediante corrupción durante OOM
CVE-2026-109618, publicada también como GHSA-w8hw-w9fx-j79w, aparece cuando la limpieza trata como terminados datos internos que un OOM dejó incompletos.
2.1. Mecanismo paso a paso
- El cliente envía una petición que obliga a PHP a crear muchas estructuras internas.
- PHP publica o hace alcanzable una estructura antes de completar todas sus asignaciones.
- Una asignación alcanza
memory_limity dispara un OOM. longjmp()abandona el contexto sin que la operación complete su inicialización o revierta el estado.- La rutina de cierre actúa sobre datos no inicializados, incompletos o ya reutilizados.
- El atacante prepara la distribución del heap para transformar esa operación inválida en una primitiva de lectura o escritura.
- Una cadena completa puede alterar el flujo de control y ejecutar código con los permisos del worker.
Una primitiva de lectura obtiene bytes de memoria; una primitiva de escritura modifica una dirección. Encadenarlas puede superar ASLR y controlar un puntero de función.
// renderizando diagrama…
Si el worker termina, esa ruta concreta se corta; si sobrevive y ejecuta el cierre, puede consumir la estructura incompleta. FPM aún puede ser alcanzable mediante OOM posteriores al inicio normal.
2.2. Cómo se fuerza el OOM
El tamaño recibido por la red y la memoria utilizada dentro del proceso no son iguales: un campo puede convertirse en varios strings, entradas de arrays y metadatos. El advisory documenta varias formas de amplificar ese consumo con multipart/form-data:
- Una petición sin un
Content-Lengthutilizable puede superar la comprobación inicial depost_max_sizemientras PHP continúa recibiendo y decodificando datos. - Cada parte multipart puede incluir muchas cabeceras. Su representación dentro de PHP puede consumir bastante más memoria que los bytes enviados por la red.
- Los controles de archivo vacíos no cuentan para
max_file_uploads, pero sí generan las estructuras de metadatos normales. - Los nombres de campos anidados multiplican el trabajo necesario para construir arrays.
- El código de una aplicación también puede ofrecer una ruta que provoque una asignación grande o consumo repetido de memoria.
Por eso una petición puede respetar post_max_size y superar memory_limit durante su procesamiento.
2.3. Diferencias por SAPI
La SAPI determina cómo se conecta PHP con el servidor web y qué ocurre cuando falla una petición. En la tabla, ruta independiente de la aplicación significa que el fallo puede activarse antes de ejecutar WordPress, Drupal, Joomla o el script solicitado.
| Entorno | Alcance documentado para la ruta OOM |
|---|---|
Apache mod_php | PHP vive dentro de los procesos de Apache. Apache ejecuta la limpieza incluso si falla el inicio de la petición, por lo que se demostró RCE antes del código de la aplicación y sin autenticación. |
| PHP-FPM | PHP funciona como un servicio separado. Si falla php_request_startup(), ese worker termina y corta la ruta temprana. Siguen siendo posibles rutas donde el OOM ocurre después del inicio normal de la petición. |
| FrankenPHP | Integra PHP en un servidor moderno. Su código llama a la limpieza de forma parecida a mod_php, por lo que el advisory considera probable una ruta independiente de la aplicación. |
| LiteSpeed / LSAPI | Su ciclo se parece a FPM. Las rutas dependientes de la aplicación se consideran posibles, pero el advisory no afirma haberlas validado. |
php-cgi | Ejecuta PHP mediante la interfaz CGI. El riesgo depende de si el proceso sobrevive, cómo se inicia y qué servidor o proxy le entrega las peticiones. |
Las cadenas de varias peticiones pierden fiabilidad cuando el servidor las reparte entre muchos workers. También influyen la compilación, las extensiones y el administrador de memoria.
2.4. Qué cambió el parche
El parche cambia la forma de responder al OOM para impedir que PHP continúe desde un estado intermedio:
- Un soft OOM aplaza el error hasta el siguiente punto seguro de la máquina virtual, donde el motor puede salir sin abandonar una estructura a mitad de inicialización.
- Un hard OOM termina inmediatamente el worker mediante
_exit()si el consumo alcanza un segundo umbral superior.
Ambas respuestas impiden que la rutina de cierre reciba una estructura abandonada a mitad de construcción.
3. CVE-2026-109619: RCE mediante estado global obsoleto
CVE-2026-109619, publicada también como GHSA-6wmf-4rv5-p3rp, afecta al estado que un worker conserva entre peticiones. Al terminar una petición, PHP libera sus datos temporales. El fallo aparece cuando una variable de larga duración todavía conserva la dirección de uno de esos datos.
Se puede comparar con una nota que dice «el documento está en el cajón 3». El documento se retira, pero la nota permanece. Más tarde alguien guarda otro objeto en el cajón 3 y el programa lo usa pensando que sigue siendo el documento anterior.
La explotación necesita dos papeles:
- Un productor deja la referencia obsoleta. Normalmente es una petición que termina de forma inesperada antes de limpiar el estado.
- Un consumidor usa esa referencia en una petición posterior, antes de que PHP tenga oportunidad de reinicializarla.
3.1. Mecanismo paso a paso
- Una operación guarda en el worker una referencia a datos del heap de la petición A.
- Un OOM, error fatal, timeout u otra interrupción evita el reset correspondiente.
- PHP libera el heap de A, pero la referencia global permanece.
- El mismo worker recibe la petición B.
- B reserva memoria que puede ocupar las direcciones liberadas por A.
- Una operación temprana consulta, libera o modifica la referencia global antes de la inicialización ordinaria.
- PHP trata los nuevos bytes como el objeto antiguo. El resultado puede ser use-after-free, divulgación de memoria, corrupción y RCE.
// renderizando diagrama…
La explotación aparece si una operación temprana consume la referencia antes de que PHP la reinicialice. Ocultar el mensaje de error no cambia ese orden interno.
3.2. Productores confirmados
Un productor peligroso deja una referencia activa y luego impide que se ejecute su limpieza. Las interrupciones documentadas incluyen:
- Errores de análisis o compilación.
- Errores no capturados.
- Fallo de
require. Maximum execution time exceeded.- OOM después de publicar estado de un error, excepción, objeto o extensión.
- Diagnósticos producidos tarde durante el cierre, después del reset ordinario del estado de errores.
El error debe ocurrir después de publicar la referencia y antes de retirarla; un error fatal aislado no demuestra la vulnerabilidad.
3.3. Consumidores confirmados
Diagnósticos INI antes de inicializar la petición
Una directiva INI configura el comportamiento de PHP. Si una directiva es inválida, conflictiva o está obsoleta, PHP puede generar un diagnóstico antes de ejecutar php_request_startup(), la inicialización normal de la petición. Ese diagnóstico temprano puede consultar el estado obsoleto. Desactivar la visualización o el registro del mensaje no evita que PHP lo construya internamente, por lo que error_reporting, @, display_errors=Off y log_errors=Off no corrigen el problema.
En mod_php, Apache puede aplicar opciones de .htaccess, php_value o php_flag durante esa ventana temprana. En PHP-FPM, el servidor situado delante de FPM tendría que enviar PHP_VALUE o PHP_ADMIN_VALUE con un valor que provoque el diagnóstico. Las opciones normales del pool se aplican al crear el worker y .user.ini se procesa más tarde, por lo que no activan por sí solas el mismo consumidor.
Progreso de carga de sesiones
session.upload_progress.enabled permite que PHP guarde en la sesión cuánto ha avanzado una carga de archivos. Como PHP debe informar el progreso mientras todavía recibe el cuerpo, esta función se ejecuta antes del script de la aplicación y puede abrir o modificar la sesión muy temprano. No hace falta que WordPress, Drupal u otra aplicación llame a session_start(). Documentación de upload progress.
Una primera carga puede dejar estado de sesión o del decodificador y terminar con un error fatal. Una segunda carga procesada por el mismo worker vuelve a entrar al módulo de sesiones antes de la limpieza normal. Esta ruta fue validada en mod_php y FPM, aunque en FPM necesita que la aplicación provoque el error después de que la petición haya iniciado correctamente.
3.4. Variantes demostradas por Wiz
La investigación de Wiz documenta cuatro primitivas concretas agrupadas bajo los dos CVE. Son demostraciones de impacto, no una lista completa de todas las variantes posibles.
Los nombres que aparecen en la tabla son variables globales internas de PHP:
BG(...)agrupa estado básico usado por funciones como serialización y deserialización.PS(...)agrupa estado del módulo de sesiones.EG(...)agrupa estado del motor que ejecuta el código.CG(...)agrupa estado usado al compilar scripts PHP.- OPcache conserva código PHP ya compilado para no repetir ese trabajo en cada petición.
| CVE | Variante demostrada | Transformación observada |
|---|---|---|
| CVE-2026-109618 | Use-after-free del deserializador durante upload progress | BG(unserialize) conserva información auxiliar después de liberar el heap de la petición. Otra petición usa esa información como si todavía describiera objetos válidos. Se usó para RCE en WordPress, Drupal y Joomla. |
| CVE-2026-109618 | Divulgación remota de memoria del proceso | PS(id) sigue apuntando al antiguo identificador de sesión. Cuando la dirección se reutiliza, PHP devuelve los nuevos bytes dentro de una cookie PHPSESSID. Así se filtra memoria sin que la aplicación llame a session_start(). |
| CVE-2026-109619 | Use-after-free del backtrace fatal | EG(last_fatal_error_backtrace) conserva la dirección de una traza de error ya liberada. Un diagnóstico temprano vuelve a utilizarla y permite corromper memoria. Se demostró RCE contra Drupal. |
| CVE-2026-109619 | Confusión de tipos en OPcache | Dos workers dejan de coincidir sobre qué tipo de dato pertenece a una posición compartida. Uno guarda un tipo y otro reutiliza el mismo índice para otro. PHP aplica el tipo antiguo al nuevo dato y se demostró RCE contra WordPress. |
Una fuga puede revelar direcciones para superar ASLR y también secretos residentes en memoria. Una cookie extraña sólo confirma la fuga si contiene bytes ajenos a la petición actual.
4. Relación entre ambos CVE
La diferencia central es cuándo queda inválida la memoria:
| Aspecto | CVE-2026-109618 | CVE-2026-109619 |
|---|---|---|
| Nombre oficial | RCE via OOM corruption | RCE via stale global state |
| Evento central | El OOM abandona una estructura parcialmente inicializada | Un estado global sobrevive a la memoria de su petición |
| Consumo peligroso | Cierre o procesamiento posterior de estado inválido | Operación temprana de otra petición antes del reset normal |
| Relación entre peticiones | No siempre necesaria para la ruta mod_php demostrada | Normalmente requiere productor y consumidor en peticiones relacionadas |
| Condición de worker | Depende de la SAPI y del punto del OOM | Las peticiones deben alcanzar el worker que conserva el estado; OPcache añade una variante compartida entre workers |
| Impacto confirmado | RCE sin autenticación | Use-after-free, fuga de memoria y RCE sin autenticación |
Una cadena puede combinar primitivas de ambos CVE. PHP2Shell nombra la familia y la herramienta de demostración; los identificadores no son duplicados.
5. Condiciones necesarias
Tener una versión vulnerable significa que el servidor debe actualizarse, pero no demuestra que alguien ya lo haya explotado. Para que una cadena funcione deben coincidir varias condiciones:
- Versión vulnerable: el proceso web no tiene la corrección ni un backport del proveedor.
- Entrada alcanzable: la petición activa el análisis multipart, la sesión o la ruta usada por la cadena.
- Error oportuno: el OOM o error fatal ocurre después de publicar el estado peligroso y antes de limpiarlo.
- Consumidor: una rutina de cierre o petición posterior vuelve a usar la estructura inválida.
- Worker adecuado: las peticiones relacionadas llegan al proceso que conserva la referencia; OPcache puede compartir estado entre workers.
- Heap preparado: los datos controlados ocupan las direcciones liberadas mediante heap grooming.
WordPress, Drupal y Joomla no son requisitos. El progreso de carga tampoco exige session_start() porque PHP puede procesarlo antes del script. Usar PHP-FPM no sustituye la actualización.
6. Impacto y límites
Una explotación completa puede proporcionar:
- Ejecución de código con la cuenta del worker web.
- Lectura de memoria del proceso, incluidos secretos que estén residentes en ese momento.
- Lectura o modificación de archivos permitidos para la cuenta del servicio.
- Acceso a servicios internos que el servidor pueda alcanzar.
- Alteración de la aplicación y disponibilidad del servicio.
El comando se ejecuta como la cuenta del worker, por ejemplo www-data, no automáticamente como root. El alcance depende de sus permisos, secretos y acceso a redes internas. #9.3. Validación del resultado distingue una señal de una explotación confirmada.
7. Versiones afectadas y corregidas
El proyecto PHP publicó correcciones para las cuatro ramas mantenidas:
| Rama | Versiones afectadas en el advisory | Primera versión corregida |
|---|---|---|
| PHP 8.5 | Anteriores a 8.5.12 | 8.5.12 |
| PHP 8.4 | Anteriores a 8.4.27 | 8.4.27 |
| PHP 8.3 | Anteriores a 8.3.36 | 8.3.36 |
| PHP 8.2 | Anteriores a 8.2.35 | 8.2.35 |
PHP 8.1 y ramas anteriores ya no reciben mantenimiento ordinario del proyecto. Un backport toma la corrección y la adapta a una versión antigua sin convertirla en una versión nueva. El equipo publicó backports mínimos de código fuente para PHP 7.4, 8.0 y 8.1 debido a la gravedad, pero recomienda usar paquetes corregidos por un proveedor con soporte de largo plazo, llamado LTS, o migrar a una rama mantenida. Aviso oficial de PHP.
Wiz indica que al menos uno de los fallos alcanza versiones desde PHP 5.4. Esta observación no sustituye el rango formal del advisory para las ramas mantenidas ni garantiza que un proveedor distribuya una corrección para cada versión EOL. Investigación de Wiz.
Una versión antigua no basta para evaluar un paquete de distribución Debian, Ubuntu, Red Hat y otros proveedores pueden añadir la corrección a un paquete antiguo y conservar casi el mismo número de versión. Por eso hay que consultar el changelog y el aviso del proveedor. Tampoco basta con ejecutar
php -v: ese comando muestra el PHP de la terminal y el servidor web puede estar usando otro ejecutable o contenedor.
8. Validación de exposición
8.1. Inventario del runtime real
En el servidor, estos comandos aportan contexto inicial:
php -r 'printf("PHP %s | SAPI %s\n", PHP_VERSION, PHP_SAPI);'
php --ini
php -i | grep -E '^(Server API|memory_limit|post_max_size|upload_max_filesize|session.upload_progress.enabled|fatal_error_backtraces)'Los comandos muestran la versión y SAPI del PHP de terminal, sus archivos INI y las opciones relevantes. Como el sitio puede usar otro binario o contenedor, hay que repetirlos en el servicio que atiende la web.
El siguiente comando localiza los procesos activos; expresiones como [p]hp-fpm evitan que aparezca el propio grep:
ps -eo user,pid,ppid,args --forest | grep -E '[p]hp-fpm|[a]pache2|[h]ttpd|[l]itespeed|[f]rankenphp'Conviene registrar ejecutable, paquete o imagen, configuración y fecha de inicio. Workers antiguos pueden seguir ejecutando el código previo hasta reiniciarse.
8.2. Criterio de conclusión
// renderizando diagrama…
Una cabecera X-Powered-By: PHP, la presencia de WordPress o una respuesta de error sólo confirma que probablemente existe PHP detrás del sitio. No identifica con seguridad la versión ni demuestra la vulnerabilidad. La confirmación más sólida combina el inventario del runtime, el estado del paquete del proveedor y la verificación de que todos los procesos se reiniciaron después de actualizar.
9. PoC
Las demostraciones disponibles cubren desde la explotación completa desarrollada por Wiz hasta una implementación pública acompañada de laboratorio, artefactos y datos de procedencia. La reproducción debe realizarse en un entorno aislado y sobre una versión deliberadamente vulnerable.
9.1. Demostración técnica de Wiz
Wiz Research afirma haber combinado las primitivas en un exploit llamado php2shell y haber demostrado RCE previa a autenticación contra imágenes de WordPress, Drupal y Joomla. También documenta una fuga de memoria independiente y cadenas para el backtrace fatal y OPcache.
Wiz no publicó el script usado en esas demostraciones ni enlazó un repositorio descargable. El artículo técnico documenta las primitivas y el resultado de la explotación, pero no existe código público de Wiz del cual extraer fragmentos literales.
9.2. PoC pública: xuehuolll/php2shell-CVE-2026-109618-109619
El repositorio xuehuolll/php2shell-CVE-2026-109618-109619 incluye código de explotación, un laboratorio reproducible, análisis del código fuente, resultados positivos y negativos, artefactos, hashes y un archivo de procedencia.
El autor informa una RCE remota verificada sobre php:8.4.26-apache con mod_php prefork. La PoC provoca el estado obsoleto, filtra direcciones mediante Set-Cookie, calcula las bases del heap y libphp, construye un HashTable falso y comprueba la ejecución como www-data.
La cadena está preparada para una compilación concreta y requiere acceso al ELF de libphp para resolver offsets, una relación adecuada entre memory_limit y el tamaño del cuerpo, persistencia del worker y calibración del heap. Los valores obtenidos para una compilación no deben reutilizarse en otra versión o paquete.
Lenguaje del exploit
remote_cve.pyestá escrito en Python, aunque ataca un proceso PHP. Los extractos siguientes conservan el código original y se muestran en bloquesphppara mantener el formato solicitado en esta nota.
La cadena remota está implementada en exploit/cve/remote_cve.py.
9.2.1. Construcción y envío de las peticiones
multipart() y Target.post() construyen y envían la petición HTTP sin utilizar una librería como requests. Esto permite controlar exactamente cada byte. multipart() separa los campos mediante un texto llamado boundary y añade el nombre y contenido de cada campo. Target.post() abre la conexión, añade las cabeceras HTTP y envía la petición completa.
Las funciones big() y file_part() crean dos piezas importantes. file_part() presenta uno de los campos como un archivo y hace que PHP active su manejador de cargas. big() añade un bloque de 12 MiB. El objetivo es que PHP acepte la petición por su tamaño HTTP, pero agote el límite de memoria mientras crea arrays, strings y metadatos internos. Los campos PHPSESSID y PHP_SESSION_UPLOAD_PROGRESS hacen que PHP también actualice el progreso de la sesión.
El bloque grande se crea literalmente así:
def big(self):
return {"name": "big", "data": b"B" * (12 * 1024 * 1024)}b"B" representa un byte con la letra B. La multiplicación repite ese byte 12 × 1024 × 1024 veces, es decir, crea aproximadamente 12 MiB. El diccionario devuelve el nombre del campo y sus datos para que multipart() los incluya en la petición.
9.2.2. Resolución de símbolos y direcciones
elf_symbols() lee el archivo binario de libphp. Dentro de ese archivo existe una lista que relaciona nombres de funciones con posiciones. La PoC busca zif_system y zval_ptr_dtor. Las posiciones obtenidas son relativas al comienzo de la biblioteca, por lo que todavía hace falta descubrir su dirección base en memoria.
chunk_base() examina los bytes filtrados en la cookie. Toma ocho bytes cada vez y los interpreta como un número porque un puntero de 64 bits ocupa ocho bytes. Descarta los números que no parecen direcciones normales de un proceso Linux de 64 bits. Después agrupa las direcciones restantes por regiones de 2 MiB y elige la región que aparece con mayor frecuencia.
La siguiente línea elimina la parte del puntero que indica una posición dentro de la región. El resultado es la dirección donde comienza esa región de 2 MiB:
c[v & ~0x1FFFFF] += 1libphp_base() toma de la segunda fuga un puntero que debería señalar a zval_ptr_dtor. La PoC ya conoce la distancia entre el comienzo de libphp y esa función. Al restar esa distancia obtiene la dirección donde empieza la biblioteca.
La primera línea realiza la resta. La segunda sólo acepta el resultado si termina en una dirección válida de comienzo de página:
base = p - dtor
return base if base & 0xFFF == 0 else 0Se puede pensar en este cálculo como una dirección postal. La fuga revela dónde comienza el edificio y el offset indica cuántos pasos hay que avanzar hasta una habitación. Con ambas fugas, la PoC calcula dónde colocar el array falso dentro del heap y dónde se encuentra zif_system dentro de libphp.
En la versión analizada, --libphp permite localizar los símbolos dentro del ELF. Sin embargo, la etapa final usa el valor recibido mediante --zif. Por eso ese argumento debe contener el offset de zif_system perteneciente exactamente a la misma compilación de libphp.
9.2.3. Rotación del worker y primera fuga
rotate() envía más de mil campos junto con el bloque grande. PHP debe reservar memoria para el contenido y también para la información que describe cada campo. La petición busca agotar memory_limit y hacer que Apache reemplace el worker. Después, una petición pequeña crea una sesión con el identificador conocido attacksessionid0001.
En el modelo prefork de Apache, el proceso maestro crea los workers copiando su propio estado. Por esa razón, los workers pueden compartir la misma distribución inicial de direcciones. La PoC intenta obtener un worker limpio sin perder la utilidad de las direcciones que filtrará después.
stage1() vuelve a provocar la condición de memoria. Luego envía cientos de campos parecidos para que el heap adopte una distribución predecible. También crea forge, un bloque de bytes preparado para que, si ocupa la posición correcta, PHP lo confunda con parte de una estructura de sesión. Esa confusión puede hacer que PHP devuelva bytes de su memoria dentro de Set-Cookie.
El fragmento que prepara ese buffer es:
forge = bytearray(b"A" * 79)
struct.pack_into("<Q", forge, 0x10, 0x1000)
forge[0x18] = ord("L")bytearray() crea 79 bytes que pueden modificarse. struct.pack_into("<Q", ...) escribe el número 0x1000 como un entero de 64 bits en la posición 0x10, que equivale a 16 bytes desde el inicio. El símbolo < indica orden little-endian y Q indica un entero sin signo de ocho bytes. La última línea escribe el valor numérico de la letra L en la posición 0x18.
9.2.4. Segunda fuga y cálculo de la base de libphp
stage2() busca una segunda fuga. Los nombres como a0001[0] hacen que PHP cree arrays, no simples strings. Crear muchos arrays de tamaños controlados cambia qué objetos quedan juntos en el heap. En una posición elegida por el perfil, la PoC crea dieciséis elementos para intentar situar un puntero a zval_ptr_dtor dentro de los bytes divulgados.
La posición elegida recibe dieciséis valores en vez de uno:
cnt = 16 if i == self.stage2_t else 1Esta expresión significa: usar 16 cuando el índice actual i coincide con la posición objetivo stage2_t; en cualquier otra posición, usar 1. Así se introduce una única asignación más grande dentro de un patrón de asignaciones pequeñas.
La respuesta pasa otra vez por cookie_bytes(). Si contiene el puntero esperado, libphp_base() resta el offset conocido. El resultado permite localizar zif_system aunque ASLR haya cargado libphp en una dirección diferente.
9.2.5. Construcción del HashTable falso
fake() crea bytes con la forma de un HashTable y de una referencia hacia él. En los campos que esa compilación espera, coloca la ubicación de los elementos, la dirección de zif_system como destructor, una referencia al array falso y un comando de hasta 14 bytes.
El puntero de destrucción y el comando se escriben directamente en el buffer:
struct.pack_into("<Q", ht, 0x30, zif)
ht[0x98:0x98 + len(cmd)] = cmd
ht[0x98 + len(cmd)] = 0La primera línea rellena pDestructor; las otras copian el comando y añaden el byte cero que termina un string de C. fake() sólo fabrica estos bytes: la ejecución ocurre si PHP los acepta después como un array real y llama al supuesto destructor.
9.2.6. Disparo y comprobación
stage3() prepara el heap y envía quince copias del HashTable falso junto con la dirección donde espera colocarlo. La repetición aumenta la probabilidad de que el puntero obsoleto alcance una copia.
El spray de estructuras se realiza con este bucle:
for i in range(15):
parts.append({"name": f"a{i:030d}"[:31], "data": ht})El bucle crea quince campos multipart distintos con ht como contenido. Si uno ocupa la posición esperada, la limpieza de la sesión lee pDestructor, llama a zif_system con el string preparado y ejecuta el comando como el worker web.
main() ejecuta rotate -> stage1 -> rotate -> stage2 -> rotate -> stage3 y reintenta tres veces por defecto. Con --proof-file, comprueba que el comando creó un archivo con contenido.
9.3. Validación del resultado
| Resultado observado | Conclusión válida |
|---|---|
session.upload_progress.enabled=1 | Existe una función relacionada; no confirma una CVE. |
Cambio en bytes_processed | La petición alcanzó upload progress; no confirma use-after-free. |
| OOM, HTTP 500 o worker caído | Se provocó un error o DoS; no confirma control del flujo. |
| Fuga de bytes que pertenecían a otra petición | Confirma divulgación de memoria para esa cadena y entorno. |
| Archivo marcador creado por el worker o salida de un comando fijo | Confirma ejecución de código con los permisos observados. |
| Éxito contra una versión y compilación | Confirma esa combinación concreta; no garantiza que la PoC funcione contra cualquier servidor. |
Para conservar una reproducción se deben registrar el commit de la PoC, hash de la imagen, compilación de PHP, SAPI, extensiones, configuración, número de workers, petición, respuesta, logs y efecto obtenido.
10. Detección e investigación
Un indicador es una señal que merece investigación, pero no siempre demuestra un ataque. No existe una sola señal que identifique todas las variantes. La mejor estrategia es buscar varios eventos ocurridos en el mismo intervalo:
- Peticiones
multipart/form-dataanómalas, especialmente con muchas partes, cabeceras por parte, campos anidados o cargas vacías repetidas. - Cuerpos sin un
Content-Lengthutilizable o diferencias llamativas entre tamaño transferido y memoria consumida. - Errores
Allowed memory size ... exhausted, errores fatales, timeouts y secuencias de respuestas HTTP 500. - Reinicios, caídas o recambios inesperados de workers PHP.
- Cookies
PHPSESSIDinusualmente grandes o malformadas cerca de solicitudes multipart. - Procesos hijos, escrituras de archivos o conexiones de red inesperadas iniciadas por Apache, PHP-FPM u otro worker web.
- Diagnósticos repetidos por directivas INI inválidas o deprecadas antes de ejecutar el script objetivo.
Un multipart grande o un OOM pueden ser legítimos; la sospecha aumenta cuando coinciden con la caída de un worker y una acción inesperada de la cuenta web. Como algunas rutas ocurren antes del script, hay que correlacionar logs del proxy, servidor web, PHP, sistema operativo, EDR y contenedores. La ausencia de errores no descarta el ataque: el worker puede morir antes de registrarlos.
10.1. Estado de explotación pública al divulgarse
El 21 de octubre de 2026, watchTowr informó observaciones que clasificó como explotación in the wild en su red de honeypots. FAQ de watchTowr. La PHP Foundation indicó que, durante la vigilancia previa a la publicación, no había identificado exploits funcionales entre las PoC aparecidas hasta el momento de la liberación. Relato de la PHP Foundation.
Las dos fuentes observaron momentos y conjuntos de muestras diferentes, por lo que sus afirmaciones no son necesariamente contradictorias. Para investigar un servidor concreto deben prevalecer sus propios logs, procesos, archivos y conexiones, junto con la información oficial más reciente.
11. Mitigación
11.1. Corrección definitiva
La corrección definitiva consiste en instalar una versión parcheada y reiniciar los procesos que todavía tienen cargado el código antiguo:
- Identificar todos los runtimes que sirven peticiones, incluidos contenedores, imágenes base, funciones, paneles y productos que embeben PHP.
- Actualizar a PHP
8.5.12,8.4.27,8.3.36,8.2.35o al paquete corregido del proveedor. - Reconstruir imágenes y artefactos que incluyan PHP.
- Reiniciar todos los workers y procesos persistentes para eliminar código y estado anteriores.
- Verificar desde el entorno desplegado la versión, procedencia del paquete y fecha de inicio del proceso.
- Revisar el periodo de exposición y rotar secretos si existe evidencia de fuga o ejecución.
Actualizar únicamente WordPress, Drupal, Joomla o sus plugins no corrige estos fallos porque están dentro del motor de PHP. Sólo la actualización de PHP o del paquete corregido por el proveedor cubre ambas familias. CERT-EU 2026-018.
11.2. Controles temporales para la ruta OOM
Si la actualización no puede aplicarse de inmediato:
- Fijar administrativamente
memory_limit=-1y comprobar que ninguna aplicación, pool o directorio restaura un límite finito. - Mantener
post_max_sizebajo yupload_max_filesizepor debajo de ese valor. - Limitar en el proxy o servidor el tamaño decodificado, cantidad de campos, partes multipart, cabeceras por parte, cargas y profundidad de anidamiento, incluso cuando falte
Content-Length. - Bloquear
multipart/form-dataantes de PHP si la aplicación no lo utiliza. - Usar límites de memoria por contenedor o cgroup como frontera de disponibilidad, preferiblemente por worker.
- No sustituir lo anterior por
RLIMIT_ASoRLIMIT_DATA, porque pueden hacer fallarmmap()y alcanzar la ruta vulnerable.
memory_limit=-1 corta el OOM predecible usado por la cadena, pero permite mayor consumo y no evita un fallo real de asignación. Un cgroup puede contener ese consumo por worker o contenedor. Los límites del proxy deben cubrir también campos, partes y cabeceras, y RLIMIT_AS o RLIMIT_DATA no sustituyen esta medida porque pueden hacer fallar mmap() en la ruta vulnerable.
11.3. Controles temporales para el estado obsoleto
- Fijar
session.upload_progress.enabled=0enphp.ini, configuración del pool o servidor, y reiniciar todos los workers. - En PHP 8.5, fijar
fatal_error_backtraces=0y reiniciar workers. - Eliminar directivas por petición inválidas o deprecadas que generen diagnósticos tempranos.
- Corregir errores fatales y tiempos de espera que un cliente pueda provocar durante cargas, importaciones, destrucción de objetos, serialización, procesamiento de salida, plugins y extensiones.
- Como reducción extrema de persistencia,
pm.max_requests=1limita cada worker FPM a una petición, con un coste alto de rendimiento y sin cubrir estado compartido como OPcache.
Estas opciones deben fijarse en php.ini, Apache o el pool de FPM y requieren reiniciar workers. ini_set() y .user.ini llegan demasiado tarde para las rutas tempranas. pm.max_requests=1 evita que un worker atienda una segunda petición, pero perjudica el rendimiento y no cubre OPcache.
11.4. Reducción del impacto
- Ejecutar los workers con una cuenta dedicada y permisos mínimos.
- Montar como sólo lectura el código y archivos que no necesiten escritura.
- Entregar al proceso únicamente los secretos indispensables.
- Restringir conexiones salientes y acceso a redes internas.
- Separar pools por aplicación para reducir el cruce de datos y el radio de impacto.
Estas medidas aplican el principio de mínimo privilegio. Si una RCE ejecuta comandos como www-data, una cuenta con pocos permisos, sin secretos innecesarios y sin acceso libre a la red interna causa menos daño. El proceso PHP sigue siendo vulnerable hasta instalar el parche.
12. Clasificación
CVSS resume la gravedad mediante varias letras. No indica que la explotación sea automática; describe desde dónde se ataca, qué condiciones exige y qué impacto puede alcanzar.
| Campo | Clasificación |
|---|---|
| Familia | PHP2Shell |
| Identificadores | CVE-2026-109618 y CVE-2026-109619 |
| Severidad | Crítica, CVSS v4.0: 9.2 para cada CVE |
| Vector | CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N |
| CWE | CWE-416, CWE-755, CWE-824, CWE-1325 y CWE-1419 |
| Vector de entrada | Peticiones HTTP hacia un runtime PHP afectado |
| Privilegios previos | Ninguno |
| Interacción de usuario | Ninguna |
| Contexto final | Permisos del worker web |
El vector CVSS se interpreta así:
| Métrica | Significado |
|---|---|
AV:N | El ataque llega por la red. |
AC:H | Construir una cadena fiable exige alta complejidad. |
AT:P | Deben cumplirse condiciones adicionales, como una distribución de heap y ciclo de worker adecuados. |
PR:N | No requiere una cuenta previa. |
UI:N | No necesita que otra persona interactúe. |
VC:H/VI:H/VA:H | El impacto en confidencialidad, integridad y disponibilidad del sistema vulnerable es alto. |
SC:N/SI:N/SA:N | El vector no asigna impacto posterior a otro sistema. |
Una CWE clasifica el tipo de error de programación, mientras que una CVE identifica una vulnerabilidad concreta. En este caso las CWE cubren varios aspectos del mismo problema: uso de memoria liberada, manejo incorrecto de errores, punteros no inicializados, secuencias de asignación incompletas e inicialización incorrecta de recursos. CWE-416, CWE-755, CWE-824, CWE-1325, CWE-1419.
13. Referencias y conexiones
- PHP: aviso de seguridad y versiones corregidas.
- PHP Security Advisory: RCE via OOM corruption.
- PHP Security Advisory: RCE via stale global state.
- NVD: CVE-2026-109618.
- NVD: CVE-2026-109619.
- PHP Foundation: contexto del descubrimiento y la corrección.
- CERT-EU: Security Advisory 2026-018.
- Wiz Research: PHP2Shell technical deep dive.
- watchTowr: PHP2Shell FAQ.
- University of Washington: PHP CVE service status.
- PHP: Session Upload Progress.
- xuehuolll: investigación, exploit y laboratorio con procedencia.
- xuehuolll: implementación de la cadena remota.
- xuehuolll: comparación y procedencia de exploits públicos.
- Denial of Service, porque los OOM y reinicios también afectan disponibilidad.
- Arbitrary File Read, como impacto posterior posible cuando el worker puede leer archivos sensibles.
- PHP CGI Argument Injection, registrada como CVE-2024-4577, otra RCE del ecosistema PHP con una causa y un vector diferentes.
- Unrestricted File Upload RCE, que no debe confundirse con PHP2Shell: aquí el multipart actúa sobre el motor y no exige guardar un archivo ejecutable.