Hunt Drop 02 | PHP2Shell: hunt PHP workers gone rogue
Builds on Intel Drop 5.
The nine hunts
- Exposure: PHP runtimes below 8.5.12 / 8.4.27 / 8.3.36 / 8.2.35, and EOL branches
- php-fpm, php-cgi, httpd or apache2 spawning sh, bash, cmd, PowerShell, curl, wget or certutil
- Reverse-shell and recon command lines (/dev/tcp, mkfifo, fsockopen, whoami, id) from the web account
- New .php, .phtml or .phar files in web roots, /tmp or /dev/shm
- Rare outbound connections from PHP workers to public IPs
- Web-worker parent-child pairs not seen in the previous 28 days
- Cron, SSH key or user changes by www-data, apache or nginx
- “PHP Fatal error … tried to allocate … Unknown on line 0” in PHP logs
- Bursts of Apache AH00052 child exits
No IOCs have been published yet, so these hunts are behavioural. Expect noise from deploy scripts and admin tooling; baseline before escalating. Treat a hit on hunt 2 or 3 as a potential compromise, and preserve the web and PHP logs first.
Once tuned to your estate, hunts 2, 3 and 7 are good candidates for promotion to detection rules.
Full KQL and CQL pack: link in the comments.
Risk: 20/25 CRITICAL (likelihood 4 × impact 5, analyst judgement). Hunt logic sourced: HIGH.
Microsoft Defender (KQL)
// ===== H1 Exposure - PHP runtimes and PHP2Shell CVEs (MDVM)
// Part A: devices where Defender Vulnerability Management already maps the CVEs (may be empty while MDVM catches up)
DeviceTvmSoftwareVulnerabilities
| where CveId in ("CVE-2026-109618", "CVE-2026-109619")
| summarize CVEs = make_set(CveId), Software = make_set(strcat(SoftwareName, " ", SoftwareVersion), 20) by DeviceId, DeviceName, OSPlatform
| join kind=leftouter (
DeviceTvmSoftwareInventory
| where SoftwareName matches regex @"(?i)^php"
| summarize PhpPackages = make_set(strcat(SoftwareName, " ", SoftwareVersion), 20) by DeviceId
) on DeviceId
| project DeviceName, OSPlatform, CVEs, Software, PhpPackages
// ===== H1b Exposure - PHP versions below the fixed builds (upstream versioning)
// Distro backports keep old version strings: confirm patch status from the package changelog, not this flag alone
DeviceTvmSoftwareInventory
| where SoftwareName matches regex @"(?i)^php([0-9]+(\.[0-9]+)?)?(-(cli|fpm|cgi|common))?(_for_linux)?$"
| extend Ver = extract(@"^(?:\d+:)?(\d+\.\d+\.\d+)", 1, SoftwareVersion)
| extend Major = toint(split(Ver, ".")[0]), Minor = toint(split(Ver, ".")[1]), Patch = toint(split(Ver, ".")[2])
| extend Status = case(
isempty(Ver), "Unknown version - check manually",
Major < 8 or (Major == 8 and Minor <= 1), "EOL branch - backport or upgrade",
Major == 8 and Minor == 2 and Patch < 35, "Below 8.2.35",
Major == 8 and Minor == 3 and Patch < 36, "Below 8.3.36",
Major == 8 and Minor == 4 and Patch < 27, "Below 8.4.27",
Major == 8 and Minor == 5 and Patch < 12, "Below 8.5.12",
"At or above fixed build")
| where Status != "At or above fixed build"
| summarize Devices = dcount(DeviceId), DeviceList = make_set(DeviceName, 100) by SoftwareName, SoftwareVersion, Status
| order by Devices desc
// ===== H1c Internet-facing Windows/IIS hosts running php-cgi.exe below the PHP2Shell fixed builds (adapted from Sergio Albea)
let InternetFacingDevices = DeviceInfo
| where Timestamp > ago(7d)
| summarize arg_max(Timestamp, IsInternetFacing, PublicIP) by DeviceId
| where IsInternetFacing == true // comment out to see all hosts, with the flag as a column
| project DeviceId, IsInternetFacing, PublicIP;
let LatestInstalledPHP = DeviceTvmSoftwareInventory
| where SoftwareName matches regex @"(?i)^php"
| summarize InstalledPHP = make_set(SoftwareVersion, 10) by DeviceId;
DeviceProcessEvents
| where Timestamp > ago(30d)
| where FileName in~ ("php-cgi.exe", "php.exe")
| extend PathVersion = extract(@"(?i)\\v?(\d+\.\d+(?:\.\d+)?)\\php(-cgi)?\.exe$", 1, FolderPath)
| extend PHPVersion = coalesce(extract(@"^(\d+\.\d+\.\d+)", 1, ProcessVersionInfoProductVersion), PathVersion)
| extend Major = toint(split(PHPVersion, ".")[0]), Minor = toint(split(PHPVersion, ".")[1]), Patch = toint(split(PHPVersion, ".")[2])
| extend Status = case(
isempty(PHPVersion), "Unknown version - check binary",
Major < 8 or (Major == 8 and Minor <= 1), "EOL branch",
Major == 8 and Minor == 2 and (isnull(Patch) or Patch < 35), "Below 8.2.35 or patch unknown",
Major == 8 and Minor == 3 and (isnull(Patch) or Patch < 36), "Below 8.3.36 or patch unknown",
Major == 8 and Minor == 4 and (isnull(Patch) or Patch < 27), "Below 8.4.27 or patch unknown",
Major == 8 and Minor == 5 and (isnull(Patch) or Patch < 12), "Below 8.5.12 or patch unknown",
"At or above fixed build")
| where Status != "At or above fixed build"
| join kind=inner InternetFacingDevices on DeviceId
| join kind=leftouter LatestInstalledPHP on DeviceId
| summarize FirstExecution = min(Timestamp), LastExecution = max(Timestamp), Executions = count(),
ProcessPaths = make_set(FolderPath, 10), IISCommandLines = make_set(InitiatingProcessCommandLine, 10)
by DeviceName, PublicIP, PHPVersion, Status, InstalledPHP = tostring(InstalledPHP)
| order by Status asc, Executions desc
// ===== H2 PHP or web worker spawning shells, interpreters or download tools
let lookback = datetime(2026-10-20T00:00:00Z);
let WebParents = @"(?i)^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$";
let Children = dynamic(["sh","bash","dash","zsh","busybox","cmd.exe","powershell.exe","pwsh.exe","python","python3","python.exe","perl","curl","curl.exe","wget","nc","ncat","socat","certutil.exe","bitsadmin.exe"]);
DeviceProcessEvents
| where Timestamp >= lookback
| where InitiatingProcessFileName matches regex WebParents
| where FileName in~ (Children)
| project Timestamp, DeviceName, AccountName, InitiatingProcessFileName, InitiatingProcessCommandLine, FileName, ProcessCommandLine, FolderPath, SHA256
| order by Timestamp desc
// ===== H3 Reverse-shell and recon command lines run as the web worker
let lookback = datetime(2026-10-20T00:00:00Z);
let WebParents = @"(?i)^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$";
let WebUsers = dynamic(["www-data","apache","nginx","http","wwwrun","nobody","daemon","iusr"]);
DeviceProcessEvents
| where Timestamp >= lookback
| where InitiatingProcessFileName matches regex WebParents
or InitiatingProcessParentFileName matches regex WebParents
or AccountName in~ (WebUsers)
| where ProcessCommandLine matches regex @"(?i)(/dev/(tcp|udp)/|mkfifo|fsockopen|n(c|cat)\s+.*-e\s|bash\s+-i|sh\s+-i|socat\s+.*exec)"
or FileName in~ ("whoami", "whoami.exe", "id", "uname", "hostname", "hostname.exe", "ipconfig.exe", "ifconfig", "systeminfo.exe")
| project Timestamp, DeviceName, AccountName, InitiatingProcessParentFileName, InitiatingProcessFileName, FileName, ProcessCommandLine
| order by Timestamp desc
// ===== H4 PHP or script files written by web workers into web roots or temp paths
let lookback = datetime(2026-10-20T00:00:00Z);
let WebParents = @"(?i)^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe|sh|bash|dash|curl|wget)$";
DeviceFileEvents
| where Timestamp >= lookback
| where ActionType in ("FileCreated", "FileModified", "FileRenamed")
| where FileName matches regex @"(?i)\.(php[0-9]?|phtml|phar)$"
| where FolderPath has_any ("/var/www/", "/srv/www/", "/usr/share/nginx/", "public_html", "wwwroot", "/tmp/", "/var/tmp/", "/dev/shm/")
| where InitiatingProcessFileName matches regex WebParents
| project Timestamp, DeviceName, ActionType, FolderPath, FileName, InitiatingProcessFileName, InitiatingProcessCommandLine, InitiatingProcessAccountName, SHA256
| order by Timestamp desc
// ===== H5 Rare outbound connections from PHP workers to public IPs
let lookback = datetime(2026-10-20T00:00:00Z);
let WebParents = @"(?i)^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|lsphp[0-9]*|frankenphp)$";
DeviceNetworkEvents
| where Timestamp >= lookback
| where ActionType == "ConnectionSuccess" and RemoteIPType == "Public"
| where InitiatingProcessFileName matches regex WebParents
| summarize Connections = count(), FirstSeen = min(Timestamp), LastSeen = max(Timestamp), Ports = make_set(RemotePort, 20), Urls = make_set(RemoteUrl, 20), Devices = make_set(DeviceName, 20) by RemoteIP, InitiatingProcessFileName
| extend DeviceCount = array_length(Devices)
| where DeviceCount <= 2
| order by Connections asc
// ===== H6 New web-worker parent to child pairs vs a 28-day baseline
let WebParents = @"(?i)^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$";
let Baseline = DeviceProcessEvents
| where Timestamp between (ago(30d) .. ago(2d))
| where InitiatingProcessFileName matches regex WebParents
| distinct DeviceId, InitiatingProcessFileName, FileName;
DeviceProcessEvents
| where Timestamp > ago(2d)
| where InitiatingProcessFileName matches regex WebParents
| join kind=leftanti Baseline on DeviceId, InitiatingProcessFileName, FileName
| summarize Executions = count(), FirstSeen = min(Timestamp), LastSeen = max(Timestamp), Commands = make_set(ProcessCommandLine, 10) by DeviceName, InitiatingProcessFileName, FileName
| order by FirstSeen desc
// ===== H7 Persistence by the web account - cron, SSH keys, new users
let lookback = datetime(2026-10-20T00:00:00Z);
let WebUsers = dynamic(["www-data","apache","nginx","http","wwwrun","nobody","daemon"]);
let ProcHits = DeviceProcessEvents
| where Timestamp >= lookback
| where AccountName in~ (WebUsers) or InitiatingProcessAccountName in~ (WebUsers)
| where FileName in~ ("crontab", "useradd", "adduser", "usermod", "passwd", "chpasswd")
or ProcessCommandLine has_any ("authorized_keys", "/etc/cron", "/var/spool/cron", "/etc/sudoers")
| project Timestamp, DeviceName, Signal = "process", Account = AccountName, Detail = ProcessCommandLine, Parent = InitiatingProcessFileName;
let FileHits = DeviceFileEvents
| where Timestamp >= lookback
| where InitiatingProcessAccountName in~ (WebUsers)
| where FolderPath has_any ("/.ssh/authorized_keys", "/etc/cron", "/var/spool/cron", "/etc/sudoers", "/etc/systemd/system/")
| project Timestamp, DeviceName, Signal = "file", Account = InitiatingProcessAccountName, Detail = FolderPath, Parent = InitiatingProcessFileName;
union ProcHits, FileHits
| order by Timestamp desc
// ===== H8 PHP fatal out-of-memory errors in web or PHP logs (Sentinel Syslog; adapt to your log table)
Syslog
| where TimeGenerated >= datetime(2026-10-20T00:00:00Z)
| where SyslogMessage has "PHP Fatal error"
| where SyslogMessage has "tried to allocate" and SyslogMessage has "Unknown on line 0"
| summarize Errors = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), Samples = make_set(SyslogMessage, 5) by Computer, ProcessName
| order by Errors desc
// ===== H9 Apache child exits (AH00052) bursts - possible exploit retries (adapt to your log table)
Syslog
| where TimeGenerated >= datetime(2026-10-20T00:00:00Z)
| where SyslogMessage has "AH00052"
| summarize Exits = count(), FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated) by Computer, bin(TimeGenerated, 10m)
| where Exits >= 10
| order by Exits desc
CrowdStrike Falcon (CQL)
// ===== H1 Where PHP runs - hosts executing PHP workers (pair with Falcon Exposure Management for versions/CVEs)
#event_simpleName=ProcessRollup2
| FileName=/^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|lsphp[0-9]*|frankenphp)$/i
| groupBy([ComputerName, event_platform, FileName, ImageFileName], function=[count(as=Executions), max(@timestamp, as=LastSeen)], limit=max)
| LastSeen := formatTime("%Y-%m-%d %H:%M", field=LastSeen, timezone="UTC")
| sort(Executions, order=desc, limit=1000)
// ===== H1c Windows/IIS hosts running php-cgi.exe below the PHP2Shell fixed builds (version from install path; filter internet-facing hosts with Falcon Exposure Management)
#event_simpleName=ProcessRollup2 event_platform=Win
| FileName=/^php(-cgi)?\.exe$/i
| regex("(?i)\\\\v?(?<Major>\\d+)\\.(?<Minor>\\d+)(?:\\.(?<Patch>\\d+))?\\\\php(-cgi)?\\.exe$", field=ImageFileName, strict=false)
| case {
Major!=* | Status := "Unknown version - check binary" ;
test(Major < 8) | Status := "EOL branch" ;
Major=8 | test(Minor <= 1) | Status := "EOL branch" ;
Major=8 Minor=2 | Patch!=* | Status := "8.2 - patch unknown" ;
Major=8 Minor=2 | test(Patch < 35) | Status := "Below 8.2.35" ;
Major=8 Minor=3 | Patch!=* | Status := "8.3 - patch unknown" ;
Major=8 Minor=3 | test(Patch < 36) | Status := "Below 8.3.36" ;
Major=8 Minor=4 | Patch!=* | Status := "8.4 - patch unknown" ;
Major=8 Minor=4 | test(Patch < 27) | Status := "Below 8.4.27" ;
Major=8 Minor=5 | Patch!=* | Status := "8.5 - patch unknown" ;
Major=8 Minor=5 | test(Patch < 12) | Status := "Below 8.5.12" ;
* | Status := "At or above fixed build" ;
}
| Status!="At or above fixed build"
| groupBy([ComputerName, ImageFileName, Status], function=[count(as=Executions), min(@timestamp, as=FirstExecution), max(@timestamp, as=LastExecution), collect([ParentBaseFileName], limit=5)], limit=max)
| FirstExecution := formatTime("%Y-%m-%d %H:%M", field=FirstExecution, timezone="UTC")
| LastExecution := formatTime("%Y-%m-%d %H:%M", field=LastExecution, timezone="UTC")
| sort(Executions, order=desc, limit=1000)
// ===== H2 PHP or web worker spawning shells, interpreters or download tools
#event_simpleName=ProcessRollup2
| ParentBaseFileName=/^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$/i
| FileName=/^(sh|bash|dash|zsh|busybox|cmd\.exe|powershell\.exe|pwsh\.exe|python[0-9.]*(\.exe)?|perl|curl(\.exe)?|wget|nc|ncat|socat|certutil\.exe|bitsadmin\.exe)$/i
| table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine, ImageFileName, SHA256HashData], limit=1000)
// ===== H3 Reverse-shell and recon command lines from web workers or web accounts
#event_simpleName=ProcessRollup2
| ParentBaseFileName=/^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$/i
OR UserName=/^(www-data|apache|nginx|http|wwwrun|nobody|daemon|iusr)$/i
| CommandLine=/(\/dev\/(tcp|udp)\/|mkfifo|fsockopen|n(c|cat)\s+.*-e\s|bash\s+-i|sh\s+-i|socat\s+.*exec)/i
OR FileName=/^(whoami(\.exe)?|id|uname|hostname(\.exe)?|ipconfig\.exe|ifconfig|systeminfo\.exe)$/i
| table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine], limit=1000)
// ===== H4 PHP or script files written into web roots or temp paths by a PHP or web process
(#event_simpleName=ProcessRollup2 FileName=/^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$/i)
OR (#event_simpleName=/^(WebScriptFileWritten|NewScriptWritten)$/ TargetFileName=/\.(php[0-9]?|phtml|phar)$/i)
| case {
#event_simpleName=ProcessRollup2 | falconPID := TargetProcessId ;
* | falconPID := ContextProcessId ;
}
| selfJoinFilter(field=[aid, falconPID], where=[{#event_simpleName=ProcessRollup2}, {#event_simpleName=/^(WebScriptFileWritten|NewScriptWritten)$/}])
| groupBy([aid, falconPID], function=[collect([ComputerName, FileName, CommandLine, TargetFileName]), max(@timestamp, as=LastSeen)], limit=max)
| TargetFileName=/(\/var\/www\/|\/srv\/www\/|\/usr\/share\/nginx\/|public_html|wwwroot|\/tmp\/|\/var\/tmp\/|\/dev\/shm\/)/i
| LastSeen := formatTime("%Y-%m-%d %H:%M", field=LastSeen, timezone="UTC")
// ===== H5 Outbound connections from PHP workers to public IPs
(#event_simpleName=ProcessRollup2 FileName=/^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|lsphp[0-9]*|frankenphp)$/i)
OR #event_simpleName=NetworkConnectIP4
| case {
#event_simpleName=ProcessRollup2 | falconPID := TargetProcessId ;
#event_simpleName=NetworkConnectIP4 | !cidr(RemoteAddressIP4, subnet=["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "169.254.0.0/16", "100.64.0.0/10"]) | falconPID := ContextProcessId ;
}
| selfJoinFilter(field=[aid, falconPID], where=[{#event_simpleName=ProcessRollup2}, {#event_simpleName=NetworkConnectIP4}])
| groupBy([aid, falconPID], function=[collect([ComputerName, FileName, CommandLine, RemoteAddressIP4, RemotePort]), count(RemoteAddressIP4, distinct=true, as=DistinctPublicIPs), max(@timestamp, as=LastSeen)], limit=max)
| DistinctPublicIPs >= 1
| LastSeen := formatTime("%Y-%m-%d %H:%M", field=LastSeen, timezone="UTC")
| sort(DistinctPublicIPs, order=asc, limit=1000)
// ===== H6 New web-worker parent to child pairs (first seen in the last 2 days; set the time picker to 30 days)
#event_simpleName=ProcessRollup2
| ParentBaseFileName=/^(php(-fpm|-cgi)?[0-9.]*(\.exe)?|httpd|apache2|nginx|lsphp[0-9]*|frankenphp|w3wp\.exe)$/i
| groupBy([aid, ComputerName, ParentBaseFileName, FileName], function=[count(as=Executions), min(@timestamp, as=FirstSeen), max(@timestamp, as=LastSeen), collect([CommandLine], limit=10)], limit=max)
| test(FirstSeen > (now() - 172800000))
| FirstSeen := formatTime("%Y-%m-%d %H:%M", field=FirstSeen, timezone="UTC")
| LastSeen := formatTime("%Y-%m-%d %H:%M", field=LastSeen, timezone="UTC")
| sort(Executions, order=asc, limit=1000)
// ===== H7 Persistence by the web account - cron, SSH keys, new users
#event_simpleName=ProcessRollup2
| UserName=/^(www-data|apache|nginx|http|wwwrun|nobody|daemon)$/i
| FileName=/^(crontab|useradd|adduser|usermod|passwd|chpasswd)$/i
OR CommandLine=/(authorized_keys|\/etc\/cron|\/var\/spool\/cron|\/etc\/sudoers|\/etc\/systemd\/system\/)/i
| table([@timestamp, ComputerName, UserName, ParentBaseFileName, FileName, CommandLine], limit=1000)
// ===== H8 PHP fatal out-of-memory errors in ingested web/PHP logs (Next-Gen SIEM; set repository/parser for your log source)
"PHP Fatal error" "tried to allocate" "Unknown on line 0"
| groupBy([@host], function=[count(as=Errors), min(@timestamp, as=FirstSeen), max(@timestamp, as=LastSeen)], limit=max)
| sort(Errors, order=desc)
// ===== H9 Apache child exits (AH00052) bursts - possible exploit retries
"AH00052"
| bucket(span=10m, field=[@host], function=count(as=Exits))
| Exits >= 10
Queries are untested: tune before use.
Sources
- Sergio Albea, Detect FYI (21 Oct 2026): https://detect.fyi/php2shell-two-critical-php-vulnerabilities-cve-2026-109618-and-cve-2026-109619-are-your-3f0a0c08f6ec
- Cyb3rMonk KQL hunt: https://github.com/Cyb3r-Monk/Threat-Hunting-and-Detection/blob/main/Uncategorized/PHP2Shell.md
- Huntress (21 Oct 2026): https://www.huntress.com/blog/php2shell-leaked-critical-php-vulnerabilities
- Abstract Security (21 Oct 2026): https://www.abstract.security/blog/php2shell-critical-php-rce-vulnerabilities-cve-2026-109618-and-cve-2026-109619
- Cybaverse (21 Oct 2026): https://www.cybaverse.co.uk/en/php2shell-checker
- Wiz Research (21 Oct 2026): https://www.wiz.io/blog/php2shell-php-rce
- PHP security release (21 Oct 2026): https://www.php.net/archive/2026.php#2026-10-21-5