PHP2Shell: Critical PHP RCE Vulnerabilities CVE-2026-109618 and CVE-2026-109619

Written by: 
Abstract Security Threat Research Organization (ASTRO)
Published on: 
Oct 21, 2026
On This Page
Share:

The PHP security team has given advance notice of an out-of-band critical security release, scheduled for Wednesday, 21 October 2026 at 14:00 UTC. It covers two vulnerabilities, both rated CVSS v4.0 9.2. Information about the vulnerabilities leaked on social media on 20 October ahead of the official announcement. ****The claims in the leaks are yet to be verified, but Proof of Concept (PoC) scripts are circulating. Exploitation in the wild is yet to be confirmed, but we expect to see reports soon.

This is a flash report where we focus on what is confirmed, what you can do before the patch lands, and the detections you can put in place. We’ll update this post as more information becomes available and can be disclosed.

What we know

  • PHP announced the coming release on 20 October. Two vulnerabilities, both CVSS v4.0 9.2. Full details, affected versions, and fixed versions will be published with the release. Up until that point there are no official technical details made publicly available.
  • The PHP team might release earlier if technical details become public or evidence of active exploitation appears.
  • The CVE identifiers circulating in public reporting are CVE-2026-109618 and CVE-2026-109619, both described as Remote Code Execution (RCE).
  • A video of an exploit tool named PHP2Shell was leaked on social media, though it is yet to be verified.
  • As of this writing there is no reported exploitation in the wild. However that doesn’t mean it isn’t happening, and given how ubiquitous PHP applications are we expect not long before activity surfaces. Stay frosty.
  • This is a flaw in PHP itself, not in any one application. Update PHP when the fixed versions are released.

Two distinct vulnerability families

Although both CVEs ship in the same emergency release, they are independent bugs with different attack surfaces:

CVE-2026-109618 ("rawkey") targets the Zend Engine's HashTable implementation. The exploit sends a multi-megabyte application/x-www-form-urlencoded POST body with an oversized field name that triggers an out-of-memory condition during $_POST parsing. It requires a large request body (>1 MB) and is blockable at the WAF/edge layer with a size gate.

CVE-2026-109619 ("php2shell") targets PHP's session.upload_progress callback, which fires on any POST endpoint before application code runs. The exploit body is only ~450 bytes, so size-based WAF rules do not help. The session.upload_progress.enabled = Off mitigation in the callout above addresses this family specifically.

Defenders should treat these as separate detection and mitigation problems.

Reported affected versions

The following application versions have been named in circulating reporting. PHP has not yet published its own affected/fixed list, so these are not officially confirmed. Note that the version that decides your exposure is your PHP version, not your CMS version.

Language Affected versions Fixed versions
PHP 5 and later 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12

‍

Application Version
WordPress 7.1.3
MediaWiki 1.46.2
phpBB 3.3.17
Magento 2.4.9
Joomla 6.2.0
Laravel 13.36.0 and 8.83.29
Symfony 8.1.9
Drupal 11.4.10
WHMCS 9.0.9

‍

What to do now

‍

  • Continuously check for updates from the PHP team ahead of the stated 14:00 UTC release in case details are revealed earlier.
  • Inventory your PHP applications to prepare for updates, prioritizing web-facing ones.
  • Isolate or upgrade any PHP applications running 8.1 or older as no official fix is coming to those (PHP supports 8.2 and later).
  • Cap or rate-limit large POST bodies at your WAF, reverse proxy, or other edge sitting in front of PHP applications. See WAF / edge hardening below for specific guidance.
  • Check with your hosting provider on their plans for the patch if you’re running PHP on one.
  • Ensure web access/error logs, PHP error logs, and process telemetry from web servers are shipped to your SIEM.
  • Assume exposure for anything internet-facing, and hunt before you call it clean.

WAF / edge hardening

A WAF or reverse proxy in front of your PHP applications can stop both exploit families before the request reaches PHP, but each needs its own signatures. rawkey is defined by an absurdly long form field name. php2shell uses small, ordinary-looking multipart uploads, so size limits do nothing against it.

php2shell - CVE-2026-109619

session.upload_progress is on by default and runs PHP's upload callback on any POST endpoint, before your application code. The exploit bodies are about 450 bytes with short field names, so there is nothing to size-limit. Instead, block three multipart shapes that no legitimate client sends. Match on part headers only, so uploaded file contents (including PHP source) never trigger the rules:

  • A PHPSESSID form field. The upload handler copies it into the session ID without checking session.use_only_cookies. Real clients send session IDs in the Cookie header. This one rule cuts off both the session-injection and handler-mismatch chains.
  • A PHP open tag in a filename (<?php or <?=). PHP writes the filename raw into the session file, where a local file inclusion (LFI) bug can include it. Leave bare <? and <?xml out to avoid false positives.
  • Serialized-object syntax in a filename (O:N:", a:N:{, s:N:", R:N;, or |O:). This is how the handler-mismatch chain writes its payload.

