FortWatch.ai

End-of-Life Software: How to Find the Unpatched Runtimes on Your Public Web

FortWatch

FortWatch Team

End-of-Life Software: How to Find the Unpatched Runtimes on Your Public Web

On 5 August we shipped a technology fingerprinting scanner and pointed it at our own estate before anyone else's. It came back with one finding, on one host, and it has re-raised the same finding on every scan for eighteen days straight: End-of-Life PHP 8.1 in use. The host was a staging box nobody had thought about in months. The evidence was a single response header the server volunteers to anyone who asks:

x-powered-by: PHP/8.1.33

PHP 8.1 stopped receiving security patches on 31 December 2025. Every vulnerability found in that branch from January onward stays unfixed forever. And the box announces which branch it is running, unprompted, in every single response.

That is the whole shape of the problem. End-of-life software is not a vulnerability you have to find by probing. It is a vulnerability you can read.

The short version:

  • End-of-life is a fact, not a judgement. A vendor either publishes an end-of-security-support date that has passed, or it doesn't. That distinction is what separates a real finding from "you're not on the latest version," which is not a security statement.
  • The version is usually public. Server, X-Powered-By, generator meta tags, session cookie names, and versioned asset filenames give the stack away in one GET. No scanning, no probing, nothing that shows up as an attack.
  • Staging is where it lives. Production gets upgraded because someone owns it. Staging, admin panels, old marketing microsites and the box behind that one client integration get left on whatever runtime they were provisioned with.
  • Most "tech detection" is wrong in ways that matter. Reading a version from the wrong place invents an EOL finding that doesn't exist — the WordPress ?ver= parameter is the classic trap.
  • Detecting a technology is not a finding. "This site runs nginx" is inventory. Only a runtime past its vendor's security-support date earns an issue.

A version number is not an implementation detail

There is a long-standing argument that hiding version banners is security through obscurity and therefore worthless. That argument is right about the wrong thing. Hiding the banner does not make you safe, and an attacker who wants your version can often infer it from behaviour anyway. But the banner is not what makes you unsafe either — the unpatched runtime is. The banner just changes the economics.

Think about it from the other side. An attacker with a list of ten thousand hosts and a working exploit for a PHP 8.1 bug does not want to fire it at ten thousand targets. That is loud, slow, and most of it fails. What they want is to filter the list down to the few hundred hosts where it will work — and a response header does that for free, at the speed of an HTTP request, with nothing that looks like an attack in your logs.

This is exactly the workflow that Shodan and its equivalents industrialise. The banner is not the vulnerability. The banner is the index.

What actually leaks a version

Five places, in rough order of how often they pay out. Our own fingerprint engine reads all five from a single 512KB response body:

SourceExampleVersion quality
Response headersServer: Apache/2.2.34, X-Powered-By: PHP/8.1.33, X-Generator, X-AspNet-VersionAuthoritative
Generator meta tag<meta name="generator" content="WordPress 6.4.2">Authoritative
Versioned asset filenames/js/jquery-3.4.1.min.js, /vendor/bootstrap-4.6.0.cssUsually reliable
Cookie namesPHPSESSID, JSESSIONID, laravel_session, csrftokenTechnology only, no version
Build-artifact paths/_next/static/, /wp-content/, cdn.shopify.comTechnology only, no version

The bottom two rows are the important ones to understand, because they are where careless tools go wrong. A PHPSESSID cookie proves the site runs PHP. It says nothing whatsoever about which PHP. Any tool that reports a version from a signal that cannot carry one is guessing, and a guessed version in an EOL check produces a fabricated vulnerability.

End-of-life is not the same as outdated

This is the distinction that decides whether a technology scanner is useful or just noisy, and it is worth being precise about.

Outdated means a newer release exists. That is true of almost everything, almost always, and it is not a security statement. Plenty of well-run infrastructure deliberately sits on an older stable branch that is still fully patched.

End-of-life means the vendor has published a date, that date has passed, and no security patches are issued for that branch at all. The consequence is not "you are missing some fixes." The consequence is that the set of known-unpatched vulnerabilities on that host only ever grows, and there is no upgrade path within the branch that closes them.

Our EOL table only admits an entry when three things are true: the vendor publishes an explicit end-of-security-support date, we can read the branch confidently from an HTTP response, and end-of-life genuinely means no patches rather than "an older stable branch." That bar excludes some things you might expect to see.

nginx is deliberately absent. nginx has no vendor EOL policy — old stable branches are superseded, not formally retired. An "outdated nginx" rule would be our judgement call dressed up as a fact. Specific CVEs in specific nginx builds are a different job, handled through version-to-CVE lookup rather than an EOL table.

WordPress core is absent for the same reason. Old WordPress majors still receive backported security releases. "Not on the latest major" is not "unpatched."

The PHP calendar, as of today

PHP publishes its dates further ahead than most runtimes, which makes it a good worked example. Each branch gets roughly two years of active support and two more of security-only fixes:

BranchSecurity support endsStatus today
8.0 and earlier26 Nov 2023 or earlierEnd of life
8.131 Dec 2025End of life
8.231 Dec 2026Security fixes only
8.331 Dec 2027Security fixes only
8.431 Dec 2028Actively supported
8.531 Dec 2029Actively supported

Note what this means for anyone still on 8.1: you are eight months past the cliff, and 8.2 — the smallest possible upgrade — buys you four months before it goes over the same edge. The correct move is almost never the minimum viable jump.

What this found on our own estate

