vulnerabilities php2shell exploit

From Dangling Pointer to system(): Heap Grooming, Fake zval Construction, and RCE Chain

php2shell.run Research Lab •

From Dangling Pointer to system(): Heap Grooming, Fake zval Construction, and RCE Chain

<time datetime="2026-10-23">Verified October 23, 2026</time> · Threat Research: Zend Memory Manager Internals & Linux ASLR Exploitation

Transforming a raw Use-After-Free (UAF) condition into deterministic Remote Code Execution (RCE) within the Zend Engine requires navigating the memory allocator without triggering a segmentation fault (SIGSEGV). In the php2shell advisory—encompassing CVE-2026-109618 and CVE-2026-109619 across PHP 5.4–8.5 (officially patched in 8.5.12, 8.4.27, 8.3.36, and 8.2.35)—attackers completely bypass application business logic. Instead, they target the deterministic design of the Zend Memory Manager (ZMM) and the fundamental 16-byte zval structure.

This technical analysis deconstructs the mechanics of the PHP2Shell exploitation chain from a defensive and forensic perspective. We examine how heap spraying prepares memory bins, how fake objects are positioned, how Address Space Layout Randomization (ASLR) is bypassed across Linux distributions, and how kernel-level defenses neutralize the exploit at runtime.


1. Internal Layout of the 16-Byte zval and Object Vtable Hijacking

In modern PHP (versions 7.0 through 8.5), every dynamic variable is represented internally by struct _zval_struct. On 64-bit architectures, this structure is strictly 16 bytes wide and aligned to 8-byte boundaries:

 0x00               0x08           0x09      0x0A      0x0C            0x10
 ┌──────────────────┬──────────────┬─────────┬─────────┬──────────────┐
 │ zend_value       │ u1.type      │ flags   │ gc_info │ u2.extra     │
 │ (8-byte pointer) │ (1 byte)     │ (1 byte)│ (2 byte)│ (4 bytes)    │
 └────────┬─────────┴──────┬───────┴─────────┴─────────┴──────────────┘
          │                │
          │                └─► 0x08: Type field set to IS_OBJECT (0x08)
          ▼
 ┌──────────────────────────────────────┐
 │ Fake zend_object located on Heap    │
 │ ┌──────────────────────────────────┐ │
 │ │ handlers (Pointer to fake vtable)│ │
 │ └──────────────┬───────────────────┘ │
 └────────────────┼─────────────────────┘
                  ▼
 ┌──────────────────────────────────────┐
 │ Fake Function Pointer Table (Vtable) │
 │ ┌──────────────────────────────────┐ │
 │ │ free_obj / dtor / call           │ │
 │ │ → Points to libc system()        │ │
 │ └──────────────────────────────────┘ │
 └──────────────────────────────────────┘

The C definition in Zend/zend_types.h illustrates this layout:

typedef union _zend_value {
    zend_long         lval;
    double            dval;
    zend_refcounted  *counted;
    zend_string      *str;
    zend_array       *arr;
    zend_object      *obj;
    zend_resource    *res;
    zend_reference   *ref;
    void             *ptr;
} zend_value;

struct _zval_struct {
    zend_value value;            /* 8 bytes: Value union (pointer or primitive) */
    union {
        struct {
            uint8_t type;        /* Data type: IS_NULL, IS_LONG, IS_OBJECT, etc. */
            uint8_t type_flags;  /* Refcounting and GC metadata */
            uint8_t u1_low;
            uint8_t u1_high;
        } v;
        uint32_t type_info;
    } u1;
    union {
        uint32_t next;           /* Hash collision chain index */
        uint32_t extra;
    } u2;
};

In the PHP2Shell exploit chain, an attacker manipulates two critical fields within the target 16-byte block:

  1. value.ptr: Rewritten to point directly to an attacker-controlled buffer in heap memory that mimics a zend_object.
  2. u1.v.type: Overwritten with integer 8 (IS_OBJECT).

