Two RCEs, one Wednesday: responsible disclosure has run out of time

Two RCEs, one Wednesday: responsible disclosure has run out of time

TL;DR. On Wednesday, October 21st, two critical RCEs went public a few hours apart, and both broke the idea that disclosure buys defenders time. php2shell, in PHP core, leaked before its patch: Amazon saw exploit attempts four days before disclosure, and our WAAP blocked a working tool 16 minutes after we shipped a rule written on rumours, almost six hours before PHP released a fix. Node2Shell, in the Brotli decoder under Node.js, went the other way: its fix had sat unreleased in a public repository since February. We stopped the first with a virtual patch built in a morning, and because it logs every body it blocks, we could watch the week after: scanners growing into out-of-memory sprays, a credential stealer cycling through six PHP gadget chains hidden in upload filenames, a secrets crawler adding php2shell to its routine overnight, and finally someone who had read the advisory and built the exploit it describes. The second we didn't have to touch, because a default borrowed from the OWASP Core Rule Set has blocked compressed request bodies for years. When the embargo no longer holds, the defences that work are the ones that don't need to know the bug.

Two clocks

Responsible disclosure is a deal about time. The researcher stays quiet, the vendor ships a fix, and on an agreed day the advisory and the patch go out together, so defenders start the race at the same moment as attackers. Under the deal sits an assumption nobody says out loud: without the details, there is no exploit. A bug nobody has described is a bug nobody can weaponise, so as long as the advisory stays quiet, defenders can afford to wait for it.

That assumption is what broke this week, and the leak is not the real reason. Keeping details secret still matters. It just no longer buys the time it used to. Exploitation used to be hard because getting from "there is a use-after-free somewhere near upload progress" to a request that lands took weeks of a specialist's work. That is not true any more. Anyone with an AI coding agent like Claude Code and a handful of facts can do it: a component, a feature name, a screenshot, a date, a commit that looks like a fix. They point the agent at the public source, have it find the code path, build a vulnerable copy locally, and iterate on a payload until it works. Node2Shell itself was found by an AI security agent. Amazon caught a php2shell exploit attempt four days before disclosure, three days before the leak, and nobody knows what its author started from. It did not need to be much. In the AI era, "no details published" no longer means "no exploits in the wild".

On October 21st both halves of that deal failed, on two different bugs, at the same time. Put the two stories on one clock and it looks like this.

The months around it

Date php2shell (PHP core) Node2Shell (Brotli)
Feb 27 The decoder fix lands in Brotli's public repository as commit 0ed4268, "Detect and report invalid stream earlier". No security label, no release, no CVE.
Oct 17 Amazon's MadPot honeypots log a first exploit attempt from an anonymisation network, four days before disclosure.
Oct 20 A Hacker News post leaks two critical PHP RCEs and is deleted. watchTowr confirms. PHP announces an emergency release for the next day at 14:00 UTC.
Oct 21 Leak, exploits, virtual patch, release. Hour by hour below. Node.js and Brotli ship the fix after eight months. Hour by hour below.
Oct 22 Imperva publishes its Node2Shell research. Our probes with Content-Encoding: br get a 403 on every site: nothing to deploy.
Oct 23 A distributed probe campaign hits 15 of our sites in about ten hours, from the same hosting provider as the first exploit. All blocked. CISA assesses exploitation as "none".
Oct 25 Cloudflare adds managed rules for Content-Encoding: br, log-only by default.
Oct 26 A hand-built exploit combining both CVEs, with a 1 MB body, hits a WordPress login page. Blocked.
Oct 27 82 external requests on our rule so far, from 24 addresses on 22 sites. All blocked. Still no Brotli-encoded request from the outside on our edge.

October 21st, hour by hour (UTC)

