Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions CHAIN-E.es.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Cadena E — la ruta FPM, medida
[English](CHAIN-E.md) · [Español](CHAIN-E.es.md)


La cadena E es el brazo PHP-FPM del primitivo E-OOM: un fallo de memoria al deserializar deja
`BG(unserialize)` en `level=1` con el puntero `data` colgando, el siguiente decode lo reutiliza,
y una escritura controlada de 16 bytes aterriza en `&sapi_module.ub_write`, que se sustituye por
`&system`.

**Es un resultado de laboratorio, no una capacidad remota.** Bajo ASLR por defecto es una
denegación de servicio determinista (caída del worker, 502). La ejecución de comandos exige ASLR
apagado o bases ya conocidas, más una sesión plantada de 24 bytes. Todo lo de abajo es lo que
medimos, incluidas las rutas que no funcionaron.

Este documento publica el layout y los números. No publica el orquestador: el código que
tenemos es un fragmento que no corre sin nuestro arnés, y uno de sus constructores acepta una
dirección que nunca usa. Publicarlo como "cadena pública" sería publicar algo que no corre.

## La escritura

Un `var_dtor_entries` falso vive en el buffer de un campo multipart. El bucle del destructor lo
recorre y escribe una entrada por iteración, así que el desplazamiento de aterrizaje lo fija el
conteo de entradas:

```
T2 = d + 0x110 + 16·N N = (T2 − d − 0x110) / 16
```

`T2` es `&sapi_module.ub_write`. `d` es la dirección del struct reclamado. Si
`(T2 − d − 0x110)` no es múltiplo de 16, la cadena no puede aterrizar: el constructor falla en
vez de desalinear en silencio.

Desplazamientos dentro del buffer falso de 12,300 bytes, medidos:

- `0x1000` — `entries.last` = `d + 0x38`
- `0x1008` — `entries.first_dtor` = `0`
- `0x1010` — `last_dtor` = `d + 0x100`
- `0x1038` — `entries.used_slots` = `0`
- `0x1100` — `fake.used_slots` = `N`
- `0x1108` — `fake.next` = `0`

## La sesión plantada: 24 bytes, y el contenido importa

La sesión debe medir **exactamente 24 bytes**, con la forma:

```
C|i:<V>;AAAA
```

Los dos hechos están medidos, no supuestos:

- `hello|i:<V>;` —también 24 bytes— **rompió el layout**. El arena de strings internados corrompe
el struct `d` antes de la escritura, y el proceso muere con SEGV en `tmp_var` (solo visible
con `WATCH`).
- La cola `AAAA` dispara un `cancel_decode` benigno. La escritura ya ocurrió en el primer par,
así que el aviso es cosmético.

## La desincronización posterior al exec

Secuestrar `ub_write` rompe el contrato de salida del motor. PHP espera que `ub_write` devuelva
el número de bytes escritos; `system()` devuelve el estado de salida de la subshell, así que la
capa SAPI ve `rax = 0`, llama a `php_handle_aborted_connection` y el cliente recibe un 502.

El payload tiene que desacoplar: `foo; (cmd; kill -TERM $$) >/dev/null 2>&1 &`. El comando corre
en una subshell, el worker dañado se recicla y la respuesta puede morir.

## Rutas que no funcionaron

Todas fueron medidas y cerradas:

- **`PS(serializer)->decode = &system`** — la escritura aplasta `http_session_vars` en `+8`, da
`Z_REFVAL(0x4)` y SEGV antes del segundo decode.
- **`GOT(memchr)` = `0x1600c38`** — `mod 16 = 8`, inalcanzable con slots alineados a 16.
- **Gadget `lea/mov 0x18(%rcx),%rdi; jmp exec`** — no existe en el binario. Tampoco importa
`system`: solo `popen` / `posix_spawn` / `fork`, y `popen` exige un modo válido en `rsi`.
- **Regla re2c `"i:" iv ";"`** — verificada en fuente: el `;` es parte de la regla. `i:<V>` sin
`;` no escribe el LONG.
- **Barrido de páginas ASLR** — ver abajo. No existe como mecanismo en nuestras herramientas.

## El barrido, medido

Teníamos un módulo llamado `aslr_sweep`, descrito como barrido determinista de páginas para FPM
remoto. Valía la pena comprobarlo, porque si funcionaba el veredicto `LAB ONLY` estaba mal.