We run FortWatch against our own infrastructure before we ask anyone else to run it against theirs, so these are real numbers rather than an illustration. Across the assets we monitor, the fingerprint layer identified a stack on 25 of them. The spread:

TechnologyAssets
Cloudflare14
PHP · Laravel · nginx · jQuery7 each
Apache HTTP Server3
Node.js3
Express2
React · Next.js · Caddy · Bootstrap1 each

Out of all of that, exactly one finding was raised: the EOL PHP 8.1 staging host. Twenty-five assets fingerprinted, one issue. That ratio is the point. A scanner that turned every one of those detections into an issue would have generated dozens of tickets and taught everyone to ignore the queue — which is the real cost of a noisy rule, and why severity has to come from impact rather than from how easy something was to detect.

The other thing worth noting: it was staging. Not the product, not the marketing site, not anything with a person's name against it. Staging boxes, forgotten admin panels and one-off microsites are where EOL runtimes accumulate, for the same reason exposed .env files and dangling DNS records accumulate there. Nobody owns them, so nobody upgrades them — and being non-production does not make a host non-reachable.

The ways version detection goes wrong

Building this taught us that the difficulty is not detection. It is not detecting things. Three rules do most of the work.

Never match a bare product name in HTML

The words "React", "Django" and "WordPress" appear in blog copy, CSS class names and marketing text on plenty of sites that do not run them. A security blog that writes about WordPress is not running WordPress. Every pattern has to anchor on something a site only emits when it genuinely runs the technology: a header the server sets, a cookie it issues, a build-artifact path, an explicit generator tag.

The ?ver= trap

This one is worth knowing about even if you never build a scanner, because it is the single most common source of fabricated version findings.

WordPress and Drupal cache-bust their assets with a ?ver= query parameter. For core-registered libraries, that value really is the library version:

/wp-includes/js/jquery/jquery.min.js?ver=3.6.0   → jQuery 3.6.0 ✓

But for theme and plugin bundles, the same parameter carries the theme's version:

/wp-content/themes/aurora/js/bundle.js?ver=1.0.2 → NOT jQuery 1.0.2 ✗

Read that second one naively and you report jQuery 1.0.2 on a site running 3.6.0 — a decade-old version that trips every vulnerability rule you own. You have just invented a critical finding out of a theme's version number. Our engine only reads ?ver= when the filename is literally jquery.js or jquery.min.js.

Only high-confidence versions raise findings

We grade every detection. High confidence means the signal is effectively unforgeable by accident — a server header, a generator meta tag, a framework cookie, a versioned filename. Medium means a strong heuristic, like a build-output path or a vendor CDN host. Medium-confidence detections enrich the asset inventory and never raise a finding, because a wrong version does not produce a slightly-wrong finding. It produces a completely fictional one.

Where a concrete CVE rather than a vendor date defines the boundary, the reasoning is different again. jQuery below 3.5.0 is the standing example: CVE-2020-11022 and CVE-2020-11023 mean that HTML from an untrusted source, even after sanitising, can execute when passed to .html(), .append() or similar. That is a version floor, not an end-of-life date, and it deserves its own finding text rather than being bolted onto the EOL rule.

Check your own stack in five minutes

You do not need a scanner to do the first pass. Point these at any host you own.

What the headers give away:

curl -sI https://example.com | grep -iE 'server|x-powered-by|x-generator|x-aspnet'

Whether there is a generator tag in the HTML:

curl -s https://example.com | grep -oE '<meta name="generator"[^>]*>'

Which JS libraries are loaded, with versions where they are in the filename:

curl -s https://example.com | grep -oE 'src="[^"]*\.js[^"]*"' | sort -u

Then take every version you find and check it against the vendor's own support page rather than a third-party summary — php.net/supported-versions, endoflife.date for a fast cross-reference across most products, and the vendor's security advisories for anything that matters.

Run it against staging and the forgotten hosts too, not just the site you think of as "the website." That is where the answer will be.

What do I do with this?

  • Inventory first, versions second. You cannot check the runtime on a host you have forgotten you own. If your asset list is stale, that is the actual first task.
  • Run the three curl commands above against every public host, including staging, admin panels and microsites. Write down every version you get back.
  • Check each version against the vendor's published end-of-security-support date. Past that date is a real finding. "Newer version exists" is not.
  • Upgrade past the cliff, not to the edge of it. Moving PHP 8.1 to 8.2 buys four months. Moving to 8.4 buys over two years.
  • Where an upgrade is genuinely blocked by application compatibility, treat it as a tracked risk with a date on it: put the service behind a WAF, restrict what can reach it, and schedule the migration. An EOL runtime does not hold steady — its list of unpatched vulnerabilities grows every month.
  • Decide deliberately about version banners. Suppressing X-Powered-By and trimming Server costs nothing and removes you from the cheap filtered lists. Just do not mistake it for having fixed anything.
  • Re-check on a schedule, not once. Support dates pass while your configuration sits still. A runtime that was supported when you deployed it becomes a finding on a date the vendor picked years ago.

FortWatch fingerprints the stack on every asset you monitor and raises an issue only when a runtime is genuinely past its vendor's security-support date — no ticket for "you run nginx." If you want to see what your own external surface is currently advertising, our free tools will show you the first pass, and continuous scanning keeps checking after the support dates move underneath you.

Share this post
Get started

Ready to secure your infrastructure?

Try for free — scan your entire attack surface in under 5 minutes. No credit card required.

  • No credit card required

  • 14-Day free trial

Ready to secure your stack?

Secure your entire stack today

Start scanning in under 5 minutes. No credit card required. 14-day free trial included.