El 21 de octubre de 2026 el equipo de seguridad de PHP publicó un aviso de seguridad que no deja lugar a medias: dos vulnerabilidades críticas con CVSS v4 9.2 que permiten ejecución remota de código sin autenticación, en prácticamente cualquier instalación de PHP que sirva peticiones web.

Lo que hace especial a PHP2SHELL no es solo su impacto, sino lo que su mecanismo revela sobre la arquitectura de PHP: un estado global que sobrevive a la petición que lo creó y que la siguiente petición consume antes de que el ciclo de vida normal del request pueda limpiarlo.

En este post analizamos el fallo, lo reproducimos de forma diferencial en un laboratorio aislado con Docker y AddressSanitizer, y —lo más importante— separamos lo que está verificado de la narrativa que circuló antes del parche.

TL;DR: actualiza a 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12. No hay mitigación completa fuera del parche. Y ojo: el parche no cierra por sí solo la inyección en el archivo de sesión.

Las dos vulnerabilidades

  CVE-2026-109618 CVE-2026-109619
Nombre oficial RCE vía corrupción por OOM RCE vía estado global obsoleto
Advisory GHSA-w8hw-w9fx-j79w GHSA-6wmf-4rv5-p3rp
CVSS v4 9.2 9.2
Versiones afectadas < 8.2.35, < 8.3.36, < 8.4.27, < 8.5.12 igual
Riesgo máximo mod_php, PHP-FPM, php-cgi, FrankenPHP, LiteSpeed igual

En términos simples:

  • CVE-2026-109618: el manejo de out of memory puede interrumpir PHP mientras los datos internos de la petición están solo parcialmente inicializados. La limpieza posterior puede tratar datos inválidos como si fueran válidos.
  • CVE-2026-109619: el estado interno propiedad de una petición puede sobrevivir después de que su memoria de soporte haya sido liberada. Una petición posterior, atendida por el mismo worker, puede usar ese estado obsoleto antes de que la inicialización normal lo reinicie.

El propio advisory de PHP es explícito sobre el riesgo: se considera más alto en mod_php, con un exploit demostrado en el que una petición no autenticada dispara el ataque antes de que se ejecute cualquier código de la aplicación.

En otras palabras: no necesitas una sesión válida, ni credenciales, ni una vulnerabilidad de negocio. Solo una petición HTTP bien formada contra un PHP vulnerable.

Superficie de ataque

Todo parte de una funcionalidad que mucha gente ni siquiera sabe que tiene activa: session.upload_progress, el mecanismo que permite a JavaScript mostrar el progreso de un upload sin polling.

Cuando está habilitado (upload_progress.enabled=On, valor por defecto en muchas instalaciones) y se recibe un POST multipart/form-data con el campo PHP_SESSION_UPLOAD_PROGRESS, PHP:

  1. Lee y decodifica la sesión asociada,
  2. Guarda un volcado del progreso en esa misma sesión,
  3. Lo hace antes de RINIT, es decir, antes de que corra una sola línea de código de la aplicación.

Ese punto —”antes de RINIT”— es la clave de todo el fallo. En esa fase el motor no ha inicializado el estado de la petición, y la limpieza de la petición todavía no puede ejecutarse.

El mecanismo: un UAF entre dos peticiones

El flujo real (verificado, no inferido) es el siguiente:

Flujo del UAF cross-request en PHP2SHELL Mecanismo cross-request: el estado global obsoleto sobrevive en el worker y lo consume una petición posterior

Request 1 — el productor

El atacante envía un multipart/form-data con PHP_SESSION_UPLOAD_PROGRESS y una sesión deliberadamente enorme. Durante el arranque, la deserialización de esa sesión supera memory_limit y provoca un OOM.

El detalle técnico es lo que lo cambia todo: zend_bailout() aborta la ejecución saltándose el PHP_VAR_UNSERIALIZE_DESTROY. El global del proceso BG(unserialize) sigue apuntando a un heap que ya fue liberado.

Request 2 — el consumidor

Una petición distinta, atendida por el mismo worker, llega a ese estado antes de que el RINIT normal pueda reiniciarlo. El deserializador recorre un puntero a memoria liberada: use-after-free.

