DEV Community

Cover image for Everyone said patch immediately. Nobody said what to do for the next six hours.
Pablo Albornoz Afanasiev
Pablo Albornoz Afanasiev

Posted on

Everyone said patch immediately. Nobody said what to do for the next six hours.

On October 20th, two critical PHP vulnerabilities went public before the advisory did. Unauthenticated remote code execution, CVSS 9.2, and proof-of-concept code already circulating. Unit 42 counted more than 22.6 million exposed instances at the time of publication.

Someone named it PHP2Shell and the name stuck.

The next day, Node.js shipped a security release fixing ten issues. One of them was an out-of-bounds write in the Brotli decoder, which also meant remote code execution. That one got called Node2Shell.

Two runtime-level RCEs in forty-eight hours, in the two runtimes that serve a very large share of the web. Every vendor blog said the same thing, and they were right: patch immediately.

Here's the part nobody writes about. Between reading the advisory and having every service in your fleet actually running the patched runtime, there are hours. Sometimes days, if you have a lot of containers, a slow registry, or a change window you can't skip.

During that gap, "patch immediately" is advice you've already taken and are still waiting on.

So what do you do in the meantime?

Why these two were unusually bad

It's worth being precise about what made these different from an ordinary dependency CVE, because that's what determines which tools can help.

PHP2Shell (CVE-2026-109618 and CVE-2026-109619) lives in PHP's error handling: out-of-memory and fatal-error paths that leave memory uninitialized or pointing at freed regions. An attacker needs nothing but an unauthenticated POST.

And here's the detail that matters most. PHP parses the request before your application code runs. Your authentication middleware, your input validation, your framework's request pipeline: none of it has executed yet. As Datadog's analysis puts it, any PHP script on the server exposes the vulnerable path.

Node2Shell (CVE-2026-111497) is an out-of-bounds write in the Brotli decoder inside the runtime. The obvious way in is a request with Content-Encoding: br reaching an app on Express 5 or body-parser 2.x, which decompresses the body for you.

But Contrast's writeup points at two other paths that matter more than they first appear: a malicious response decoded by fetch(), and any direct call to zlib.brotliDecompress(). The decoding happens inside the runtime, after traffic has already passed your perimeter, which is why a WAF is an unreliable control here.

In both cases the vulnerable code sits below your application. You can't write your way out of it.

I want to say that plainly up front, because the rest of this article is about response, not prevention, and I'd rather not pretend otherwise.

What hour one actually looked like

Three things, roughly, depending on how much the organization had to lose.

Some teams took services offline. At least one European government took its website down as a precaution. Given that in another case attackers had password hashes and MFA secrets within two hours of initial exploitation, and were attempting Domain Admin escalation, that wasn't an overreaction.

Some blocked at the perimeter. Reasonable for PHP. Less reliable for Node, where the decoder runs after the request is already inside.

And some applied the partial mitigations. For PHP2Shell that meant memory_limit=-1, session.upload_progress.enabled=0, and on PHP 8.5 also fatal_error_backtraces=0. For Node2Shell, a virtual patch rejecting every request carrying a Brotli content encoding.

Now look at what those mitigations cost you.

memory_limit=-1 removes the memory ceiling from your PHP workers. You're trading a code execution risk for an availability risk, and unless you already have request-size limits and container memory limits in place, you've just moved the problem somewhere else. Rejecting all Brotli bodies breaks legitimate clients that were doing nothing wrong.

None of them is a fix. Every advisory said so.

These are the right calls under the circumstances. They're also exactly the kind of change you want live for forty-eight hours and gone the moment you're patched.

Every lever you have is a deploy

Here's what I think the real structural problem is, and it isn't specific to these two CVEs.

Turn off file uploads. Reject one content encoding. Lock outbound calls to a short allowlist. Put the app in read-only mode. Kill the one feature that touches the risky path.

Every one of those is a behavior change. And in most codebases, every one of them is a deploy: a config change, a rebuild, a rollout, a worker restart, across however many services you run. Under incident pressure. Through the same pipeline your platform team is currently using to ship the actual patch.

That's the thing worth sitting with. During an incident, the fix path and the mitigation path compete for the same machinery, and that machinery is the riskiest tool in the building at precisely the moment everyone is tired and moving fast.

Knight Capital lost $460 million in forty-five minutes partly because the attempted fix ran through the deployment process that had caused the problem, and made it worse.

If the only way to change what your application does is to ship code, your incident response time has a floor set by your build pipeline. Not by how fast you understand the problem.

What changes when behavior is data

The alternative is that some of those behaviors live outside your code, as rules your application reads at runtime. That's what a policy engine does. Govplane is the one I work on, and the same argument holds for any of them.

A policy is a rule in a signed file. Your app loads it, verifies it, and evaluates decisions in-process with no network call. Changing a rule means shipping a new file, not a new build.

Two concrete examples from the Node side, because that's where policy can genuinely sit in the path.

Rejecting Brotli bodies, per route, without a deploy

The body gets decompressed by body-parser, so a check that runs before it never reaches the decoder:

// must be registered BEFORE express.json() / body-parser
app.use((req, res, next) => {
  const decision = govplane.evaluate({
    target:  { service: 'api', resource: 'request', action: 'decode' },
    context: {
      contentEncoding: req.get('content-encoding') ?? 'none',
      route: req.path,
    },
  });

  if (decision.decision === 'deny') {
    return res.status(415).json({ error: 'content encoding not accepted' });
  }
  next();
});
Enter fullscreen mode Exit fullscreen mode