Time php2shell (PHP core) Node2Shell (Brotli)
02:00–08:00 Amazon sees more attempts from several actors, with varying payloads and infrastructure.
08:02 We commit the virtual patch: block any multipart part named PHP_SESSION_UPLOAD_PROGRESS.
08:08 The rule is live in production on every protected site, detection-only sites included.
08:23 First php2shell exploit blocked on our WAAP, user agent php2shell-109618.
08:37 Raw request body logging enabled on the rule.
09:14 Same attacker, second site. Full payload captured.
10:38 Node.js 22.23.4, 24.22.1 and 26.12.1 ship with the patched decoder.
14:06 PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 go out, with the two advisories.
19:27 Brotli itself publishes 1.0.10, 1.1.1 and 1.2.1.

Two different ways for the clock to break. In php2shell the bug got out before the patch. In Node2Shell the patch got out before the bug. The rest of this post is about both, and about what still worked when neither clock could be trusted.

When the bug leaks before the patch: php2shell

What leaked

On Tuesday, October 20th someone posted on Hacker News that two critical remote code execution bugs in PHP core were about to be disclosed. The post was removed, but screenshots were already everywhere. The rest followed within hours:

  • watchTowr confirmed the bugs were real and said they had reproduced CVE-2026-109618, without sharing details.
  • The PHP security team published an advance notice: two critical vulnerabilities, both rated CVSS v4.0 9.2, emergency release scheduled for Wednesday, October 21st at 14:00 UTC. Fixes only for supported branches, which means PHP 8.2 and newer. Everything from 8.1 down is end of life and stays unpatched.
  • The two IDs are CVE-2026-109618 and CVE-2026-109619. The name php2shell, from a leaked tool screenshot credited to Calif, says what you get at the end: a request in, a shell out.
  • Sansec predicted mass exploitation within 15 minutes of the patch, because a published diff is a published map.

The embargo was supposed to hold until Thursday. The leak brought it forward by a day. And as Amazon showed a day later, even the leak was late: their honeypots had caught a first attempt on October 17th, four days before disclosure, from someone who already knew where to aim. Neither PHP nor Amazon could say whether those early attempts would have worked. That is beside the point. Someone outside the embargo was already trying.

Reading the rumours

When there is no advisory, you read what the people who claim to have the exploit are saying, and you trust none of it until something confirms it. By the morning of the 21st, one researcher who said they had reproduced the chain in a lab described it this way:

  1. A heap use-after-free in php_session_rfc1867_callback() (ext/session/session.c), the handler behind session.upload_progress. The feature is on by default, and it is reached by a multipart/form-data POST that carries a field called PHP_SESSION_UPLOAD_PROGRESS.
  2. A second bug in PHP's multipart parser, reachable by any multipart POST, whether upload progress is enabled or not.

None of this was official. But it was specific, it matched the "every version since 5.4" claim (upload progress landed in 5.4), and it matched the one thing everybody agreed on: PHP parses multipart bodies before your script runs. Your application does not need to handle uploads. It does not even need to start a session. If PHP is behind the URL, the parser is reachable.

The community mitigations reflected that uncertainty. One was session.upload_progress.enabled = Off, which is fine but covers only the first bug and needs someone to touch every php.ini out there. The other was to block every multipart POST at the web server, which also blocks every contact form, upload field and WordPress media library on the planet.

The rule we shipped

We protect a lot of PHP: WordPress, WooCommerce, ticketing platforms, custom frameworks, some of them on versions that will never see an official fix. Blocking multipart wholesale was not an option. Waiting for 14:00 UTC was not one either.

The first bug offered something solid to stand on. To reach the upload progress callback, the request must contain a multipart part whose name is exactly the configured upload progress name, PHP_SESSION_UPLOAD_PROGRESS by default. PHP compares that name byte for byte, and its multipart parser only unescapes a backslash in front of another backslash or a quote, so you cannot spell the name in a way that PHP accepts and a WAF misses. And almost nobody uses server-side upload progress on purpose: it predates the JavaScript progress bars everybody uses today.