No funciona, y estructuralmente no puede: la petición que envía es `{"page": N}` como form data,
sin cuerpo forjado y sin aritmética de direcciones. Nunca intenta un aterrizaje.

Peor: su criterio de éxito coincidía con `"OK"` en el cuerpo de la respuesta
independientemente del `marker` que se le pasara, así que contra una página que imprime `OK`
reporta éxito en la página 0.

Medido el 2026-11-09, `phpdebug:8.4.26-fpm` con `pm = static`, un worker, detrás de nginx,
`randomize_va_space = 2`:

- con el criterio del propio código: `success = True`, página 0, 0.01 s, una petición — falso
positivo, nada se ejecutó
- con criterio estricto (`uid=` y `gid=` obligatorios): **387 páginas, 387 respuestas 200, 0
aciertos, 0.34 s, 0 caídas**, la página sigue sirviendo, ningún payload construido

Transcripción: [`raw/chain-e-aslr-sweep-387.txt`](raw/chain-e-aslr-sweep-387.txt).

## Qué significa

`LAB ONLY` se sostiene, y ahora está cerrado por los dos lados: el bloqueo de fragmentación
bin-16 que ya teníamos, y la ausencia de cualquier barrido que lo sortee.

Si estás leyendo esto para reproducir: necesitas ASLR apagado o bases conocidas, una sesión de
exactamente 24 bytes con la forma de arriba plantada bajo el id de la víctima, una página que
llame a `session_start()`, y un worker que no se recicle entre las dos peticiones. Sin las
cuatro, esto es un 502.
108 changes: 108 additions & 0 deletions CHAIN-E.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# Chain E — the FPM path, measured
[English](CHAIN-E.md) · [Español](CHAIN-E.es.md)


Chain E is the PHP-FPM arm of the E-OOM primitive: a decode out-of-memory leaves
`BG(unserialize)` at `level=1` with a dangling `data` pointer, the next decode reuses it, and a
controlled 16-byte write lands on `&sapi_module.ub_write`, which is replaced with `&system`.

**It is a lab result, not a remote capability.** Under default ASLR it is a deterministic
denial of service (worker crash, 502). Command execution requires ASLR off or already-known
bases, plus a planted 24-byte session. Everything below is what we measured, including the
paths that did not work.

This document publishes the layout and the numbers. It does not publish the orchestrator: the
code we have is a fragment that cannot run without our harness, and one of its builders accepts
an address it never uses. Shipping that as a "public chain" would be shipping something that
does not run.

## The write

A fake `var_dtor_entries` sits in a multipart field buffer. The destructor loop walks it and
writes one entry per iteration, so the landing offset is set by the entry count:

```
T2 = d + 0x110 + 16·N N = (T2 − d − 0x110) / 16
```

`T2` is `&sapi_module.ub_write`. `d` is the address of the reclaimed struct. If
`(T2 − d − 0x110)` is not a multiple of 16 the chain cannot land at all — the builder refuses
rather than silently misaligning.

Field offsets inside the 12,300-byte fake entries buffer, as measured:

- `0x1000` — `entries.last` = `d + 0x38`
- `0x1008` — `entries.first_dtor` = `0`
- `0x1010` — `last_dtor` = `d + 0x100`
- `0x1038` — `entries.used_slots` = `0`
- `0x1100` — `fake.used_slots` = `N`
- `0x1108` — `fake.next` = `0`

## The planted session: 24 bytes, and the contents matter

The session must be **exactly 24 bytes**, of the form:

```
C|i:<V>;AAAA
```

Both facts are measured, not assumed:

- `hello|i:<V>;` — also 24 bytes — **broke the layout**. The interned-strings arena corrupts
struct `d` before the write, and the process dies with SEGV in `tmp_var` (only visible under
`WATCH`).
- The trailing `AAAA` triggers a benign `cancel_decode` warning. The write has already happened
in the first pair, so the warning is cosmetic.

## The post-exec desynchronisation

Hijacking `ub_write` breaks the engine's output contract. PHP expects `ub_write` to return the
number of bytes written; `system()` returns the subshell's exit status, so the SAPI layer sees
`rax = 0`, calls `php_handle_aborted_connection`, and the client gets a 502.

