Skip to content
Back to Articles

Dissecting WordPress 7.1.4: The Core Updater Race (CVE-2026-119977) in Our Lab

Share:

On October 28, WordPress shipped 7.1.4 with a fix for CVE-2026-119977, a race condition in the core updater that WordPress.org describes as "a weakness in the WordPress upgrade process which can lead to remote code execution during the upgrade window." On November 3 the US cybersecurity agency CISA added it to its catalog of actively exploited vulnerabilities. Three days later a report added an uncomfortable twist: the update that installs the fix is itself unpacked by the old, unpatched code.

We wanted to see that with our own eyes, so we did a full dissection. We downloaded the real 7.1.3 and 7.1.4 packages, diffed the updater line by line, rebuilt the update in an isolated lab, timed the window, and tested the web server rules that close it. This article walks through all of it. It is written for defenders: there is no exploit here, only the patch, the measurements and the commands to check your own sites.

Isolated lab, no internet, real WordPress 7.1.3 and 7.1.4 packages, 73 seconds. [fs] = what appears on disk, [web] = what the web server answers.

🧩 The short version

  • What: during a core update, WordPress unpacks the new release into wp-content/upgrade/, which sits inside the web root. For a few seconds, that staging folder exists on a path the web server will happily serve.
  • Affected: WordPress 4.7 through 7.1.3. Fixed: 7.1.4 and security releases on every branch back to 4.7 (list below).
  • Exploited: CISA added CVE-2026-119977 to its Known Exploited Vulnerabilities catalog on November 3, 2026. Wordfence, the CVE's assigning authority, reported blocked attack attempts within minutes of deploying its rule.
  • The twist: the update from 7.1.3 to 7.1.4 is staged by the 7.1.3 code. The new naming logic only protects the next update. Our lab confirms it.
  • What to do: block /wp-content/upgrade/ at the web server first, then update. Snippets for nginx and Apache below, both tested in the lab.

🔬 Dissection 1: where the update gets unpacked

All plugin, theme and core updates go through WP_Upgrader::unpack_package() in wp-admin/includes/class-wp-upgrader.php. Here is the relevant part in 7.1.3:

$upgrade_folder = $wp_filesystem->wp_content_dir() . 'upgrade/';

// Clean up contents of upgrade directory beforehand.
$upgrade_files = $wp_filesystem->dirlist( $upgrade_folder );
if ( ! empty( $upgrade_files ) ) {
	foreach ( $upgrade_files as $file ) {
		$wp_filesystem->delete( $upgrade_folder . $file['name'], true );
	}
}

// We need a working directory - strip off any .tmp or .zip suffixes.
$working_dir = $upgrade_folder . basename( basename( $package, '.tmp' ), '.zip' );

Three things stand out once you read it slowly:

  1. The folder lives in the web root. wp-content/upgrade/ is a normal directory under the document root. Nothing in WordPress tells the web server to stay out of it.
  2. The cleanup wipes everything, including an index.php. WordPress ships a "Silence is golden" index.php in wp-content/, plugins/ and themes/ so those folders can't be listed. upgrade/ never got one, and the cleanup loop would delete it anyway. On a server with directory listing on, /wp-content/upgrade/ shows its contents to anyone.
  3. The working directory name comes from the package name. When WordPress downloads an update, download_url() saves it through wp_tempnam(), which appends a 6-character random suffix. That is better than nothing, but it gives no protection when the folder can be listed. When the package is a local file (for example wp core update /path/wordpress-7.1.4.zip), there is no suffix at all: the folder is literally upgrade/wordpress-7.1.4.

Now the 7.1.4 version. First, a new helper:

/**
 * Builds an unpredictable name for the directory a package is unpacked into.
 *
 * @since 7.1.4
 */
private function get_working_dir_name( $package ) {
	// Strip off any .tmp or .zip suffixes.
	$name = basename( basename( $package, '.tmp' ), '.zip' );

	return $name . '-' . wp_generate_password( 16, false );
}

Then the unpack step itself:

// Clean up contents of upgrade directory beforehand, keeping its index.php.
$upgrade_files = $wp_filesystem->dirlist( $upgrade_folder );
if ( ! empty( $upgrade_files ) ) {
	foreach ( $upgrade_files as $file ) {
		if ( 'index.php' === $file['name'] ) {
			continue;
		}

		$wp_filesystem->delete( $upgrade_folder . $file['name'], true );
	}
}

/*
 * The upgrade directory is web-served. Keep it from being listed, as core does
 * for wp-content and its plugins and themes directories, so the name of the
 * working directory created below cannot be discovered from a directory index.
 */