Con un layout de heap controlado, ese UAF deja de ser un crash y se convierte en ejecución remota de código.

El detalle que hace la diferencia

Productor y consumidor deben caer en el mismo worker. En FPM real eso depende del pool, pero es completamente alcanzable: basta con que el pool tenga pocos workers o que el atacante envíe peticiones en paralelo para que dos de ellas colisionen en el mismo proceso.

Y hay un matiz que explica muchos “casi reproduces” fallidos: la sesión pequeña del consumidor debe escribirse fuera de banda (directamente en el almacenamiento de sesiones). Si se planta con una petición HTTP, esa misma petición es servida por el worker y su RINIT normal reinicia el estado obsoleto antes de que llegue el consumidor.

El parche: qué cambió en el código

El fix de 8.4.27 en ext/session/session.c documenta el problema con una honestidad notable:

1
2
3
4
5
6
7
8
9
10
11
12
/* The upload progress handler (php_session_rfc1867_update()) decodes and
 * re-encodes the session during request startup, before RINIT, when no user
 * code may run. Objects are then kept as __PHP_Incomplete_Class: no
 * autoloading, no unserialize_callback_func, no __wakeup() or __unserialize(),
 * and they are written back unchanged. */
static void php_session_unserialize_init(php_unserialize_data_t *var_hash)
{
    PHP_VAR_UNSERIALIZE_INIT(*var_hash);
    if (PS(in_rfc1867_update)) {
        php_var_unserialize_set_incomplete_classes_only(*var_hash, true);
    }
}

Y el ciclo de vida documentado en el advisory es el que describimos arriba:

  1. Productor: el hook de upload progress decodifica la sesión durante el arranque, antes de RINIT. Ese estado (sesión y decodificador) pertenece a la petición.
  2. Un OOM (u otro error fatal) posterior a su publicación impide el reset correspondiente → PHP libera el heap de la petición mientras el global persistente del worker sigue apuntando a él.
  3. Consumidor: una petición posterior servida por el mismo worker alcanza ese estado y lo trata como válido → UAF → con layout de heap controlado: RCE.

CVE-2026-109618 es la otra cara de la misma moneda: el manejo del OOM puede interrumpir PHP con datos de petición a medio inicializar, y la limpieza trata lo inválido como válido. Por eso 8.4.27 no es un parche puntual sino una campaña global de endurecimiento del OOM (ZEND_HAS_OOM(), delayed_oom, memory_hard_limit_percent, zend_mm_oom_info_dtor y cientos de tests oom_*.phpt nuevos).

La narrativa que NO se sostiene

Antes del parche circuló una narrativa muy concreta sobre el mecanismo: un “stale bucket pointer” en php_session_rfc1867_progress, un spray de 149 campos t[] y un zend_fcall_info forjado. El diagrama original de la investigación describe esa hipótesis:

Mecánica original de la vulnerabilidad La descripción original “2a/2b/2c” de la mecánica: precisa sobre el código, incorrecta sobre la premisa

En nuestro laboratorio verificamos esa descripción contra el código publicado y la conclusión es clara:

  • La mecánica de los punteros cacheados en php_session_rfc1867_progress es una descripción exacta del código (verificada en 8.4.4, 8.4.26, 8.5.4, 8.5.11 y en el paquete de Ubuntu).
  • Pero la premisa de que el árbol decodificado libera los arrays cacheados no se sostiene en el PHP publicado. La línea que lo impide es:
1
2
3
progress->cancel_upload |= php_check_cancel_upload(progress);
Z_TRY_ADDREF(progress->data);      /* <-- el interruptor */
zend_hash_update(Z_ARRVAL_P(sess_var), progress->key.s, &progress->data);

zend_hash_update() mueve el zval sin addref (ZVAL_COPY_VALUE). Con esa línea, el array de progreso obtiene su propia referencia: track_init() libera la copia deserializada y no el array vivo, y los punteros cacheados nunca quedan colgando.

Lo comprobamos con un experimento de ablación: compilando PHP sin Z_TRY_ADDREF(progress->data), la misma petición de 258 bytes produce el UAF de inmediato, con una traza que coincide con la publicada. Sin la ablación, no hay UAF.