The payload has to decouple: `foo; (cmd; kill -TERM $$) >/dev/null 2>&1 &`. The command runs in
a subshell, the damaged worker is recycled, and the response is allowed to die.

## Paths that did not work

Each of these was measured and closed:

- **`PS(serializer)->decode = &system`** — the write crushes the neighbouring `http_session_vars`
at `+8`, giving `Z_REFVAL(0x4)` and a SEGV before the second decode.
- **`GOT(memchr)` = `0x1600c38`** — `mod 16 = 8`, unreachable with 16-byte-aligned slots.
- **Gadget `lea/mov 0x18(%rcx),%rdi; jmp exec`** — does not exist in the binary. It does not
import `system` either, only `popen` / `posix_spawn` / `fork`, and `popen` needs a valid mode
string in `rsi`.
- **re2c rule `"i:" iv ";"`** — verified in source: the `;` is part of the rule. `i:<V>` without
`;` does not write the LONG at all.
- **ASLR page sweep** — see below. It does not exist as a mechanism in our tooling.

## The sweep, measured

We had a module called `aslr_sweep` described as a deterministic page sweep for remote FPM. It
was worth checking, because if it worked, the `LAB ONLY` verdict would be wrong.

It does not work, and structurally cannot: the request it sends is `{"page": N}` as form data,
with no forged body and no address arithmetic. It never attempts a landing.

Worse, its success check matched `"OK"` in the response body regardless of any marker passed in,
so against a page that prints `OK` it reports success on page 0.

Measured on 2026-11-09, `phpdebug:8.4.26-fpm` with `pm = static`, one worker, behind nginx,
`randomize_va_space = 2`:

- with the code's own criterion: `success = True`, page 0, 0.01 s, one request — a false
positive, nothing executed
- with a strict criterion (`uid=` and `gid=` required): **387 pages, 387 responses 200, 0 hits,
0.34 s, 0 crashes**, page still serving, no payload ever built

Transcript: [`raw/chain-e-aslr-sweep-387.txt`](raw/chain-e-aslr-sweep-387.txt).

## What this means

`LAB ONLY` stands, and now it is closed from both sides: the bin-16 fragmentation blocker we
already had, and the absence of any sweep that bypasses it.

If you are reading this to reproduce: you need ASLR off or known bases, a session of exactly
24 bytes in the shape above planted under the victim's id, a page that calls `session_start()`,
and a worker that does not recycle between the two requests. Without all four, this is a 502.
4 changes: 2 additions & 2 deletions README.es.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# php2shell — Verificación Independiente y Auditoría de Límites de Explotación
### CVE-2026-109618 · CVE-2026-109619 · CVE-2026-115560