if ( ! $wp_filesystem->is_dir( $upgrade_folder ) ) {
	$wp_filesystem->mkdir( $upgrade_folder, FS_CHMOD_DIR );
}
if ( ! $wp_filesystem->exists( $upgrade_folder . 'index.php' ) ) {
	$wp_filesystem->put_contents( $upgrade_folder . 'index.php', "<?php\n// Silence is golden.\n", FS_CHMOD_FILE );
}

// We need a working directory.
$working_dir = $upgrade_folder . $this->get_working_dir_name( $package );

Line by line, the patch does three things:

  • 16 random alphanumeric characters are appended to every working directory. wp_generate_password( 16, false ) draws from 62 characters, so that is 6216 (about 4.7 × 1028) possible names. Nobody guesses that within a few seconds.
  • The cleanup loop now skips index.php, so the guard survives every future update.
  • The guard is created if missing. With an index.php present, a request for /wp-content/upgrade/ runs that empty PHP file instead of producing a directory listing, so the random name stays secret.

The comment in the patch states the threat model in one sentence: the upgrade directory is web-served. The fix makes the staging folder impossible to find. It does not move the folder out of the web root. That is why the web server rule further down is still worth adding.

🔬 Dissection 2: a second lock in update-core.php

The other security-relevant change is one line in wp-admin/includes/update-core.php, in the list of paths that update_core() skips when it copies the new release over the site:

// 7.1.3
$skip = array( 'wp-content', 'wp-includes/version.php' );

// 7.1.4 - new comment above it: "Never copy a wp-config.php, which could have
// been written to the working directory."
$skip = array( 'wp-content', 'wp-includes/version.php', 'wp-config.php' );

An official WordPress package never contains a wp-config.php, so legitimate updates are unaffected. The change means that even if something unexpected ends up in the staging folder, the updater will never copy a configuration file from it into the site root. It is a defence-in-depth guard behind the first fix.

⏳ Dissection 3: why the fix runs on old code "one last time"

This is the part the November 6 report highlighted, and it follows directly from how Core_Upgrader::upgrade() in class-core-upgrader.php is ordered:

$working_dir = $this->unpack_package( $download );   // (1) runs the class already in memory = OLD code

// Copy update-core.php from the new version into place.
$wp_filesystem->copy( $working_dir . '/wordpress/wp-admin/includes/update-core.php',
                      $wp_dir . 'wp-admin/includes/update-core.php', true );   // (2)

require_once ABSPATH . 'wp-admin/includes/update-core.php';   // (3) NEW update_core()

$result = update_core( $working_dir, $wp_dir );               // (4) copies the release

Step (1) runs inside the PHP process that started the update. WP_Upgrader is the class from 7.1.3, already loaded in memory. So the 7.1.4 package is unpacked with the 7.1.3 logic: no index.php guard, and no 16-character name. Steps (2)–(4) then pull in the new update-core.php before any files are copied.

Two consequences follow, and both are visible in our lab:

  • The naming and listing fix (Dissection 1) protects the update after 7.1.4, not the one that installs it.
  • The wp-config.php skip (Dissection 2) is already active during the 7.1.4 update itself, because update_core() is loaded from the new file.

The same applies to every backport branch: the jump to the fixed release is the last one staged the old way. That is why the order matters: block the folder first, update second.

🧪 Our lab: timing the window

We reproduced the update end to end in an isolated environment:

  • Docker on an --internal network: no internet access, nothing published to the host.
  • Apache 2.4 (Debian) with PHP 8.3, MariaDB 11, WP-CLI 2.12.
  • The official packages from wordpress.org, verified against the published SHA-1 checksums: 7.1.3 = 13eb1d042b1ca39224e44906cd84fe203112210c, 7.1.4 = b552c39cd95d89895b4e7a55af53a03687aff4f6.
  • Updates run as the site user (www-data) with wp core update /tmp/wordpress-7.1.4.zip. That goes through the same Core_Upgrader code path as the dashboard button.
  • The official WordPress Docker image turns directory listing off (Options -Indexes). We switched it back on so the lab matches a stock Debian/Ubuntu apache2.conf, which ships with Options Indexes for /var/www/.

