top of page

WordPress CVE-2026-119977 Exploited: Hunt the Upgrade Race

Writer: Inception Security
Inception Security
13 hours ago
13 min read
Glitch races a red installer key out of an open upgrade folder while Inceptor blocks with a shield and flashlight (WordPress upgrade-window RCE hunt comic)

What this means for your clients: while a WordPress site unpacks a full core update, installer files can appear under a public staging path long enough for an unauthenticated request to finish a fresh install and leave a webshell.


For a few seconds during a full-package core update, WordPress unpacks new files into a predictable folder under wp-content/upgrade. That staging path can sit on the same web root visitors hit. If PHP can run there, an unauthenticated probe for setup-config.php can land while the files are still in place.


WordPress CVE-2026-119977 is the name for that race. GitHub advisory GHSA-4w3m-c66w-69mg rates it Low. NVD lists CVSS 8.1 High from the Wordfence metric. CISA added it to the KEV catalog on November 3, 2026, with a federal due date of November 6, 2026. Low reflects how hard it is to win a short time-of-check to time-of-use (TOCTOU) window. The 8.1 figure reflects full remote code execution if someone does win. KEV is the exploitation signal for prioritization, not a third severity score.


How the upgrade window opens


On October 28, 2026 WordPress shipped 7.1.4 as a security release. The advisory from Umut Bayram (Picus Security, per BleepingComputer) describes a complete-package update that extracted into a guessable directory inside the web-served upgrade folder. Winning that race yields remote code execution. Affected releases run from 4.7.0 through 7.1.3; the fix was backported to every maintained branch.


Wordfence reports that probes began the same day as disclosure. Its WAF blocked over 240,000 attempts, with a peak over 74,000 on October 30. Observed requests go to installer files under the staging path, for example /wp-content/upgrade/wordpress-VERSION/setup-config.php. Wordfence also says blocked attempts are not compromises, and that real exploitation is hard because the window is short. Say both when you brief a client. Compromised counts and who is spraying remain unknown.


Who is exposed


Marketing sites, agency fleets and hosted blogs sit in the blast radius. A win can mean a new administrator, a webshell under wp-content, defacement, SEO spam, and a path into database credentials in wp-config. Mass scanners poll the staging path while auto updates or a manual core update is running. We covered a related upload path in our Bricksforge GIF-to-PHP upload hunt. Other 7.1.4 fixes include several stored XSS issues.


How to mitigate


Patched builds on the GitHub advisory, by branch: 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, and 4.7.39.


Confirm the running build with wp core version and wp core verify-checksums via wp-cli, then move every site onto a patched release. Before you update from a still-vulnerable build, deny PHP execution under /wp-content/upgrade/ at Apache, Nginx or IIS. Updating from a vulnerable build runs the vulnerable unpack one last time, so the patch alone does not protect the very update that installs it.


Keep auto updates on. Add or confirm a WAF rule that flags .php requests under that upgrade folder. Use a least-privilege database user, turn on file integrity monitoring for wp-content, and audit administrator accounts after every core update. Defender for Endpoint on Linux and Microsoft Sentinel web logs help only if WordPress hosts are actually onboarded.


Hunts


These hunts treat web access logs and Linux endpoint telemetry as leads, not proof. Validate field names in your Splunk, Elastic, CrowdStrike Falcon or Microsoft KQL tenant before you trust an empty result. Every query floors at October 28, 2026 00:00 UTC.


Test query


Confirm web access logs for WordPress hosts reach the SIEM with URI, status code and source IP.


Splunk


(index=web OR index=proxy OR index=wordpress) (sourcetype=access_combined OR sourcetype=nginx:plus:access OR sourcetype=iis) earliest=1793145600
| eval uri=coalesce(uri_path, uri, cs_uri_stem, url, "-"), status=coalesce(status, sc_status, http_status, "-"), sip=coalesce(clientip, src_ip, src, c_ip, "-"), host_name=coalesce(host, dest, "-")
| where uri!="-"
| fillnull value="-" uri status sip host_name
| stats count as events by host_name status sip
| head 50