So the rule is one condition:

if request.body.multipart.name contains "php_session_upload_progress" (case-insensitive)
  → ctl:ruleEngine=On, deny 403

A few details made it work in practice:

  • It applies everywhere. It is a global rule in Karna, our WAF engine, and it forces blocking mode (ctl:ruleEngine=On) even on sites that are still in detection only.
  • Big uploads are not a bypass. Karna parses the multipart body even when nginx has spilled it to disk, so a large upload does not escape inspection. This mattered, as you will see.
  • The parser is strict. Bare CR or LF in the framing, unquoted or RFC 5987 name*= parameters, duplicated Content-Disposition headers are all rejected rather than guessed at. That narrows the room for parser-differential tricks between us and PHP.

It went through staging, through a test matrix (field with and without a file, lowercase name, 2 MB uploads blocked; clean multipart forms and uploads untouched), and reached production at 08:08 UTC on October 21st, about six hours before PHP's release.

It does not cover the second bug, and it does not cover a site that renamed session.upload_progress.name. We said so to our customers, and we say it here.

Half an hour later we made one more change that turned out to be the most useful of the day: every request blocked by this rule now carries its raw request body in the audit log. If something real was going to hit the rule, we wanted to see what it was trying to do.

Sixteen minutes

At 08:23:58 UTC, sixteen minutes after the rule went live, it fired on a production site for the first time.

POST / HTTP/1.1
User-Agent: php2shell-109618
Content-Type: multipart/form-data; boundary=B7kQpoc618
Content-Length: 62929
Cookie: PHPSESSID=[redacted]
Connection: close

The request came from a single VPS in a hosting ASN in Dallas, aimed at the root of an Italian PHP site. 403, no upstream latency, PHP never saw it. That first request arrived before we had switched on body logging, so we only had the headers.

At 09:14:06 UTC the same host came back to a second, unrelated site we protect. Same tool, same boundary, same 62,929 bytes, byte for byte. This time we had the body.

That was a working tool, used against real sites, almost six hours before a patch existed. It was not the last: what came after is further down.

What was inside

Customer hostnames and the attacker's address are left out. This is a walkthrough of the technique, and we are deliberately not reproducing the binary structures.

The body is about 62 KB, and it is built in four layers:

1. The session, twice. A PHPSESSID field in the body, with the same value as the cookie. The upload progress code looks for the session ID to know where to record the progress, so the exploit makes sure it finds one.

2. The trigger. A PHP_SESSION_UPLOAD_PROGRESS field with the value x. That turns on the vulnerable callback. Our rule stops the request here.

3. The groom. A file part named f, filename a, holding 5,184 A bytes followed by 5,184 B bytes. That is heap shaping: chunks of a chosen size, so the freed memory gets reused in a predictable way.

4. The spray. About 150 copies of a multipart field called t[], each holding the same 288-byte binary blob. Most of each blob is zero bytes and a few small values that look like forged internal PHP structures, and at the end there are two strings:

system
echo P2SHC4GM;cat .env||find / -maxdepth 4 -name .env -exec cat {} \;

Every value in a t[] array becomes a fresh allocation inside PHP during the multipart parsing, so sending the same fake structure 150 times fills the heap with copies of it. The goal is that the freed memory the use-after-free reads from is one of those copies. When that happens, PHP ends up calling system() on a string the attacker chose.

The order of the parts is the exploit. PHPSESSID and the trigger come first, because upload progress only arms itself when it can tie a progress entry to a session. The spray comes before the file, so the heap is already carpeted with forged structures when PHP starts streaming the upload. The file comes last, because the large upload is what drives the callback that trips the use-after-free. That is also why our rule sits where it does: the trigger is near the front, and we refuse the whole request the moment we see its name.

What they wanted

