CISA Lists the WordPress Update-Window RCE as Exploited Six Days After the Patch, but 497 of the 502 Partial Update Packages for the Fix Omit the Installer the Attack Targets
CISA added CVE-2026-119977 to its exploited-vulnerabilities catalog on November 3, six days after WordPress 7.1.4; Wordfence reported attack attempts on October 29, no public page names a compromised...
CISA added CVE-2026-119977, the race condition in WordPress core’s update routine that 7.1.4 fixed, to its Known Exploited Vulnerabilities catalog on Tuesday, November 3, six days after the fix, and gave federal agencies until Friday, November 6. CISA has not said what it saw. The attack data we could find on public pages is Wordfence’s: its firewall logged the first matching request nine minutes after it deployed a rule on October 28, counted more than 18,500 requests for staged installer paths in the first 21 hours, and showed 15,404 blocked attacks over the past 24 hours when we read its advisory page on November 4. None of that is a report of a compromised site. We also looked inside WordPress’s update packages. The installer those requests are aimed at is missing from 497 of the 502 partial packages that WordPress.org offers for the fix, and it is present in the no-content package, one of the fallbacks WordPress uses when no partial package applies.
Table Of Content
This follows our October 28 report on the release, which ended with four open questions: severity and identifiers, exploitation, reach, and backport timing. All four now have answers, set out below. We did not run or test any attack, and we did not open, run or link any repository that claims to hold exploit code.
What CISA added and what the record now says
The catalog entry is named “WordPress WordPress Core Time-of-check Time-of-use (TOCTOU) Race Condition Vulnerability.” It carries CWE-367, a due date of November 6, the forensic triage flag set to Yes, and ransomware use marked Unknown. Its description says the flaw “could allow unauthenticated attackers to execute code on the server during the upgrade window,” because of “predictable, web-accessible staging paths used during full-package core upgrades.” The CVE record also holds CISA’s own SSVC data: exploitation active, automatable no, technical impact total. By the Table 1 reading in our Ivanti report, total control is what earns the three-day deadline and the triage flag.
It is the fourth WordPress core entry in the catalog this year. Two went in on July 21, four days after 7.0.2 shipped on July 17 (our report), and one on September 25, three days after 7.1.2 shipped on September 22 (our report). This one took six days.
| Open on October 28 | Answer on November 4 |
|---|---|
| Severity and identifier | CVE-2026-119977, assigned by Wordfence: reserved October 30 at 17:17 UTC and published October 31 at 05:33 UTC, 2 days 13 hours after the 7.1.4 zip appeared. Wordfence scores it CVSS 3.1 8.1 (network, high complexity, no privileges, no interaction, high impact on confidentiality, integrity and availability). NVD shows that score as a secondary one and none of its own. |
| Exploitation | Wordfence reported attack attempts on October 29, and CISA listed active exploitation on November 3. Neither names a victim. |
| Reach | The record lists 25 affected version ranges, 4.7.0 through 7.1.3, and 25 fixed versions: 7.1.4 and 24 backports. WordPress.org’s status list marks 7.1.3 and 6.5.13 insecure, 6.5.14 outdated and 7.1.4 latest. |
| Backport timing | All 19 older-branch zips appeared on October 28 between 20:43 and 21:51 UTC. The first, 6.5.14, came 4 hours 26 minutes after the 7.1.4 zip and the last, 4.7.39, 5 hours 33 minutes after. On October 6 the same two milestones took 2 hours 8 minutes and 3 hours 25 minutes. We read the times from the Last-Modified headers on the download server. |
Timeline, in UTC
| When | What |
|---|---|
| Oct 28, 16:17 | The 7.1.4 zip appears on the download server |
| Oct 28, 17:13 | WordPress publishes the 7.1.4 release post |
| Oct 28, 20:30 | Wordfence deploys a blocking rule for its paying customers |
| Oct 28, about 20:39 | The first request matches that rule, nine minutes later |
| Oct 28, 20:43 to 21:51 | The 19 older-branch zips appear, 6.5.14 first and 4.7.39 last |
| Oct 29, 21:58 | Wordfence publishes its 7.1.4 post, headlined “Exploitation Attempts Detected After Release” |
| Oct 30, 17:17 | Wordfence reserves CVE-2026-119977 |
| Oct 31, 05:33 | The CVE record is published; NVD’s copy is stamped 06:16 |
| Nov 3 | CISA adds the flaw to the catalog; the feed release is stamped 20:00 |
| Nov 4, 04:15, 10:12 and 18:16 | Three GitHub repositories with the CVE ID in their names are created |
| Nov 4, 04:56 | CISA’s SSVC and KEV data are added to the CVE record |
| Nov 4, about 19:10 | We read 15,404 blocked attacks in 24 hours on Wordfence’s advisory page |
What the exploitation evidence is, and is not
Wordfence’s October 29 post is the earliest public report of attack traffic that we found. It says its Threat Response Team deployed a blocking rule on October 28 at 20:30 UTC and then reports: “The first match for our new firewall rule arrived nine minutes after deployment. During the first 21 hours, we recorded more than 18,500 requests targeting staged setup-config.php installer paths.” It adds: “Two of the largest sources repeatedly polled small groups of sites for roughly 14.5 hours, behavior consistent with trying to catch the brief upgrade window.” It also says “Not every request used the exact vulnerable path.”
| Figure | Where it appears | What it counts |
|---|---|---|
| More than 18,500 in the first 21 hours | Wordfence blog, October 29 | Requests targeting staged installer paths |
| 13,095 in 24 hours | BleepingComputer, November 4, quoting an advisory update “over the weekend” | Blocked “attacks targeting this vulnerability” |
| 15,404 in 24 hours | Wordfence advisory page, read by us at about 19:10 UTC on November 4 | Blocked “attacks targeting this vulnerability” |
These are firewall counts, and four limits apply. They count requests, not sites or sources, and the page does not define “attack.” The rule reached only paying customers first: “Wordfence Free users will receive the same rule after the standard 30-day delay, on November 27, 2026.” The rule can only fire where the firewall runs before the requested file. Wordfence says “This exploit directly requests a staged PHP file without first loading WordPress” and tells users to verify Extended Protection, and its documentation says that in the default mode “The plugin will load as a regular plugin after WordPress loads” and that “attackers can access some plugin, core, or theme files directly, and in that case, your server will not load the firewall to protect you.” On our reading, the counts therefore come from sites running Extended Protection, a subset of the sites that run Wordfence. And a blocked request did not succeed, so the counts say nothing about requests that reached sites without that protection.
Patchstack described the same gap from the other side on October 28: “A PHP firewall cannot block it.” Its reason was that the site’s firewall never runs for the request, and Wordfence’s answer is a firewall configured to run before PHP files do. Patchstack also scored the attack complexity high and wrote that it “would not call it practical to exploit.” Wordfence says “This narrow window makes a one-off request unlikely to succeed, but repeated automated polling can improve an attacker’s chances.” CISA’s listing and a high-complexity score do not conflict: complexity describes how hard one attempt is, not whether anyone is trying.
No public page we read names a compromised site. BleepingComputer notes that “CISA didn’t provide more details on attackers exploiting CVE-2026-119977 in the wild,” Wordfence describes attempts, and the CVE record holds no victim data. GitHub’s repository search for the CVE ID returned three repositories at about 19:15 UTC on November 4, created at 04:15, 10:12 and 18:16 UTC that day. One describes itself as a detection-only local triage scanner; the other two have no description, and one has POC in its name. We did not open, run or link any of them, and a repository name is not evidence that its code works.
Which update packages carry the installer
Wordfence’s write-up says the attacker “needs a full-package update containing the installer files.” WordPress does not unpack the same package for every update, so we read the code that chooses, in the 7.1.3 tag, and then looked inside the packages.
The choice is made in Core_Upgrader::upgrade (lines 96 to 120). It takes the partial package only when the update offer lists one, the request is not a re-install, the site’s installed version equals the offer’s partial_version, and, if the pre_check_md5 option is on, every core file outside wp-content matches WordPress.org’s checksums. A code comment gives the order: “If partial update is returned from the API, use that, unless we’re doing a reinstallation.” Failing that, WordPress takes the new_bundled package when the update crosses the version that introduced new bundled items, then no_content, then full. The option defaults to on and the dashboard’s update call does not change it (lines 902 to 908). The background updater turns it off, with the comment “Always use partial builds if possible for core updates” (lines 478 to 489).
The packages differ in what they stage. The partial package that a 7.1.3 site is offered is 0.7 MB and holds 50 files, including update-core.php and class-wp-upgrader.php, but not wp-admin/setup-config.php, the installer. The no-content package is 23.7 MB, holds 3,338 files and includes it. To see what a given version is offered, run this script; it needs only Python’s standard library.
# which_package.py
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://api.wordpress.org/core/version-check/1.7/?version={version}"
with urllib.request.urlopen(url) as response:
offers = json.load(response)["offers"]
for offer in offers:
partial = offer["packages"]["partial"]
print(
f"{offer['response']:<10} {offer['current']:<7} partial_version={offer['partial_version']!s:<6}",
"partial package:",
partial if partial else "none (no_content or full package is used)",
)
For python which_package.py 7.1.3 the API returned, on November 4:
upgrade 7.1.4 partial_version=7.1.3 partial package: https://downloads.wordpress.org/release/wordpress-7.1.4-partial-3.zip
autoupdate 7.1.4 partial_version=7.1.3 partial package: https://downloads.w.org/release/wordpress-7.1.4-partial-3.zip
The offer names a partial package for the fix. The second script downloads the 7.1.4 no-content package, then asks the update check for the partial package it offers each release below the fix on the 25 patched branches, and looks for the installer in each.
# installer_scan.py
import io
import json
import re
import urllib.request
import zipfile
from concurrent.futures import ThreadPoolExecutor
FIXED = ["4.7.39", "4.8.34", "4.9.35", "5.0.31", "5.1.28", "5.2.30", "5.3.27", "5.4.25", "5.5.24", "5.6.23", "5.7.21",
"5.8.19", "5.9.20", "6.0.18", "6.1.16", "6.2.15", "6.3.14", "6.4.14", "6.5.14", "6.6.11", "6.7.11", "6.8.12",
"6.9.11", "7.0.8", "7.1.4"]
BRANCH_FIX = {".".join(v.split(".")[:2]): v for v in FIXED}
INSTALLER = "wp-admin/setup-config.php"
def fetch(url):
with urllib.request.urlopen(url, timeout=120) as response:
return response.read()
def branch_of(version):
return ".".join(version.split(".")[:2])
def sort_key(version):
return tuple(int(part) for part in re.findall(r"\d+", version))
def file_names(zip_bytes):
return [n for n in zipfile.ZipFile(io.BytesIO(zip_bytes)).namelist() if not n.endswith("/")]
# 1. The package a site gets when WordPress cannot use a partial one.
data = fetch("https://downloads.wordpress.org/release/wordpress-7.1.4-no-content.zip")
names = file_names(data)
print(f"no_content package for 7.1.4: {len(data) / 1e6:.1f} MB, {len(names)} files, installer included:",
any(n.endswith(INSTALLER) for n in names))
# 2. The partial package offered to every release below the fix on a patched branch.
statuses = json.loads(fetch("https://api.wordpress.org/core/stable-check/1.0/"))
releases = [v for v in statuses
if re.fullmatch(r"\d+\.\d+(\.\d+)?", v) and branch_of(v) in BRANCH_FIX and v != BRANCH_FIX[branch_of(v)]]
def scan(version):
fix = BRANCH_FIX[branch_of(version)]
offers = json.loads(fetch(f"https://api.wordpress.org/core/version-check/1.7/?version={version}"))["offers"]
url = next((o["packages"]["partial"] for o in offers if o["current"] == fix and o["response"] == "autoupdate"), None)
if not url:
return version, None, None
names = file_names(fetch(url.replace("downloads.w.org", "downloads.wordpress.org")))
return version, len(names), any(n.endswith(INSTALLER) for n in names)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(scan, releases))
scanned = [r for r in results if r[1] is not None]
with_installer = sorted((r[0] for r in scanned if r[2]), key=sort_key)
print(f"releases below the fix on the 25 patched branches: {len(results)}")
print(f" offered a partial package for the fix: {len(scanned)}")
print(f" partial packages with the installer: {len(with_installer)} ({', '.join(with_installer)})")
print(f" files per partial package: {min(r[1] for r in scanned)} to {max(r[1] for r in scanned)}")
It makes about 500 small downloads and took us under a minute. The output on November 4:
no_content package for 7.1.4: 23.7 MB, 3338 files, installer included: True
releases below the fix on the 25 patched branches: 502
offered a partial package for the fix: 502
partial packages with the installer: 5 (4.8, 4.8.1, 5.2, 5.2.1, 5.2.2)
files per partial package: 14 to 542
WordPress.org offers a partial package for the same-branch fix to every one of the 502 releases we checked, and only five of them contain the installer: 4.8 and 4.8.1, and 5.2 through 5.2.2. The other 497 do not. Putting that together with the code gives this table.
| Update | Package WordPress takes | Installer staged? |
|---|---|---|
| Background update to the fix, from any release on the branch | Partial, built for the starting version | No, except when the site starts from 4.8, 4.8.1, 5.2, 5.2.1 or 5.2.2 |
| Dashboard update to the fix, core files match the official checksums | Partial | Same |
| Dashboard update to the fix, any core file modified or missing | No-content | Yes |
| Re-install of the current version | No-content, because a re-install never uses the partial | Yes |
| Update that crosses branches, for example 6.5.13 to 7.1.4, where the offer lists no partial | No-content, or new-bundled when the update crosses the version that added new bundled items | Yes |
Two cautions. First, a partial package still unpacks its changed files, 14 to 542 of them across the packages we checked, into the same staging folder, and none of Wordfence, Patchstack or WordPress says whether any of them can be abused; Wordfence’s description names the installer. Second, the update that installs the fix unpacks with the old code. Core_Upgrader calls unpack_package from the class that is already loaded and only then copies update-core.php from the new package into place and runs it (lines 156 to 178, where the comment reads “Copy update-core.php from the new version into place.”). So the random folder name and the index.php guard take effect from the next update, while the rule that never copies wp-config.php applies at once. That is our reading of the code, which bears out the inference in our October 28 report; neither WordPress nor Wordfence says it.
If the reading holds, a site on 7.1.3 that updated by itself on October 28 or 29 did not stage an installer during that update, and the exposure sits with the other paths in the table. An attacker has no obvious way to tell from outside which site is about to take one of them, which fits Wordfence’s account of sources that “repeatedly polled small groups of sites for roughly 14.5 hours.” It does not make an unpatched site safe: the next dashboard update on a site with a modified core file, or a re-install, stages the installer.
Who is still exposed
- Patched: sites on 7.1.4 or one of the 24 backports. WordPress’s release post says “If you have sites that support automatic background updates, the update process will begin automatically.”
- Exposed at the next installer-bearing update: sites still on an affected version that update with modified core files, re-install, or cross branches. Add the web server rule below before any such update.
- No fix: WordPress 4.6 and earlier, about 1 percent of the sites that report their version on November 4. The CVE record’s range starts at 4.7, and Patchstack says the sink “dates back to WordPress 2.8”; neither says whether 4.6 and earlier can be attacked.
- Backported branches: 4.7 through 6.5 hold 11.9 percent of reporting sites, against about 12 percent on October 28. The statistics are by branch, so the share still on an affected point release is not public.
- Wordfence Free: the rule arrives November 27. Wordfence’s own advice is “Do not rely on the firewall instead of patching.”
What to do now
- Confirm the version. Check Dashboard, then Updates, or run the first command below. Install 7.1.4, or the fix for your branch: 7.0.8, 6.9.11, 6.8.12, 6.7.11, 6.6.11, 6.5.14, and the older fixes down to 4.7.39. We took the WP-CLI syntax from its documentation and did not run it.
wp core version
wp core check-update
- Block the staging folder at the web server, before you update. Patchstack recommends denying access to
wp-content/upgrade/and notes that “Between updates it holds nothing but an index.php.” The nginx rule passed our test on nginx 1.31.8 in the October 28 report; we did not run the Apache line. If WordPress lives in a subdirectory, add it to the path.
location ^~ /wp-content/upgrade/ {
return 403;
}
RedirectMatch 403 "^/wp-content/upgrade/"
- Look for requests into the folder. Normal visitors and administrators have no reason to request that path. We ran these two commands against the seven-line log below, saved as
access.log, which we made up in the combined format, with one 403, two 404s and one 200 among its four staging requests. The first counts every request by status, and the second lists any that got a 200. Usezgrepfor rotated logs; the patterns also match a WordPress install in a subdirectory.
192.0.2.10 - - [03/Nov/2026:08:00:01 +0000] "GET / HTTP/1.1" 200 5123 "-" "Mozilla/5.0"
198.51.100.7 - - [03/Nov/2026:08:00:02 +0000] "GET /wp-content/upgrade/ HTTP/1.1" 403 153 "-" "curl/8.21.0"
198.51.100.7 - - [03/Nov/2026:08:00:03 +0000] "GET /wp-content/upgrade/example-folder/index.php HTTP/1.1" 404 153 "-" "curl/8.21.0"
192.0.2.11 - - [03/Nov/2026:08:00:04 +0000] "GET /wp-login.php HTTP/1.1" 200 2210 "-" "Mozilla/5.0"
203.0.113.9 - - [03/Nov/2026:08:00:05 +0000] "POST /wp-content/upgrade/example-folder/wp-admin/page.php HTTP/1.1" 200 842 "-" "python-requests/2.34.2"
203.0.113.9 - - [03/Nov/2026:08:00:06 +0000] "GET /wp-content/themes/twentytwenty/style.css HTTP/1.1" 200 9120 "-" "Mozilla/5.0"
198.51.100.7 - - [03/Nov/2026:08:00:07 +0000] "GET /wp-content/upgrade/other-folder/ HTTP/1.1" 404 153 "-" "curl/8.21.0"
grep -h '/wp-content/upgrade/' access.log | awk '{print $9}' | sort | uniq -c
awk '$7 ~ /\/wp-content\/upgrade\// && $9 == 200 {print $1, $4, $5, $6, $7, $9}' access.log
1 200
1 403
2 404
203.0.113.9 [03/Nov/2026:08:00:05 +0000] "POST /wp-content/upgrade/example-folder/wp-admin/page.php 200
- Check the folder and the config. Between updates the folder should hold only
index.php. Compare the modified time ofwp-config.phpwith the last time you edited it on purpose, and look, as Wordfence advises, for “unfamiliar administrator accounts, unexpected plugins, or PHP files you did not place there.” - Federal agencies and anyone following their lead: the due date is November 6 and the triage flag is set. In our first Ivanti report we quoted CISA’s BOD 26-04 implementation guidance telling agencies to “not alter or remediate systems prior to evidence/artifact collection when possible,” and noting that patching “may jeopardize the availability of artifacts.” Here the evidence is mostly files that persist, so copy the web server logs, the contents of
wp-content/upgrade/andwp-config.phpbefore you update, and store the copies securely, sincewp-config.phpholds database credentials.
What is still unknown
- CISA’s evidence. The listing does not say what CISA saw, and no public page names a site that was compromised.
- The partial path. Whether anything staged by a partial package can be abused, by any route, is not addressed in public sources.
- The repositories. We did not look at the three that carry the CVE ID, so we cannot say whether any holds working code.
- How many sites take installer-bearing updates. The statistics are by branch, not by point release or update path.
- Basic-mode firewalls. Firewalls that load as plugins do not see requests that reach a staged file directly, so sites in that mode contribute nothing to the counts above.
Related coverage and sources
Our earlier reports: the 7.1.4 release, 7.1.2, 7.0.2, and the Ivanti reports on the first zero-day and the two that followed.
Sources: CISA KEV entry and KEV feed; CVE record and NVD record; Wordfence advisory; Wordfence, “WordPress 7.1.4 Patches Unauthenticated Upgrade-Window RCE”; Wordfence firewall optimization documentation; BleepingComputer; Patchstack, WordPress 7.1.4 Security Release; WordPress 7.1.4 release post; HelpHub, Version 7.1.4; WordPress.org update-check API, version status list, version statistics, the 7.1.3 to 7.1.4 partial package and the 7.1.4 no-content package; wordpress-develop 7.1.3 source for class-core-upgrader.php, update-core.php and class-wp-automatic-updater.php; WP-CLI core version and core check-update.








No Comment! Be the first one.