When the Zend Engine garbage collector or request shutdown routine encounters this memory location, it evaluates u1.type == IS_OBJECT as true. Consequently, the VM dereferences value.obj->handlers->free_obj() or dtor_obj(), handing execution control to whatever function pointer the attacker has placed in their fake virtual method table.


2. Phase 1: Heap Grooming and ZMM Size Bin Determinism

The Zend Memory Manager does not invoke the operating system malloc() for every small allocation. To maximize throughput, ZMM reserves 2MB chunk blocks from the OS and segments them into discrete size bins:

  • Small Bins: Fixed increments of 8, 16, 24, 32, ... up to 3072 bytes.
  • Bin Lists: Managed as singly-linked Last-In, First-Out (LIFO) free lists.
   Zend Memory Manager (ZMM) Free Bin 2 (32 Bytes):
   ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
   │ Chunk Slot A │ ──► │ Chunk Slot B │ ──► │ Chunk Slot C │
   │ (Head)       │     │              │     │              │
   └──────────────┘     └──────────────┘     └──────────────┘

Under LIFO recycling, the chunk that was most recently freed (_efree()) is placed at the head of the free list. When an allocation request of the exact same size bin arrives (_emalloc()), ZMM immediately returns that precise chunk address.

In PHP2Shell, the attacker executes three distinct grooming steps:

  1. Draining Existing Bin Slack: The attacker delivers a series of small, uniform HTTP POST requests containing varied header lengths and boundary markers. This drains fragmented slots and forces the allocator into a deterministic state.
  2. Inducing the UAF Free: The attacker emits an RFC1867 multipart POST with a crafted session progress boundary, triggering php_session_reset() or HashTable rehashing to free the progress zval slot into Free Bin 1 (16 bytes) or Bin 2 (32 bytes).
  3. Re-occupying with Controlled Payload: Immediately within the same HTTP connection or concurrent stream, the attacker sends a multipart field whose payload occupies the freed size bin. The attacker's controlled bytes now occupy the memory location that cached_post_bytes still references.

3. Phase 2: ASLR Bypass without Direct Info Leak via FPM Fork Layout

Modern Linux server distributions (Ubuntu 22.04/24.04, Debian 12, RHEL 9, Alpine) enforce Address Space Layout Randomization (ASLR) and Position Independent Executables (PIE). Under standard conditions, jumping to an arbitrary memory address without knowing the base address of libc.so or php-fpm triggers an immediate segmentation fault.

However, PHP-FPM architectures introduce a structural weakness: Process Pre-Forking.

                    [ PHP-FPM Master Process ]
                     (ASLR randomized at boot)
                                │
        ┌───────────────────────┼───────────────────────┐
        ▼                       ▼                       ▼
  [ Worker 1 (PID 1021) ]  [ Worker 2 (PID 1022) ] [ Worker 3 (PID 1023) ]
  • fork() copy of Master  • fork() copy of Master • fork() copy of Master
  • SAME libc base address • SAME libc base address• SAME libc base address
  • SAME heap layout base  • SAME heap layout base • SAME heap layout base

When the PHP-FPM master spawns worker pools, it calls the Linux fork() system call:

  • The Linux kernel clones the entire virtual address space of the parent process.
  • ASLR base addresses are NOT re-randomized on fork.
  • Every worker process in the pool shares the exact same base addresses for libc.so.6, libssl.so, and the PHP executable image.

If an exploit attempt causes Worker 1 to crash with a segfault, the master process logs the crash and quietly forks Worker 4—with the exact same memory map. An attacker can perform automated byte-by-byte address probing (or leverage known distribution offset tables for standard cloud images like docker.io/library/php:8.3-fpm) without worrying that worker restarts will randomize the memory offsets.


4. Phase 3: Constructing the Fake zend_object and Executing system()

Once the attacker identifies the libc base offset for the target distribution, they construct the fake object payload:

Offset    Size      Field Name            Exploit Value
───────────────────────────────────────────────────────────────────
+0x00     8 bytes   value.ptr             Points to &fake_zend_object
+0x08     1 byte    u1.v.type             0x08 (IS_OBJECT)
+0x09     1 byte    u1.v.type_flags       0x00 (Non-refcounted)
+0x0A     2 bytes   u1.v.extra            0x0000
+0x0C     4 bytes   u2.extra              0x00000000

fake_zend_object payload (located at known heap address):
+0x00     8 bytes   gc.refcount           1
+0x08     8 bytes   ce (class_entry)      Points to dummy class entry
+0x10     8 bytes   handlers              Points to &fake_vtable
+0x18     Variable  properties / data     Contains "/bin/sh -c 'curl ... | sh'"

fake_vtable payload:
+0x00     8 bytes   free_obj              Address of libc system()

When the request terminates and PHP initiates the request shutdown phase, the engine loops through registered variables and executes the destructor handler:

/* Zend/zend_objects_API.c */
ZEND_API void zend_objects_store_del(zend_object *object)
{
    /* Dereference vtable pointer and invoke destructor */
    object->handlers->free_obj(object);
}

On the x86-64 ABI, the first argument to any C function call is placed in register RDI. When free_obj(object) is called:

  • Register RDI holds the pointer to object.
  • The function pointer points directly to system() in libc.so.6.
  • system() receives the memory buffer of object as its command string argument.
  • A reverse shell or command execution payload executes instantly with the system privileges of www-data or nginx.

5. Breaking the Exploitation Chain: Runtime Detection and Sandboxing

To render this multi-phase attack inert, security teams must deploy layered controls across the runtime, engine, and kernel boundaries:

5.1 Engine-Level Remediation (Immediate)

Neutralize the vulnerability at its source by turning off the vulnerable C callback:

; /etc/php/8.x/fpm/php.ini
session.upload_progress.enabled = Off

Or upgrade directly to patched releases: PHP 8.5.12, 8.4.27, 8.3.36, or 8.2.35.

5.2 Falco Runtime Threat Detection

Deploy this eBPF Falco rule to alert on unexpected process spawning from PHP-FPM workers:

- rule: PHP-FPM Spawns Shell Binary
  desc: Detects PHP2Shell RCE where PHP worker executes interactive shell or download utility
  condition: >
    spawned_process and
    proc.pname in (php-fpm, php-fpm8.2, php-fpm8.3, php-fpm8.4, php-fpm8.5) and
    proc.name in (sh, bash, dash, zsh, curl, wget, python, perl, nc, ncat, socat)
  output: >
    CRITICAL: PHP2Shell exploit detected! Worker (pname=%proc.pname pid=%proc.pid) 
    spawned shell binary (name=%proc.name cmdline=%proc.cmdline user=%user.name)
  priority: CRITICAL
  tags: [mitre_execution, cve_2026_109618, cve_2026_109619, php2shell]

5.3 Seccomp System Call Sandboxing

Prevent PHP workers from executing shell commands entirely by restricting execve and execveat via Docker or systemd Seccomp profiles:

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

When execve returns EPERM (Operation Not Permitted), any attempt by system() to spawn /bin/sh fails instantly with zero damage to the underlying host.


6. Incident Response & Memory Dump Forensics

If you suspect an active exploitation attempt against your PHP cluster:

  1. Capture Worker Core Dumps: Configure Linux kernel core dump capture using coredumpctl or /proc/sys/kernel/core_pattern to preserve memory artifacts when a worker crashes with SIGSEGV.
  2. Inspect Process Environment Tables: Look for anomaly strings in /proc/<pid>/cmdline and open network connections via ss -tpn | grep php-fpm.
  3. Verify Upload Directory Permissions: Ensure webroot upload paths are mounted with the noexec option to prevent secondary binaries dropped during exploitation from executing.

By understanding the exact lifecycle from dangling pointer to system(), infrastructure architects can build verifiable defense-in-depth across the application, process, and kernel tiers.