The command is the interesting part, because it is not a webshell and it is not a reverse shell:

  • echo P2SHC4GM is a marker. The tool greps the HTTP response for it to know whether code execution happened. It is the same trick as the w2s… marker in the WP2Shell exploits we wrote about: the tool signs its own output.
  • cat .env || find / -maxdepth 4 -name .env -exec cat {} \; reads the .env file in the working directory and, if there is none, looks for one up to four levels deep from the root and prints it.

A .env file is where a modern PHP application keeps its database password, its app key, its SMTP credentials, its payment provider and cloud API keys. This first wave is not after the box. It is harvesting secrets, one request at a time, quietly. No file is written, no process stays alive, and nothing shows up in the web root. If it had worked, the only trace would be one POST in the access log and credentials that are now somebody else's.

A blind shot, on purpose

The request we blocked is the byte-for-byte output of a public payload generator published by the same researcher who posted the early session.upload_progress analysis. The attacker did not build anything. They ran a script with its default settings and pointed it at a list of PHP sites. The generator labels its own work: boundary B7kQpoc618 and user agent php2shell-109618 for one CVE, …619 for the other.

The 288-byte blob in each t[] part is a hand-built fake PHP internal object: if the use-after-free dereferences one of these copies, it presents a callable whose target is system and whose argument is the attacker's command. It also carries a pointer, and that pointer is hardcoded. Every public proof of concept for this bug is whitebox: it works because the author knows the memory layout of the build they test against. The request that hit us did not know our customers' layouts and did not try to. It fired the tool's default address at everyone, the same bytes to two different sites.

That is a dragnet, and it bets on one thing: servers where ASLR or PIE is off. On a hardened modern build the fixed guess almost never lands and the worst outcome is a crashed worker. On an old package, an odd distro build, some containers, a lot of 32-bit, the layout is stable, the pointer resolves, and the blind shot goes straight through to system() with no per-target work at all.

The generator can also split the body into two writes with a deliberate stall between them, to control the gap between allocation and free. For a defender that means one thing: do not rely on seeing the whole body at once. Karna buffers and parses the full multipart body, including the part nginx spills to disk, before any rule runs, so a slow two-phase send does not slip past the name check.

Everything clever in this payload, the forged objects, the spray, the slow send, happens after the one part the rule keys on. The trigger has to be there, and PHP recognises it by an exact byte comparison that leaves no room to disguise it. Refuse the request on that field and the 150 traps are never laid.

What the official advisory said

At 14:00 UTC, on schedule, PHP shipped the release and the two advisories:

  • CVE-2026-109618, "RCE via OOM corruption." PHP uses longjmp() to bail out of an out-of-memory condition, and when it bails out mid-way through setting up a request it can leave the request half-initialised for shutdown to read. The trigger strategies are all about forcing that OOM through multipart parsing. It does not need the PHP_SESSION_UPLOAD_PROGRESS field.
  • CVE-2026-109619, "RCE via stale global state." A persistent worker keeps global state pointing at memory owned by a request that has already ended, and a later request touches it. One named way in is session upload progress handling, on by default, no session_start() needed. RCE confirmed on both mod_php and PHP-FPM, back to PHP 5.4.

Put them together and you have the request we caught: the oversized body is the OOM producer (109618), and the PHP_SESSION_UPLOAD_PROGRESS field is the consumer that dereferences what the OOM left dangling (109619). Our rule keys on the consumer, the path every public tool is spraying, and that still holds with zero false positives. The generic 109618 path does not carry our field; what blunts it is something already in our engine: the multipart parser accepts only Content-Disposition and Content-Type as part headers, so the "part with unbounded headers" strategy never gets its first extra header through. None of that replaces the update. It buys the time to apply it.

For a few hours on the 21st the severity itself was in dispute: one of the researchers behind the early analysis published a correction downgrading the bug to a non-RCE. Wiz Research then demonstrated pre-auth RCE against stock WordPress, Drupal and Joomla, and PHP's own advisory said "unauthenticated remote code execution" in plain text. A virtual patch keyed on the delivery mechanism rather than the root cause is what stays standing while that debate sorts itself out.