Log PHP_SESSION_UPLOAD_PROGRESS fields too, but don't block on them: that field is the legitimate progress-bar mechanism. Paired with a PHPSESSID field, it is the exact PoC shape.

These rules stop the write to the session file, not a later LFI read. A session file poisoned before you deployed can still be included until it expires, so purge session.save_path as part of the rollout.

rawkey - CVE-2026-109618

Block form field names of 1,000 bytes or more. Every variant of this bug needs a field name large enough that copying the key exhausts memory_limit during request startup. On real configurations that means megabytes. No legitimate client sends a field name anywhere near 1 KB, so the threshold can sit far below the exploit's minimum and still produce no false positives. Two details matter:

  • Match names, not values. On urlencoded bodies, anchor at a field boundary: (^|&)[^&=]{1000,}. A run that starts at & and reaches 1,000 bytes without hitting & or = can only be a key, so large values never match. Don't require a trailing =: PHP treats a run with no = as a bare key.
  • Cover multipart too. The bug triggers just as well through a huge multipart part name. Pair the urlencoded rule with (?i)\bname\s*=[^\r\n]{1000,} on part headers, gated on multipart/form-data. This also fires on a short part name followed by a filename over 1,000 characters. That is rare, and suspicious in its own right.

Never gate on Content-Length. Chunked requests have no Content-Length and HTTP/2 doesn't require one, so a header-based size check is a one-line bypass. A chunked PoC got past an earlier revision of these rules for exactly this reason.

Optional: cap urlencoded POST bodies at 1 MB. The smallest field name that can trigger the bug is around 1 MiB. If your applications never accept urlencoded posts that large, a hard cap closes the urlencoded path. This is a mitigation, not a signature: it doesn't stop the multipart variant, and it will block any legitimate large form posts you have. Measure the actual body, not the header.

Rate-limit large POSTs per source IP. The ASLR brute-force phase sends about 590 oversized requests over roughly 15 minutes from one IP. Blocking more than 10 large POSTs per IP per minute stops it long before it finishes. A patient attacker can slow down or spread across IPs, so treat this as a backstop.

Platform notes

  • ModSecurity (2.9.6+):
    • Set SecRequestBodyAccess On (the default is Off).
    • Raise both SecRequestBodyLimit and SecRequestBodyNoFilesLimit above your largest legitimate POST; we tested with 64 MB. SecRequestBodyNoFilesLimit is the one that applies to urlencoded bodies, and the recommended config sets it to 128 KB.
    • Keep SecRequestBodyLimitAction Reject. ProcessPartial means partial inspection.
    • Alongside the raw regexes, check parsed name lengths (ARGS_POST_NAMES|FILES_NAMES) and reject any body ModSecurity can't parse (REQBODY_ERROR, MULTIPART_STRICT_ERROR). PHP accepts some bodies that ModSecurity can't parse, and those would otherwise leave the name rules nothing to inspect. This is what catches the multipart variant whose part-header line exceeds 4 KB.
    • For the PHPSESSID rule, match ARGS_POST_NAMES. MULTIPART_PART_HEADERS only carries headers for file parts, so a plain form field never shows up there.
  • AWS WAF (untested):
    • Put the urlencoded and multipart name patterns in separate regex pattern sets, each gated by a Content-Type ByteMatchStatement. In a shared set, the multipart pattern would fire on a big name= value in a urlencoded body.
    • Use OversizeHandling: CONTINUE on the regex rules. The 1,000-byte pattern fits inside even ALB's 8 KB inspection window, and MATCH would block every form POST larger than that window.
    • Size rules need the opposite: SizeConstraintStatement needs MATCH. With CONTINUE it only sees the truncated body, so > 1 MB never fires.
    • Because of that, "body > 1 MB" really means "body larger than the inspection window." On ALB the rate-based rule counts every POST over 8 KB, so check it against your upload traffic before switching it to Block, and run the size rule itself in Count.
    • On CloudFront, raise the body inspection limit to 64 KB.
  • Cloudflare (untested):
    • The body fields (http.request.body.raw, http.request.body.size) require Enterprise, and regex matches requires Business or higher.
    • On Enterprise, block on the name regexes. Add http.request.body.size > 1048576 as a separate log rule, and only block on it if your origins never take form posts that large.
    • Below Enterprise these signatures can't be written at all. Deploy them at the origin with ModSecurity, or on an upstream WAF that can inspect bodies.

Both cloud WAFs only run regexes against the first part of the body: 8 KB on ALB, up to 64 KB on CloudFront, and about 128 KB on Cloudflare Enterprise. The rawkey pattern fits in any of those, but an attacker can add padding fields to push the giant name past the window. The size rule flags those requests, but only the origin ModSecurity rules, which see the whole body, actually block them. The php2shell rules need none of this tuning because their bodies fit in any window.

On every platform, deploy in log or Count/detect mode first, review the matches, then switch to block.

Detection guidance

These detections are based on PoCs in circulation and are not yet confirmed linked to the incoming official PHP security team release.

0. WAF detections and mitigations