Moraleja: el PoC público de ~62 KB no logra ejecución de código en el PHP publicado. Reproduciéndolo con un handler envenenado: HTTP 200, sin ASAN, y el handler forjado nunca llega a interpretarse.

Este tipo de verificación es exactamente el valor que aporta un laboratorio reproducible frente a la amplificación sin control de una cadena de retweets.

Verificación diferencial en laboratorio

Montamos un laboratorio aislado en Docker que compila PHP desde código fuente con AddressSanitizer, con dos perfiles: uno para detección de UAF intra-petición (ASAN=1, USE_ZEND_ALLOC=0) y otro para provocar el OOM real con Zend MM activo (ASAN=0, USE_ZEND_ALLOC=1).

El laboratorio completo está publicado en github.com/julio-romo-muninn/PHP2SHELL (MIT), con el informe técnico, los PoCs y las recipes de build.

Escenario Versión Resultado
Cross-request real (2 requests, sesión enorme + OOM) 8.4.26 worker muerto: SEGV en tmp_var con traza ASAN
Igual 8.4.27 HTTP 200, el worker sobrevive — sin ASAN
Trigger canónico de 258 B (single request) 8.4.4, 8.5.4 HTTP 200, sin ASAN
Forma publicada (62.929 B, handler envenenado) 8.4.4 HTTP 200, sin ASAN; el handler nunca se interpretó
Igual, build ablad (sin Z_TRY_ADDREF) 8.4.4 ASAN heap-use-after-free (zend_array_count ← serialize ← php_session_flush ← update ← callback)
OOM vía campo gigante (variante) 8.4.26 / 8.4.27 OOM en main/rfc1867.c:632 en ambos; sin UAF en el consumidor

La traza del caso vulnerable:

1
2
3
4
5
6
7
8
9
10
==1==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000134 (READ)
    #0 tmp_var                 ext/standard/var_unserializer.c:168
    #1 var_tmp_var             ext/standard/var_unserializer.c:190
    #2 ps_srlzr_decode_php     ext/session/session.c:1134
    #3 php_session_decode      ext/session/session.c:279
    #4 php_session_initialize  ext/session/session.c:502
    #5 php_session_rfc1867_update  ext/session/session.c:3196
    #6 php_session_rfc1867_callback ext/session/session.c:3381
    #7 rfc1867_post_handler    main/rfc1867.c:1273
   #10 php_auto_globals_create_post main/php_variables.c:818

Esto establece un UAF cross-request con un diferencial limpio vulnerable/parcheado: no es un “creo que funciona”, es un SEGV reproducible en 8.4.26 y un 200 limpio en 8.4.27.

La segunda cadena: actualizar no basta

El hallazgo más incómodo del laboratorio no es la vulnerabilidad: es lo que sobrevive al parche.

El fix PR #22113 no forma parte de 8.4.27. La siguiente cadena sigue viva:

  1. POST multipart/form-data con PHPSESSID en el body (session fixation: el handler MULTIPART_EVENT_FORMDATA no valida use_only_cookies) y un campo de archivo cuyo nombre lleva código PHP.
  2. El handler rfc1867 guarda el nombre sin sanear en el archivo de sesión.
  3. Un include() de ese archivo (sink LFI en la aplicación) ejecuta el PHP inyectado.
Escenario Resultado
8.4.26 (vulnerable) RCE: uid=33(www-data)
8.4.27 (parcheado) RCE: uid=33(www-data) — la cadena sigue viva
8.4.27 + session.upload_progress.enabled=Off bloqueado

Es decir: el parche corrige la corrupción de memoria, pero no cierra la inyección en el archivo de sesión ni la fijación de sesión desde el body. El riesgo residual que queda en el PHP parcheado es:

  1. Session fixation: el atacante elige el SID (vía campo PHPSESSID en el body, no en cookie).
  2. Inyección de contenido en el archivo de sesión (la clave de progreso y el nombre del archivo se escriben literalmente).
  3. RCE solo si existe un sink en la aplicación que haga include() del archivo de sesión.

Parchear corrige el UAF. No corrige un LFI en tu aplicación.

Detección

