---
title: "#115 - Julien Bisconti on Node2Shell, PHP and Patch Panic"
date: 2026-10-26
episode: 115
---


Has the fix reached production, does the container need a shell, and who can reach the service? Julien Bisconti joins a discussion that turns those checks into practical priorities for the next vulnerability response.

[Discuss the episode or ask us anything on LinkedIn](https://www.linkedin.com/company/devsecops-talks/)

<!--more-->

{{< whats-in-this-post >}}

<!-- Player -->

 {{<  podbean wqi8b-1b922bf-pb "DEVSECOPS Talks #115 - Julien Bisconti on Node2Shell, PHP and Patch Panic"  >}}

---

<!-- Video -->

## The distance between a merged fix and a patched server {#problem}

The Dependabot pull request is merged. Main is green. The vulnerable runtime is still answering requests, because nothing has redeployed since Tuesday. That distance, between a fix existing in your repository and the same fix running in production, is the number that decides how a bad week goes for you. If you haven't measured yours, a week like this one measures it for you.

Between Saturday 17 October and Wednesday 21 October 2026, a lot of teams found out. Four emergencies landed in five days, on software you probably run. Eight eMudhra certificate authority intermediates turned out to have recoverable private keys. GitLab patched an unauthenticated remote code execution flaw rated CVSS 10.0 reaching back to version 7.0. PHP shipped emergency releases for two unauthenticated RCE bugs that hit every installation serving web requests. Node.js shipped ten CVEs, one an out-of-bounds write in its bundled Brotli decoder. For a few days the internet was on fire.

A week like that tests three decisions you made months ago: how long a fix takes to travel from an advisory to the running process, what an attacker can do in the ten minutes after the first bug lands, and who can reach the service at all. Julien Bisconti, a site reliability engineer working independently from Sweden, a Google Developer Expert for Google Cloud and the co-host of this show through its first 68 episodes, works this problem from the Kubernetes and Google Cloud side.

## Patching main is not patching production {#patch-to-production}

Start with the only part of this you fully control, which is the distance named above.

#### How fast does a fix have to reach production?

Fast enough to be measured in hours, because that's the window the exploit writers are working in.

Node.js published the Brotli fix on 21 October. Within a day, Imperva's researchers reported [running code inside a fresh container built on the official Node.js 24.22.0 image](https://www.imperva.com/blog/node2shell-from-brotli-data-to-remote-code-execution/). Their chain was configuration specific, ran on Linux arm64 and took several attempts, so read it as proof that exploitation is reachable rather than a one-shot that works everywhere. It still arrived in a day.

The fixes land on three release lines, as 22.23.4, 24.22.1 and 26.12.1. A line that has left support gets nothing at all. [Node.js says it plainly](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases): "End-of-Life versions are always affected when a security release occurs." On 20.x or earlier, your patch is a runtime migration, which is a sprint rather than an afternoon.

GitLab is the sharper example, because one patch wasn't enough. The 12 October releases, 19.4.2, 19.3.5 and 19.2.8, closed an integer overflow in the CI/CD regular expression compiler. Nine days later a second overflow in the same engine, [CVE-2026-110264](https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-1-released/), arrived at CVSS 10.0, unauthenticated, affecting every version from 7.0 onwards. The October 12 builds don't carry that fix; the seven that do run from 18.11.13 to 19.5.1. Self-managed admins had to upgrade twice in nine days. On a monthly change window, you did neither.

{{< key-point >}}There's always going to be a next exploit coming. "The speed of you finding it and pushing to production is going to be the key here."{{< /key-point >}}

This is the oldest thing we talk about on this show: build the delivery pipeline, with tests you trust, so a one-line dependency bump reaches production the hour it merges. We've been doing continuous delivery for years, and the payoff on a week like this one is the Wednesday afternoon you don't spend improvising.

Imperva makes the same point from the attacker's side: <mark>check the Node.js version inside each deployed image</mark>, because package inventories miss the runtime bundled in the base layer.

## Decide what the internet is allowed to reach {#exposure}

Speed closes the window. The next question is how much sits behind the window while it's open.

#### Should a self-hosted GitLab answer on the public internet?

Not unless people outside your company need to log in to it, and for most self-managed instances they don't.

GitLab.com was [already running the patched version](https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-1-released/) when CVE-2026-110264 went public, and Dedicated tenants too, which is what you pay a managed service for. The exposed population was self-managed installs, with no workaround published and an upgrade as the only answer. GitLab's own wording is not hedged: "we strongly recommend that all self-managed GitLab installations be upgraded to one of these versions immediately."

A private instance is still an instance you upgrade today.

The box deserves that care because of what is inside it. A Git host with a package registry, a container registry, a CI runner fleet and the credentials your pipelines use is a rich collection of tooling to live off. Remote code execution there is not one service compromised.

{{< key-point >}}If nobody outside your company needs to log in to it, it doesn't need a public IP address. That shrinks the set of people who can reach the bug while you ship the fix.{{< /key-point >}}

Keeping access without keeping exposure is a solved problem now. WireGuard based mesh networks, Tailscale and NetBird among them, build a network on top of the one you have, with per-device identity and revocation instead of a shared secret to rotate. The cost is another control plane joining your attack surface. Against a public port on an unpatched Git server, we'd take that trade.

The blunt version is turning the thing off. For a brochure site or an internal tool, an hour of downtime is cheaper than a web shell, and that week [Canada's Cyber Centre](https://www.cyber.gc.ca/en/alerts-advisories/al26-026-critical-vulnerabilities-impacting-php-cve-2026-109618-cve-2026-109619) and other agencies issued PHP alerts while [operators pulled sites offline](https://therecord.media/php-script-vulnerabilities-warnings) rather than serve them unpatched.

## A WAF only sees the traffic that passes through it {#waf}

Taking a service down is not always on the table, which is where the appliance in front of it earns its keep.

#### Can a WAF hold the line until you patch?

Only for attacks that arrive on the inbound path it inspects, and even there it is a stopgap, not a fix.

The PHP bugs arrive in web requests, so a WAF or CDN gets to look at them. Node.js is messier, because the Brotli decoder can be reached more than one way. The project describes the flaw as reachable by "an attacker who can supply Brotli data that an application decompresses with `node:zlib`". [Imperva's reproduction](https://www.imperva.com/blog/node2shell-from-brotli-data-to-remote-code-execution/) fills that in: a bare Node.js HTTP server "does not decode incoming Brotli bodies simply by receiving them", Express 5 and body-parser 2 can when their parsers are configured for it, and the service Imperva exploited "consumed an external Brotli response with Node.js's built-in `fetch()`".

Sit with that last path. The malicious bytes come back from a server you called: your inbound WAF never sees them, your CDN never sees them, and no rule about request bodies touches them.

Where a WAF is in the path, it buys room rather than safety. Imperva shipped rules for the attempts it reproduced and calls that virtual patching, with "Complete remediation requires updating Node.js".

[PHP's own advisory](https://www.php.net/archive/2026.php#2026-10-21-5) is blunter about its mitigations, and two of the three are for the gap before the patch only. Holding `post_max_size` down to 8M costs you large uploads. Pinning `memory_limit=-1` through `php_admin_value`, so applications can't override it, takes PHP's own memory ceiling away, so the advisory wants that ceiling put back outside PHP while the setting is on, as "a container or cgroup limit as an availability boundary, preferably with a policy that kills only the affected worker". Not `RLIMIT_AS` or `RLIMIT_DATA`, which can cause the allocation failure that reaches the vulnerable path. Return both settings to your normal values once the patched build is serving traffic.

The mitigation that stays is `session.upload_progress.enabled=0`, and where you set it decides whether it works. PHP reads the setting before it parses the request, so it belongs in `php.ini`, an FPM pool file or the server configuration, the same `php_admin_value` route as above. An `ini_set()` call runs long after the upload was parsed, and an FPM `.user.ini` does too. Restart every worker once it is set, patch or no patch. On 23 October PHP flagged [a third RCE vector](https://www.php.net/archive/2026.php), CVE-2026-115560, reachable only through a session configuration the project calls unlikely, and asks you to keep the setting off until the next regular bugfix release. Node.js published no workaround at all.

## Does the container need a shell? {#no-shell}

The other way to spend an attacker's time is to make the step after the bug harder, which is a build decision.

#### Does removing the shell stop remote code execution?

No. It removes the cheapest next move after it.

The nickname Node2Shell describes the goal rather than the bug. [CVE-2026-111497](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) is an out-of-bounds write in the Brotli decoder bundled with Node.js, rated High by the project rather than Critical, and the interesting part is what an attacker does next. The standard move is to spawn a shell and call home. In an image with no shell, no `curl` and no `wget`, that move fails.

It fails, and the attacker is still inside your process. The Node interpreter is right there, and anyone determined enough to land the first bug can write the fetch they wanted in JavaScript instead. The same holds for PHP. So the claim stays narrow: a minimal image breaks payloads that need a shell and the follow-up commands that assume one, and leaves everything inside the interpreter untouched. The patch is just as urgent as it was.

{{< key-point >}}A shell-less container doesn't fix the vulnerability. It removes the payloads that need a shell, and an attacker who can't shell out will probably go and find an easier target.{{< /key-point >}}

The build checklist is worth repeating anyway. A Go binary can be statically linked and dropped into a `scratch` image, with no package manager, no shell and nothing to live off. We keep [example images](https://github.com/fivexl/secure-container-image) in the open, and we haven't updated them in a while: the Node.js one still builds on `Dockerfile.base-nodejs20-distroless-debian-11`, and the 20 line gets no fix. Take the pattern, replace the base image with a current one first. Vendors sell hardened base images if you'd rather buy the problem away. What drags a shell back in is usually the health check, because runtimes want one and the obvious implementation is `curl`. That's why we built [`lprobe`](https://github.com/fivexl/lprobe), a local health-check binary that keeps a general-purpose HTTP client out of the image. Pointing at localhost is not the same as being unable to leave: its HTTP check follows redirects like any Go client, so enforce the boundary on the network.

Then close the way out. A reverse shell needs an outbound connection to the attacker's machine, so egress filtering breaks the step after the step. A managed container platform, ECS Fargate or Cloud Run, also means you don't own the VM underneath, which is one fewer layer to monitor for the escape.

## The PHP engines nobody owns {#php}

Minimal images and egress rules assume somebody is tending the deployment. The week's largest population is the one where that assumption fails.

#### Why did two PHP bugs put so much of the web at risk?

Because the flaw is in the engine rather than in your application, and if nobody owns the engine, the security update can sit unapplied.

[CVE-2026-109618 and CVE-2026-109619](https://www.php.net/archive/2026.php#2026-10-21-5) are unauthenticated remote code execution in PHP itself, so they land on anything serving web requests: PHP-FPM, Apache with `mod_php`, php-cgi, FrankenPHP, LiteSpeed. The project rates the risk highest for `mod_php`, where a demonstrated exploit triggers before any application code runs. Your WordPress version is irrelevant. The engine is what's vulnerable, and it comes from an operating system package that on a five-page business site may not have been updated since it was built.

The scale is the argument. W3Techs' [PHP survey of 24 October 2026](https://w3techs.com/technologies/details/pl-php) puts PHP on 69.3% of the sites whose server-side language it can identify, and 26.8% of those PHP sites on a 7.x release. Its [WordPress survey](https://w3techs.com/technologies/details/cm-wordpress) puts WordPress alone on 39.6% of all websites. Upstream fixed four branches, so the minimum safe build is 8.5.12, 8.4.27, 8.3.36 or 8.2.35, depending which branch you're on. Older branches get nothing from php.net beyond minimal source backports for 7.4, 8.0 and 8.1, which you apply by compiling PHP yourself. That is not happening on a restaurant site.

{{< key-point >}}When the leak happens, "it's not only you, it's also your loved ones, your local business", because the newsletter list on your hobby site is somebody's real contact details.{{< /key-point >}}

Checking the version is harder than it looks. Upstream numbers are the wrong comparison for a distribution package, because distributions and LTS vendors got the patches in advance and ship backports that keep the old version string; read your vendor's advisory instead. And `php -v` answers for the command-line SAPI, [as the manual's own example shows](https://www.php.net/commandline.options), while the build serving requests is a different one. `php-fpm -v` reports the binary on disk, the one your next restart will pick up, not the workers still running. So read the installed package, restart the pool, then confirm with a temporary page that echoes `PHP_VERSION` and nothing else, restricted to your own address and deleted once read. Not `phpinfo()`: [the list of what it prints](https://www.php.net/manual/en/function.phpinfo.php) runs from environment variables through HTTP headers to all EGPCS data, which is a configuration dump on a URL a stranger can find.

## Static files and server-side rendering {#rebuild}

PHP is fun to start with, it's how a lot of us learned the web, and "it works on a shared host" built an enormous amount of what you browse. We'd still steer you off it, because a dynamic language gives you no guarantee that the data moving through your own code has the shape you expected, and a type checker settles that more cheaply than a gazillion tests. Forced to pick a dynamic stack today, we'd take TypeScript, with the caveat that static types stop at the compiler: [TypeScript erases them](https://www.typescriptlang.org/docs/handbook/typescript-from-scratch.html), so "the resulting plain JS code has no type information". Types catch your own mistakes. A request body from a stranger is still whatever the sender made it, so untrusted input needs validation at the boundary, at runtime, in any language. None of that helps an unattended deployment, which makes the useful question what you rebuild an unowned site as.

#### What should a small PHP site become?

Static files if it's a brochure, server-rendered HTML if it needs logic, and as little JavaScript as the job allows.

For a one-page site with a contact form, there's no reason for a server-side interpreter to exist: hand the source to an AI assistant, have it rebuilt as plain HTML and CSS, and host the result as static files. The form still has to post somewhere, so point it at a hosted form service or a small serverless endpoint and keep that one component patched. What you removed is the PHP engine on your own host, not every attack surface the site has. An application with customer data gets treated as one instead: supported runtime, automated deploys, an owner. That case keeps growing, because when a small business asks an AI tool for a website, the tool picks the stack and the person paying can't judge the answer.

For anything with logic, server-side rendering is close to what PHP did: the server does the work and the response is HTML. The alternative ships JavaScript that renders the page in the browser, and on a slow connection the user pays for every megabyte. Rendering on the server feels snappier, and the less JavaScript you ship to the browser, the fewer places untrusted data meets the DOM. Plenty of what used to need a framework is in browsers now.

The objection is server cost, and for most applications it doesn't bite: we've seen whole teams build a dashboard for five account managers. Server-side rendering doesn't make cross-site scripting impossible either, so keep your output encoding.

## Your runtime trusts a different list than your browser {#ca-bundles}

One incident that week didn't need any of your code to be vulnerable. Browser protection doesn't reach the services that still trust the affected certificates.

#### Who has to act when a certificate authority's keys leak?

Browser vendors moved within hours, with one gap on mobile. Every CA bundle on your own machines is your problem.

[Mozilla's bug](https://bugzilla.mozilla.org/show_bug.cgi?id=2083449) has the facts. Eight eMudhra intermediate CA certificates were issued with P-384 private keys that were not random: "The 8 impacted keys all used integers less than 256", so each key is recoverable from the public certificate in seconds. Mozilla recovered them itself on 17 October and distrusted them in Firefox that evening. eMudhra revoked the eight next morning, blaming a key generation script that picked a weaker test key pair. Nobody has independently verified that account.

That block went out through Firefox's remote blocklist, and [some Firefox for Android profiles never applied it](https://bugzilla.mozilla.org/show_bug.cgi?id=2083449#c29) before the 158.0.2 app update. Check that version on managed phones.

Traffic stays encrypted either way, and encryption was never the point: a trusted key plus control of the DNS answer is a man-in-the-middle session with a padlock on it.

[Google reports](https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/) that no TLS certificates chaining to the affected intermediates were logged to Certificate Transparency before it blocked them, so "no fraudulent certificates issued by these CAs could have been used against CT-enforcing clients", Chrome among them. Clients that enforce neither CT nor a current revocation list got protection from neither side. That is a lot of what you run.

{{< key-point >}}Browser vendors pushed this block in hours, and some Firefox for Android profiles still needed an app update to get it. Your Go binaries, Python images and curl calls update when someone updates them.{{< /key-point >}}

Start with the OS trust store in each image. Turning the build cache off re-runs your own instructions but [does not pull a fresh base image](https://docs.docker.com/build/building/best-practices/), so a bundle inherited from the base comes back unchanged. Pull the base again or pin a current digest, refresh the certificate package, then read the bundle out of the built image, not off the Dockerfile.

Then chase the bundles that ride inside tools. `certifi` removed the affected emSign roots in `2026.10.18` and restored the unaffected ones in `2026.10.19`, so take the later one. [Python's incident guidance](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576) adds that pip loads its own vendored copy, which a `certifi` upgrade does not reach. Update pip to 26.2.2 and uv to 0.13.4, in "all existing virtual environments on your system".

If you hold a certificate from that hierarchy, replace it now: Google expects to drop the parent root on 27 October.

## What this means for teams {#what-this-means}

Nothing this week was novel. A CA with bad entropy, a regex compiler with an integer overflow, a decompressor with an out-of-bounds write: old shapes, and AI-assisted bug hunting will keep finding more. Planning for fewer vulnerabilities is not a plan.

What you can change is three numbers: hours from advisory to deployed, useful next moves an attacker finds inside the process, and strangers who can reach the service at all.

Four things worth doing this week:

- **Time both clocks, and don't confuse them.** Push a one-line dependency bump and measure merge to running in production: that's your deployment delay, the part you own end to end. Then measure this week's incidents from advisory to patched build serving traffic, which also counts noticing and triaging. That second number is your exposure window.
- **Check the runtime that answers requests, not the lockfile.** Find every image on a supported Node.js line below 22.23.4, 24.22.1 or 26.12.1, and every one on a line that gets no fix at all. For PHP, compare `php-fpm -v` to the minimum for your branch, 8.2.35, 8.3.36, 8.4.27 or 8.5.12 upstream, or to your vendor's advisory for a distribution package. Then restart the pools and read the version back from a temporary, access-restricted `PHP_VERSION` page you delete afterwards, because until that restart the running workers are still the old build.
- **Take one internal service off the public internet.** The self-hosted Git server is the obvious first candidate. Put it behind a VPN or a mesh network, and upgrade it today regardless.
- **Delete the shell from one image.** Start with the service most exposed to untrusted input, replace the `curl` health check with a local probe, and add an egress rule so a reverse shell has nowhere to dial.

And subscribe to a threat feed that moves faster than a weekly podcast.

## Common questions, answered {#faq}

### How do I patch PHP if I'm stuck on 7.4 or 8.1? {#faq-old-php}

Install your distribution's or LTS vendor's updated package first: they got the patches in advance, and the backport keeps the old version string. If no vendor build exists for your branch, the PHP security team published minimal source backports for 7.4, 8.0 and 8.1, which you compile yourself. While you wait, set `session.upload_progress.enabled=0` in `php.ini`, an FPM pool file or the server configuration, not through `ini_set()` or a `.user.ini`, and restart every worker. `php -v` answers for the command-line build, so check the installed web build with `php-fpm -v`, restart every worker again after the upgrade, then read the version back from a temporary page that echoes `PHP_VERSION` alone, locked to your own address and deleted afterwards. Don't use `phpinfo()` for that: it hands your configuration and environment to whoever loads the page. If neither path is open today, take the site offline until one is.

### Do I need to update Node.js if my app never touches Brotli? {#faq-node-brotli}

Update anyway, to 22.23.4, 24.22.1 or 26.12.1. The decoder is reachable from more paths than it looks: a body parser configured for Brotli bodies, an outbound `fetch()` to a server you don't control, or a `node:zlib` call on attacker-supplied data. A line that has left support gets no fix at all, so there the update is a runtime migration.

### How do I know whether my servers still trust the compromised eMudhra certificates? {#faq-ca-bundle}

Update every CA bundle you ship, then verify on the host. Chrome and desktop Firefox blocked those intermediates within hours, so a clean browser says nothing about your servers. The copies that matter are the OS trust store in each image, `certifi` at 2026.10.19 or later, pip 26.2.2 and uv 0.13.4 in every virtual environment, and any bundle an application pins itself. Rebuild from a freshly pulled base image, then redeploy. Update Firefox for Android to 158.0.2 or later, which fixes profiles that never applied the blocklist.

## Related episodes {#related-episodes}

- [Episode 114, Fridge Hacks and Security by Obscurity](/episodes/114/) is last week's exposure question, with management interfaces on public IP addresses.
- [Episode 108, Assume You're Vulnerable: Security Beyond Patching](/episodes/108/) is what you build when patching alone can't keep up.
- [Episode 102, The 90-Day Patch Window Is Dead](/episodes/102/) covers how fast exploitation follows disclosure, with Ian Amit and Matias Madou.
- [Episode 109, Docker Got Quiet. Did You Miss Anything?](/episodes/109/) is the build-side detail behind minimal images.

## Resources {#resources}

- [Node.js October 2026 Security Releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases) settles the High rating, the `node:zlib` path, the three fixed lines, the end-of-life position and the absence of a workaround.
- [PHP's critical RCE advisory, 21 October 2026](https://www.php.net/archive/2026.php#2026-10-21-5) settles which SAPIs are at risk, the fixed release per branch, which mitigations come off after patching, which one stays and where it has to be set, and the advice to check your distribution vendor. The 23 October session note is on the same page.
- [GitLab patch release 19.5.1](https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-1-released/) settles CVE-2026-110264's CVSS 10.0 score, the range from 7.0 onwards, the seven fixed versions and the immediate-upgrade advice.
- [Mozilla bug 2083449, eMudhra private key compromise](https://bugzilla.mozilla.org/show_bug.cgi?id=2083449) is the primary record: the "integers less than 256" finding, the timeline, and the Firefox for Android profiles that needed 158.0.2.
- [Google on the compromised eMudhra intermediates](https://blog.google/security/protecting-chrome-and-android-users-from-compromised-emudhra-intermediate-ca-certificates/) settles the Certificate Transparency position and the planned root removal.
- [Information for Python users regarding the eMudhra incident](https://discuss.python.org/t/information-for-python-users-regarding-emudhra-emsign-ca-certificate-security-incident/109576) settles which Python tools carry their own trust store and the versions to update to.
- [Imperva, Node2Shell: From Brotli Data to Remote Code Execution](https://www.imperva.com/blog/node2shell-from-brotli-data-to-remote-code-execution/) settles which paths reach the decoder, the conditions their exploit needed, and that WAF rules are virtual patching rather than remediation.
- [Julien Bisconti's site](https://bisconti.cloud/) collects the Google Cloud, Kubernetes and site reliability work behind his read on this week.