We’ve analyzed both of the RCE vectors with POCs and have the following mod_security and Suricata rules available to help defenders block these exploit attempts:

mod_security - php2shell
# =============================================================================
# php2shell (CVE-2026-109619) — session.upload_progress
# chains. Qi Deng (qideng.substack.com), POC: github.com/ikow/php2shell-poc.
# DIFFERENT bug family from rawkey (same 2026-10-21 PHP emergency release):
# rfc1867 runs on ANY POST endpoint before app code, so the request shapes
# are small and ordinary — there is no size heuristic to key on.
#
# RULES ONLY (same convention as rawkey-rev4.conf): engine settings live in
# the main config. Include AFTER rawkey-rev4.conf; prerequisites identical
# (SecRuleEngine On, SecRequestBodyAccess On, SecRequestBodyLimit* sized to
# your uploads, SecRequestBodyLimitAction Reject, ModSec v2.9.6+).
#
# CHAINS (per the writeup):
#   A: PHPSESSID form field fixes the session ID ("No use_only_cookies
#      check!") + filename stored raw in the session file (no sanitization,
#      no length limit) -> LFI include.
#   B: app-level unserialize() var_destroy UAF (out of HTTP scope).
#   C: cross-directory serialize-handler mismatch: a gadget filename
#      `x|O:8:"..."` written under php_serialize, decoded under php; the
#      decoder fails but __destruct fires on BOTH success and failure.
#
# DESIGN:
#   * All request-side rules gate on REQBODY_PROCESSOR MULTIPART: the
#     session.upload_progress callbacks only run for multipart bodies.
#   * Kill 3201001 (PHPSESSID as a form field) cuts chains A and C at the
#     root: no legitimate client ever puts a session ID in a form field.
#     session.name is configurable — covered for the default (PHPSESSID),
#     which is also what the POC uses.
#   * 3201002/3201003 key on the part HEADERS (MULTIPART_PART_HEADERS),
#     never on part CONTENT (file contents may legitimately be PHP code).
#   * 3201004 is a correlate (pass/log): PHP_SESSION_UPLOAD_PROGRESS is the
#     documented legit upload-progress mechanism — never block on it alone;
#     together with 3201001 it is the exact POC shape.
#
# THRESHOLD: none (patterns are literal shapes).
# ROLLOUT: same as rawkey-rev4.conf — SecRuleUpdateActionById 3201001 "pass"
# etc. for a log-only first pass.
# =============================================================================

# ---- 3201001: session ID injected via multipart form field ------------------
# The MULTIPART_EVENT_FORMDATA handler copies a POST-body value into the
# session ID regardless of session.use_only_cookies. Chains A and C both
# require it. ENGINE FINDING (verified live on 2.9.7): MULTIPART_PART_HEADERS
# only carries FILE-part headers — a plain form part like name="PHPSESSID"
# never appears in it (a filename= rule kept firing while this stayed blind),
# so this keys on the PARSED name (ARGS_POST_NAMES, which MULTIPART populates
# with form-part names) instead of the raw header. Exact match, no FP.
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
    "id:3201001,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'php2shell: session ID in multipart form field (session fixation)',\
    tag:'attack-rce',\
    tag:'attack-php',\
    tag:'php2shell',\
    severity:'CRITICAL',\
    rev:2,\
    ver:20261021,\
    chain"
    SecRule ARGS_POST_NAMES "@streq PHPSESSID"


# ---- 3201002: PHP code in a multipart filename ------------------------------
# FILE_START stores the client filename raw into the session file (bug 2:
# "No sanitization, no encoding, no length limit"). Any PHP-open tag in a
# filename is an LFI-include payload waiting for a bug to write it somewhere
# executable. <?php and <?= (short_echo, on by default since 5.4); bare <?
# and <?xml deliberately NOT matched (FP on xml-ish filenames).
# Value-side gaps are [ \t]* not \s*: \s crosses \r\n, so a form VALUE that
# is the literal string PHPSESSID (a config form posting it) would match
# name\s*=\s*["]?\s*PHPSESSID across the header/value boundary.
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
    "id:3201002,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'php2shell: PHP open tag in multipart filename',\
    tag:'attack-rce',\
    tag:'attack-php',\
    tag:'php2shell',\
    severity:'CRITICAL',\
    rev:2,\
    ver:20261021,\
    chain"
    SecRule MULTIPART_PART_HEADERS "@rx (?i)(?:^|\r\n)Content-Disposition\s*:[^\r\n]{0,500}?filename[ \t]*=[ \t]*[\x22\x27]?[^\r\n]{0,2000}?<\?(?:php\b|=)"