The rule driving it is one line of JSON. The reason this beats hardcoding the same check is that you can scope it.

Block br on the three public webhook routes where you never expected it. Leave it alone on the internal endpoint where a partner legitimately sends compressed payloads. Then change your mind twenty minutes later when you learn more, without touching the build.

Constraining outbound calls

The fetch() vector is the one I saw people underestimate, and it's the nastier of the two. If your application fetches a URL and the response comes back Brotli-encoded, your own outbound call is the attack vector.

That matters a lot if any URL in your system is user-influenced: a webhook target, an integration endpoint, an avatar importer.

const decision = govplane.evaluate({
  target:  { service: 'integrations', resource: 'outbound-http', action: 'fetch' },
  context: { host: new URL(url).hostname, tenant: req.tenantId },
});

if (decision.decision !== 'allow') {
  throw new OutboundBlockedError(url, decision.ruleId);
}
Enter fullscreen mode Exit fullscreen mode

Most teams already have an outbound allowlist somewhere. It's usually an environment variable, an array in a config file, or a regex somebody wrote in 2023.

During an incident you want to shrink it to four hosts for a day and then put it back. The difference between that being a thirty-second change and a forty-minute change is whether it lives in a file your app reads, or in the build.

Shed the surface, not the service

Taking the whole site down is the blunt instrument. Sometimes it's the correct one. If you have no idea what's reachable, pulling the plug is defensible.

But most of the exposure in these two incidents was concentrated. For CVE-2026-109619, upload processing was a confirmed path, and upload progress is enabled by default, and runs even without session_start(). For Node2Shell, the inbound path is specifically Brotli-encoded request bodies.

If uploads are a policy-controlled capability rather than a code path, hour one looks different. You disable uploads across every service at once, keep the other 95% of the product running, and patch on a schedule your platform team sets rather than one panic sets.

Containment doesn't have to mean shutdown. It should mean removing the capability that's dangerous right now, and leaving everything that works alone.

The mitigations are temporary. Make them look temporary.

This is the part I'd most like people to take away, and it's the least discussed.

You set memory_limit=-1 on Friday. You're patched by Tuesday.

Is it still set?

Emergency mitigations have a way of becoming permanent configuration. They get applied under pressure, usually by whoever was on call, usually outside the normal review process. The ticket to revert them gets written at 3am by someone who then takes two days off.

Six months later nobody remembers why that value is there, and the safety ceiling it removed is still gone.

When a mitigation is a policy rather than a config change, two things get easier. It carries metadata (who turned it on, why, and when it should be reviewed) in the same artifact as the rule. And reverting it is the same cheap operation as applying it, which removes the main excuse for leaving it.

{
  "key": "cve-2026-111497-brotli-block",
  "meta": {
    "owner": "platform-oncall",
    "reason": "Node2Shell mitigation; remove once fleet is on 22.23.4+",
    "reviewBy": "2026-10-27"
  }
}
Enter fullscreen mode Exit fullscreen mode

That's not a clever technical feature. It's just writing down the expiry date at the moment you know it, instead of six months later when you don't.

What you'll want in the post-mortem

The week after, somebody is going to ask what was live when. If you're in a regulated sector, somebody external will ask, and "we believe uploads were disabled sometime Tuesday afternoon" isn't an answer.

The useful artifact is a record that says: this request was blocked, by this rule, in this policy, from this bundle version, at this timestamp.

Not a log line saying a request was rejected. An explanation of why it was rejected, and under what authority. Those are different questions, and the second one is what separates "our control worked" from "we got lucky."

That's a general property of pushing behavior into data. When the rules are a versioned, signed artifact, the history of what was in force is a byproduct instead of an archaeology project.

What this does not do

I'd rather state these than have them pointed out.

It would not have prevented either vulnerability. PHP2Shell executes before application code. Nothing in userland runs early enough to matter: not a framework, not a policy engine, not middleware. For Node2Shell, a direct zlib.brotliDecompress() call inside a dependency you don't control is equally invisible to a policy layer.

It doesn't tell you which runtime versions you're running. Both advisories stressed inventorying each runtime separately, because the PHP inside a container image is often not the PHP on the host, and bundled interpreters differ from the CLI one. That's an SBOM and asset inventory problem, and it was the genuinely hard part of October for a lot of teams. A policy engine has nothing useful to say about it.

It doesn't patch anything. The patched versions are PHP 8.2.35, 8.3.36, 8.4.27 and 8.5.12, and Node 22.23.4, 24.22.1 and 26.12.1. Everything above is about the window before you get there. It is not a substitute for getting there.

The actionable bit

The mitigations you need during a runtime CVE are mostly knowable before one happens. They're generic shapes: turn off uploads, reject an encoding, shrink the outbound allowlist, go read-only, kill one feature.

If those exist as rules you can flip, hour one is an operational decision.

If they don't, hour one is a design session followed by a deploy, run by tired people, through the pipeline that's already busy.

Write them before you need them. That's the whole recommendation, and it's worth doing whether or not you use a policy engine to do it.


I work on Govplane, an open-source runtime policy engine. It's MIT licensed, the bundle format is published, and it runs locally with no account. The code samples above use its Node SDK.

Further reading:

Tenable's php2shell FAQ

Aikido - Node2Shell: Node.js Brotli vulnerability allows remote code execution (CVE-2026-111497)

Brandefense - Node2Shell: Node.js Brotli RCE (CVE-2026-111497), Fixed Versions and How to Check

Node.js October 2026 security releases.

Top comments (0)