Two small helper scripts did the watching. watch-upgrade polls wp-content/upgrade/ every 5 ms and logs every entry that appears and disappears, with timestamps. probe-upgrade asks the web server, every 100 ms, whether a staging folder it sees on disk can be reached over HTTP. Here is what we measured:

  • Window length: the staging folder was on disk for 1.5 to 2.3 seconds across six runs (2259, 1530 and 1929 ms for 7.1.3 → 7.1.4, and 1729, 1484 and 1750 ms for 7.1.4 reinstalls). That is a small site with a local package on fast storage. Slow disks, big sites and shared hosting will stretch it, and every automatic background update opens it again.
  • Old code, predictable name: the 7.1.3 → 7.1.4 update was staged in wp-content/upgrade/wordpress-7.1.4, exactly as Dissection 3 predicts.
  • Listing: before the fix, GET /wp-content/upgrade/ returned 200 with Index of /wp-content/upgrade.
  • With the block rule: the folder and everything under it returned 403, including the staging folder during the update (the [web] line in the video). / and /wp-login.php kept returning 200, and the update still completed: the updater writes through the filesystem, not over HTTP.
  • New code: a forced reinstall of 7.1.4 (now running the patched updater) was staged as wordpress-7.1.4-Tk2y5tU0htCLr43r. A fresh 16-character suffix every run, and upgrade/index.php created and kept. GET /wp-content/upgrade/ now returned 200 with an empty body instead of a listing.

The two helpers are plain bash. They are useful if you want to watch your own staging copy during an update:

#!/bin/bash
# watch-upgrade: log every entry that appears in / disappears from wp-content/upgrade/
D=${D:-/var/www/html/wp-content/upgrade}; end=$((SECONDS+${1:-30}))
declare -A born
while [ $SECONDS -lt $end ]; do
  now=$(date +%s%3N); declare -A seen=()
  for e in $(ls -1 "$D" 2>/dev/null); do
    seen[$e]=1
    [ -z "${born[$e]}" ] && { born[$e]=$now; printf '[fs] %s  + %s\n' "$(date +%T.%3N)" "$e"; }
  done
  for e in "${!born[@]}"; do
    [ -z "${seen[$e]}" ] && { printf '[fs] %s  - %s  (on disk %d ms)\n' "$(date +%T.%3N)" "$e" $((now-born[$e])); unset "born[$e]"; }
  done
  unset seen; sleep 0.005
done
#!/bin/bash
# probe-upgrade: while a staging dir exists, ask the web server whether it can be reached
D=/var/www/html/wp-content/upgrade; end=$((SECONDS+${1:-30})); last=""
while [ $SECONDS -lt $end ]; do
  for e in $(ls -1 "$D" 2>/dev/null); do
    [ -d "$D/$e" ] || continue
    c=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost/wp-content/upgrade/$e/")
    [ "$e $c" != "$last" ] && { printf '[web] %s  GET /wp-content/upgrade/%s/ -> %s\n' "$(date +%T.%3N)" "$e" "$c"; last="$e $c"; }
  done
  sleep 0.1
done
# Usage on a STAGING copy, as root, with the site user running the update:
watch-upgrade 15 & probe-upgrade 15 & sleep 0.3
sudo -u www-data wp core update --path=/var/www/html; wait

🛡️ Close the window: block first, update second

Step 1: deny web access to the staging folder. WordPress never needs to serve anything from wp-content/upgrade/ over HTTP.

nginx, inside the site's server { } block, then nginx -t && systemctl reload nginx:

location ^~ /wp-content/upgrade/ {
    deny all;
}

The ^~ is not decoration. In our lab we tested the same rule without it. A plain prefix location /wp-content/upgrade/ returned 403 for the folder, but a request for a .php path underneath it was picked up by the site's location ~ \.php$ block instead and passed to the PHP handler (200). nginx checks regex locations after plain prefixes, and the regex wins. ^~ tells nginx to stop at this prefix, and with it every path under the folder returned 403.

Apache, in the vhost or at the top of the site's root .htaccess (outside the # BEGIN WordPress block, which WordPress rewrites):

# Block web access to the core updater staging area
RedirectMatch 403 ^/wp-content/upgrade/

Check it from outside. You want 403:

curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-content/upgrade/

Step 2: update. Install 7.1.4, or the security release for your branch. Check what you are actually running afterwards:

sudo -u SITEUSER wp core version --path=/path/to/site
grep "wp_version =" /path/to/site/wp-includes/version.php

Step 3: keep the rule. This is our own practice, not part of the advisory: we leave the block in place permanently. It costs nothing, and it covers any future updater bug of the same kind.

🔍 Read-only checks for sites that updated late

If a site sat on a vulnerable version after October 28, these checks are a good start. None of them change anything.

# 1) Is the staging folder listable or reachable from the web? (200 + "Index of" = listable)
curl -s https://example.com/wp-content/upgrade/ | grep -o '<title>.*</title>'
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/wp-content/upgrade/

# 2) Any web requests to the staging folder at all? Legitimate traffic has no reason to go there.
zgrep -h '/wp-content/upgrade/' /path/to/access.log* | awk '{print $1, $4, $6, $7, $9}' | sort | uniq -c | sort -rn | head -50