# ---- 3201003: serialized-object syntax in a multipart filename ------
# Chain C's write primitive: a gadget filename like `x|O:8:"stdClass":0:{}`
# (pipe = the php handler's key/value separator; O:/a:/s: object/array/
# string serialized forms; R: back-reference). Filenames are names, not
# data — serialized syntax in one is always attacker-crafted. Known small
# FP surface: a benign filename that happens to contain e.g. `a:1;` —
# accepted (same tradeoff as rawkey's filename rule).
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
    "id:3201003,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'php2shell: serialized-object syntax in multipart filename',\
    tag:'attack-rce',\
    tag:'attack-php',\
    tag:'php2shell',\
    severity:'CRITICAL',\
    rev:2,\
    ver:20261021,\
    chain"
    SecRule MULTIPART_PART_HEADERS "@rx (?i)(?:^|\r\n)Content-Disposition\s*:[^\r\n]{0,500}?filename[ \t]*=[ \t]*[\x22\x27]?[^\r\n]{0,2000}?(?:[oO]\s*:\s*\d{1,10}\s*:\s*[\x22\x27]|[aA]\s*:\s*\d{1,10}\s*:\s*\{|[sS]\s*:\s*\d{1,10}\s*:\s*[\x22\x27]|[rR]\s*:\s*\d{1,10}\s*;|\|\s*[oO]\s*:\s*\d{1,10})"


# ---- 3201004: correlate — upload-progress form field ------------------------
# The documented legit mechanism (session.upload_progress), set implicitly
# by real big-upload clients; the POC also sets it. LOG ONLY — never block:
# pair it with 3201001 for the exact POC shape. Parsed name for the same
# engine reason as 3201001 (form parts are not in MULTIPART_PART_HEADERS).
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
    "id:3201004,\
    phase:2,\
    pass,\
    log,\
    msg:'php2shell correlate: PHP_SESSION_UPLOAD_PROGRESS form field present',\
    tag:'php2shell',\
    severity:'INFO',\
    rev:2,\
    ver:20261021,\
    chain"
    SecRule ARGS_POST_NAMES "@streq PHP_SESSION_UPLOAD_PROGRESS"

‍

mod_security - rawkey
# =============================================================================
# PHP rawkey-class (php-gh11189 family): block oversized form field NAMES
# rev 4 / ver 20261021
#
# RULES ONLY. Engine settings live in your main ModSecurity config so that
# Include'ing this file can't flip other rule sets (e.g. CRS in
# DetectionOnly) into blocking mode or change their body limits.
#
# PREREQUISITES (ModSecurity v2.9.6+, needed for MULTIPART_PART_HEADERS):
#   SecRuleEngine On
#   SecRequestBodyAccess On
#   SecRequestBodyLimitAction Reject      # never ProcessPartial: a partial
#                                         # body means a partial inspection
#   SecRequestBodyNoFilesLimit  >= your largest legitimate non-upload POST
#   SecRequestBodyLimit         >= your largest legitimate upload
#   No SecRequestBodyInMemoryLimit change and no ctl:forceRequestBodyVariable
#   needed: nothing here regexes raw multipart bodies (file contents).
#
# DESIGN
#   * No Content-Length gate: chunked / HTTP/2 bodies have no such header.
#   * No method gate: ModSecurity parses form bodies for any method, and
#     PHP 8.4's request_parse_body() lets apps parse PUT/PATCH the same way.
#   * Gated on REQBODY_PROCESSOR, i.e. ModSecurity's own decision that the
#     body is a form, which is case-insensitive and prefix-based (a superset
#     of PHP's exact content-type match).
#   * Layered: parsed-name length (primary) + raw checks (catch parser
#     differentials) + strict-parsing rules (refuse bodies ModSecurity
#     couldn't parse the way PHP will).
#
# THRESHOLD: 1000 chars. To change it, edit "@gt 999" and each "{1000,}".
#
# ROLLOUT: run in log-only mode first, e.g. in the main config after this
# file is included:
#   SecRuleUpdateActionById 3101004 "pass"
#   (repeat for 3101005, 3101007, 3101008, 3101009)
# =============================================================================

# ---- 3101004: parsed field names, urlencoded + multipart -------------------
# ModSecurity URL-decodes urlencoded names and extracts multipart part names.
# Covers bare keys ("&AAAA&", no "=") and file-field names (FILES_NAMES).
SecRule REQBODY_PROCESSOR "@rx ^(?:URLENCODED|MULTIPART)$" \
    "id:3101004,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'PHP rawkey-class: form field NAME >= 1000 chars (parsed)',\
    logdata:'%{MATCHED_VAR_NAME}: length %{MATCHED_VAR}',\
    tag:'attack-rce',\
    tag:'attack-php',\
    tag:'php-gh11189-family',\
    severity:'CRITICAL',\
    rev:4,\
    ver:20261021,\
    chain"
    SecRule ARGS_POST_NAMES|FILES_NAMES "@gt 999" "t:none,t:length"

# ---- 3101005: raw urlencoded names -----------------------------------------
# Measures names BEFORE decoding, so a %-encoded name (3 raw bytes per char)
# is caught on its raw length. No trailing "=" required: a run starting at
# "&" (or body start) that hits neither "&" nor "=" is a key, never a value,
# so long VALUES don't match but bare keys do.
SecRule REQBODY_PROCESSOR "@streq URLENCODED" \
    "id:3101005,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'PHP rawkey-class: urlencoded field NAME >= 1000 raw chars',\
    tag:'attack-rce',\
    tag:'attack-php',\
    tag:'php-gh11189-family',\
    severity:'CRITICAL',\
    rev:4,\
    ver:20261021,\
    chain"
    SecRule REQUEST_BODY "@rx (?:^|&)[^&=]{1000,}"