After the patch: the tools got better

This is the part we did not expect to write. Because every request the rule blocks carries its raw body into our logs, we did not just count the attacks of the following week. We could read them. Over six days the rule fired on 82 external requests from 24 addresses against 22 of the sites we protect, every one blocked, and in those bodies you can watch the tooling evolve: from a default script fired at everyone, to scanners, to people who read the advisory and built their own.

Customer names and attacker addresses are left out. Times are UTC.

The canaries

The first thing the internet did with php2shell was check for it. Most hits are a few hundred bytes, and each scanner leaves its own fingerprint in what it puts in the trigger field:

  • a random marker (p2s<hex>, php2shell-<random>, TEST_VULN_<hex>, p2s_probe), the cheapest way to see whether the value comes back anywhere;
  • an inert PHP canary, <?php /* php2shell-scan inert canary */ ?>, placed both in the trigger value and in the uploaded file's name. That one is not testing the memory bug at all. It is testing whether PHP code can be smuggled into the session file, the old upload-progress trick for turning a local file inclusion into code execution;
  • a dozen tools that just announce themselves in the user agent, from commercial attack-surface scanners to php2shell-scanner/2.0 (authorized security testing).

As we write this, a Go client calling itself php2shell-go/1.0 is still sweeping 20 of our sites from a cloud region in Belgium: over 500 bare GET / requests in two days, with three headers and nothing else, fingerprinting what answers.

The scanner that grew up

One tool used a boundary starting with safenetphp- and ran from four different cloud providers in 17 hours. Every source ran the same three steps:

  1. The trigger field with a marker, p2s<hex>. Is upload progress reachable?
  2. The trigger field set to x|O:8:"stdClass":0:{}, an empty serialized object. Will the session serializer take an object?
  3. A 2,749,266-byte POST with more than 2,048 multipart parts.

The third request has the shape of the generic out-of-memory path, CVE-2026-109618: enough parts to make PHP's multipart parser allocate until it hits the memory limit at the wrong moment. That path does not need the upload progress field, so it is exactly the case our rule was not built for. We never saw the body, because the request did not get that far. What stopped it is a limit that predates the bug: the engine refuses any request with more than 2,048 arguments, before any rule runs. The log line says 2049 > 2048 every time, and the body size is identical, to the byte, from all four providers.

Telling patched from unpatched

On the night of October 23rd, four addresses swept 15 of our sites in about ten hours. Two of them sit at the same small hosting provider as the very first exploit on October 21st. Each site got the same set of requests, all with the boundary p2sprobe:

PHPSESSID = !!!                          ← a session ID PHP will refuse
PHPSESSID = probeaaaaaaaaaaaaaaaaaaaaaa  ← a session ID PHP will accept
PHP_SESSION_UPLOAD_PROGRESS = x
file "n.txt" = z

plus a third, smaller request carrying only the session ID. The two main requests differ in one thing: whether PHP accepts the session ID, and therefore whether the upload-progress code runs at all. Compare the two responses and you learn whether the target goes down the vulnerable path, without sending a single byte of exploit. It is reconnaissance built for a world where half the targets have patched and the other half have not, and it runs from rented servers overnight in Europe.

Six gadget chains in two hours

The most persistent attacker went after a single site, a PHP business application, with a Go HTTP client. It ignored the memory bug and went for session poisoning. Upload progress writes attacker-controlled strings into the session file. With PHP's default session serializer, the | character separates session keys, so a value like x|O:… can break out of its key and plant a serialized object that PHP will rebuild the next time the session is read. That makes any gadget chain in the application's dependencies reachable.

