<# .SYNOPSIS Read-only PHP2Shell (CVE-2026-109618 / CVE-2026-109619) evidence collector for Windows / IIS. https://scan.theadminstack.com/php2shell/ .DESCRIPTION Finds every PHP build on this host AND works out which ones are actually in use: - IIS: FastCGI applications and the handler mappings that point at them (applicationHost.config, including blocks, and each site's web.config) - Apache: LoadModule php*_module / PHPIniDir in the httpd.conf of installed httpd services - Windows services whose command line runs PHP - Running PHP processes (php-cgi.exe spawned by IIS, php.exe workers, etc.) - Scheduled tasks that run PHP - PATH (which php.exe a plain `php` command resolves to) Folders that nothing references (old copies, backups) are reported as not in use. Run it from an ELEVATED PowerShell: without admin rights IIS's configuration and other users' processes are invisible, and the page will tell you the usage picture is incomplete. It changes nothing and sends nothing. Paste the output into the page. .EXAMPLE powershell -ExecutionPolicy Bypass -File .\php2shell-check.ps1 | Set-Clipboard .EXAMPLE .\php2shell-check.ps1 -Path 'D:\Apps\php83' # extra install folders to inspect .EXAMPLE .\php2shell-check.ps1 -AppHostConfig .\applicationHost.config # analyse a copied IIS config #> [CmdletBinding()] param( # Extra folders that contain php.exe / php-cgi.exe (non-standard install locations) [string[]]$Path = @(), # Alternative applicationHost.config to read (default: the live IIS config) [string]$AppHostConfig = '', # Extra Apache config files to read (default: found from installed httpd services) [string[]]$ApacheConf = @() ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'SilentlyContinue' $IniKeys = @('session.upload_progress.enabled','memory_limit','expose_php','fatal_error_backtraces','file_uploads','post_max_size') $out = New-Object System.Collections.Generic.List[string] $dirs = New-Object System.Collections.Generic.HashSet[string]([StringComparer]::OrdinalIgnoreCase) $usage = @{} # dir (lower-case) -> list of "role|detail" $PhpExeRe = '([A-Za-z]:\\[^"|<>*?\r\n]*?\\php(?:-cgi|-win)?\.exe)' function Get-DirKey([string]$exe) { try { $full = [Environment]::ExpandEnvironmentVariables($exe.Trim().Trim('"')) $full = [IO.Path]::GetFullPath($full) return (Split-Path $full -Parent) } catch { return $null } } function Add-Use([string]$exeOrDir, [string]$role, [string]$detail, [switch]$IsDir) { $d = if ($IsDir) { [IO.Path]::GetFullPath([Environment]::ExpandEnvironmentVariables($exeOrDir.Trim().Trim('"').TrimEnd('\','/'))) } else { Get-DirKey $exeOrDir } if (-not $d) { return } [void]$dirs.Add($d) $k = $d.ToLowerInvariant() if (-not $usage.ContainsKey($k)) { $usage[$k] = New-Object System.Collections.Generic.List[string] } $entry = "$role|$($detail -replace '[;\r\n]', ' ')" if (-not $usage[$k].Contains($entry)) { $usage[$k].Add($entry) } } $isAdmin = $false try { $isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch {} $os = Get-CimInstance -ClassName Win32_OperatingSystem $out.Add('#php2shell-check v2 powershell') $out.Add('[host]') $out.Add("hostname=$env:COMPUTERNAME") $out.Add('os_id=windows') $out.Add("os_version=$($os.Version)") $out.Add("os_name=$($os.Caption)") $out.Add("collected_at=$((Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))") $out.Add('usage_scan=2') $out.Add("elevated=$([int]$isAdmin)") # --- 1. IIS ----------------------------------------------------------------------------------- $appHost = if ($AppHostConfig) { $AppHostConfig } else { Join-Path $env:windir 'System32\inetsrv\config\applicationHost.config' } if (Test-Path -LiteralPath $appHost) { $out.Add('iis_present=1') $x = $null try { [xml]$x = Get-Content -LiteralPath $appHost -Raw -ErrorAction Stop } catch {} if ($x) { $out.Add('iis_config_read=1') # FastCGI applications registered with IIS foreach ($app in @($x.SelectNodes('//fastCgi/application'))) { if ($app.fullPath -match 'php') { Add-Use $app.fullPath 'iis-fastcgi-registered' 'Registered as an IIS FastCGI application' } } # Handler mappings: server-wide and in blocks foreach ($h in @($x.SelectNodes('//handlers/add[@scriptProcessor]'))) { $sp = ($h.scriptProcessor -split '\|')[0] if ($sp -notmatch 'php') { continue } $loc = $h.SelectSingleNode('ancestor::location') $scope = if ($loc -and $loc.path) { "site '$($loc.path)'" } else { 'server-wide' } Add-Use $sp 'iis-handler' "IIS handler '$($h.name)' ($scope)" } # Each site/application's own web.config foreach ($site in @($x.SelectNodes('//sites/site'))) { foreach ($app in @($site.SelectNodes('application'))) { $vd = $app.SelectSingleNode('virtualDirectory[@path="/"]') if (-not $vd) { continue } $phys = [Environment]::ExpandEnvironmentVariables($vd.physicalPath) $wc = Join-Path $phys 'web.config' if (-not (Test-Path -LiteralPath $wc)) { continue } try { [xml]$w = Get-Content -LiteralPath $wc -Raw -ErrorAction Stop foreach ($h in @($w.SelectNodes('//handlers/add[@scriptProcessor]'))) { $sp = ($h.scriptProcessor -split '\|')[0] if ($sp -match 'php') { Add-Use $sp 'iis-handler' "IIS site '$($site.name)$($app.path)' web.config handler '$($h.name)'" } } } catch {} } } } else { $out.Add('iis_config_read=0') } } else { $out.Add('iis_present=0') } # --- 2. Apache httpd ---------------------------------------------------------------------------- $confs = New-Object System.Collections.Generic.List[string] foreach ($c in $ApacheConf) { $confs.Add($c) } foreach ($svc in @(Get-CimInstance Win32_Service | Where-Object { $_.PathName -match 'httpd\.exe' })) { if ($svc.PathName -match '"?([A-Za-z]:\\[^"]*?\\bin\\httpd\.exe)"?') { $root = Split-Path (Split-Path $Matches[1] -Parent) -Parent $main = if ($svc.PathName -match '-f\s+"?([^"]+?\.conf)"?') { $Matches[1] } else { Join-Path $root 'conf\httpd.conf' } $confs.Add("$main|$($svc.Name)|$($svc.State)") Get-ChildItem -Path (Join-Path $root 'conf\extra\*.conf') | ForEach-Object { $confs.Add("$($_.FullName)|$($svc.Name)|$($svc.State)") } } } foreach ($entry in $confs) { $parts = $entry -split '\|' $file = $parts[0]; $svcName = if ($parts.Count -gt 1) { $parts[1] } else { 'Apache' }; $state = if ($parts.Count -gt 2) { $parts[2] } else { '' } if (-not (Test-Path -LiteralPath $file)) { continue } foreach ($line in (Get-Content -LiteralPath $file)) { if ($line -match '^\s*LoadModule\s+php\w*_module\s+"?([^"]+)"?') { $role = if ($state -eq 'Running' -or -not $state) { 'apache-module' } else { 'apache-module-stopped' } Add-Use ($Matches[1] -replace '/', '\') $role "Apache service '$svcName' ($state) loads $(Split-Path $Matches[1] -Leaf)" } elseif ($line -match '^\s*PHPIniDir\s+"?([^"]+)"?') { $out.Add("apache_phpinidir=$($Matches[1])") } } } # --- 3. Windows services that run PHP --------------------------------------------------------- foreach ($svc in @(Get-CimInstance Win32_Service | Where-Object { $_.PathName -match 'php' })) { foreach ($m in [regex]::Matches($svc.PathName, $PhpExeRe)) { $role = if ($svc.State -eq 'Running') { 'service' } else { 'service-stopped' } Add-Use $m.Groups[1].Value $role "Windows service '$($svc.Name)' ($($svc.State))" } } # --- 4. Running PHP processes ----------------------------------------------------------------- $procs = @(Get-CimInstance Win32_Process -Filter "Name LIKE 'php%'") if (-not $procs -or $procs.Count -eq 0) { $procs = @(Get-Process | Where-Object { $_.Name -like 'php*' } | ForEach-Object { [pscustomobject]@{ ExecutablePath = $_.Path; ProcessId = $_.Id; Name = $_.Name } }) } $byExe = @{} foreach ($p in $procs) { if ($p.ExecutablePath) { $byExe[$p.ExecutablePath] = 1 + [int]$byExe[$p.ExecutablePath] } } foreach ($exe in $byExe.Keys) { Add-Use $exe 'running-process' "$($byExe[$exe]) running process(es) of $(Split-Path $exe -Leaf)" } $out.Add("php_processes_seen=$($procs.Count)") # --- 5. Scheduled tasks ----------------------------------------------------------------------- foreach ($t in @(Get-ScheduledTask)) { foreach ($a in @($t.Actions)) { $text = "$($a.Execute) $($a.Arguments)" if ($text -notmatch 'php') { continue } foreach ($m in [regex]::Matches($text, $PhpExeRe)) { $role = if ($t.State -eq 'Disabled') { 'scheduled-task-disabled' } else { 'scheduled-task' } Add-Use $m.Groups[1].Value $role "Scheduled task '$($t.TaskPath)$($t.TaskName)' ($($t.State))" } } } # --- 6. PATH ---------------------------------------------------------------------------------- $first = $true foreach ($c in @(Get-Command php.exe -All)) { if (-not ($c -and $c.Source)) { continue } if ($first) { Add-Use $c.Source 'path-default' "First php.exe on PATH (what 'php' runs)"; $first = $false } else { Add-Use $c.Source 'path' 'On PATH (shadowed by an earlier entry)' } } # --- 7. Other copies on disk (reported so you can remove them) -------------------------------- foreach ($p in $Path) { if (Test-Path -LiteralPath $p) { [void]$dirs.Add((Resolve-Path -LiteralPath $p).Path) } } $globs = @('C:\php*', 'D:\php*', 'C:\Program Files\PHP\*', 'C:\Program Files (x86)\PHP\*', 'C:\Program Files\PHP', 'C:\xampp\php', 'C:\wamp64\bin\php\*', 'C:\wamp\bin\php\*', 'C:\laragon\bin\php\*', 'C:\tools\php*', "$env:ProgramData\chocolatey\lib\php\tools\*", 'C:\inetpub\php*') foreach ($g in $globs) { Get-Item -Path $g | Where-Object { $_.PSIsContainer } | ForEach-Object { if ((Test-Path (Join-Path $_.FullName 'php.exe')) -or (Test-Path (Join-Path $_.FullName 'php-cgi.exe'))) { [void]$dirs.Add($_.FullName) } } } # --- per installation ------------------------------------------------------------------------- $found = 0 foreach ($d in ($dirs | Sort-Object)) { $php = Join-Path $d 'php.exe' $cgi = Join-Path $d 'php-cgi.exe' $bin = if (Test-Path -LiteralPath $php) { $php } elseif (Test-Path -LiteralPath $cgi) { $cgi } else { $null } if (-not $bin) { # Apache module folders hold the DLL but not always php.exe; report the reference anyway if ($usage.ContainsKey($d.ToLowerInvariant())) { $out.Add('[php]'); $out.Add("path=$d"); $out.Add("in_use=$(($usage[$d.ToLowerInvariant()] | ForEach-Object { ($_ -split '\|')[0] }) -join ',')"); $out.Add('#note=Referenced, but no php.exe in this folder to read the version from') } continue } $found++ $roles = @(); $details = @() if ($usage.ContainsKey($d.ToLowerInvariant())) { foreach ($u in $usage[$d.ToLowerInvariant()]) { $r = $u -split '\|', 2; $roles += $r[0]; $details += $r[1] } } $out.Add('[php]') $out.Add("path=$bin") $out.Add("in_use=$($roles -join ',')") if ($details.Count) { $out.Add("usage_detail=$($details -join '; ')") } if ($roles -contains 'iis-handler' -or $roles -contains 'iis-fastcgi-registered') { $out.Add('sapi=cgi-fcgi') } elseif ($roles -contains 'apache-module' -or $roles -contains 'apache-module-stopped') { $out.Add('sapi=apache2handler') } else { $out.Add('sapi=cli') } $ver = (& $bin -n -r 'echo PHP_VERSION;' 2>$null) | Select-Object -First 1 if (-not $ver) { $line = (& $bin -v 2>$null) | Select-Object -First 1 if ($line -match '^PHP ([0-9][^\s]*)') { $ver = $Matches[1] } } $out.Add("php_version=$ver") $code = 'foreach (explode(",", getenv("P2S_KEYS")) as $k) { $v = ini_get($k); if ($v !== false) echo "ini.$k=$v\n"; }' $env:P2S_KEYS = ($IniKeys -join ',') $ini = & $bin -r $code 2>$null foreach ($l in @($ini)) { if ($l) { $out.Add($l) } } $loaded = (& $bin -r 'echo php_ini_loaded_file();' 2>$null) | Select-Object -First 1 if ($loaded) { $out.Add("ini_file=$loaded") } $fi = Get-Item -LiteralPath $bin if ($fi.VersionInfo.FileVersion) { $out.Add("file_version=$($fi.VersionInfo.FileVersion)") } } Remove-Item Env:\P2S_KEYS -ErrorAction SilentlyContinue $out.Add("#installs_found=$found") if (-not $isAdmin) { $out.Add('#warning=Not run as Administrator: IIS configuration and other users'' processes may be invisible, so usage is incomplete. Re-run from an elevated PowerShell.') } if ($found -eq 0) { $out.Add('#note=No PHP found in standard locations, IIS, Apache, services, processes or PATH.') } $out.Add('#end') $out -join "`n"