# ---- 3101007: raw multipart part headers -----------------------------------
# Scans part HEADERS only (not file contents). Matches what PHP's rfc1867
# parser accepts but a naive name="..." regex misses: any case (NAME=),
# single quotes, unquoted names, and backslash-escaped quotes inside the
# name, by measuring everything after "name=" to end of line.
# \bname does not match inside "filename".
SecRule REQBODY_PROCESSOR "@streq MULTIPART" \
    "id:3101007,\
    phase:2,\
    deny,\
    status:403,\
    log,\
    msg:'PHP rawkey-class: multipart part NAME >= 1000 chars (raw header)',\
    tag:'attack-rce',\
    tag:'attack-php',\
    tag:'php-gh11189-family',\
    severity:'CRITICAL',\
    rev:4,\
    ver:20261021,\
    chain"
    SecRule MULTIPART_PART_HEADERS "@rx (?i)\bname\s*=[^\r\n]{1000,}"

# ---- 3101008: refuse bodies ModSecurity failed to parse ---------------------
# Without this, a body ModSecurity can't parse (e.g. a multipart part-header
# line longer than its line buffer) leaves ARGS/FILES empty and the rules
# above see nothing, while PHP parses it happily.
# SKIP if your main config already has the recommended rule 200002.
SecRule REQBODY_ERROR "!@eq 0" \
    "id:3101008,\
    phase:2,\
    deny,\
    status:400,\
    log,\
    msg:'Failed to parse request body',\
    logdata:'%{REQBODY_ERROR_MSG}',\
    tag:'php-gh11189-family',\
    severity:'CRITICAL',\
    rev:4,\
    ver:20261021"

# ---- 3101009: strict multipart validation ----------------------------------
# Refuses multipart oddities where ModSecurity and PHP may disagree about
# part names (invalid quoting, header folding, etc.).
# SKIP if your main config already has the recommended rule 200003.
# Most likely rule to false-positive on unusual clients: log-only first.
SecRule MULTIPART_STRICT_ERROR "!@eq 0" \
    "id:3101009,\
    phase:2,\
    deny,\
    status:400,\
    log,\
    msg:'Multipart request body failed strict validation: \
PE %{REQBODY_PROCESSOR_ERROR}, \
BQ %{MULTIPART_BOUNDARY_QUOTED}, \
BW %{MULTIPART_BOUNDARY_WHITESPACE}, \
DB %{MULTIPART_DATA_BEFORE}, \
DA %{MULTIPART_DATA_AFTER}, \
HF %{MULTIPART_HEADER_FOLDING}, \
LF %{MULTIPART_LF_LINE}, \
SM %{MULTIPART_MISSING_SEMICOLON}, \
IQ %{MULTIPART_INVALID_QUOTING}, \
IP %{MULTIPART_INVALID_PART}, \
IH %{MULTIPART_INVALID_HEADER_FOLDING}, \
FL %{MULTIPART_FILE_LIMIT_EXCEEDED}',\
    tag:'php-gh11189-family',\
    severity:'CRITICAL',\
    rev:4,\
    ver:20261021"

‍

Suricata - php2shell
# Suricata rules — php2shell (CVE-2026-109618 / CVE-2026-109619)
# session.upload_progress chains (Qi Deng; qideng.substack.com;
# POC: github.com/ikow/php2shell-poc). Companion to rawkey-rev4.rules —
# a DIFFERENT bug family from the same 2026-10-21 PHP emergency release.
#
# No size heuristic exists here: rfc1867 runs on ANY POST endpoint before
# app code, and the POC bodies are ~450 bytes with 8-26 byte field names.
# What the chains CANNOT avoid (the "invariant" here):
#   * Chains A and C both need a PHPSESSID form FIELD (session fixation
#     through the body — no legitimate client puts a session ID in a form
#     field; it rides the Cookie header).
#   * Chain A's payload is PHP code in the multipart FILENAME (stored raw
#     into the session file: no sanitization, no length limit).
#   * Chain C's write is serialized-object syntax in the filename
#     (gadget `x|O:8:"stdClass":0:{}`, pipe = the php handler separator).
#   * All three ride Content-Disposition part headers (never part
#     contents — file CONTENTS may legitimately be PHP code).
# Bodies are tiny: everything fires on DEFAULT config, no raised limits.
# `;` is written \x3b — a literal ";" ends a rule option and silently
# truncates the regex.
#
# Verified on Suricata 8.0.7 against the real POC request shapes
# (verify_session_injection.py x3, verify_handler_mismatch.py x2,
# captured on the docker bridge): all kills fire; benign upload stays
# clean; see package README. First draft over-escaped the pcres (\\r) and
# matched nothing — escapes are SINGLE backslash in Suricata rules files.
# rev 2: value-side gaps are [ \t]* not \s* — \s crosses \r\n, so a form
# VALUE that is the literal string PHPSESSID would match across the
# header/value boundary (caught in offline corpus building).
# If running inline (IPS), change "alert" to "drop" on the kill rules.