The order of their requests, around 00:40 on October 22nd:

  1. A plain probe, trigger value x, with a marker file WF119MARKER.txt. Blocked by our rule.
  2. One second later, four requests that moved the payload out of the trigger value and into the uploaded file's name. Upload progress records filenames in the session too, so the filename is a second injection point, and one that never touches the trigger value a filter might be watching. The four names held four gadget chains: two variants of the Monolog chain, Guzzle's FileCookieJar and Laravel's PendingBroadcast.
  3. Two hours later, six more: the same ones plus Laravel's PendingSingletonResourceRegistration and Guzzle's Psr7\FnStream.

None of those ten requests reached our rule. The multipart parser refused all of them first. A serialized object is full of double quotes, and they were dropped unescaped inside filename="…", which breaks the Content-Disposition grammar. A lenient parser would guess where the filename ends, and could guess differently from PHP. Ours refuses to guess and rejects the request as malformed.

At 15:51 the same client came back to the trigger value with the full Monolog chain (SyslogUdpHandler wrapping a BufferHandler whose processors end in system) and a 923 KB body. Most of it was padding in an upload called handch.txt, which keeps the request, and the progress entry, alive long enough to matter. The command it wanted to run is a credential harvester with a budget:

  • it prints PHP2WIRE$((6*7)). The shell turns that into PHP2WIRE42, so the string that proves execution never appears in the request. A page that simply reflects input cannot produce a false positive;
  • it greps the whole filesystem, with a 10-second timeout, for AWS, Google, OpenAI, OpenRouter, GitHub, Slack and SendGrid keys, private keys, and database connection strings;
  • it finds and prints every .env* file;
  • it greps web and home directories for SMTP and mail-provider credentials;
  • it caps each step's output at a few KB, so the loot fits in a single HTTP response.

Two days later the same client, with the same WF119 marker, came back from a different hosting provider. Same site, same 403.

A secrets scanner adds php2shell

One address had been on our sites since October 13th, a week before the leak, sweeping 36 of them for secrets: /.git/config, /.env.example, /.aws/credentials, appsettings.json, ServiceAccountKey.json, about 3,250 requests in all. At 01:54 on October 22nd it added two new requests to its routine, on two sites ten minutes apart:

  • p1: a session ID and the trigger field with the value p1. Nothing else.
  • p2: a 32-character session ID, the trigger p2, a 63-byte field zf, and 200 nested fields named a0000[k] to a0199[k], each holding v.

The zf blob is 16 As, then 00 00 04 00 00 00 00 00, then the alphabet: filler, an 8-byte little-endian value of 0x400, more filler. That is the shape of a fake structure with a length or size field. Each nested name forces PHP to build an array and a key during parsing, which is one of the allocation patterns the official advisory lists. We can't tell from one request what layout it was trying to build. We can tell that a crawler that already lived on stolen config files had a php2shell module less than a day after the leak.

The one that read the advisory

On October 26th at 07:23, an address spent 13 seconds mapping a WordPress site: the home page, readme.html, wp-login.php, admin-ajax.php, /login/, /my-account/, /register/. Then it sent two POSTs to /wp-login.php in the same second, one of 264 KB and one of 1 MB:

boundary:     ----P2Shell<hex>
PHPSESSID:    <32 hex>
PHP_SESSION_UPLOAD_PROGRESS = rce
field_01 … field_06: filler of 172, 194, 215, 172, 194, 215 bytes
file "oom_probe_<hex>.bin": the rest of the body

Everything in it lines up with what PHP published on the 21st. The upload, named oom_probe, is the producer that drives PHP into out-of-memory. The trigger field is the consumer that dereferences what the OOM leaves behind. The two sizes, a quarter of a megabyte and then four times that, are a search for the threshold where the OOM fires. The repeating filler sizes look like someone aiming at specific allocator size classes. And /wp-login.php is a deliberate target: on a WordPress site it is the one URL guaranteed to execute PHP on every POST, with no cache, no static file and no rewrite in the way.

This was not the public generator any more. It was someone who had read the advisory and built the exploit it describes.