Elastic


FROM logs-nginx.access-*, logs-apache.access-*, logs-*, filebeat-*
| WHERE @timestamp >= "2026-10-28T00:00:00Z"
| EVAL uri = COALESCE(TO_STRING(url.path), TO_STRING(url.original), "-"), status = COALESCE(TO_STRING(http.response.status_code), "-"), sip = COALESCE(TO_STRING(source.ip), "-"), host_name = COALESCE(TO_STRING(host.name), "-")
| WHERE uri != "-"
| STATS events = COUNT(*) BY host_name, status, sip
| LIMIT 50

CrowdStrike


// Assumes WordPress or WAF access logs ingested into Falcon LogScale (Next-Gen SIEM); fields url.path, http.response.status_code, source.ip, host.name are assumed and unverified until run against your tenant
*
| cutoff := 1793145600000  // 2026-10-28 00:00 UTC
| test(@timestamp >= cutoff)
| default(field=[url.path, http.response.status_code, source.ip, host.name, ComputerName], value="-", replaceEmpty=true)
| url.path!="-"
| case {
    ComputerName!="-" | host_name := ComputerName ;
    * | host_name := host.name }
| groupBy([host_name, http.response.status_code, source.ip], function=count(as=events), limit=max)
| head(50)

Microsoft KQL


union isfuzzy=true
    (W3CIISLog
        | where TimeGenerated >= datetime(2026-10-28)
        | extend uri = tostring(csUriStem), status = tostring(scStatus), sip = tostring(cIP), host_name = tostring(Computer)),
    (Syslog
        | where TimeGenerated >= datetime(2026-10-28)
        | extend uri = extract(@" (/[^ ]+)", 1, SyslogMessage), status = extract(@'" ([0-9]{3}) ', 1, SyslogMessage), sip = extract(@"([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", 1, SyslogMessage), host_name = Computer)
| where isnotempty(uri)
| summarize events = count() by host_name, status, sip
| take 50

Needs web access logs (Apache, Nginx, IIS/W3CIISLog, or WAF) with URI, status and source IP. Elastic uses ECS url.path or url.original, http.response.status_code, source.ip and host.name from nginx/apache or filebeat indices. CrowdStrike hunts 0 to 3 (the Hunt 3 web arm) assume Falcon LogScale web or WAF ingest (assumed, unverified). Cover October 28, 2026 00:00 UTC.


Hunt 1: PHP under /wp-content/upgrade/


This hunt returns requests to a .php file under /wp-content/upgrade/, including setup-config.php, with status and source IP since October 28, 2026. Prioritize HTTP 200 leads.


Splunk


(index=web OR index=proxy OR index=wordpress) (sourcetype=access_combined OR sourcetype=nginx:plus:access OR sourcetype=iis) earliest=1793145600
| eval uri=coalesce(uri_path, uri, cs_uri_stem, url, "-"), status=coalesce(status, sc_status, http_status, "-"), sip=coalesce(clientip, src_ip, src, c_ip, "-"), host_name=coalesce(host, dest, "-")
| where match(lower(uri), "/wp-content/upgrade/.*\.php")
| fillnull value="-" uri status sip host_name
| stats count as events min(_time) as first_seen values(status) as statuses by host_name sip uri

Elastic


FROM logs-nginx.access-*, logs-apache.access-*, logs-*, filebeat-*
| WHERE @timestamp >= "2026-10-28T00:00:00Z"
| EVAL uri = TO_LOWER(COALESCE(TO_STRING(url.path), TO_STRING(url.original), "-")), status = COALESCE(TO_STRING(http.response.status_code), "-"), sip = COALESCE(TO_STRING(source.ip), "-"), host_name = COALESCE(TO_STRING(host.name), "-")
| WHERE uri RLIKE """.*/wp-content/upgrade/.*\.php.*"""
| STATS events = COUNT(*), first_seen = MIN(@timestamp), statuses = VALUES(status) BY host_name, sip, uri
| LIMIT 10000

CrowdStrike