# ---- kill: session ID injected via multipart form field --------------------
# Chains A + C root (MULTIPART_EVENT_FORMDATA copies the POST value into
# the session ID with no use_only_cookies check).
alert http any any -> any any (msg:"php2shell - session ID in multipart form field (tier 2)"; flow:established,to_server; http.content_type; content:"multipart/form-data"; startswith; nocase; http.request_body; pcre:"/(?i)(?:^|\r\n)Content-Disposition\s*:[^\r\n]{0,500}?\bname[ \t]*=[ \t]*[\x22\x27]?[ \t]*PHPSESSID[ \t]*[\x22\x27]?[ \t]*[\x3b\r\n]/"; classtype:web-application-attack; sid:1001201; rev:2; metadata:created_at 2026_10_21, updated_at 2026_10_21, tag php2shell;)

# ---- kill: PHP open tag in multipart filename ------------------------------
# Chain A payload vehicle (FILE_START stores the filename raw into the
# session file). <?php and <?= only — bare <? / <?xml deliberately not
# matched (FP on xml-ish filenames).
alert http any any -> any any (msg:"php2shell - PHP open tag in multipart filename (tier 2)"; flow:established,to_server; http.content_type; content:"multipart/form-data"; startswith; nocase; http.request_body; pcre:"/(?i)(?:^|\r\n)Content-Disposition\s*:[^\r\n]{0,500}?filename[ \t]*=[ \t]*[\x22\x27]?[^\r\n]{0,2000}?<\?(?:php\b|=)/"; classtype:web-application-attack; sid:1001202; rev:2; metadata:created_at 2026_10_21, updated_at 2026_10_21, tag php2shell;)

# ---- kill: serialized-object syntax in multipart filename -------------------
# Chain C write primitive: gadget filename `x|O:8:"stdClass":0:{}`.
# O:/a:/s: serialized forms, R: back-reference, | = php-handler separator.
# Filenames are names, not data — always attacker-crafted. Small known FP
# surface (a benign filename containing e.g. `a:1;`) accepted, same tradeoff
# as rawkey's filename rule.
alert http any any -> any any (msg:"php2shell - serialized object syntax in multipart filename (tier 2)"; flow:established,to_server; http.content_type; content:"multipart/form-data"; startswith; nocase; http.request_body; pcre:"/(?i)(?:^|\r\n)Content-Disposition\s*:[^\r\n]{0,500}?filename[ \t]*=[ \t]*[\x22\x27]?[^\r\n]{0,2000}?(?:[oO]\s*:\s*\d{1,10}\s*:\s*[\x22\x27]|[aA]\s*:\s*\d{1,10}\s*:\s*\{|[sS]\s*:\s*\d{1,10}\s*:\s*[\x22\x27]|[rR]\s*:\s*\d{1,10}\s*\x3b|\|\s*[oO]\s*:\s*\d{1,10})/"; classtype:web-application-attack; sid:1001203; rev:2; metadata:created_at 2026_10_21, updated_at 2026_10_21, tag php2shell;)

# ---- correlate: upload-progress form field ----------------------------------
# The documented legit mechanism (session.upload_progress) — the POC sets
# it too, so pair with 1001201 for the exact POC shape. NEVER block on it.
alert http any any -> any any (msg:"php2shell correlate - PHP_SESSION_UPLOAD_PROGRESS form field present"; flow:established,to_server; http.content_type; content:"multipart/form-data"; startswith; nocase; http.request_body; pcre:"/(?i)(?:^|\r\n)Content-Disposition\s*:[^\r\n]{0,500}?\bname[ \t]*=[ \t]*[\x22\x27]?[ \t]*PHP_SESSION_UPLOAD_PROGRESS[ \t]*[\x22\x27]?[ \t]*[\x3b\r\n]/"; classtype:web-application-attack; sid:1001204; rev:2; metadata:created_at 2026_10_21, updated_at 2026_10_21, tag php2shell;)

‍