Five days after the patch, the traffic had gone from a default script fired at everything to purpose-built tools for the official root cause, tools that moved the payload into a different part of the request and probed for the vulnerable path before attacking. Every one that went through upload progress still had to send a field called PHP_SESSION_UPLOAD_PROGRESS, because without it that bug is out of reach. The ones that tried to go around it, through the filename or through sheer volume, ran into checks that were in place before anyone had heard of php2shell. More on that below.

When the patch leaks before the bug: Node2Shell

While we were reading php2shell payloads, the second bug went public on the same Wednesday. CVE-2026-111497, nicknamed Node2Shell, is an out-of-bounds write in Google's Brotli decoder, reported by OpenAI's Codex Security team. Node.js, Bun, .NET, Envoy, Istio and the Python Brotli bindings all ship that decoder. Imperva's red team turned it into code execution on Node.js 24, through a crafted Brotli request body (Express 5, body-parser 2) or through a malicious response read with fetch().

Here the embargo did not leak. It was never really there. The fix had been sitting in Brotli's public source tree since February 27th, as a one-line commit message about invalid streams, with no release and no CVE, for eight months. Every Node.js, .NET and Envoy build shipped in that time carried the vulnerable decoder, and anyone who read the commit log carried the map. Nobody has reported exploitation before October 21st, and we saw none. But "nobody noticed" is a much weaker guarantee than "nobody knew".

On the WAAP side, this time we did not have to write anything. The server-side vector needs a request with Content-Encoding: br, and our engine refuses by default every request that carries a Content-Encoding header. On October 22nd we probed our edge to confirm it: 403 everywhere. In the following days we did not see a single Brotli-encoded request from the outside.

We saw something else, though. Over the previous 30 days the header had shown up on a few hundred requests. Most came from scanners probing Next.js Server Actions (the next-action header) with Content-Encoding: gzip, or with the header left empty, against dozens of sites that do not even run Next.js. That is the old trick the rule exists for: compress or mislabel the body so the WAF cannot read it, and hope the backend can. Every one was blocked.

Defending against what you can't see

Put the two bugs side by side and the same question sits under both: what do you do on the day you know something is wrong but not what?

For php2shell the answer was a rule written in a morning, on rumours, keyed on the one part of the request the exploit could not do without. It had to be specific enough not to break uploads and contact forms, and generic enough to survive a root cause nobody had published yet.

For Node2Shell the answer had been written years ago, by somebody else, for a different reason.

Credit where it's due: OWASP CRS

The Content-Encoding rule is not ours. It is the restricted_headers_basic list from the OWASP Core Rule Set (rule 920450). CRS has long argued that blocking Content-Encoding on requests "shouldn't break anything and increases security", because WAF engines cannot properly inspect compressed bodies. Karna enforces that list natively, but the idea, and years of tuning it across countless deployments, come from the CRS project. A default written for a generic class of WAF bypass ended up closing the door to a 2026 memory-corruption RCE, without anyone having to know the bug existed. Thank you, CRS.

It happened with php2shell too. Our rule keys on one field, and some attackers tried to get around it. Three generic controls, none of them written for this bug, caught what the specific rule was not built to cover:

  • A limit on the number of arguments. The 2.7 MB requests with more than 2,048 parts, the shape of CVE-2026-109618's generic out-of-memory path, never reached PHP: the engine refuses a request with more arguments than any real form sends.
  • A strict multipart parser. Ten gadget chains hidden in upload filenames, an injection point our rule does not look at, were refused because their unescaped quotes made the Content-Disposition header malformed.
  • CRS again. Rule 943120, CRS's session fixation check, flags a PHPSESSID parameter in a request body. It fired on its own on the distributed campaign and on several scanners, because the upload-progress payloads carry the session ID in the body. A rule written for session fixation recognised a 2026 memory-corruption exploit.