// Falcon LogScale web/WAF ingest assumed; fields unverified
*
| cutoff := 1793145600000  // 2026-10-28 00:00 UTC
| test(@timestamp >= cutoff)
| default(field=[url.path, http.response.status_code, source.ip, host.name, ComputerName], value="-", replaceEmpty=true)
| url.path=/\/wp-content\/upgrade\/.*\.php/i
| case {
    ComputerName!="-" | host_name := ComputerName ;
    * | host_name := host.name }
| groupBy([host_name, source.ip, url.path], function=[count(as=events), min(@timestamp, as=first_seen), collect(http.response.status_code)], limit=max)

Microsoft KQL


union isfuzzy=true
    (W3CIISLog
        | where TimeGenerated >= datetime(2026-10-28)
        | extend uri = tolower(tostring(csUriStem)), status = tostring(scStatus), sip = tostring(cIP), host_name = tostring(Computer)),
    (Syslog
        | where TimeGenerated >= datetime(2026-10-28)
        | extend uri = tolower(extract(@" (/[^ ]+)", 1, SyslogMessage)), status = extract(@'" ([0-9]{3}) ', 1, SyslogMessage), sip = extract(@"([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", 1, SyslogMessage), host_name = Computer)
| where uri matches regex @"(?i)/wp-content/upgrade/.*\.php"
| summarize events = count(), first_seen = min(TimeGenerated), statuses = make_set(status) by host_name, sip, uri

Needs the same web log fields as the test query. All four keep host, source IP (sip / source.ip / cIP) and URI. The Syslog arm parses the status from the common and combined access log line; if your Syslog feed is not that format, the status will be empty. Cover October 28, 2026 00:00 UTC.


Hunt 2: Same source IP follow-on to admin paths


This hunt finds source IPs that hit a .php path under /wp-content/upgrade/ and a follow-on path (/wp-admin/, wp-login.php, or plugin or theme upload) inside the same clock hour on the same host. All four group by host and source IP and require both kinds.


Splunk


(index=web OR index=proxy OR index=wordpress) (sourcetype=access_combined OR sourcetype=nginx:plus:access OR sourcetype=iis) earliest=1793145600
| eval uri=coalesce(uri_path, uri, cs_uri_stem, url, "-"), sip=coalesce(clientip, src_ip, src, c_ip, "-"), host_name=coalesce(host, dest, "-"), ts=_time
| eval kind=case(match(lower(uri), "/wp-content/upgrade/.*\.php"), "upgrade_php", match(lower(uri), "/wp-admin/") OR match(lower(uri), "wp-login\.php") OR match(lower(uri), "(plugin|theme).*upload"), "followon", true(), "other")
| where kind!="other"
| fillnull value="-" sip host_name kind uri
| where sip!="-" AND host_name!="-"
| bin _time span=1h
| stats earliest(ts) as first_ts, values(kind) as kinds, values(uri) as uris by host_name sip _time
| where mvcount(kinds)>1

Elastic


FROM logs-nginx.access-*, logs-apache.access-*, logs-*, filebeat-*
| WHERE @timestamp >= "2026-10-28T00:00:00Z"
| EVAL uri = TO_LOWER(COALESCE(TO_STRING(url.path), TO_STRING(url.original), "-")), sip = COALESCE(TO_STRING(source.ip), "-"), host_name = COALESCE(TO_STRING(host.name), "-")
| WHERE sip != "-" AND host_name != "-"
| EVAL kind = CASE(uri RLIKE """.*/wp-content/upgrade/.*\.php.*""", "upgrade_php", uri RLIKE ".*/wp-admin/.*" OR uri RLIKE """.*wp-login\.php.*""" OR uri RLIKE ".*(plugin|theme).*upload.*", "followon", "other")
| WHERE kind != "other"
| EVAL bucket = DATE_TRUNC(1 hour, @timestamp)
| STATS kinds = VALUES(kind), first_seen = MIN(@timestamp), uris = VALUES(uri) BY host_name, sip, bucket
| WHERE MV_COUNT(kinds) > 1
| LIMIT 10000