[![Release](https://img.shields.io/badge/Release-v1.12.0_(2026--11--09)-0284c7?style=flat-square&logo=github)](https://github.com/JoshRob297/php2shell-research/releases/tag/v1.12.0)
[![Release](https://img.shields.io/badge/Release-v1.13.0_(2026--11--09)-0284c7?style=flat-square&logo=github)](https://github.com/JoshRob297/php2shell-research/releases/tag/v1.13.0)
[![Target](https://img.shields.io/badge/Objetivos_PHP-8.2_·_8.3_·_8.4_·_8.5-334155?style=flat-square)](#)
[![CVSS](https://img.shields.io/badge/CVSS_v4.0-9.2_CRÍTICO-b91c1c?style=flat-square)](#)
[![ASLR](https://img.shields.io/badge/ASLR-Ventana_Cookie_1.88_MB-d97706?style=flat-square)](#)
Expand All @@ -22,7 +22,7 @@ Contacto: Telegram [@JocesLab](https://t.me/JocesLab) · correo [joshrob.sec@pro
- **Estado**: Publicación v1.9.2, 2026-11-09 (verificación del estado de parches contra php.net y php-src, más el puntero de v1.0.0 a la última; calibración en imágenes oficiales de distribución, pruebas de aislamiento de stdout en FastCGI, desambiguación de arquitecturas SAPI, alcance de ASLR medido por tamaño de lectura, transcripciones por corrida de la cadena 9bie y arnés de medición publicado).
- **Alcance**: Ramas de PHP 8.4.x y adyacentes (8.2, 8.3, 8.5), imágenes oficiales en contenedores, código público de prueba de concepto (PoC) y reproducción independiente en laboratorio.
- **Alcance aplicativo**: Se utilizó un entorno de prueba de Nextcloud 33.0.x (PHP 8.4.21, Apache `mod_php` prefork) para validar la exposición de configuración y nivel de parche. La prueba de RCE se obtuvo sobre la imagen oficial `php:8.4.26-apache`. El fallo reside en el inicio de la petición de PHP, no en el código de Nextcloud.
- **Artefactos**: `PORTABILITY.es.md` (la matriz medida de qué condiciona la cadena), `TIMELINE.es.md` (cronología de la divulgación con fuentes), versiones formales en PDF (`REPORT.pdf` y `RESUMEN.pdf`), instrumentación de laboratorio reproducible (`tooling/labkit/` y `tooling/docker-compose.yml`), herramienta de diagnóstico no destructiva (`tooling/checker/`), calculadoras offline de alcanzabilidad y reglas de detección.
- **Artefactos**: `PORTABILITY.es.md` (la matriz medida de qué condiciona la cadena), `TIMELINE.es.md` (cronología de la divulgación con fuentes), `CHAIN-E.es.md` (el brazo FPM, medido: layout, precondiciones y las rutas que no funcionaron), `RELIABILITY.es.md` (qué decide si dispara, y con qué frecuencia), versiones formales en PDF (`REPORT.pdf` y `RESUMEN.pdf`), instrumentación de laboratorio reproducible (`tooling/labkit/` y `tooling/docker-compose.yml`), herramienta de diagnóstico no destructiva (`tooling/checker/`), calculadoras offline de alcanzabilidad y reglas de detección.
- **Asistencia de IA**: Se utilizaron múltiples modelos de IA y subagentes especializados bajo dirección humana para revisión, orquestación de laboratorio y comprobación documental; cada hallazgo fue contrastado contra comandos, salidas reales o fuentes públicas. Ver `METHODOLOGY.md`.

---
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
# php2shell — Independent Verification & Exploitation Boundary Audit
### CVE-2026-109618 · CVE-2026-109619 · CVE-2026-115560

[![Release](https://img.shields.io/badge/Release-v1.12.0_(2026--11--09)-0284c7?style=flat-square&logo=github)](https://github.com/JoshRob297/php2shell-research/releases/tag/v1.12.0)
[![Release](https://img.shields.io/badge/Release-v1.13.0_(2026--11--09)-0284c7?style=flat-square&logo=github)](https://github.com/JoshRob297/php2shell-research/releases/tag/v1.13.0)
[![Target](https://img.shields.io/badge/PHP_Targets-8.2_·_8.3_·_8.4_·_8.5-334155?style=flat-square)](#)
[![CVSS](https://img.shields.io/badge/CVSS_v4.0-9.2_CRITICAL-b91c1c?style=flat-square)](#)
[![ASLR](https://img.shields.io/badge/ASLR-Cookie_Window_1.88_MB-d97706?style=flat-square)](#)
Expand All @@ -19,10 +19,10 @@ Contact: Telegram [@JocesLab](https://t.me/JocesLab) · email [joshrob.sec@proto
---
</div>

- **Status**: public release v1.12.0, 2026-11-09 (portability matrix and disclosure timeline published, patch-state check against php.net and php-src, plus the v1.0.0→latest pointer; official stock distribution image calibration, FastCGI stdout isolation proofs, SAPI architecture disambiguation, measured ASLR scope per read size, per-run 9bie transcripts, and the published measurement harness).
- **Status**: public release v1.13.0, 2026-11-09 (portability matrix and disclosure timeline published, patch-state check against php.net and php-src, plus the v1.0.0→latest pointer; official stock distribution image calibration, FastCGI stdout isolation proofs, SAPI architecture disambiguation, measured ASLR scope per read size, per-run 9bie transcripts, and the published measurement harness).
- **Scope**: PHP 8.4.x and adjacent branches (8.2, 8.3, 8.5), official PHP container images, public proof-of-concept code, and independent lab reproduction.
- **Application scope**: a Nextcloud 33.0.x test deployment (PHP 8.4.21, Apache `mod_php` prefork) was used to validate the exposed configuration and patch level. The RCE proof was obtained on the official `php:8.4.26-apache` image. The bug is in PHP request startup, not Nextcloud code.
- **Artifacts**: `PORTABILITY.md` (the measured matrix of what gates the chain), `TIMELINE.md` (disclosure timeline with sources), formal typography PDF releases (`REPORT.pdf` and `RESUMEN.pdf`), reproducible lab instrumentation (`tooling/labkit/` and `tooling/docker-compose.yml`), non-destructive diagnostic tooling (`tooling/checker/`), offline reachability calculators, and detection rules.
- **Artifacts**: `PORTABILITY.md` (the measured matrix of what gates the chain), `TIMELINE.md` (disclosure timeline with sources), `CHAIN-E.md` (the FPM arm, measured: layout, preconditions and the paths that did not work), `RELIABILITY.md` (what decides whether it fires, and how often), formal typography PDF releases (`REPORT.pdf` and `RESUMEN.pdf`), reproducible lab instrumentation (`tooling/labkit/` and `tooling/docker-compose.yml`), non-destructive diagnostic tooling (`tooling/checker/`), offline reachability calculators, and detection rules.
- **AI-assisted**: multiple AI models and specialized subagents were used under human direction for review, lab orchestration and documentation checks; every finding was validated against commands, outputs or public sources. See `METHODOLOGY.md`.

## Executive summary
Expand Down
61 changes: 61 additions & 0 deletions RELIABILITY.es.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Fiabilidad — cuántas veces dispara de verdad
[English](RELIABILITY.md) · [Español](RELIABILITY.es.md)


Una corrida exitosa no es un resultado. Estas son las condiciones que deciden si la cadena con
`display_errors=Off` y ASLR encendido dispara o no, y con qué frecuencia, medidas sobre targets
nuevos.

## Geometría: 128M y `php.ini-production`, o nada

La cadena `pure/` del upstream está calibrada para la geometría stock: `memory_limit=128M`, el
`php.ini-production` oficial y el pool de workers tal como lo trae la imagen.

- 128M, un worker, imagen oficial → **3/3**
- 8M, un worker, misma imagen → **0/3**

El fallo a 8M es **calibración, no el disparador**. A1 sí dispara a 8M: nuestra cadena anterior
da **8/8** ahí con `display_errors=On` (ver `PORTABILITY.es.md`). Lo que se rompe son la
aritmética de offsets y la ventana del leak, ambas dimensionadas para la arena de 128M.

Esto importa porque circula la frase de que bajar `memory_limit` no ayuda, porque el atacante
dimensiona el OOM al límite que haya. **Las dos afirmaciones son ciertas y hablan de cadenas
distintas.** En la cadena de cookie de mod_php el atacante elige el tamaño del cuerpo y el
límite no lo frena. En la cadena `pure/` del upstream las constantes están fijas para 128M, así
que un target de 8M falla por una razón que no tiene nada que ver con el control del cuerpo.

## El `php.ini` del target decide más que la versión

Nuestro propio `tooling/docker-compose.yml` **no** reproduce el 10/10. El servicio `vuln-8426` no
carga ningún `php.ini`:

```
Loaded Configuration File => (none)
display_errors => STDOUT
```

Añadir `display_errors=Off` encima de `hardening-off.ini` da **0/3**, porque eso es un overlay de
`conf.d`, no `php.ini-production`. La cadena necesita el archivo que trae la imagen, no una
directiva que se le parezca.

## El arranque en frío no está caracterizado

Target nuevo, primera corrida: **0/3**, con `no heap pointers in leak window`. El mismo target
tras `docker restart`: **3/3**. El resultado de diez corridas en
`raw/9bie-pure-off-aslr-on-10run.txt` reinicia el target antes de cada corrida exactamente por
esto.

No hemos caracterizado el fallo de la primera corrida: puede ser calentamiento del pool, estado
del allocator o residencia de páginas. Es una incógnita conocida, escrita en vez de promediada.

## Lo que no está medido

- **FPM**: el brazo E-OOM es `LAB ONLY` y está en `CHAIN-E.es.md`. Sin resultado remoto con ASLR
encendido.
- **CVE-2026-115560**: sin arreglo estable al momento de escribir; nuestro intento fue
inconcluso.
- **Carga sostenida**: todas las corridas son de segundos a minutos. Nada de aquí dice nada sobre
el comportamiento tras horas de carga.

Si un número de este repositorio no está en este archivo ni en una transcripción de `raw/`, no es
una medición: es una cita.
Loading
Loading