
Detecting and Virtually Patching WordPress CVE-2026-87902 with a Node.js Triage Script
Why a Node.js triage script for WordPress CVE-2026-87902
Four write-ups from 2026-09-24 described the same shape: a critical WordPress RCE tracked as CVE-2026-87902, a patch, and attackers using the flaw within hours of the fix going public. The Hacker News piece landed at 05:36 UTC, TechNadu at 11:15 UTC, tech-insider.org at 18:08 UTC, HotHardware at 18:50 UTC. If that timeline holds, the operational fact outlives the vulnerability. On a CMS deployed this widely, the patch window is measured in hours now, not weeks.
I am not going to re-report the story with more adjectives. This post is one Node.js file you can point at a fleet the moment a WordPress advisory drops, before anyone knows which endpoint is affected: unauthenticated fingerprinting, evidence records you can paste into a ticket, a virtual-patch template you fill in once the advisory names the parameter, and the webshell-hunting queries that tell you whether the patch is closing a door someone already walked through.
What the reporting confirms about CVE-2026-87902 and what it leaves open
The four reports agree on a small set of facts. I want those kept separate from everything I say later.
| Claim | Status |
|---|---|
| CVE-2026-87902 is a critical remote code execution issue in WordPress | Stated by all four 2026-09-24 reports |
| A patch was released | Stated by all four reports |
| Exploitation began within hours of the fix | Stated by all four reports |
| Affected component (core, plugin, theme) | Not confirmed in anything I could read |
| Affected version range | Not confirmed |
| Authentication required? | Not confirmed |
| Request shape / parameter | Not confirmed |
I could not verify the vulnerability details myself. No advisory text, no diff, no proof of concept was in front of me when I wrote the script, and I will not invent a version range or a parameter name to make the post feel complete. That constraint shaped the design: the script has to produce useful output when the only confirmed fact is that a critical patched RCE exists.
Why hours-long exploitation changes the WordPress response order
Mass patch-and-pray assumes you can log into every install, that each one updates without breaking a client's custom theme, and that auto-updates never fail silently on hosts blocking outbound HTTP. For 50 sites, that is days you do not have.
The four responses available in the first 24 hours, ranked:
| Response | Time to effect | Coverage | Main failure mode |
|---|---|---|---|
| Patch everything | days for most fleets | removes the flaw where it lands | sites you cannot reach, breakage from untested updates |
| Virtual patch (proxy/WAF rule) | minutes | blocks the request shapes you wrote for | bypassed by encoding or an alternate route |
| Vendor WAF signature | hours to days | broad once it ships | lag; first-day rules are often wrong |
| Detection-only triage | minutes | inventory and evidence, no blocking | protects nothing by itself |
My position: in the first 24 hours, run rough virtual patching and triage in parallel, and treat full patching as the thing you finish rather than the thing you start. Rough means a rule that is slightly too broad and logs a lot, instead of a precise rule you cannot write yet. A noisy rule you can tune in an hour beats a clean one that arrives on day three.
Building a Node.js triage script for vulnerable WordPress installs
Scope, deliberately narrow:
- In scope: unauthenticated fingerprinting, version-signal collection, JSON evidence output, and a human summary.
- Out of scope: exploitation, authentication, brute force, and anything that writes to the target. It only issues GETs against a handful of public paths.
- Non-goal: replacing a scanner. It is a "what do I have, and which ones do I hit first" tool.
Node was the easy pick: it is already on most jump hosts and CI runners, there is no PHP toolchain to install, global fetch removes the dependency step, and JSON.stringify hands me a ticket-ready artifact for free. The flow is: read a target list, probe five paths concurrently with a bounded timeout, classify, emit one JSON record per target plus a summary table.
Fingerprinting WordPress installs with unauthenticated probes
Six signals do most of the work, and they disagree more often than people expect.
| Signal | Request | What it proves | Common false positive |
|---|---|---|---|
generator meta tag | GET / | core version, if the theme emits it | page cache serving stale HTML |
asset ?ver= | GET / | core version from wp-includes URLs | plugin/theme assets carry their own version |
| feed generator | GET /?feed=rss2 | core version from the core RSS generator | CDN rewrites or strips feeds |
| REST index | GET /wp-json/ | WordPress present, no version | other apps proxying /wp-json/ |
| login form | GET /wp-login.php | WordPress present | SPA catch-all returns 200 with index.html |
readme.html | GET /readme.html | hygiene signal, should be 404 | some hosts rewrite it regardless |
const PATHS = ["/", "/wp-login.php", "/wp-json/", "/?feed=rss2", "/readme.html"];
const TIMEOUT_MS = 8000;
async function probe(base, path) {
const res = await fetch(new URL(path, base), {
redirect: "manual",
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { "user-agent": "hackyjs-triage/0.3 (defensive inventory)" },
}).catch((err) => ({ status: 0, error: err.name }));
const body = res.status ? (await res.text()).slice(0, 200_000) : "";
return { path, status: res.status ?? 0, body };
}
function classify(pages) {
const home = pages["/"]?.body ?? "";
const feed = pages["/?feed=rss2"]?.body ?? "";
const rest = pages["/wp-json/"]?.body ?? "";
const login = pages["/wp-login.php"]?.body ?? "";
const versions = [];
const gen = home.match(/<meta name="generator" content="WordPress ([0-9.]+)"/i);
if (gen) versions.push({ source: "generator-meta", version: gen[1] });
const ver = home.match(/wp-includes\/[^"'?]+\?ver=([0-9.]+)/i);
if (ver) versions.push({ source: "asset-ver", version: ver[1] });
const feedGen = feed.match(/wordpress\.org\/\?v=([0-9.]+)/i);
if (feedGen) versions.push({ source: "feed-generator", version: feedGen[1] });
// Login page must contain the real form fields, not just a 200 status.
const isLogin = /name="log"/.test(login) && /name="pwd"/.test(login);
const isRest = /"namespaces"\s*:/.test(rest);
const isWp = Boolean(gen || ver || feedGen) || isLogin || isRest;
// When signals disagree, keep the oldest: assume the worst until proven otherwise.
const unique = [...new Set(versions.map((v) => v.version))].sort(compareVersions);
return {
isWordPress: isWp,
version: unique[0] ?? null,
versionSources: versions.map((v) => v.source),
confidence: unique.length === 1 ? "high" : unique.length > 1 ? "low" : isRest || isLogin ? "medium" : "unknown",
};
}Load balancers, full-page caches, and hardened installs strip different subsets of these. When a site returns no version anywhere, the correct output is "version": "unknown", not a guess derived from the theme's release year. I would rather chase ten unknowns manually than trust ten invented version numbers.
Capturing evidence you can paste into a ticket
Each target writes one record. This is the shape:
{
"url": "https://staging.lab.test",
"probedAt": "2026-09-25T07:41:13.201Z",
"isWordPress": true,
"version": "unknown",
"versionSources": [],
"confidence": "medium",
"http": { "/": 200, "/wp-login.php": 200, "/wp-json/": 200, "/?feed=rss2": 404, "/readme.html": 403 },
"notes": ["version hidden by cache and stripped headers; REST index and login form confirm WordPress"]
}
I ran it against a small lab list: two local containers, a staging install with version output deliberately stripped, and a static site I added as a negative control.
$ node triage.mjs targets.txt --out triage-2026-09-25T0741.json
probed 5 targets in 6.9s (concurrency 4)
TARGET WP VERSION SOURCE CONF NOTES
http://127.0.0.1:8080 yes 6.8.2 generator-meta,asset-ver high readme.html present (404 it)
http://127.0.0.1:8081 yes 6.7.4 feed-generator high generator meta stripped by theme
https://staging.lab.test yes unknown rest-index,wp-login medium version hidden by cache + stripped headers
https://static.lab.test no - no-wp-signals high wp-login.php 200 from SPA fallback (see note)
http://127.0.0.1:9999 - - - - connection refused
wrote 5 records -> triage-2026-09-25T0741.json
The false positive is static.lab.test, and it was my bug, not the site's. My first classifier matched wp-login.php in the URL string and treated a 200 as proof of WordPress. That host serves a single-page app, and the reverse proxy falls back to index.html for unknown paths, so every probe came back 200 text/html with the React root element in the body. The fix was to require the actual form fields — name="log" and name="pwd" — and to treat HTML with no wp- markers as a miss. One line change, and false positives on my negative controls dropped to zero. It is also why the fingerprint table spells out that login-form failure mode so precisely.
Virtually patching WordPress before the vendor signature lands
A precise block rule needs the endpoint and the parameter. No advisory I could read names them, so precise is not on the table yet. Anyone shipping a "CVE-2026-87902 rule" on day one either knows something the reports do not contain, or is guessing and calling the guess coverage.
So ship a template plus interim hardening. The template stays inert until you fill in the placeholders:
## Interim hardening. These are hygiene controls, not a fix for the flaw.
location ~* ^/wp-content/uploads/.*\.php$ { deny all; } # no PHP execution under uploads
location = /readme.html { return 404; }
location = /xmlrpc.php { deny all; } # only if you do not use it
location = /wp-config.php { deny all; }
## mod_security TEMPLATE - placeholder, not a confirmed vector.
## Pick an unused ID from your reserved range; 100087902 is an example only.
SecRule REQUEST_URI "@contains /wp-admin/REPLACE_WITH_ADVISORY_ENDPOINT" \
"id:100087902,phase:1,pass,log,msg:'interim watch for CVE-2026-87902 - template'"
Plus the cheap WordPress-level controls: set DISALLOW_FILE_EDIT and DISALLOW_FILE_MODS if your workflow allows, cap failed logins, restrict /wp-admin and /wp-login.php by source IP where the client has a stable one, and require MFA through a plugin you actually trust.
Be honest about what this buys. A virtual patch narrows the ways in; it does not close the flaw. An alternate route, a differently encoded request, or a second vulnerable endpoint you did not know about can all walk around it. It buys you the hours to apply the real patch. Nothing more.
Hunting for post-exploitation webshells after the exploit
Triage matters even on sites you patched on day zero, because the patch removes the door, not the intruder. If someone was already inside before you updated, you now own a current site with someone else's code on it.
Treat this as hypothesis testing, not a signature scan. The hypothesis: someone placed executable code or a persistent account on this host, and left something behind that outlives the vulnerable endpoint.
Filesystem indicators: webshells, drop-ins, and mu-plugins
Start with modification time, scoped to the window you care about. I dirtied a local container with two fake webshells so I could test the queries before running them anywhere real:
$ find /var/www/shop.lab.test -xdev -type f -name '*.php' \
-newermt '2026-09-01' -printf '%TY-%Tm-%Td %TH:%TM %10s %p\n' | sort
2026-09-02 09:14:22 1204 /var/www/shop.lab.test/wp-content/themes/twentytwentyfive/functions.php
2026-09-24 06:02:51 18942 /var/www/shop.lab.test/wp-content/uploads/2026/09/class-wp-cache.php
2026-09-24 06:03:07 417 /var/www/shop.lab.test/wp-content/mu-plugins/0-load.php
Two findings stand out, and neither would appear in a plugin list. A .php file under uploads/ is basically never legitimate on a correctly configured host. A file in mu-plugins/ loads on every request regardless of the active_plugins option, which makes it one of the best hiding places on a WordPress install — and wp plugin list will not show it.
Then check for obfuscation patterns:
$ grep -rIl --include='*.php' \
-E 'eval[[:space:]]*\([[:space:]]*(base64_decode|gzinflate|str_rot13)' \
/var/www/shop.lab.test/wp-content
/var/www/shop.lab.test/wp-content/uploads/2026/09/class-wp-cache.php
This catches the lazy version only. It misses hex-escaped calls like \x65\x76\x61\x6c(...), assert() callbacks, variable functions assembled from string concatenation, and anything hidden inside a serialized option value. I have run enough of these to say it plainly: a clean grep is one hypothesis tested, not proof of a clean host.
Account, option, and cron indicators
$ wp user list --role=administrator --fields=ID,user_login,user_registered,user_email --format=table
+----+------------+---------------------+--------------------------+
| ID | user_login | user_registered | user_email |
+----+------------+---------------------+--------------------------+
| 1 | admin | 2024-03-11 08:22:10 | [email protected] |
| 7 | wp_support | 2026-09-24 06:05:44 | [email protected] |
+----+------------+---------------------+--------------------------+
$ wp cron event list --fields=hook,next_run_relative,recurrence --format=csv
hook,next_run_relative,recurrence
wp_version_check,2 hours,1 hour
myplugin_daily,5 hours,24 hours
wp_remote_fetch,11 minutes,1 hour
A new administrator created four minutes after the dropped file. A cron hook called wp_remote_fetch that belongs to no installed plugin. Either one alone would be worth a look; together they read as a foothold.
| Check | False-positive rate | Look at first? |
|---|---|---|
| New admin user | low, if you keep an access register | yes — cheapest and loudest |
New mu-plugins / drop-ins | medium (hosts push cache/security drop-ins) | second |
active_plugins diff | medium (auto-updates change it constantly) | third |
| Unknown cron hooks | medium (plugin leftovers) | fourth |
| Theme file hash mismatches | high (legitimate customizations) | last |
Admin list first, every time. It takes a second, it is near-zero noise if you know who is supposed to have access, and an attacker who wants to come back needs either an account or a file. This catches the account half.
Contain, patch, rotate — order of operations
- Contain. Block the source range if you have it, or put the site behind a maintenance page if you do not. Snapshot the filesystem and database before touching anything — that snapshot is your only evidence if the client asks what happened.
- Patch. Apply the current WordPress release, then audit every plugin and theme for updates. Re-run the fingerprint script afterwards to confirm the version actually changed.
- Verify the foothold is gone. Re-run the filesystem, account, option, and cron checks. If the webshell is still there, step 4 does nothing for you.
- Rotate. Database user password, then
AUTH_KEY/SECURE_AUTH_KEY/LOGGED_IN_KEY/NONCE_SALTsalt values, then admin passwords, then API keys, deploy keys, and any SSH or hosting credentials tied to the host. - Review for persistence. Re-check the four indicator groups at 24 hours and one week. Attacks that survive cleanup usually do it through a second file you missed.
The sequence is the part people get wrong. Rotate admin passwords and API keys before removing the foothold and it backfires in two specific ways. A webshell reads wp-config.php and talks to the database directly, so password rotation does not evict it — the attacker re-creates the admin account using credentials they already hold. And rotating salts logs out every session, yours included, which destroys the session history you would have used to trace what they touched, all while the intruder still has a working entry point. Contain first. Rotate after. Early rotation feels productive and mostly costs you evidence.
What I confirmed, what I did not, and where the script fails
What I confirmed by running things: the fingerprinting probes behave as described against my lab containers and a deliberately version-stripped staging target; the SPA catch-all produced exactly the false positive I documented, and requiring the real login form fields removed it; the find, grep, wp user list, and wp cron event list queries returned the dirtied indicators I planted.
What I did not test: the vulnerability itself. I have no proof of concept, no advisory text, and no independent confirmation of the affected component, version range, authentication requirement, or request shape. Everything about attacker behavior and timing in this post comes from the four 2026-09-24 news reports, which I am treating as secondary coverage of a vendor advisory I have not read.
Known gaps in the script:
- Encoded or concatenated PHP callables defeat the
grepheuristic. A miss is not a clean bill of health. - Fingerprinting returns
unknownfor installs that strip generator tags, feeds, and asset versioning behind a cache or CDN. That is intended behavior, but it means an unknown result needs manual follow-up, not a pass. - It only issues unauthenticated GETs. If the vulnerable endpoint requires a session, it will never see it.
- It is not a substitute for patching. Virtual patching is a delay, and delay decays.
Further Reading
- The Hacker News — attackers exploit WordPress CVE-2026-87902 within hours of disclosure — news coverage
- HotHardware — hackers exploit critical WordPress flaw hours after patch release — news coverage
- TechNadu — WordPress CVE-2026-87902 under active attack — news coverage
- tech-insider.org — attackers exploit WordPress CVE-2026-87902 in hours — news coverage
- WordPress security release announcements — the primary channel for the vendor advisory; trust this over any of the news links above
- CVE-2026-87902 on CVE.org — check whether the record has resolved; if the page is empty, the authoritative details are not published yet