CrowdStrike


// Falcon LogScale web/WAF ingest assumed; host.name assumed when ComputerName is absent
*
| cutoff := 1793145600000  // 2026-10-28 00:00 UTC
| test(@timestamp >= cutoff)
| default(field=[url.path, source.ip, host.name, ComputerName], value="-", replaceEmpty=true)
| case {
    ComputerName!="-" | host_name := ComputerName ;
    * | host_name := host.name }
| source.ip!="-" host_name!="-"
| case {
    url.path=/\/wp-content\/upgrade\/.*\.php/i | kind := "upgrade_php" ;
    url.path=/\/wp-admin\//i | kind := "followon" ;
    url.path=/wp-login\.php/i | kind := "followon" ;
    url.path=/(plugin|theme).*upload/i | kind := "followon" ;
    * | kind := "other" }
| kind!="other"
| slot_raw := @timestamp / 3600000
| math:floor(field=slot_raw, as=slot)
| groupBy([host_name, source.ip, slot], function=[count(kind, distinct=true, as=kind_count), min(@timestamp, as=first_seen), collect(kind)], limit=max)
| test(kind_count > 1)

Microsoft KQL


let start = datetime(2026-10-28);
union isfuzzy=true
    (W3CIISLog | where TimeGenerated >= start | extend uri = tolower(tostring(csUriStem)), sip = tostring(cIP), host_name = tostring(Computer), ts = TimeGenerated),
    (Syslog | where TimeGenerated >= start | extend uri = tolower(extract(@" (/[^ ]+)", 1, SyslogMessage)), sip = extract(@"([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)", 1, SyslogMessage), host_name = Computer, ts = TimeGenerated)
| where isnotempty(sip) and sip != "-" and isnotempty(host_name) and host_name != "-"
| extend hit_kind = case(uri matches regex @"(?i)/wp-content/upgrade/.*\.php", "upgrade_php", uri contains "/wp-admin/" or uri contains "wp-login.php" or uri matches regex @"(?i)(plugin|theme).*upload", "followon", "other")
| where hit_kind != "other"
| extend bucket = bin(ts, 1h)
| summarize kinds = make_set(hit_kind), first_seen = min(ts), uris = make_set(uri) by host_name, sip, bucket
| where array_length(kinds) > 1

Needs the same host and source IP fields as Hunt 1. All four group by host and source IP and require both kinds inside the same clock hour. A pair that straddles the top of the hour is missed, so rerun with the bucket start shifted if one IP shows a single kind near a boundary. CrowdStrike host.name is assumed when ComputerName is blank; the hour slot uses a two-step math:floor(field=slot_raw, as=slot), unverified until run in the tenant. Cover October 28, 2026 00:00 UTC.


Hunt 3: Core update timing near Hunt 1 hits


This hunt pairs .php requests under /wp-content/upgrade/ with file activity under the upgrade folder (or an update log line) on the same host inside a five minute bucket. All four use a normalized short hostname and require both arms.


Splunk


(index=web OR index=proxy OR index=linux OR index=wordpress) earliest=1793145600
| eval uri=coalesce(uri_path, uri, cs_uri_stem, url, "-"), path=coalesce(file_path, ObjectName, TargetFilename, path, "-"), host_raw=coalesce(host, dest, "-"), sip=coalesce(clientip, src_ip, src, c_ip, "-")
| eval host_key=lower(mvindex(split(host_raw,"."),0))
| eval kind=case(match(lower(uri), "/wp-content/upgrade/.*\.php"), "http_upgrade", match(lower(path), "/wp-content/upgrade/") OR match(_raw, "(?i)(core update|auto-update)"), "update_write", true(), "other")
| where kind!="other"
| fillnull value="-" host_key kind
| bin _time span=5m
| stats values(kind) as kinds values(uri) as uris values(path) as paths by host_key _time
| where mvcount(kinds)>1

Elastic