Suricata - rawkey
# PHP rawkey-class (php-gh11189 family) - Suricata 7+
#
# Changes from rev 2:
#  * No Content-Length gate: chunked and HTTP/2 bodies have none.
#  * http.content_type + startswith instead of a substring anywhere in a header.
#  * Urlencoded split in two. "&" + 1000 non-&= bytes is always a key, never a
#    value, so 1001103 can't false-positive. 1001105 covers the first key with
#    a "^" anchor, which is only the true body start when the body is
#    inspected from offset 0 (see REQUIRED CONFIG).
#  * No trailing "=" required, so bare keys ("&AAAA&") match.
#  * Multipart scoped to Content-Disposition lines (not file contents) and
#    matches what PHP accepts: any case, single/double/no quotes, escaped quotes.
#  * Context rules for the two blind spots: folded part headers and bodies
#    bigger than the body-inspection limit.
#  * If running inline (IPS), change "alert" to "drop" on the kill-tier rules.
#
# REQUIRED CONFIG (suricata.yaml, libhtp default-config and any server-config
# that applies to your PHP hosts):
#     request-body-limit:                N   # covers your largest form POST
#     request-body-minimal-inspect-size: N   # same value
#     request-body-inspect-window:       N   # same value
# With the shipped 32kb/4kb settings, Suricata inspects large bodies in
# sliding windows, "^" lands mid-body, and 1001105 fires on any long value
# (verified on 7.0.3). With all three equal it inspects the whole body once
# from offset 0. If you can't change these, delete 1001105; 1001103 and
# 1001104 work under either setting. Also: stream.reassembly.depth >= N,
# and Suricata only sees plaintext HTTP (behind TLS termination).

# ---- kill: urlencoded field NAME >= 1000 raw bytes, after "&" ---------------
alert http any any -> any any (msg:"PHP rawkey HashTable RCE - urlencoded field name >=1000 raw bytes (tier 2)"; flow:established,to_server; http.content_type; content:"application/x-www-form-urlencoded"; startswith; nocase; http.request_body; pcre:"/&[^&=]{1000}/"; classtype:web-application-attack; sid:1001103; rev:3; metadata:created_at 2026_10_20, updated_at 2026_10_20, tag php-gh11189-family;)

# ---- kill: urlencoded FIRST field NAME >= 1000 raw bytes --------------------
# Needs REQUIRED CONFIG above, or it false-positives on long values.
alert http any any -> any any (msg:"PHP rawkey HashTable RCE - urlencoded first field name >=1000 raw bytes (tier 2)"; flow:established,to_server; http.content_type; content:"application/x-www-form-urlencoded"; startswith; nocase; http.request_body; pcre:"/^[^&=]{1000}/"; classtype:web-application-attack; sid:1001105; rev:1; metadata:created_at 2026_10_20, tag php-gh11189-family;)

# ---- kill: multipart part NAME >= 1000 bytes in a Content-Disposition line --
# \bname does not match inside "filename". Quoted names: measures from the
# opening quote to end of line (1000 name bytes + closing quote), so escaped
# quotes inside the name don't split the count. Unquoted names: PHP ends them
# at ";" or whitespace. (";" is written \x3b: a literal ";" ends a
# Suricata rule option and silently truncates the regex.)
alert http any any -> any any (msg:"PHP rawkey HashTable RCE - multipart part name >=1000 bytes (tier 2)"; flow:established,to_server; http.content_type; content:"multipart/form-data"; startswith; nocase; http.request_body; content:"content-disposition"; nocase; pcre:"/content-disposition\s*:[^\r\n]*?\bname\s*=\s*(?:[\x22\x27][^\r\n]{1001}|[^\x22\x27\s\x3b][^\x3b\s]{999})/i"; classtype:web-application-attack; sid:1001104; rev:3; metadata:created_at 2026_10_20, updated_at 2026_10_20, tag php-gh11189-family;)

# ---- correlate: folded multipart Content-Disposition header -----------------
# PHP unfolds continuation lines, so a long name can be split across short
# lines that 1001104 never sees as one. Browsers don't fold part headers;
# uploaded .eml files containing folded headers will trip this.
alert http any any -> any any (msg:"PHP rawkey HashTable RCE - folded multipart Content-Disposition (tier 3, correlate)"; flow:established,to_server; http.content_type; content:"multipart/form-data"; startswith; nocase; http.request_body; content:"content-disposition"; nocase; pcre:"/content-disposition\s*:[^\r\n]*\r?\n[ \t]/i"; classtype:web-application-attack; sid:1001106; rev:1; metadata:created_at 2026_10_20, tag php-gh11189-family;)

# ---- context: form body larger than the body-inspection limit ---------------
# Anything past libhtp request-body-limit is never inspected by the rules above.
# Set the number to your request-body-limit in bytes (102400 = 100kb).
# Can't see chunked bodies (no Content-Length).
alert http any any -> any any (msg:"PHP rawkey - form body exceeds IDS body inspection limit (context)"; flow:established,to_server; http.content_type; pcre:"/^(?:application\/x-www-form-urlencoded|multipart\/form-data)/i"; http.content_len; byte_test:0,>,102400,0,string,dec; classtype:policy-violation; sid:1001107; rev:1; metadata:created_at 2026_10_20, tag php-gh11189-family;)

‍

AWS & Cloudflare WAF

Rules for AWS WAF and Cloudflare WAF are untested, so we're sharing them as a download rather than inline. The package includes the AWS WAF and Cloudflare rule files with implementation notes, plus every rule file and script referenced in this post. Download the WAF rules package (.zip)

‍

1. PHP fatal error from an unknown script

Run this against applications logging PHP errors e.g. web server error logs from Apache and Nginx. This looks for PHP fatal errors where the engine could not attribute a memory allocation failure to any executing script.