That is the pattern worth keeping. Amazon closed its own write-up with a line that fits both stories: "An embargo protects defenders only until someone else finds the same bug." When the bug leaks or the fix gives it away, what holds is not the signature you write after the advisory. It is the narrow rule on the delivery mechanism that you can ship before it, and the boring default that refuses things a normal client never sends.

The edge only covers part of it, though. A Node.js process that fetches a Brotli response from a hostile server never goes through a WAF. That includes the automation and AI agents that read URLs from emails or tickets. A PHP host nobody put behind the WAAP was exposed from October 17th, not from the 21st.

IOCs

If you run PHP and keep request logs, these are worth a search, starting from October 17th, not from the disclosure date:

User-Agent:      php2shell-109618   (and the php2shell-109619 twin)
Boundary:        B7kQpoc618         (and B7kQpoc619 for the second bug)
Content-Length:  62929              (default of the 618 variant)
Multipart field: PHP_SESSION_UPLOAD_PROGRESS
Spray field:     many near-identical binary t[] values
Response marker: P2SHC4GM           (attacker-chosen, not a tool default)
Target:          POST /

Seen in later waves:
Boundaries:      p2sprobe, safenetphp-<hex>, WF119BOUNDARY, nzBND12345678, P2Shell<hex>, php2shellprobe
Upload names:    oom_probe_<hex>.bin, WF119MARKER.txt, handch.txt
Trigger values:  serialized objects, e.g. x|O:8:"stdClass"…, x|O:32:"Monolog\Handler\SyslogUdpHandler"…, |O:6:"Logger"…
Spray fields:    nested names a0000[k] … a0199[k]
Filenames:       serialized gadget chains in filename="…" (Monolog, Guzzle FileCookieJar/FnStream,
                 Laravel PendingBroadcast/PendingSingletonResourceRegistration)
Markers:         PHP2WIRE$((6*7)) → PHP2WIRE42 in the response, "php2shell-scan inert canary", TEST_VULN_<hex>
User-Agents:     php2shell-go/1.0, php2shell-scanner/2.0, sudo3rs-PHP2Shell-Checker/1.4.0
Targets:         also /index.php, /upload.php, /api/upload.php, /wp-login.php

The user agent, the boundary and the marker are trivially changed, so treat their absence as meaningless and their presence as a strong tell. The things that are hard to fake are structural: a PHP_SESSION_UPLOAD_PROGRESS field on a site that does not use upload progress, a PHPSESSID in a request body, a serialized object (|O:) where a progress key should be, and a multipart body stuffed with scores of near-identical parts.

For Node2Shell, look for any request with Content-Encoding: br reaching a Node.js, .NET or Envoy service, and for unusually large Brotli payloads.

What to do

PHP

  • Patch now to 8.2.35, 8.3.36, 8.4.27 or 8.5.12, internet-facing servers first, and restart every long-running worker (Apache, PHP-FPM).
  • On PHP 8.1 or older there is no official fix. Upgrade to a supported branch or get a backport from your distribution; until then, isolate the server.
  • Set session.upload_progress.enabled = Off if you do not use it, which is almost certainly the case.
  • Assume the .env files may already be gone on any unprotected PHP host exposed since October 17th. Rotate the secrets inside them if you see this traffic, or if you cannot tell.
  • Keep ASLR and PIE on. It does not fix the bug, but it is the difference between a crashed worker and a stolen .env.

Node.js and Brotli

  • Update to Node.js 22.23.4, 24.22.1 or 26.12.1 (Bun 1.4.4, Brotli 1.0.10 / 1.1.1 / 1.2.1, and the .NET, Envoy and Istio fixes), then restart: a running process keeps the old decoder.
  • Reject Content-Encoding on requests at the edge if your applications do not need it. Almost none do.
  • Check what your Node.js services fetch. Agents and automations that follow URLs from untrusted input are the vector no WAF sees.

Both

  • Measure patch time in hours, and build detections that do not depend on a CVE-specific signature. On October 21st, the signatures arrived last.