No hay un exploit limpio con firma conocida, así que la detección se apoya en señales de comportamiento:

  • log_errors=On con un error_log funcional y error_reporting=E_ALL.
  • Buscar warnings y deprecations repetidos antes del script, errores fatales y Maximum execution time exceeded.
  • Peticiones multipart/form-data con el campo PHP_SESSION_UPLOAD_PROGRESS desde origen no confiable.
  • Peticiones con SID en el body en lugar de en la cookie.
  • Desapariciones de workers: si un worker muere durante una petición y el siguiente request tarda de más, es una señal.
  • Reglas de detección públicas: break-the-build/php-uploadprogress-waf para Suricata / ModSecurity / nginx-njs.

Mitigación

Lo único que funciona de verdad

Actualizar a 8.2.35, 8.3.36, 8.4.27 o 8.5.12. El equipo de PHP lo dice literalmente: “There are no complete mitigations, updating is the only safe choice”. Los vendors de distribuciones LTS recibieron los parches por adelantado: revisa tu proveedor.

PHP 8.1 y anteriores ya no reciben actualizaciones del proyecto PHP. Si estás en esa versión, migra a una soportada.

Si no puedes actualizar hoy

Esto reduce la exposición, pero no te hace seguro:

Para CVE-2026-109618 (corrupción por OOM):

  • memory_limit=-1, de preferencia en un lugar que la aplicación no pueda sobrescribir (php_admin_value en el pool FPM o en la configuración de Apache), y verifica que ningún pool, directorio o ajuste de la aplicación vuelva a poner un límite finito.
  • post_max_size bajo: 8M si la app no necesita subidas, 32M si sí. Mantén upload_max_filesize por debajo de post_max_size.
  • Impón límites en el servidor web o proxy: tamaño del body decodificado, número de campos, número de partes multipart, número de cabeceras y profundidad de anidamiento. Deben cubrir también las peticiones sin Content-Length usable.
  • Usa un límite de contenedor o cgroup como frontera de disponibilidad, con una política que mate solo al worker afectado. No sustituyas por RLIMIT_AS o RLIMIT_DATA.

Para CVE-2026-109619 (estado global obsoleto):

  • session.upload_progress.enabled=0 y reinicia todos los workers. Usa php.ini, un pool FPM o configuración de servidor que se aplique antes del parseo de la petición. No confíes en ini_set() ni en .user.ini de FPM. Esto corta las rutas de sesión confirmadas, pero no las demás variantes.
  • En PHP 8.5: fatal_error_backtraces=0 y reinicio de workers.
  • Elimina los ajustes inválidos o deprecados aplicados por petición que emiten diagnósticos antes de la inicialización. Ocultarlos con error_reporting, display_errors o @ no ayuda.
  • Corrige los errores fatales y los timeouts alcanzables por el atacante (callbacks, procesamiento de subidas o importaciones, y la propia limpieza).

Lado aplicación:

  • Elimina cualquier include() / require() sobre el archivo de sesión.
  • Rechaza un PHPSESSID que venga en el body.
  • Considera pm.max_requests=1 en FPM.

Conclusión

PHP2SHELL es un recordatorio de hasta dónde llega un fallo de gestión de ciclo de vida. No hace falta una vulnerabilidad de lógica de negocio, ni un deserializador roto, ni una inyección: basta con que un global de proceso sobreviva a la petición que lo creó y que la siguiente lo lea antes de que el motor tenga oportunidad de limpiarlo.

Tres ideas para llevarse:

  1. El estado que cruza peticiones es estado privilegiado. En PHP, un worker es un proceso de larga vida. Cualquier puntero global que apunte a memoria de petición es un UAF esperando su turno.
  2. Verificar es tan importante como explotar. La narrativa que circuló antes del parche describía el código con precisión pero partía de una premisa falsa; el PoC público de 62 KB no lograba ejecución de código. Un laboratorio reproducible con ASAN y un build con ablación convierten opiniones en hechos.
  3. Parchear no es cerrar. La cadena de inyección en el archivo de sesión + LFI siguió funcionando en la versión parcheada. El parche corrige la corrupción de memoria; el resto depende de tu configuración y de tu código.

Actualiza hoy. Y auditá tus include() sobre sesiones mientras lo haces.


1
2
$ echo "¡Gracias por leernos!"
¡Gracias por leernos!

Referencias