ALL of:
  product in (Apache HTTP, Nginx, ...)
  event.dataset is "error"
  error.message contains "PHP Fatal error"
  error.message contains "tried to allocate"
  error.message contains "in Unknown on line 0"

‍

Usually application memory exhaustion names the file and the line that caused it. The phrase in Unknown on line 0 means the allocation happened outside normal script execution.

‍

2. Oversized POST requests to a PHP endpoint

Look for unusually large HTTP POST requests to PHP endpoints. This is more of a hunt than a detection as it will likely encounter false positives, and the WAF rules should be used wherever possible.

ALL of:
  product in (web server, proxy, load balancer, WAF, ...)
  http.request.method is "POST"
  http.request.bytes >= 1000000
  url.path contains ".php"  # This is not a required condition

‍

Also look for bursts of such requests over time, like so:

COUNT of matching events >= 10
  group by source_address
  over a rolling 10-minute window

‍

3. Web server spawning a suspicious shell

This is a more generic approach that targets the overall class of web application exploitation.

ALL of:
  event.category is "process"
  host.os.type is "linux"
  process.parent.name in (
      apache2, httpd, nginx, lighttpd, caddy,
      php-cgi*, php-fcgi, php-fpm*
      ...
  ) or user_name in (apache, www-data, nginx, lighttpd, ...)
  process.name in (sh, bash, dash, zsh, ksh, csh, tcsh, mksh, fish)
  process.command_line contains any of (" -c", " -lc")
  process.command_line contains <suspicious pattern> e.g. any of (
      /id, whoami, hostname,
      /nc, ncat, netcat, socat,
      xxd, base64, openssl,
      curl, wget, chmod, s_client
      python, perl, /php, /lua,
      /dev/tcp,  /dev/udp,  /dev/shm/,
      /tmp/,  /.ssh,  /etc/passwd,  /etc/shadow
      ...
  )

‍

Web server processes do legitimately spawn shells and some of the matching binaries/patterns depending on the application running, so this will likely require tuning to your environment.

Post-exploitation: what to look for if you were exposed

If your PHP applications were internet-facing before the patch, assume exploitation is possible and hunt for signs of prior compromise. The exploit executes arbitrary shell commands as the web server user (typically www-data, apache, or nginx) without crashing the worker process, so there may be no obvious disruption to indicate it happened.

Process artifacts

  • Unexpected child processes of apache2, httpd, nginx, php-fpm, or php-cgi workers in process telemetry or audit logs. Look for sh -c invocations, especially with commands like id, whoami, curl, wget, base64, nc, ncat, or python.
  • Files created or modified in /tmp, /dev/shm, /var/tmp, or web-writable directories by the web server user. Common payloads drop webshells, reverse shell scripts, or SSH keys.
  • Outbound network connections from the web server user to external IPs, particularly on non-standard ports.

Log artifacts

  • PHP fatal errors containing in Unknown on line 0 in web server error logs. This indicates the memory exhaustion happened outside normal script execution, which is the exploit's signature.
  • Bursts of HTTP 200 responses to large POST requests from the same source IP, especially where the response body contains Allowed memory size errors. Each successful exploitation attempt returns HTTP 200.
  • If the attacker brute-forced ASLR first: hundreds of large POST requests from one IP producing AH00052 (child process exit signal) entries in Apache error logs, followed by one request that takes several seconds to return.

Credentials and persistence

  • New or modified SSH authorized_keys files, particularly under web server user home directories or /var/www.
  • New cron entries for the web server user.
  • Unfamiliar PHP files in web roots (webshells).
  • If the web server user has database access: check for new database users, modified records, or data exfiltration indicators.

Cover the entire exposure window from when the vulnerability became publicly known (20 October 2026), not just from when your instance was patched. Patching stops future exploitation but does not undo access already taken.

Abstract detections

Abstract customers have the following content pack analytics available out of the box to detect potential exploitation:

Web content pack

  • Web - PHP Fatal Error from Unknown Script (Potential PHP2Shell Exploitation)
  • Web - Oversized POST Request to PHP Endpoint
  • Web - Burst of Oversized POST Requests from Single Source

Linux content pack

  • Linux - Web Server Spawning Suspicious Shell

References

‍

Written by:
Abstract Security Threat Research Organization (ASTRO)
ASTRO is a coalition of cyber defenders, threat hunters, and incident responders that have spent decades responding to real world incidents. The ASTRO team is committed to helping companies detect, understand, and counter the most sophisticated cyber threats of the 21st century.
Get started with Abstract today
Save your security teams from drowning in data noise and hassle!
Get a Demo
GET
‍ABSTRACTED

We would love you to be a part of the journey, lets grab a coffee, have a chat, and set up a demo!

‍

Your friends at Abstract AKA one of the most fun teams in cyber ;)

White light beam passing through a black circle with a pink abstract symbol, dispersing into multicolored beams on the right.
Thank you!
Your submission has been received.
Oops! Something went wrong while submitting the form.