# 3) Anything left in the staging folder outside an update?
ls -la /path/to/site/wp-content/upgrade/
find /path/to/site/wp-content/upgrade/ -type f -printf '%TY-%Tm-%Td %TH:%TM %s %p\n' | sort

# 4) Core files modified? (needs outbound access to api.wordpress.org)
sudo -u SITEUSER wp core verify-checksums --path=/path/to/site

# 5) wp-config.php is NOT covered by core checksums: compare it with a known-good backup
#    and look at its change/birth time, not just mtime.
stat /path/to/site/wp-config.php
diff <(grep -v '^\s*$' /path/to/backup/wp-config.php) <(grep -v '^\s*$' /path/to/site/wp-config.php)

# 6) Administrators you don't recognise, newest first
sudo -u SITEUSER wp user list --role=administrator --fields=ID,user_login,user_email,user_registered --orderby=registered --order=DESC --path=/path/to/site

Any 200 response in step 2 to a path under /wp-content/upgrade/, from an IP that isn't yours, deserves a closer look at what changed on the site around that time.

🔢 Fixed versions

WordPress 7.1.4 was released on October 28, 2026. The fix was also shipped as security releases for older branches, back to 4.7, as listed in the GitHub advisory and the Rescana analysis:

  • 7.1.4 · 7.0.8 · 6.9.11 · 6.8.12 · 6.7.11 · 6.6.11 · 6.5.14 · 6.4.14 · 6.3.14 · 6.2.15 · 6.1.16 · 6.0.18
  • 5.9.20 · 5.8.19 · 5.7.21 · 5.6.23 · 5.5.24 · 5.4.25 · 5.3.27 · 5.2.30 · 5.1.28 · 5.0.31
  • 4.9.35 · 4.8.34 · 4.7.39
  • 4.6 and older: no fix. Those sites need a real upgrade, and the web server block is the only protection in the meantime.

The vulnerability was reported by Umut Bayram of Picus Security. 7.1.4 also contains a set of unrelated security fixes (KSES, REST comments, user and privacy screens, the bundled CA certificates and others), so it is worth installing even where the upgrade folder is already blocked.

⚖️ Where the sources disagree

  • Severity: Wordfence, the CNA, scores it CVSS 3.1 8.1 (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H). The "high complexity" reflects the timing requirement. WordPress.org labelled the issue "Low" in its own release post.
  • Weakness class: some write-ups use CWE-367 (time-of-check time-of-use), others CWE-362 (race condition). Both describe the same short window.
  • Attack volume: the numbers quoted depend on the time window. Rescana reports Wordfence blocking 18,500+ requests in the first 21 hours, with the first one 9 minutes after the rule went live. BleepingComputer cites 13,095 blocked in 24 hours over a weekend. The Hacker News cites 62,969 in 24 hours, a figure Rescana says it could not find in Wordfence's own post. In every count the activity is real and started within minutes.
  • Number of fixes in 7.1.4: one analysis counts 22 fixes across 17 advisories. The WordPress.org post lists 12 items plus the certificate bundle update.

💡 Takeaways

  • The core updater stages new code inside the web root. Up to 7.1.3, that staging folder was listable and, for local packages, predictably named.
  • 7.1.4 fixes it with a 16-character random name, a protected index.php, and a rule that never copies wp-config.php out of staging.
  • The update that installs 7.1.4 is unpacked by the old code. The new naming only protects the next update.
  • In our lab the window was 1.5–2.3 seconds per update. That is short, but it repeats on every update, and attack traffic began within minutes of disclosure.
  • Block /wp-content/upgrade/ at the web server before updating, and use ^~ on nginx. Then keep the rule.

❓ FAQ

Is updating to 7.1.4 enough?

For future updates, yes: from 7.1.4 on, the staging folder gets a random name and an index.php guard. The update to 7.1.4 is still staged by the old code, so add the web server block first, then update, then check the site with the read-only commands above if it stayed vulnerable for a while.

My site auto-updated already. Am I fine?

Check the version and run the read-only checks. Automatic background updates use the same updater, so the last vulnerable-to-fixed update was staged the old way on your site too. If your access logs show no requests to /wp-content/upgrade/, that is a good sign.

Will blocking /wp-content/upgrade/ break updates?

No. In our lab, updates completed normally with the block in place for both Apache and nginx. WordPress writes and reads that folder through the filesystem, never over HTTP.

Why does nginx need ^~?

Without it, a regex location such as location ~ \.php$ takes priority for .php paths, and those requests skip your deny rule. We confirmed this in the lab. ^~ makes the prefix match final.

📚 Sources

Dates, version lists and attack figures come from these sources as published between October 28 and November 6, 2026. The code dissection, the lab reproduction, the timings, the nginx ^~ test and the checks above are our own work.