FROM logs-*, filebeat-*, logs-endpoint.events.file-*
| WHERE @timestamp >= "2026-10-28T00:00:00Z"
| EVAL uri = TO_LOWER(COALESCE(TO_STRING(url.path), TO_STRING(url.original), "-")), path = TO_LOWER(COALESCE(TO_STRING(file.path), "-")), host_raw = COALESCE(TO_STRING(host.name), "-"), msg = TO_LOWER(COALESCE(TO_STRING(message), "-"))
| EVAL host_key = TO_LOWER(MV_FIRST(SPLIT(host_raw, ".")))
| EVAL kind = CASE(uri RLIKE """.*/wp-content/upgrade/.*\.php.*""", "http_upgrade", path RLIKE ".*/wp-content/upgrade/.*" OR LOCATE(msg, "core update") > 0 OR LOCATE(msg, "auto-update") > 0, "update_write", "other")
| WHERE kind != "other"
| EVAL bucket = DATE_TRUNC(5 minutes, @timestamp)
| STATS kinds = VALUES(kind) BY host_key, bucket
| WHERE MV_COUNT(kinds) > 1
| LIMIT 10000

CrowdStrike


// Web/WAF arm assumed in Falcon LogScale (url.path, host.name); Falcon Linux file writes use TargetFileName when the sensor emits them
*
| cutoff := 1793145600000  // 2026-10-28 00:00 UTC
| test(@timestamp >= cutoff)
| match(file="aid_master_main.csv", field=aid, include=ComputerName, strict=false)
| default(field=[url.path, TargetFileName, ComputerName, host.name, #event_simpleName], value="-", replaceEmpty=true)
| case {
    ComputerName!="-" | host_raw := ComputerName ;
    * | host_raw := host.name }
| lower(host_raw, as=host_lc)
| regex("^(?<host_key>[^.]+)", field=host_lc, strict=false)
| case {
    url.path=/\/wp-content\/upgrade\/.*\.php/i | kind := "http_upgrade" ;
    #event_simpleName=/Written$/ TargetFileName=/\/wp-content\/upgrade\//i | kind := "update_write" ;
    * | kind := "other" }
| kind!="other"
| slot_raw := @timestamp / 300000
| math:floor(field=slot_raw, as=slot)
| groupBy([host_key, slot], function=[count(kind, distinct=true, as=kind_count), collect(kind)], limit=max)
| test(kind_count > 1)

Microsoft KQL


let start = datetime(2026-10-28);
let linux = DeviceInfo | where Timestamp >= start | where OSPlatform contains "Linux" | distinct DeviceId, DeviceName;
let web = union isfuzzy=true
    (W3CIISLog | where TimeGenerated >= start | extend uri = tolower(tostring(csUriStem)), host_raw = tostring(Computer), ts = TimeGenerated, path = ""),
    (Syslog | where TimeGenerated >= start | extend uri = tolower(extract(@" (/[^ ]+)", 1, SyslogMessage)), host_raw = Computer, ts = TimeGenerated, path = "");
let files = DeviceFileEvents
| where Timestamp >= start
| join kind=inner linux on DeviceId
| where ActionType in ("FileCreated", "FileModified", "FileRenamed")
| where FolderPath contains "/wp-content/upgrade/"
| extend uri = "", host_raw = DeviceName, ts = Timestamp, path = tolower(tostring(FolderPath));
union isfuzzy=true (web), (files)
| extend host_key = tolower(tostring(split(host_raw, ".")[0]))
| extend hit_kind = case(uri matches regex @"(?i)/wp-content/upgrade/.*\.php", "http_upgrade", path contains "/wp-content/upgrade/", "update_write", "other")
| where hit_kind != "other"
| extend bucket = bin(ts, 5m)
| summarize kinds = make_set(hit_kind) by host_key, bucket
| where array_length(kinds) > 1

Needs web logs and Linux file telemetry. All four normalize a short hostname and require both arms in the same five minute bucket. A pair that straddles a five minute boundary is missed, so rerun with the bucket start shifted if one host shows a single kind near a boundary.


Splunk and Elastic match any file-path activity under the upgrade folder regardless of operation, including reads and deletes, and also treat log text that mentions a core update or auto-update as an update signal. CrowdStrike and Microsoft KQL count file writes under the upgrade folder only, so they depend on file telemetry being on. CrowdStrike file arms depend on your Falcon Linux sensor emitting file-write events for paths under the upgrade folder and may return nothing by design if it does not; unverified until run in the tenant. Elastic file.path and message come from endpoint or audit indices. Cover October 28, 2026 00:00 UTC.


Hunt 4: Written or modified PHP under wp-content and web server shells


This hunt has two arms: written or modified .php files under wp-content/uploads, upgrade, plugins or themes since October 28, and Linux web server processes (php-fpm including versioned names, apache2, nginx, httpd) spawning sh, bash, curl or wget. All four stay on Linux parents; w3wp is excluded.


Splunk


index=linux (sourcetype=linux:audit OR sourcetype=sysmon:linux OR sourcetype=auditd) earliest=1793145600
| eval path=coalesce(file_path, ObjectName, TargetFilename, path, "-"), proc=coalesce(process_name, process, Image, exe, "-"), parent=coalesce(parent_process, ParentImage, "-"), cmd=coalesce(CommandLine, cmdline, "-"), host_name=coalesce(host, dest, "-")
| eval proc_base=lower(replace(proc, "^.*[\\\\/]", "")), parent_base=lower(replace(parent, "^.*[\\\\/]", ""))
| where match(lower(path), "/wp-content/(uploads|upgrade|plugins|themes)/.*\.php$") OR ((match(parent_base, "^php-fpm[0-9.]*$") OR match(parent_base, "^(apache2|nginx|httpd)$")) AND match(proc_base, "^(sh|bash|curl|wget)$"))
| fillnull value="-" host_name path proc parent
| stats count as events min(_time) as first_seen values(path) as paths values(cmd) as cmds by host_name proc

Elastic


FROM logs-endpoint.events.file-*, logs-endpoint.events.process-*, logs-system.audit-*
| WHERE @timestamp >= "2026-10-28T00:00:00Z"
| EVAL path = TO_LOWER(COALESCE(TO_STRING(file.path), "-")), proc = TO_LOWER(COALESCE(TO_STRING(process.name), "-")), parent = TO_LOWER(COALESCE(TO_STRING(process.parent.name), "-")), host_name = COALESCE(TO_STRING(host.name), "-")
| WHERE path RLIKE """.*/wp-content/(uploads|upgrade|plugins|themes)/.*\.php""" OR (parent RLIKE "(php-fpm[0-9.]*|apache2|nginx|httpd)" AND proc RLIKE "(sh|bash|curl|wget)")
| STATS events = COUNT(*), first_seen = MIN(@timestamp) BY host_name, proc, path
| LIMIT 10000

CrowdStrike


(#event_simpleName=/Written$/ OR #event_simpleName=ProcessRollup2) event_platform=Lin
| cutoff := 1793145600000  // 2026-10-28 00:00 UTC
| test(@timestamp >= cutoff)
| match(file="aid_master_main.csv", field=aid, include=ComputerName, strict=false)
| default(field=[TargetFileName, FileName, ParentBaseFileName, CommandLine, ComputerName], value="-")
| (TargetFileName=/\/wp-content\/(uploads|upgrade|plugins|themes)\/.*\.php$/i or (ParentBaseFileName=/^(php-fpm[0-9.]*|apache2|nginx|httpd)$/i FileName=/^(sh|bash|curl|wget)$/i))
| groupBy([ComputerName, FileName, TargetFileName], function=[count(as=events), min(@timestamp, as=first_seen)], limit=max)

Microsoft KQL


let start = datetime(2026-10-28);
let linux = DeviceInfo | where Timestamp >= start | where OSPlatform contains "Linux" | distinct DeviceId, DeviceName;
union isfuzzy=true
    (DeviceFileEvents
        | where Timestamp >= start
        | join kind=inner linux on DeviceId
        | where ActionType in ("FileCreated", "FileModified", "FileRenamed")
        | extend full_path = strcat(FolderPath, "/", FileName)
        | where full_path matches regex @"(?i)/wp-content/(uploads|upgrade|plugins|themes)/.*\.php$"
        | extend arm = "file", proc = InitiatingProcessFileName, path = full_path, host_name = DeviceName),
    (DeviceProcessEvents
        | where Timestamp >= start
        | join kind=inner linux on DeviceId
        | where InitiatingProcessFileName matches regex @"(?i)^(php-fpm[0-9.]*|apache2|nginx|httpd)$" and FileName matches regex @"(?i)^(sh|bash|curl|wget)$"
        | extend arm = "proc", proc = FileName, path = FolderPath, host_name = DeviceName)
| summarize events = count(), first_seen = min(Timestamp) by host_name, arm, proc, path

Needs Linux file and process telemetry. Splunk and Elastic see both arms when Sysmon for Linux or an Elastic endpoint sensor records written or modified files and process starts with parent names; plain auditd records the parent PID, not the parent name, so the parent arm needs Sysmon for Linux or an Elastic endpoint sensor.


CrowdStrike file arms depend on your Falcon Linux sensor emitting file-write events for .php and may return nothing by design if it does not; unverified until run in the tenant. Defender needs a DeviceInfo join for Linux OSPlatform; w3wp is excluded because the arms are Linux. Elastic uses file.path, process.name, process.parent.name and host.name from endpoint or audit indices. CrowdStrike and Microsoft KQL keep file writes only; Splunk and Elastic do not filter on file action, so deletions can appear. Cover October 28, 2026 00:00 UTC.


Manual checks


Grep access logs for requests into /wp-content/upgrade/. On the host, run wp user list --role=administrator with wp-cli, find new .php files by mtime under wp-content since October 28, and run wp core verify-checksums. Treat a new administrator after an upgrade PHP 200 as a hands-on lead.


Interpret before you escalate


Read each result against this table before anyone opens a ticket.


Signal

Usually benign

Escalate when

GET to setup-config.php under wp-content/upgrade

Your own staging test you can name

HTTP 200 from an unknown source IP during or near a core update

404 or 403 on a .php under wp-content/upgrade from internet scanners

Background scanning, expected on any public WordPress host

The same source later returns a 200, or reaches /wp-admin/

Follow-on /wp-admin/ from same source IP

An admin who also hit a plugin page

Upgrade PHP 200 then wp-login or upload within the same clock hour

File writes under wp-content/upgrade

A documented core update by the web user

Writes paired with external upgrade PHP hits

Written or modified .php under wp-content/plugins or themes

A plugin or theme update by your deploy user

The writer is the web server user outside a change window, or the file is under uploads or upgrade

php-fpm spawning curl or bash

A known maintenance script

First sighting after October 28 on a WordPress host

New administrator via wp-cli list

A ticketed account change

Account created near Hunt 1 activity


What to alert on


We would alert on Hunt 1 HTTP 200s to .php under /wp-content/upgrade/ and on Hunt 4 web server shells. Hunt 2 and Hunt 3 still need a person; update traffic and admin browsing look similar until baselined.


What This Means for Your Team


If you run WordPress, finish the move to a patched build, deny PHP under wp-content/upgrade before the last vulnerable update, and hunt access logs even when checksums look clean. Empty web URI fields in Splunk, Elastic, CrowdStrike or Microsoft Sentinel are a coverage gap until the test query returns your hosts. File integrity and administrator audits close what the SIEM misses.


Sources



Inception Protection


Inception Protection is our managed detection and response service. Our analysts review endpoint, identity, email and cloud alerts around the clock, including web and Linux signals that matter when a CMS update path is under active probe.


Inception Foresight


Our free Inception Foresight M365 Assessment is read-only and checks identity, email, data sharing, devices, and audit and governance settings in Microsoft 365, including whether logging and alerting are turned on. It does not assess WordPress, Linux or web servers. You get a prioritized remediation plan: https://www.inceptionsecurity.com/m365assessment.

bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page