Zum Hauptinhalt springen
Diese Website enthält mit KI erstellte Inhalte. Mehr erfahren
Hintergrundbild zu „PHP2Shell unter Windows: Prüfskripte für Einzelplatz, Test und Monitoring“KI
Aktuell

PHP2Shell unter Windows: Prüfskripte für Einzelplatz, Test und Monitoring

Kostenlose PowerShell-Skripte gegen PHP2Shell: PHP auf Windows finden, bewerten und absichern – Einzelplatzprüfung, Testumgebung und Monitoring.

  • Security
  • Server
  • Monitoring

PHP2Shell betrifft auch Windows-Server: PHP läuft dort im IIS, in Ticket- oder Zeiterfassungssystemen, in Fachanwendungen oder als vergessene Testinstallation – und taucht in keinem Patch-Management auf. Dafür veröffentlichen wir drei PowerShell-Skripte, die wir selbst bei Kunden einsetzen, zum Kopieren und Anpassen.

Kernaussage

Die Skripte finden jede PHP-Installation, bewerten die Version, zeigen die Nutzung und suchen nach Spuren der Angriffswelle. Die Prüfung ist rein lesend; Update und php.ini-Absicherung bietet nur das Einzelplatz-Skript an, ausgeführt erst nach Bestätigung und mit Sicherung.

Laufende Aktualisierung

Stand der Skripte: 25.10.2026

Neue Angriffsmerkmale und Versionen pflegen wir zeitnah ein und veröffentlichen hier angepasste Fassungen. Aufruf, Parameter und Ausgaben bleiben dabei gleich, eingerichtete Abläufe funktionieren weiter. Die Fassung steht im Kopf jedes Skripts und über dem Codeblock; beim Monitoring das Prüfskript nach einem Update einfach neu erzeugen.

Drei Werkzeuge

Was es gibt

Einzelplatzprüfung

Test-PHP2Shell.ps1 prüft einen Rechner interaktiv, erstellt einen Bericht und bietet Update und php.ini-Absicherung an.

Testumgebung

Setup-PHP2ShellTestumgebung.ps1 baut auf einem Testrechner veraltetes PHP und harmlose Testspuren auf – ohne IIS.

Monitoring-Generator

New-PHP2ShellMonitorScript.ps1 erzeugt ein Prüfskript zur Verteilung, das sich in jedes Monitoring einbinden lässt.

Was die Skripte prüfen

PrüfungWie
PHP-InstallationenSuche nach php.exe und php-cgi.exe auf allen lokalen Festplatten; ein Ordner mit beiden Programmen zählt als eine Installation
VersionAus den Dateieigenschaften – PHP wird dafür nicht ausgeführt
BewertungGegen 8.2.35, 8.3.36, 8.4.27 und 8.5.12; Versionen bis 8.1 erhalten keine offizielle Korrektur
NutzungIIS-FastCGI und IIS-Handler, laufende PHP-Prozesse, Apache-Dienst mit PHP-Modul
php.iniOb session.upload_progress.enabled = 0 gesetzt ist (CVE-2026-115560)
ProtokolleIIS-Protokolle der letzten 30 Tage auf bekannte Merkmale der Angriffswelle
Session-DateienSession-Ordner jeder Installation auf die Webshell aus einem dokumentierten Angriffsversuch
ÜbergangsschutzErkennt die Community-Erweiterung php_uploadprogress_guard (ändert die Bewertung nicht)

Wie weit zurück prüfen?

Standardmäßig werden die IIS-Protokolle der letzten 30 Tage durchsucht. Sophos empfiehlt, exponierte Systeme ab dem 1. August zu prüfen – für die Erstprüfung deshalb .\Test-PHP2Shell.ps1 -LogDays 90 aufrufen, beim Monitoring den Wert LogTage im Konfigurationsblock anpassen. Für die laufende Überwachung reichen danach 30 Tage.

Einzelprüfung: Test-PHP2Shell.ps1

  1. Ablegen: Skript in einen eigenen Ordner legen, empfohlen C:\PHP2ShellCheck – dort landen Protokolle, Berichte und Sicherungen.
  2. Als Administrator starten: Auf Wunsch verschickt das Skript den Bericht per Mail, mit Exchange über das Pickup-Verzeichnis, sonst per SMTP.
  3. Angebote bestätigen: Für verwundbare Versionen bietet es das passende offizielle Windows-Paket an, danach für jeden Ordner die php.ini-Absicherung.
  4. Bericht lesen: Am Ende öffnet sich der HTML-Bericht mit Ampel und Empfehlung je Installation.

Test-PHP2Shell.ps1

Einzelplatzprüfung mit Bericht, Update-Angebot und php.ini-Absicherung. Version 1.11, Stand 25.10.2026.

Test-PHP2Shell.ps1
#Requires -Version 5.1
<#
.SYNOPSIS
    Prueft einen Windows-Rechner auf PHP2Shell (CVE-2026-109618, CVE-2026-109619)
    und CVE-2026-115560, erstellt Protokoll und Bericht, bietet - interaktiv und nur
    nach Bestaetigung - das offizielle Windows-Update an und verschickt den Bericht
    auf Wunsch selbst per Mail.

.DESCRIPTION
    EINRICHTUNG
      1. Ordner C:\PHP2ShellCheck anlegen
      2. Dieses Skript in den Ordner legen
         (Das Skript arbeitet immer im eigenen Ordner; ein anderer Ort ist moeglich,
          empfohlen und in dieser Anleitung verwendet wird C:\PHP2ShellCheck.)
      3. Als Administrator starten:
            powershell.exe -ExecutionPolicy Bypass -File .\Test-PHP2Shell.ps1

    ABLAUF (Einzelplatzpruefung)
      1. Abfrage: Bericht nur im Skriptordner ablegen oder zusaetzlich per Mail senden.
         Bei Mail: vorhandene Einstellungen bestaetigen oder neu eingeben
         (Exchange-Pickup, falls Exchange installiert, sonst SMTP mit Anmeldedaten).
      2. Pruefung, bei verwundbarem PHP Angebot fuer Update und php.ini-Absicherung.
      3. Bericht wird immer im Skriptordner abgelegt, bei Wunsch per Mail versendet
         und anschliessend im Browser geoeffnet.

    ABLAGE (alles im Ordner des Skripts)
      PHP2Shell_<Rechner>_<Zeit>.log            Protokoll jedes Laufs
      PHP2Shell_Bericht_<Rechner>_<Zeit>.html   Bericht (auch als Mailtext)
      PHP2Shell_Bericht_<Rechner>_<Zeit>.txt    Bericht als Text
      PHP2Shell_<Rechner>_<Zeit>.json           Ergebnis maschinenlesbar
      PHP2Shell_Verlauf.csv                     Verlauf aller Laeufe
      config.json                               Mail-Einstellungen
      smtp-credential.xml                       SMTP-Kennwort, verschluesselt (nur falls noetig)
      Sicherungen\                              Sicherungen von PHP-Ordnern und php.ini

    PRUEFUNG (immer, rein lesend)
      1. Suche nach php.exe / php-cgi.exe auf allen lokalen Festplatten
      2. Version aus den Dateieigenschaften (PHP wird nicht ausgefuehrt)
      3. Bewertung gegen 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12, bis 8.1 = ohne offizielle Korrektur
      4. Nutzung: IIS-FastCGI und -Handler je Site, laufende Prozesse, Apache mit PHP-Modul
      5. php.ini: session.upload_progress.enabled (CVE-2026-115560)
      6. Webserver-Protokolle: bekannte Angriffsmerkmale
      6a. Session-Ordner jeder PHP-Installation: Webshell aus dem bekannten Angriffsversuch
      7. Abgleich mit dem offiziellen Windows-Paketverzeichnis von PHP

    AENDERUNGEN (nur interaktiv, nur nach ausdruecklicher Bestaetigung)
      - Update auf das offizielle, korrigierte Paket desselben Zweigs
        (Download, SHA256-Pruefung, Sicherung, IIS/Apache stoppen, ersetzen, starten, pruefen)
      - session.upload_progress.enabled = 0 in der php.ini (mit Sicherung)

    MAILVERSAND (das Skript versendet selbst)
      Interaktiv:
        - Das Skript prueft, ob Exchange auf dem Server installiert ist. Nur dann wird der
          Versand ueber das Pickup-Verzeichnis angeboten (kein Relay, keine Zugangsdaten).
        - Auf allen anderen Servern fragt es SMTP-Server, Port und Anmeldedaten ab.
          Das Kennwort wird verschluesselt (DPAPI) fuer das angemeldete Konto gespeichert.
      Monitoring / RMM (unbeaufsichtigt):
        - SMTP-Daten im Block "MAIL-EINSTELLUNGEN FUER DAS MONITORING" weiter unten
          eintragen und dort Aktiv = $true setzen.
        - Empfohlen: interner Relay ohne Anmeldung oder ein eigenes Postfach, das nur senden darf.
        - Ein Kennwort im Skript ist fuer jeden lesbar, der den Ordner lesen darf. Dann den
          Ordner auf Administratoren und SYSTEM beschraenken, z. B.:
            icacls C:\PHP2ShellCheck /inheritance:r /grant:r *S-1-5-32-544:(OI)(CI)F *S-1-5-18:(OI)(CI)F
          (S-1-5-32-544 = Administratoren, S-1-5-18 = SYSTEM; funktioniert in jeder Sprache)
        - Versand je nach "SendenAb" (Immer / Warnung / Kritisch / Nie), mit -SendMail immer.
      Reihenfolge: config.json (interaktiv eingerichtet) vor dem Block im Skript.

.PARAMETER LogDays
    Tage, die Webserver-Protokolle rueckwirkend durchsucht werden. Standard: 30

.PARAMETER AdditionalLogPath
    Weitere Protokollverzeichnisse (z. B. Apache).

.PARAMETER SkipLogScan
    Protokollsuche ueberspringen.

.PARAMETER SkipOnlineCheck
    Kein Abgleich mit dem PHP-Paketverzeichnis (Offline-Betrieb).

.PARAMETER NoPrompt
    Keine Rueckfragen (fuer Monitoring/RMM). Automatisch bei nicht interaktiver Sitzung.

.PARAMETER Quiet
    Nur die Zusammenfassungszeile auf der Konsole (impliziert -NoPrompt).

.PARAMETER SetupMail
    Nur die Mail-Einstellungen einrichten und eine Testmail senden.

.PARAMETER SendMail
    Bericht in jedem Fall per Mail senden (unabhaengig von "SendenAb").

.OUTPUTS
    Exit-Codes: 0 = OK, 1 = WARNUNG, 2 = KRITISCH, 3 = FEHLER

.NOTES
    Codekunst Systems GmbH - Version 1.11 - 25.10.2026

    HAFTUNGSAUSSCHLUSS
    Dieses Skript wird ohne Gewaehr bereitgestellt. Die Pruefung ist rein lesend; Aenderungen
    (Update, php.ini) erfolgen nur nach ausdruecklicher Bestaetigung und mit vorheriger Sicherung.
    Vor dem Einsatz auf produktiven Systemen in einer Testumgebung pruefen. Eingesetzt auf
    eigene Verantwortung des Betreibers. Ersetzt keine forensische Untersuchung.
    Windows PowerShell 5.1, Administratorrechte empfohlen (fuer Patch erforderlich).
    Hintergrund: codekunst-systems.com/aktuell/php2shell-statusuebersicht
#>
[CmdletBinding()]
param(
    [int]     $LogDays           = 30,
    [string[]]$AdditionalLogPath = @(),
    [switch]  $SkipLogScan,
    [switch]  $SkipOnlineCheck,
    [switch]  $NoPrompt,
    [switch]  $Quiet,
    [switch]  $SetupMail,
    [switch]  $SendMail
)

$ErrorActionPreference = 'Stop'
# Umlauts for user-visible texts, built from character codes so this file stays pure ASCII
# (distinct names: PowerShell variable names are case-insensitive)
$c_ae = [char]0xE4; $c_oe = [char]0xF6; $c_ue = [char]0xFC; $c_sz = [char]0xDF
$c_AeGross = [char]0xC4; $c_OeGross = [char]0xD6; $c_UeGross = [char]0xDC

$ScriptVersion = '1.11'
if ($Quiet) { $NoPrompt = $true }
if (-not [Environment]::UserInteractive) { $NoPrompt = $true }

# =============================================================================
#  MAIL-EINSTELLUNGEN FUER DAS MONITORING  (pro Kunde anpassen)
#  Wird nur verwendet, wenn Aktiv = $true und keine config.json vorhanden ist.
#  Benutzer leer lassen = Versand ohne Anmeldung (interner Relay / Connector).
#  ACHTUNG: Ein hier eingetragenes Kennwort ist im Klartext lesbar. Nur ein eigenes
#  Postfach verwenden, das ausschliesslich senden darf, und Ordnerrechte einschraenken.
# =============================================================================
$ScriptMail = @{
    Aktiv            = $false
    Empfaenger       = @('it@kunde.de')            # mehrere: @('a@x.de','b@x.de')
    Absender         = 'php2shell@kunde.de'
    SmtpServer       = 'smtp.office365.com'        # oder interner Relay
    SmtpPort         = 587
    Verschluesselung = $true                       # STARTTLS/TLS
    Benutzer         = ''                          # leer = ohne Anmeldung
    Kennwort         = ''                          # siehe ACHTUNG oben
    SendenAb         = 'Warnung'                   # Immer | Warnung | Kritisch | Nie
}
# =============================================================================

# --- Reference data ---------------------------------------------------------
$FixedVersions = @{ '8.2' = 35; '8.3' = 36; '8.4' = 27; '8.5' = 12 }
$Indicators = @(
    'php2shell-', 'B7kQpoc618', 'B7kQpoc619', 'p2s9rdme2st6guc9g0rafic', 'php://filter',
    'xqsuvwz3n97wu8j3emz1axuo5y',
    '85.11.167.243', '217.60.242.76', '45.198.224.169', '221.14.159.134', '91.90.123.25',
    '87.121.84.174', '181.215.186.252', '185.196.220.132', '185.161.210.29', '152.233.51.69',
    '45.134.225.16', '107.189.14.87', '193.47.62.24', '103.147.220.17',
    '2409:40d2:204e:4be:2a1f:f9c9:a055:e00b', '206.188.197.105',
    '92.118.112.100', '134.199.154.64', 'godofthunder.st'
)
# Unclear sources, rated as warning (investigate, do not block):
#  109.71.252.97 - user agent claims a self-assessment (not verifiable)
#  Amazon list   - anonymization services, also used legitimately
$InfoIndicators = @('109.71.252.97',
    '103.216.220.92', '31.223.184.166', '37.19.200.5', '134.209.159.64', '103.114.163.188', '95.217.98.37')
# Content of the web shell seen in a documented attempt (session file)
$SessionShellPatterns = @('PWNED::', 'system($_GET')
$ExcludedDirNames = @('$Recycle.Bin', 'System Volume Information', 'WinSxS', 'Installer')
$ReleasesUrl  = 'https://downloads.php.net/~windows/releases/releases.json'
$DownloadBase = 'https://downloads.php.net/~windows/releases/'

# --- Folders: everything lives next to the script ---------------------------
$BaseDirectory   = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
$BackupDirectory = Join-Path $BaseDirectory 'Sicherungen'
$ConfigPath      = Join-Path $BaseDirectory 'config.json'
$CredentialPath  = Join-Path $BaseDirectory 'smtp-credential.xml'

$Start    = Get-Date
$Stamp    = $Start.ToString('yyyyMMdd-HHmmss')
$Computer = $env:COMPUTERNAME
$LogPath  = Join-Path $BaseDirectory "PHP2Shell_$Computer`_$Stamp.log"

function Write-Log {
    param([string]$Text, [ValidateSet('INFO','WARN','ERROR','ACTION')][string]$Level = 'INFO')
    $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Text
    for ($try = 0; $try -lt 3; $try++) {
        try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 -ErrorAction Stop; break }
        catch { Start-Sleep -Milliseconds 200 }
    }
    if (-not $Quiet) {
        switch ($Level) {
            'WARN'   { Write-Host $Text -ForegroundColor Yellow }
            'ERROR'  { Write-Host $Text -ForegroundColor Red }
            'ACTION' { Write-Host $Text -ForegroundColor Cyan }
            default  { Write-Host $Text }
        }
    }
}

function Test-IsAdmin {
    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
    return (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Read-YesNo {
    param([string]$Question)
    return ((Read-Host "$Question [j/N]") -match '^(j|ja|y|yes)$')
}

function ConvertTo-HtmlSafe {
    param([string]$Text)
    if ($null -eq $Text) { return '' }
    return [System.Net.WebUtility]::HtmlEncode($Text)
}

function Get-UniqueBackupPath {
    # Never overwrite an existing backup: append _2, _3, ... if needed
    param([string]$Path)
    if (-not (Test-Path -LiteralPath $Path)) { return $Path }
    $n = 2
    while (Test-Path -LiteralPath "$Path`_$n") { $n++ }
    return "$Path`_$n"
}

function New-BackupDirectory {
    if (-not (Test-Path -LiteralPath $BackupDirectory)) {
        New-Item -ItemType Directory -Path $BackupDirectory -Force | Out-Null
    }
}

# --- Discovery --------------------------------------------------------------
function Find-PhpBinaries {
    $found = New-Object System.Collections.Generic.List[string]
    $roots = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' | ForEach-Object { $_.DeviceID + '\' }
    foreach ($root in $roots) {
        $stack = New-Object System.Collections.Generic.Stack[string]
        $stack.Push($root)
        while ($stack.Count -gt 0) {
            $dir = $stack.Pop()
            if ($dir -ieq $BackupDirectory) { continue }   # own backups are not installations
            try {
                foreach ($file in [System.IO.Directory]::GetFiles($dir)) {
                    $name = [System.IO.Path]::GetFileName($file)
                    if ($name -ieq 'php.exe' -or $name -ieq 'php-cgi.exe') { $found.Add($file) }
                }
                foreach ($sub in [System.IO.Directory]::GetDirectories($dir)) {
                    if ($ExcludedDirNames -contains [System.IO.Path]::GetFileName($sub)) { continue }
                    if ([System.IO.File]::GetAttributes($sub) -band [System.IO.FileAttributes]::ReparsePoint) { continue }
                    $stack.Push($sub)
                }
            } catch { }
        }
    }
    return $found
}

function Get-PhpVersion {
    param([string]$Path)
    try {
        $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($Path)
        foreach ($candidate in @($vi.ProductVersion, $vi.FileVersion)) {
            if ($candidate -and $candidate -match '^(\d+)\.(\d+)\.(\d+)') {
                return [pscustomobject]@{
                    Text = '{0}.{1}.{2}' -f $Matches[1], $Matches[2], $Matches[3]
                    Major = [int]$Matches[1]; Minor = [int]$Matches[2]; Patch = [int]$Matches[3]
                }
            }
        }
    } catch { }
    return $null
}

function Get-VersionStatus {
    param($Version)
    if ($null -eq $Version) { return 'UNBEKANNT' }
    $branch = '{0}.{1}' -f $Version.Major, $Version.Minor
    if ($Version.Major -lt 8 -or ($Version.Major -eq 8 -and $Version.Minor -lt 2)) { return 'EOL' }
    if ($FixedVersions.ContainsKey($branch)) {
        if ($Version.Patch -ge $FixedVersions[$branch]) { return 'GEPATCHT' } else { return 'VERWUNDBAR' }
    }
    return 'GEPATCHT'
}

function Get-PeArchitecture {
    param([string]$Path)
    try {
        $fs = [System.IO.File]::OpenRead($Path)
        try {
            $br = New-Object System.IO.BinaryReader($fs)
            $fs.Position = 0x3C
            $peOffset = $br.ReadInt32()
            $fs.Position = $peOffset + 4
            switch ($br.ReadUInt16()) { 0x8664 { return 'x64' } 0x14c { return 'x86' } default { return 'unbekannt' } }
        } finally { $fs.Dispose() }
    } catch { return 'unbekannt' }
}

function Get-ThreadSafety {
    param([string]$PhpDir)
    if (Get-ChildItem -LiteralPath $PhpDir -Filter 'php*ts.dll' -ErrorAction SilentlyContinue) { return 'ts' }
    if (Get-ChildItem -LiteralPath $PhpDir -Filter 'php?.dll'  -ErrorAction SilentlyContinue) { return 'nts' }
    return 'unbekannt'
}

function Get-UploadProgressSetting {
    param([string]$PhpDir)
    $ini = Join-Path $PhpDir 'php.ini'
    if (-not (Test-Path -LiteralPath $ini)) { return 'an (Standard, keine php.ini)' }
    $value = $null
    foreach ($line in [System.IO.File]::ReadAllLines($ini)) {
        if ($line -match '^\s*session\.upload_progress\.enabled\s*=\s*"?([^";\s]+)') { $value = $Matches[1] }
    }
    if ($null -eq $value) { return 'an (Standard, nicht gesetzt)' }
    if ($value -match '^(0|off|false|no)$') { return 'aus' }
    return 'an'
}

function Get-IisContext {
    $ctx = [pscustomobject]@{ FastCgiPaths = @(); Handlers = @(); LogDirectories = @() }
    $cfgPath = Join-Path $env:windir 'System32\inetsrv\config\applicationHost.config'
    if (-not (Test-Path -LiteralPath $cfgPath)) { return $ctx }
    try {
        [xml]$cfg = [System.IO.File]::ReadAllText($cfgPath)
        $apps = $cfg.configuration.'system.webServer'.fastCgi.application
        if ($apps) { $ctx.FastCgiPaths = @($apps | ForEach-Object { [Environment]::ExpandEnvironmentVariables($_.fullPath) }) }
        $handlerList = New-Object System.Collections.Generic.List[object]
        foreach ($h in @($cfg.configuration.'system.webServer'.handlers.add)) {
            if ($h -and $h.scriptProcessor) {
                $handlerList.Add([pscustomobject]@{ Ebene = 'Server'; Name = $h.name
                    Prozessor = [Environment]::ExpandEnvironmentVariables(($h.scriptProcessor -split '\|')[0]) })
            }
        }
        foreach ($loc in @($cfg.configuration.location)) {
            if (-not $loc) { continue }
            foreach ($h in @($loc.'system.webServer'.handlers.add)) {
                if ($h -and $h.scriptProcessor) {
                    $handlerList.Add([pscustomobject]@{ Ebene = "Site: $($loc.path)"; Name = $h.name
                        Prozessor = [Environment]::ExpandEnvironmentVariables(($h.scriptProcessor -split '\|')[0]) })
                }
            }
        }
        $ctx.Handlers = $handlerList
        $logDir = $cfg.configuration.'system.applicationHost'.sites.siteDefaults.logFile.directory
        if (-not $logDir) { $logDir = '%SystemDrive%\inetpub\logs\LogFiles' }
        $ctx.LogDirectories = @([Environment]::ExpandEnvironmentVariables($logDir))
    } catch { Write-Log "IIS-Konfiguration nicht lesbar: $($_.Exception.Message)" 'WARN' }
    return $ctx
}

function Get-ApacheServices {
    @(Get-CimInstance -ClassName Win32_Service -ErrorAction SilentlyContinue | Where-Object { $_.PathName -match 'httpd\.exe' })
}

function Search-Indicators {
    param([string[]]$Directories, [int]$Days)
    $hits = New-Object System.Collections.Generic.List[object]
    $since = (Get-Date).AddDays(-$Days)
    foreach ($dir in $Directories | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) {
        $files = Get-ChildItem -LiteralPath $dir -Recurse -File -Include *.log -ErrorAction SilentlyContinue |
                 Where-Object { $_.LastWriteTime -ge $since }
        foreach ($f in $files) {
            $m = Select-String -LiteralPath $f.FullName -Pattern ($Indicators + $InfoIndicators) -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 20
            foreach ($x in $m) {
                $line = $x.Line
                $kind = if (@($InfoIndicators | Where-Object { $line.Contains($_) }).Count -gt 0) { 'Unklar' } else { 'Angriffsmerkmal' }
                $hits.Add([pscustomobject]@{ Datei = $f.FullName; Zeile = $x.LineNumber; Merkmal = $x.Pattern; Einstufung = $kind
                    Auszug = $x.Line.Substring(0, [Math]::Min(300, $x.Line.Length)) })
            }
        }
    }
    return $hits
}

function Get-SessionDirectories {
    # session.save_path from php.ini of each installation, plus the Windows default
    param($Installs)
    $dirs = New-Object System.Collections.Generic.List[string]
    foreach ($i in $Installs) {
        $ini = Join-Path $i.Ordner 'php.ini'
        if (Test-Path -LiteralPath $ini) {
            foreach ($line in [System.IO.File]::ReadAllLines($ini)) {
                if ($line -match '^\s*session\.save_path\s*=\s*"?([^"]+?)"?\s*$') {
                    $v = $Matches[1]
                    if ($v -match ';') { $v = ($v -split ';')[-1] }   # "N;/path" or "N;MODE;/path"
                    if ($v) { $dirs.Add([Environment]::ExpandEnvironmentVariables($v.Trim())) }
                }
            }
        }
    }
    $dirs.Add((Join-Path $env:windir 'Temp'))
    return @($dirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique)
}

function Search-SessionWebshells {
    param([string[]]$Directories)
    $hits = New-Object System.Collections.Generic.List[object]
    foreach ($dir in $Directories) {
        $files = Get-ChildItem -LiteralPath $dir -Recurse -File -Filter 'sess_*' -ErrorAction SilentlyContinue
        foreach ($f in $files) {
            $known = $f.Name -eq 'sess_xqsuvwz3n97wu8j3emz1axuo5y'
            $m = Select-String -LiteralPath $f.FullName -Pattern $SessionShellPatterns -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($known -or $m) {
                $hits.Add([pscustomobject]@{ Datei = $f.FullName; Geaendert = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm')
                    Merkmal = if ($m) { $m.Pattern } else { 'bekannter Dateiname' } })
            }
        }
    }
    return $hits
}

# --- Official Windows packages ------------------------------------------------
function Get-WindowsReleases {
    try {
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        return Invoke-RestMethod -Uri $ReleasesUrl -UseBasicParsing -TimeoutSec 30
    } catch {
        Write-Log "Abgleich mit dem PHP-Paketverzeichnis nicht m${c_oe}glich: $($_.Exception.Message)" 'WARN'
        return $null
    }
}

function Find-OfficialPackage {
    param($Releases, $Version, [string]$Ts, [string]$Arch)
    if ($null -eq $Releases -or $null -eq $Version) { return $null }
    $branch = '{0}.{1}' -f $Version.Major, $Version.Minor
    $entry = $Releases.$branch
    if (-not $entry) { return [pscustomobject]@{ Verfuegbar = $false; Grund = "Zweig $branch nicht im Paketverzeichnis" } }
    if (-not ($entry.version -match '^(\d+)\.(\d+)\.(\d+)')) { return $null }
    $latestPatch = [int]$Matches[3]
    if (-not $FixedVersions.ContainsKey($branch) -or $latestPatch -lt $FixedVersions[$branch]) {
        return [pscustomobject]@{ Verfuegbar = $false; Grund = "Neuestes Windows-Paket f${c_ue}r $branch ist $($entry.version) (ohne Korrektur)" }
    }
    if ($Version.Patch -ge $latestPatch) { return [pscustomobject]@{ Verfuegbar = $false; Grund = 'Bereits aktuell' } }
    $pick = $entry.PSObject.Properties | Where-Object { $_.Name -match "^$Ts-v[sc](\d+)-$Arch$" } |
            Sort-Object { [int]([regex]::Match($_.Name, 'v[sc](\d+)').Groups[1].Value) } -Descending |
            Select-Object -First 1
    if (-not $pick) { return [pscustomobject]@{ Verfuegbar = $false; Grund = "Kein Paket f${c_ue}r $Ts/$Arch gefunden" } }
    return [pscustomobject]@{
        Verfuegbar = $true; Version = $entry.version; Variante = $pick.Name
        Url = $DownloadBase + $pick.Value.zip.path; Sha256 = $pick.Value.zip.sha256; Datei = $pick.Value.zip.path
    }
}

# --- Changes (interactive only) ---------------------------------------------
function Set-UploadProgressOff {
    param([string]$PhpDir)
    $ini = Join-Path $PhpDir 'php.ini'
    if (-not (Test-Path -LiteralPath $ini)) {
        Write-Log "Keine php.ini in $PhpDir - bitte php.ini-production als php.ini anlegen und Einstellung setzen." 'WARN'
        return $false
    }
    New-BackupDirectory
    $backup = Get-UniqueBackupPath (Join-Path $BackupDirectory ('php.ini_{0}_{1}' -f (Split-Path -Leaf $PhpDir), $Stamp))
    Copy-Item -LiteralPath $ini -Destination $backup
    $lines = [System.IO.File]::ReadAllLines($ini)
    $changed = $false
    for ($n = 0; $n -lt $lines.Count; $n++) {
        if ($lines[$n] -match '^\s*;?\s*session\.upload_progress\.enabled\s*=') {
            $lines[$n] = 'session.upload_progress.enabled = 0'; $changed = $true
        }
    }
    if (-not $changed) { $lines += 'session.upload_progress.enabled = 0' }
    [System.IO.File]::WriteAllLines($ini, $lines)
    Write-Log "php.ini angepasst: session.upload_progress.enabled = 0 ($ini, Sicherung: $backup)" 'ACTION'
    return $true
}

function Stop-PhpConsumers {
    param($Install)
    $state = [pscustomobject]@{ IisStopped = $false; ApacheServices = @() }
    if ($Install.Nutzung -match 'IIS') {
        Write-Log 'Stoppe IIS (iisreset /stop) ...' 'ACTION'
        & iisreset.exe /stop | Out-Null
        $state.IisStopped = $true
    }
    foreach ($svc in Get-ApacheServices) {
        if ($svc.State -eq 'Running') {
            Write-Log "Stoppe Dienst $($svc.Name) ..." 'ACTION'
            Stop-Service -Name $svc.Name -Force
            $state.ApacheServices += $svc.Name
        }
    }
    Start-Sleep -Seconds 3
    $left = Get-CimInstance Win32_Process -Filter "Name='php-cgi.exe' OR Name='php.exe'" -ErrorAction SilentlyContinue |
            Where-Object { $_.ExecutablePath -and ((Split-Path -Parent $_.ExecutablePath) -ieq $Install.Ordner) }
    foreach ($p in $left) {
        Write-Log "Beende verbleibenden Prozess $($p.ProcessId) ($($p.ExecutablePath))" 'ACTION'
        Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue
    }
    return $state
}

function Start-PhpConsumers {
    param($State)
    foreach ($name in $State.ApacheServices) { Write-Log "Starte Dienst $name ..." 'ACTION'; Start-Service -Name $name }
    if ($State.IisStopped) { Write-Log 'Starte IIS (iisreset /start) ...' 'ACTION'; & iisreset.exe /start | Out-Null }
}

function Invoke-PhpUpdate {
    param($Install)
    $pkg    = $Install.Paket
    $tmpZip = Join-Path $env:TEMP $pkg.Datei
    $tmpDir = Join-Path $env:TEMP "php-update-$Stamp"
    New-BackupDirectory
    $backup = Get-UniqueBackupPath (Join-Path $BackupDirectory ('{0}_{1}' -f (Split-Path -Leaf $Install.Ordner), $Stamp))
    $state  = $null
    try {
        Write-Log "Lade $($pkg.Url) ..." 'ACTION'
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        Invoke-WebRequest -Uri $pkg.Url -OutFile $tmpZip -UseBasicParsing -TimeoutSec 300
        $hash = (Get-FileHash -LiteralPath $tmpZip -Algorithm SHA256).Hash
        if (-not $pkg.Sha256 -or $hash -ine $pkg.Sha256) {
            Write-Log "Pr${c_ue}fsumme stimmt nicht (erwartet $($pkg.Sha256), erhalten $hash) - Abbruch, nichts ver${c_ae}ndert." 'ERROR'
            return $false
        }
        Write-Log "Pr${c_ue}fsumme SHA256 korrekt." 'ACTION'
        Expand-Archive -LiteralPath $tmpZip -DestinationPath $tmpDir -Force
        Write-Log "Sichere $($Install.Ordner) nach $backup ..." 'ACTION'
        Copy-Item -LiteralPath $Install.Ordner -Destination $backup -Recurse
        $state = Stop-PhpConsumers -Install $Install
        Write-Log 'Ersetze Programmdateien (php.ini und eigene Erweiterungen bleiben erhalten) ...' 'ACTION'
        Get-ChildItem -LiteralPath $tmpDir | ForEach-Object {
            Copy-Item -LiteralPath $_.FullName -Destination $Install.Ordner -Recurse -Force
        }
        $newVersion = Get-PhpVersion -Path $Install.Referenz
        if ($newVersion -and (Get-VersionStatus $newVersion) -eq 'GEPATCHT') {
            Write-Log "Update erfolgreich: $($Install.Version) -> $($newVersion.Text)" 'ACTION'
            $Install.Version = $newVersion.Text; $Install.Status = 'GEPATCHT'
            $Install.Dateidatum = (Get-Item -LiteralPath $Install.Referenz).LastWriteTime.ToString('yyyy-MM-dd')
            $Install.Aktion  = "Aktualisiert auf $($newVersion.Text), Sicherung: $backup"
            return $true
        }
        Write-Log "Version nach dem Update nicht wie erwartet - bitte pr${c_ue}fen, Sicherung: $backup" 'ERROR'
        return $false
    } catch {
        Write-Log "Update fehlgeschlagen: $($_.Exception.Message). Sicherung: $backup" 'ERROR'
        return $false
    } finally {
        if ($state) { Start-PhpConsumers -State $state }
        Remove-Item -LiteralPath $tmpZip -Force -ErrorAction SilentlyContinue
        Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
    }
}

# --- Assessment -------------------------------------------------------------
function Get-Severity {
    param($Install)
    $sev = 0
    if ($Install.Status -in 'VERWUNDBAR', 'EOL') { if ($Install.AktivGenutzt) { $sev = 2 } else { $sev = 1 } }
    if ($Install.Status -eq 'UNBEKANNT') { $sev = [Math]::Max($sev, 1) }
    if ($Install.UploadFortschritt -ne 'aus') { $sev = [Math]::Max($sev, 1) }
    return $sev
}

function Test-GuardExtension {
    # Detects the community mitigation php_uploadprogress_guard (Hosted Power)
    param([string]$PhpDir)
    $ini = Join-Path $PhpDir 'php.ini'
    if (Test-Path -LiteralPath $ini) {
        foreach ($line in [System.IO.File]::ReadAllLines($ini)) {
            if ($line -match '^\s*(zend_)?extension\s*=\s*"?[^";]*uploadprogress_guard') { return $true }
        }
    }
    $confD = Join-Path $PhpDir 'conf.d'
    if (Test-Path -LiteralPath $confD) {
        foreach ($f in Get-ChildItem -LiteralPath $confD -Filter '*.ini' -ErrorAction SilentlyContinue) {
            if (Select-String -LiteralPath $f.FullName -Pattern '^\s*(zend_)?extension\s*=.*uploadprogress_guard' -Quiet) { return $true }
        }
    }
    return $false
}

function Get-Advice {
    param($Install)
    $a = New-Object System.Collections.Generic.List[string]
    switch ($Install.Status) {
        'VERWUNDBAR' {
            if ($Install.Plesk) { $a.Add("${c_UeGross}ber den Plesk-Updater aktualisieren.") }
            elseif ($Install.Paket -and $Install.Paket.Verfuegbar) { $a.Add("Offizielles Paket $($Install.Paket.Version) ($($Install.Paket.Variante)) verf${c_ue}gbar.") }
            elseif ($Install.Paket) { $a.Add("$($Install.Paket.Grund). Bis dahin vom Netz nehmen oder absichern.") }
            else { $a.Add('Auf die korrigierte Version des Zweigs aktualisieren.') }
        }
        'EOL' {
            if ($Install.Plesk) { $a.Add("Plesk-Altversion: Build-Datum ab 21.10.2026 pr${c_ue}fen, mittelfristig Version wechseln.") }
            else { $a.Add('Keine offizielle Korrektur: auf PHP 8.3 oder 8.4 wechseln, bis dahin nicht aus dem Internet erreichbar betreiben.') }
        }
        'UNBEKANNT' { $a.Add("Version manuell pr${c_ue}fen (php -v im PHP-Ordner).") }
    }
    if ($Install.UploadFortschritt -ne 'aus') { $a.Add('session.upload_progress.enabled = 0 setzen (CVE-2026-115560).') }
    if ($Install.Uebergangsschutz -and $Install.Status -ne 'GEPATCHT') { $a.Add("${c_UeGross}bergangsschutz php_uploadprogress_guard ist aktiv - bleibt eine Br${c_ue}cke, Update bzw. Versionswechsel weiterhin n${c_oe}tig.") }
    if (-not $Install.AktivGenutzt -and $Install.Status -ne 'GEPATCHT') { $a.Add("Nicht aktiv genutzt gefunden: entfernen, falls nicht ben${c_oe}tigt.") }
    return ($a -join ' ')
}

# --- Reports ----------------------------------------------------------------
function New-Reports {
    param($Installs, $LogHits, [string]$Overall, [bool]$IsAdmin)
    $base = Join-Path $BaseDirectory "PHP2Shell_Bericht_$Computer`_$Stamp"

    $sb = New-Object System.Text.StringBuilder
    [void]$sb.AppendLine("PHP2Shell-Pr${c_ue}fbericht - $Computer")
    [void]$sb.AppendLine("Erstellt: $($Start.ToString('dd.MM.yyyy HH:mm')) - Skriptversion $ScriptVersion - Codekunst Systems")
    [void]$sb.AppendLine(('=' * 72))
    [void]$sb.AppendLine("Gesamtergebnis: $Overall")
    if (-not $IsAdmin) { [void]$sb.AppendLine("Hinweis: ohne Administratorrechte ausgef${c_ue}hrt, Ergebnis evtl. unvollst${c_ae}ndig.") }
    [void]$sb.AppendLine('')
    if ($Installs.Count -eq 0) { [void]$sb.AppendLine('Keine PHP-Installation (php.exe / php-cgi.exe) gefunden.') }
    foreach ($i in $Installs) {
        [void]$sb.AppendLine("- $($i.Pfad)  [$($i.Programme)]")
        [void]$sb.AppendLine("  Version: $($i.Version)  ($($i.Variante))  Status: $($i.Status)  Dateidatum: $($i.Dateidatum)")
        [void]$sb.AppendLine("  Genutzt: $(if ($i.AktivGenutzt) { $i.Nutzung } else { 'nicht festgestellt' })")
        [void]$sb.AppendLine("  Upload-Fortschrittsanzeige: $($i.UploadFortschritt)")
        if ($i.Uebergangsschutz) { [void]$sb.AppendLine("  ${c_UeGross}bergangsschutz: php_uploadprogress_guard geladen (Community-Erweiterung, ersetzt kein Update)") }
        if ($i.Empfehlung) { [void]$sb.AppendLine("  Empfehlung: $($i.Empfehlung)") }
        if ($i.Aktion)     { [void]$sb.AppendLine("  Durchgef${c_ue}hrt: $($i.Aktion)") }
        [void]$sb.AppendLine('')
    }
    if ($SkipLogScan) { [void]$sb.AppendLine("Protokollsuche: ${c_ue}bersprungen.") }
    elseif ($LogHits.Count -eq 0) { [void]$sb.AppendLine("Protokollsuche ($LogDays Tage): keine bekannten Angriffsmerkmale.") }
    else {
        [void]$sb.AppendLine("Protokollsuche ($LogDays Tage): $($LogHits.Count) Treffer - Angriffsversuch, nicht zwingend erfolgreich.")
        [void]$sb.AppendLine("Vor weiteren ${c_AeGross}nderungen Beweise sichern und den Vorfall pr${c_ue}fen lassen.")
        foreach ($h in ($LogHits | Select-Object -First 25)) { [void]$sb.AppendLine("  $($h.Datei):$($h.Zeile) [$($h.Merkmal)]") }
    }
    if ($infoHits.Count -gt 0) {
        [void]$sb.AppendLine("Au${c_sz}erdem $($infoHits.Count) Treffer von Adressen mit unklarer Einstufung (Anonymisierungsdienste laut Amazon bzw. nicht ${c_ue}berpr${c_ue}fbarer Pr${c_ue}fscanner) - untersuchen, nicht automatisch sperren.")
    }
    [void]$sb.AppendLine('')
    if ($sessionHits.Count -eq 0) {
        [void]$sb.AppendLine('Session-Ordner: keine Webshell-Merkmale gefunden.')
    } else {
        [void]$sb.AppendLine("Session-Ordner: $($sessionHits.Count) Datei(en) mit Webshell-Merkmalen - starker Hinweis auf eine Kompromittierung!")
        [void]$sb.AppendLine("System nicht neu starten und nichts l${c_oe}schen: zuerst Beweise sichern (Speicherabbild, Dateien, Protokolle) und den Vorfall pr${c_ue}fen lassen.")
        foreach ($h in $sessionHits) { [void]$sb.AppendLine("  $($h.Datei) (ge${c_ae}ndert $($h.Geaendert)) [$($h.Merkmal)]") }
    }
    [void]$sb.AppendLine('')
    [void]$sb.AppendLine("Rein lesende Pr${c_ue}fung, sofern oben keine Aktion aufgef${c_ue}hrt ist. Ersetzt keine Pr${c_ue}fung")
    [void]$sb.AppendLine('beim Hoster oder in Containern und keine forensische Untersuchung.')
    $txtPath = "$base.txt"
    $sb.ToString() | Set-Content -LiteralPath $txtPath -Encoding UTF8

    $color = @{ 'OK' = '#2e7d32'; 'WARNUNG' = '#ef6c00'; 'KRITISCH' = '#c62828' }[$Overall]
    $rows = foreach ($i in $Installs) {
        $c = @('#2e7d32', '#ef6c00', '#c62828')[$i.Schweregrad]
        "<tr><td>$(ConvertTo-HtmlSafe $i.Pfad)<br><small>$(ConvertTo-HtmlSafe $i.Programme)</small></td><td>$(ConvertTo-HtmlSafe $i.Version)<br><small>$(ConvertTo-HtmlSafe $i.Variante)</small></td>" +
        "<td style='color:$c;font-weight:bold'>$(ConvertTo-HtmlSafe $i.Status)$(if ($i.Uebergangsschutz) { '<br><small>&Uuml;bergangsschutz aktiv</small>' })</td>" +
        "<td>$(if ($i.AktivGenutzt) { ConvertTo-HtmlSafe $i.Nutzung } else { 'nicht festgestellt' })</td>" +
        "<td>$(ConvertTo-HtmlSafe $i.UploadFortschritt)</td>" +
        "<td>$(ConvertTo-HtmlSafe $i.Empfehlung)$(if ($i.Aktion) { '<br><b>Durchgef&uuml;hrt:</b> ' + (ConvertTo-HtmlSafe $i.Aktion) })</td></tr>"
    }
    $hitRows = foreach ($h in ($LogHits | Select-Object -First 50)) {
        "<tr><td>$(ConvertTo-HtmlSafe $h.Datei)</td><td>$($h.Zeile)</td><td>$(ConvertTo-HtmlSafe $h.Merkmal)</td><td><code>$(ConvertTo-HtmlSafe $h.Auszug)</code></td></tr>"
    }
    $logText = if ($SkipLogScan) { 'Protokollsuche &uuml;bersprungen.' }
               elseif ($LogHits.Count -eq 0) { "Keine bekannten Angriffsmerkmale in den Protokollen der letzten $LogDays Tage." }
               else { "<b>$($LogHits.Count) Treffer</b> auf bekannte Angriffsmerkmale &ndash; ein Angriffsversuch, nicht zwingend ein erfolgreicher Angriff. Vor weiteren &Auml;nderungen Beweise sichern." }
    $adminNote = if (-not $IsAdmin) { '<p><b>Hinweis:</b> ohne Administratorrechte ausgef&uuml;hrt, Ergebnis eventuell unvollst&auml;ndig.</p>' } else { '' }
    $table = if ($Installs.Count -eq 0) { '<p>Keine PHP-Installation gefunden.</p>' } else {
        "<table><tr><th>Pfad</th><th>Version</th><th>Status</th><th>Genutzt von</th><th>Upload-Fortschritt</th><th>Empfehlung</th></tr>$($rows -join '')</table>" }
    $hitTable = if ($LogHits.Count -gt 0) { "<table><tr><th>Datei</th><th>Zeile</th><th>Merkmal</th><th>Auszug</th></tr>$($hitRows -join '')</table>" } else { '' }
    $html = @"
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><title>PHP2Shell-Pr&uuml;fbericht $Computer</title>
<style>body{font-family:Segoe UI,Arial,sans-serif;font-size:14px;color:#222;margin:24px}
h1{font-size:20px}table{border-collapse:collapse;width:100%;margin:12px 0}
th,td{border:1px solid #ccc;padding:6px 8px;text-align:left;vertical-align:top}th{background:#f0f0f0}
.badge{display:inline-block;padding:4px 12px;border-radius:4px;color:#fff;background:$color;font-weight:bold}
small{color:#666}</style></head><body>
<h1>PHP2Shell-Pr&uuml;fbericht &ndash; $Computer</h1>
<p>Erstellt am $($Start.ToString('dd.MM.yyyy')) um $($Start.ToString('HH:mm')) Uhr &middot; Skriptversion $ScriptVersion &middot; Codekunst Systems GmbH</p>
<p>Gesamtergebnis: <span class="badge">$Overall</span></p>
$adminNote
<h2>Gefundene PHP-Installationen ($($Installs.Count))</h2>
$table
<h2>Webserver-Protokolle</h2><p>$logText</p>
$(if ($infoHits.Count -gt 0) { "<p><small>Au&szlig;erdem $($infoHits.Count) Treffer von Adressen mit unklarer Einstufung (Anonymisierungsdienste laut Amazon bzw. nicht &uuml;berpr&uuml;fbarer Pr&uuml;fscanner) &ndash; untersuchen, nicht automatisch sperren.</small></p>" })
$hitTable
<h2>Session-Ordner</h2>
$(if ($sessionHits.Count -eq 0) { '<p>Keine Webshell-Merkmale in den Session-Dateien gefunden.</p>' } else {
"<p style='color:#c62828'><b>$($sessionHits.Count) Session-Datei(en) mit Webshell-Merkmalen &ndash; starker Hinweis auf eine Kompromittierung.</b> System nicht neu starten und nichts l&ouml;schen: zuerst Beweise sichern und den Vorfall pr&uuml;fen lassen.</p><ul>" + (($sessionHits | ForEach-Object { '<li>' + (ConvertTo-HtmlSafe $_.Datei) + ' (ge&auml;ndert ' + $_.Geaendert + ') [' + (ConvertTo-HtmlSafe $_.Merkmal) + ']</li>' }) -join '') + '</ul>' })
<h2>Bewertungsgrundlage</h2>
<p>Korrigierte Versionen: 8.2.35, 8.3.36, 8.4.27, 8.5.12 (PHP-Sicherheitsupdate vom 21.10.2026). Versionen bis 8.1 erhalten vom PHP-Projekt keine Korrektur.
Zus&auml;tzlich sollte <code>session.upload_progress.enabled = 0</code> gesetzt sein (CVE-2026-115560).
Hintergrund: <a href="https://www.codekunst-systems.com/aktuell/php2shell-statusuebersicht">codekunst-systems.com/aktuell/php2shell-statusuebersicht</a></p>
<p><small>Rein lesende Pr&uuml;fung, sofern keine durchgef&uuml;hrte Aktion aufgef&uuml;hrt ist. Ersetzt keine Pr&uuml;fung beim Hoster oder in Containern und keine forensische Untersuchung.</small></p>
</body></html>
"@
    $htmlPath = "$base.html"
    $html | Set-Content -LiteralPath $htmlPath -Encoding UTF8
    return [pscustomobject]@{ Txt = $txtPath; Html = $htmlPath; HtmlContent = $html }
}

# --- Mail -------------------------------------------------------------------
function Get-ExchangePickupDirectory {
    # Same approach as the Exchange monitoring script: no relay, no credentials
    if (-not $env:ExchangeInstallPath) { return $null }
    $candidate = Join-Path $env:ExchangeInstallPath 'TransportRoles\Pickup'
    if (Test-Path -LiteralPath $candidate) { return $candidate }
    return $null
}

function Get-MailConfig {
    if (Test-Path -LiteralPath $ConfigPath) {
        try { return Get-Content -LiteralPath $ConfigPath -Raw | ConvertFrom-Json } catch {
            Write-Log "config.json nicht lesbar: $($_.Exception.Message)" 'WARN'
        }
    }
    if ($ScriptMail.Aktiv) {
        $useAuth = [bool]$ScriptMail.Benutzer
        if ($useAuth -and $ScriptMail.Kennwort) {
            Write-Log "Mailversand mit Kennwort aus dem Skript - Ordnerrechte pr${c_ue}fen (nur Administratoren/SYSTEM)." 'WARN'
        }
        return [pscustomobject]@{
            Empfaenger = @($ScriptMail.Empfaenger); Absender = $ScriptMail.Absender; Methode = 'SMTP'
            SmtpServer = $ScriptMail.SmtpServer; SmtpPort = $ScriptMail.SmtpPort
            Verschluesselung = $ScriptMail.Verschluesselung; Anmeldung = $useAuth
            Benutzer = $ScriptMail.Benutzer; KennwortKlartext = $ScriptMail.Kennwort
            SendenAb = $ScriptMail.SendenAb; Quelle = 'Skript'
        }
    }
    return $null
}

function New-MailConfig {
    Write-Host ''
    Write-Host 'Einrichtung Mailversand' -ForegroundColor Cyan
    $to   = (Read-Host "Empf${c_ae}nger (mehrere mit Komma getrennt)") -split '\s*,\s*' | Where-Object { $_ }
    $from = Read-Host 'Absenderadresse (z. B. php2shell@kunde.de)'
    $pickup = Get-ExchangePickupDirectory
    $method = 'SMTP'
    if ($pickup) {
        Write-Host "Exchange auf diesem Server gefunden ($pickup)." -ForegroundColor Cyan
        if (Read-YesNo "${c_UeGross}ber das Exchange-Pickup-Verzeichnis versenden (kein Relay, keine Zugangsdaten)?") { $method = 'Pickup' }
    } else {
        Write-Host "Kein Exchange auf diesem Server - Versand ${c_ue}ber SMTP." -ForegroundColor Cyan
    }
    $server = ''; $port = 587; $ssl = $true; $auth = $false
    if ($method -eq 'SMTP') {
        $server = Read-Host 'SMTP-Server (z. B. smtp.office365.com oder interner Relay)'
        $p = Read-Host 'Port [587]'; if ($p) { $port = [int]$p }
        $ssl = -not ((Read-Host "Verschl${c_ue}sselung STARTTLS/TLS verwenden? [J/n]") -match '^(n|nein|no)$')
        $user = Read-Host "SMTP-Benutzer (leer lassen f${c_ue}r Relay ohne Anmeldung)"
        if ($user) {
            $pw = Read-Host "Kennwort f${c_ue}r $user" -AsSecureString
            $cred = New-Object System.Management.Automation.PSCredential($user, $pw)
            $cred | Export-Clixml -LiteralPath $CredentialPath
            $auth = $true
            Write-Log "SMTP-Anmeldung verschl${c_ue}sselt gespeichert ($CredentialPath, nur f${c_ue}r Konto $env:USERNAME auf $Computer lesbar)." 'ACTION'
        }
    }
    $sendOn = 'Nie'   # unattended sending is configured separately for monitoring
    $config = [pscustomobject]@{
        Empfaenger = @($to); Absender = $from; Methode = $method
        SmtpServer = $server; SmtpPort = $port; Verschluesselung = $ssl; Anmeldung = $auth
        SendenAb = $sendOn; Eingerichtet = (Get-Date).ToString('s'); EingerichtetVon = "$env:USERDOMAIN\$env:USERNAME"
    }
    $config | ConvertTo-Json | Set-Content -LiteralPath $ConfigPath -Encoding UTF8
    Write-Log "Mail-Einstellungen gespeichert: $ConfigPath" 'ACTION'
    return $config
}

function Send-ReportMail {
    param($Config, [string]$Subject, [string]$HtmlBody, [string[]]$Attachments)
    # Attach copies so that no file used by this script stays locked during sending
    $tempDir  = Join-Path $env:TEMP ('php2shell-mail-' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
    $sendList = New-Object System.Collections.Generic.List[string]
    foreach ($a in $Attachments | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) {
        $copy = Join-Path $tempDir ([System.IO.Path]::GetFileName($a))
        if ($a -like '*.log') { $copy = "$copy.txt" }
        Copy-Item -LiteralPath $a -Destination $copy -Force
        $sendList.Add($copy)
    }
    $ok = $false; $err = $null
    $msg = New-Object System.Net.Mail.MailMessage
    try {
        $msg.From = $Config.Absender
        foreach ($r in $Config.Empfaenger) { $msg.To.Add($r) }
        $msg.Subject = $Subject
        $msg.SubjectEncoding = [System.Text.Encoding]::UTF8
        $msg.Body = $HtmlBody
        $msg.BodyEncoding = [System.Text.Encoding]::UTF8
        $msg.IsBodyHtml = $true
        foreach ($a in $sendList) { $msg.Attachments.Add((New-Object System.Net.Mail.Attachment($a))) }
        $client = New-Object System.Net.Mail.SmtpClient
        if ($Config.Methode -eq 'Pickup') {
            $pickup = Get-ExchangePickupDirectory
            if (-not $pickup) { throw 'Exchange-Pickup-Verzeichnis nicht gefunden.' }
            $client.DeliveryMethod = [System.Net.Mail.SmtpDeliveryMethod]::SpecifiedPickupDirectory
            $client.PickupDirectoryLocation = $pickup
        } else {
            [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
            $client.Host = $Config.SmtpServer
            $client.Port = [int]$Config.SmtpPort
            $client.EnableSsl = [bool]$Config.Verschluesselung
            if ($Config.Anmeldung) {
                if ($Config.PSObject.Properties['KennwortKlartext'] -and $Config.KennwortKlartext) {
                    $client.Credentials = New-Object System.Net.NetworkCredential($Config.Benutzer, $Config.KennwortKlartext)
                } else {
                    if (-not (Test-Path -LiteralPath $CredentialPath)) { throw 'Gespeicherte SMTP-Anmeldung fehlt - Einrichtung mit -SetupMail wiederholen.' }
                    $cred = Import-Clixml -LiteralPath $CredentialPath
                    $client.Credentials = $cred.GetNetworkCredential()
                }
            }
        }
        $client.Send($msg)
        $ok = $true
    } catch {
        $err = $_.Exception.Message
        if ($_.Exception.InnerException) { $err += ' / ' + $_.Exception.InnerException.Message }
    } finally {
        $msg.Dispose()
        Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
    }
    if ($ok) {
        Write-Log "Bericht per Mail versendet an $($Config.Empfaenger -join ', ') ($($Config.Methode))." 'ACTION'
    } else {
        Write-Log "Mailversand fehlgeschlagen: $err" 'ERROR'
        if ($Config.SmtpServer -match 'office365|outlook') {
            Write-Log "Hinweis Microsoft 365: SMTP-Anmeldung (SMTP AUTH) ist oft deaktiviert oder per MFA blockiert. Postfach-Einstellung pr${c_ue}fen oder Connector/Relay nutzen." 'WARN'
        }
    }
    return $ok
}

# --- Main -------------------------------------------------------------------
try {
    # Mail setup only
    if ($SetupMail) {
        $cfg = New-MailConfig
        $subject = "PHP2Shell-Pr${c_ue}fung $Computer - Testmail"
        $body = "<p>Testmail der PHP2Shell-Pr${c_ue}fung auf <b>$Computer</b>. Der Mailversand ist eingerichtet.</p>"
        if (Send-ReportMail -Config $cfg -Subject $subject -HtmlBody $body -Attachments @()) { exit 0 } else { exit 3 }
    }

    $isAdmin = Test-IsAdmin
    Write-Log "PHP2Shell-Pr${c_ue}fung $ScriptVersion auf $Computer gestartet (Ordner: $BaseDirectory, Admin: $isAdmin)"

    # Interactive: decide delivery up front
    $wantMail = $false
    $config   = $null
    if (-not $NoPrompt) {
        Write-Host ''
        Write-Host 'Bericht:' -ForegroundColor Cyan
        Write-Host "  [1] Nur im Skriptordner ablegen ($BaseDirectory)"
        Write-Host "  [2] Ablegen und zus${c_ae}tzlich per Mail senden"
        if ((Read-Host 'Auswahl [1]') -eq '2') {
            $wantMail = $true
            $config = Get-MailConfig
            if ($config) {
                $via = if ($config.Methode -eq 'Pickup') { 'Exchange-Pickup' } else { "$($config.SmtpServer):$($config.SmtpPort)" }
                Write-Host "Vorhandene Mail-Einstellungen: an $($config.Empfaenger -join ', ') ${c_ue}ber $via"
                if (-not (Read-YesNo 'Diese Einstellungen verwenden?')) { $config = New-MailConfig }
            } else {
                $config = New-MailConfig
            }
        }
    }
    if (-not $isAdmin) { Write-Log "Ohne Administratorrechte: Pr${c_ue}fung eventuell unvollst${c_ae}ndig, Patchen nicht m${c_oe}glich." 'WARN' }

    $iis       = Get-IisContext
    $processes = @(Get-CimInstance Win32_Process -Filter "Name='php-cgi.exe' OR Name='php.exe'" -ErrorAction SilentlyContinue |
                   Where-Object { $_.ExecutablePath } | ForEach-Object { $_.ExecutablePath })
    $apache    = Get-ApacheServices
    $releases  = $null
    if (-not $SkipOnlineCheck) { $releases = Get-WindowsReleases }

    Write-Log 'Suche PHP-Installationen auf allen lokalen Festplatten ...'
    $binaries = Find-PhpBinaries
    Write-Log "$($binaries.Count) PHP-Programmdatei(en) gefunden."

    # One installation per folder (php.exe and php-cgi.exe in the same folder are one PHP)
    $installs = @(foreach ($grp in ($binaries | Group-Object { (Split-Path -Parent $_).ToLowerInvariant() })) {
        $bins = @($grp.Group | Sort-Object)
        $dir  = Split-Path -Parent $bins[0]
        $ref  = ($bins | Where-Object { $_ -like '*\php.exe' } | Select-Object -First 1)
        if (-not $ref) { $ref = $bins[0] }
        $version = Get-PhpVersion -Path $ref
        $usage   = New-Object System.Collections.Generic.List[string]
        foreach ($bin in $bins) {
            if ($iis.FastCgiPaths | Where-Object { $_ -ieq $bin }) { $usage.Add('IIS-FastCGI') }
            foreach ($h in $iis.Handlers | Where-Object { $_.Prozessor -ieq $bin }) { $usage.Add("IIS-Handler $($h.Name) ($($h.Ebene))") }
            if ($processes | Where-Object { $_ -ieq $bin }) { $usage.Add("laufender Prozess ($(Split-Path -Leaf $bin))") }
        }
        if (($apache | Where-Object { $_.State -eq 'Running' }) -and
            (Get-ChildItem -LiteralPath $dir -Filter 'php*apache*.dll' -ErrorAction SilentlyContinue)) {
            $usage.Add("Apache-Dienst l${c_ae}uft, PHP-Modul vorhanden")
        }
        $ts   = Get-ThreadSafety -PhpDir $dir
        $arch = Get-PeArchitecture -Path $ref
        $obj = [pscustomobject]@{
            Pfad = $dir; Ordner = $dir; Referenz = $ref
            Programme = (($bins | ForEach-Object { Split-Path -Leaf $_ }) -join ', ')
            Version = if ($version) { $version.Text } else { '' }
            Variante = "$ts / $arch"
            Status = Get-VersionStatus -Version $version
            AktivGenutzt = $usage.Count -gt 0
            Nutzung = (($usage | Select-Object -Unique) -join ', ')
            UploadFortschritt = Get-UploadProgressSetting -PhpDir $dir
            Uebergangsschutz = Test-GuardExtension -PhpDir $dir
            Plesk = [bool]($dir -match '\\Plesk\\|\\Parallels\\')
            Dateidatum = (Get-Item -LiteralPath $ref).LastWriteTime.ToString('yyyy-MM-dd')
            Paket = $null; Empfehlung = ''; Aktion = ''; Schweregrad = 0
        }
        if ($obj.Status -eq 'VERWUNDBAR' -and -not $obj.Plesk -and $ts -ne 'unbekannt' -and $arch -ne 'unbekannt') {
            $obj.Paket = Find-OfficialPackage -Releases $releases -Version $version -Ts $ts -Arch $arch
        }
        Write-Log ("Gefunden: {0} ({1}) | {2} | {3} | {4} | Upload-Fortschritt: {5}" -f $dir, $obj.Programme, $obj.Version, $obj.Status,
                   $(if ($obj.AktivGenutzt) { $obj.Nutzung } else { 'nicht genutzt festgestellt' }), $obj.UploadFortschritt)
        $obj
    })

    # Interactive remediation offers
    if (-not $NoPrompt -and $isAdmin) {
        foreach ($i in $installs) {
            if ($i.Status -eq 'VERWUNDBAR' -and $i.Paket -and $i.Paket.Verfuegbar) {
                Write-Host ''
                Write-Host "F${c_ue}r $($i.Pfad) ($($i.Version)) gibt es das offizielle Paket $($i.Paket.Version) [$($i.Paket.Variante)]." -ForegroundColor Cyan
                if ($i.AktivGenutzt) { Write-Host "Achtung: W${c_ae}hrend des Updates werden IIS bzw. Apache kurz gestoppt (Ausfall der Webseiten)." -ForegroundColor Yellow }
                if (Read-YesNo "Jetzt aktualisieren (Sicherung nach $BackupDirectory)?") {
                    [void](Invoke-PhpUpdate -Install $i)
                } else { Write-Log "Update f${c_ue}r $($i.Pfad) vom Benutzer abgelehnt." }
            }
            if ($i.UploadFortschritt -ne 'aus') {
                if (Read-YesNo "session.upload_progress.enabled = 0 in der php.ini von $($i.Ordner) setzen (mit Sicherung)?") {
                    if (Set-UploadProgressOff -PhpDir $i.Ordner) {
                        $i.UploadFortschritt = 'aus'
                        $i.Aktion = (@($i.Aktion, "Upload-Fortschrittsanzeige abgeschaltet (IIS/Apache-Neustart n${c_oe}tig)") | Where-Object { $_ }) -join '; '
                    }
                }
            }
        }
    }

    $logHits = @(); $infoHits = @()
    if (-not $SkipLogScan) {
        Write-Log "Durchsuche Webserver-Protokolle der letzten $LogDays Tage ..."
        $allHits  = @(Search-Indicators -Directories (@($iis.LogDirectories) + $AdditionalLogPath) -Days $LogDays)
        $logHits  = @($allHits | Where-Object { $_.Einstufung -eq 'Angriffsmerkmal' })
        $infoHits = @($allHits | Where-Object { $_.Einstufung -eq 'Unklar' })
        if ($logHits.Count -gt 0)  { Write-Log "$($logHits.Count) Treffer auf bekannte Angriffsmerkmale!" 'WARN' }
        if ($infoHits.Count -gt 0) { Write-Log "$($infoHits.Count) Treffer von Adressen mit unklarer Einstufung (untersuchen)." 'WARN' }
    }

    $sessionHits = @()
    if ($installs.Count -gt 0) {
        $sessionDirs = Get-SessionDirectories -Installs $installs
        Write-Log "Durchsuche Session-Ordner nach Webshells: $($sessionDirs -join ', ')"
        $sessionHits = @(Search-SessionWebshells -Directories $sessionDirs)
        if ($sessionHits.Count -gt 0) { Write-Log "$($sessionHits.Count) Session-Datei(en) mit Webshell-Merkmalen gefunden - starker Hinweis auf Kompromittierung!" 'ERROR' }
    }

    foreach ($i in $installs) { $i.Schweregrad = Get-Severity -Install $i; $i.Empfehlung = Get-Advice -Install $i }
    $overall = 0
    if ($installs.Count -gt 0) { $overall = ($installs | Measure-Object -Property Schweregrad -Maximum).Maximum }
    if ($infoHits.Count -gt 0 -and $overall -lt 1) { $overall = 1 }
    if ($logHits.Count -gt 0 -or $sessionHits.Count -gt 0) { $overall = 2 }
    if (-not $isAdmin -and $overall -eq 0) { $overall = 1 }
    $overallText = @('OK', 'WARNUNG', 'KRITISCH')[$overall]

    $jsonPath = Join-Path $BaseDirectory "PHP2Shell_$Computer`_$Stamp.json"
    [pscustomobject]@{
        Skriptversion = $ScriptVersion; Computer = $Computer; Zeitpunkt = $Start.ToString('s'); Administrator = $isAdmin
        Gesamtergebnis = $overallText; ExitCode = $overall
        Installationen = @($installs | Select-Object Pfad, Programme, Version, Variante, Status, AktivGenutzt, Nutzung, UploadFortschritt, Uebergangsschutz, Plesk, Dateidatum, Empfehlung, Aktion, Schweregrad)
        Protokolltreffer = $logHits
        UnklareTreffer = $infoHits
        SessionTreffer = $sessionHits
    } | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $jsonPath -Encoding UTF8

    $csvPath = Join-Path $BaseDirectory 'PHP2Shell_Verlauf.csv'
    $csvRows = if ($installs.Count -gt 0) {
        $installs | ForEach-Object { [pscustomobject]@{ Zeitpunkt = $Start.ToString('s'); Computer = $Computer; Pfad = $_.Pfad
            Version = $_.Version; Status = $_.Status; AktivGenutzt = $_.AktivGenutzt; UploadFortschritt = $_.UploadFortschritt
            Aktion = $_.Aktion; Protokolltreffer = $logHits.Count; Ergebnis = $overallText } }
    } else {
        [pscustomobject]@{ Zeitpunkt = $Start.ToString('s'); Computer = $Computer; Pfad = ''; Version = ''; Status = 'KEIN PHP'
            AktivGenutzt = $false; UploadFortschritt = ''; Aktion = ''; Protokolltreffer = $logHits.Count; Ergebnis = $overallText }
    }
    $csvRows | Export-Csv -LiteralPath $csvPath -NoTypeInformation -Append -Encoding UTF8 -Delimiter ';'

    $reports = New-Reports -Installs $installs -LogHits $logHits -Overall $overallText -IsAdmin $isAdmin
    Write-Log "Bericht erstellt: $($reports.Html)"

    if ($isAdmin) {
        try {
            $src = 'PHP2ShellCheck'
            if (-not [System.Diagnostics.EventLog]::SourceExists($src)) { New-EventLog -LogName Application -Source $src }
            Write-EventLog -LogName Application -Source $src -EventId @(1000, 2000, 3000)[$overall] `
                -EntryType @('Information', 'Warning', 'Error')[$overall] `
                -Message "PHP2Shell-Pr${c_ue}fung: $overallText. Installationen: $($installs.Count), Protokolltreffer: $($logHits.Count), Session-Treffer: $($sessionHits.Count). Bericht: $($reports.Html)"
        } catch { Write-Log "Ereignisprotokoll nicht beschreibbar: $($_.Exception.Message)" 'WARN' }
    }

    $attachments = @($reports.Html, $reports.Txt, $LogPath)
    $subject = "PHP2Shell-Pr${c_ue}fung $Computer - $overallText - $($Start.ToString('dd.MM.yyyy HH:mm'))"

    if ($NoPrompt) {
        # Unattended: send according to configuration
        $config = Get-MailConfig
        if ($config) {
            $threshold = switch ($config.SendenAb) { 'Immer' { 0 } 'Warnung' { 1 } 'Kritisch' { 2 } default { 99 } }
            if ($SendMail -or $overall -ge $threshold) {
                [void](Send-ReportMail -Config $config -Subject $subject -HtmlBody $reports.HtmlContent -Attachments $attachments)
            }
        }
    } else {
        Write-Host ''
        Write-Host (Get-Content -LiteralPath $reports.Txt -Raw)
        Write-Host "Bericht abgelegt: $($reports.Html)" -ForegroundColor Cyan
        if ($wantMail -and $config) {
            [void](Send-ReportMail -Config $config -Subject $subject -HtmlBody $reports.HtmlContent -Attachments $attachments)
        }
        try { Invoke-Item -LiteralPath $reports.Html } catch { Write-Log "Bericht konnte nicht automatisch ge${c_oe}ffnet werden: $($_.Exception.Message)" 'WARN' }
    }

    Write-Log "Pr${c_ue}fung beendet: $overallText"
    Write-Output ("PHP2SHELL {0} | {1} | PHP: {2} | Treffer: {3} | {4}" -f $overallText, $Computer, $installs.Count, $logHits.Count, $reports.Html)
    exit $overall
}
catch {
    try { Write-Log "Abbruch: $($_.Exception.Message)" 'ERROR' } catch { }
    Write-Output ("PHP2SHELL FEHLER | {0} | {1}" -f $env:COMPUTERNAME, $_.Exception.Message)
    exit 3
}

Den gesamten Inhalt mit der Kopieren-Schaltfläche übernehmen, in einem Editor einfügen und als Test-PHP2Shell.ps1 speichern, am besten im Ordner C:\PHP2ShellCheck. Das Skript enthält nur ASCII-Zeichen; Umlaute in Bericht und Konsole werden zur Laufzeit erzeugt. Ausführen in Windows PowerShell 5.1 als Administrator.

Schritt beim UpdateWas passiert
DownloadOffizielles Paket aus dem PHP-Paketverzeichnis
PrüfsummeSHA256-Vergleich; bei Abweichung Abbruch, nichts wird verändert
SicherungVollständige Kopie des PHP-Ordners in den Unterordner Sicherungen; vorhandene Sicherungen werden nie überschrieben
DiensteIIS bzw. Apache stoppen, Dateien ersetzen (php.ini und eigene Erweiterungen bleiben erhalten), Dienste wieder starten
KontrolleNeue Version wird geprüft und im Bericht vermerkt

Vor dem Update beachten

Während des Updates sind die Webseiten des Servers kurz nicht erreichbar – also außerhalb der Geschäftszeiten aktualisieren. Plesk-Installationen aktualisiert das Skript nicht, das übernimmt Plesk selbst. Für Versionen bis 8.1 gibt es kein offizielles Paket. Und bringt eine Fachanwendung ihr PHP selbst mit, vor dem Update den Hersteller fragen.

ErgebnisBedeutung
OKKein PHP gefunden oder alle Installationen gepatcht und abgesichert
WARNUNGVeraltetes PHP, das nicht aktiv genutzt wird; Upload-Fortschrittsanzeige aktiv; Version nicht bestimmbar; Treffer von Adressen mit unklarer Einstufung
KRITISCHVerwundbares PHP wird aktiv genutzt, bekannte Angriffsmerkmale in den Protokollen oder Webshell-Merkmale in Session-Dateien

Testumgebung: Setup-PHP2ShellTestumgebung.ps1

Vor dem Einsatz bei Kunden lässt sich auf einem Testrechner prüfen, ob alles richtig erkannt wird – auch auf einem normalen Windows-11-Rechner ohne IIS. Das Skript lädt PHP aus dem offiziellen Archiv mit Prüfsummenkontrolle und startet den eingebauten Webserver nur auf 127.0.0.1; die Testspuren enthalten keinen ausführbaren Code. Aufruf: .\Setup-PHP2ShellTestumgebung.ps1 -MitTestspuren, aufräumen mit -Aufraeumen.

Setup-PHP2ShellTestumgebung.ps1

Baut auf einem Testrechner eine Umgebung mit absichtlich veralteten PHP-Versionen und harmlosen Testspuren auf. Stand 25.10.2026.

Setup-PHP2ShellTestumgebung.ps1
#Requires -Version 5.1
<#
.SYNOPSIS
    Richtet auf einem Windows-Rechner eine lokale Testumgebung fuer das
    PHP2Shell-Pruefskript ein - ohne IIS.

.DESCRIPTION
    1. Laedt PHP 8.3.30 (verwundbar, Update moeglich) und PHP 8.1.34 (EOL)
       als NTS-x64-Pakete aus dem offiziellen PHP-Archiv
    2. Entpackt nach C:\php83 und C:\php81 und legt je eine php.ini an
    3. Prueft, ob PHP startet (Visual C++ Runtime vorhanden?)
    4. Startet den eingebauten PHP-Webserver NUR auf 127.0.0.1:8080
    5. Prueft per HTTP, ob die Testseite erreichbar ist
    Optional:
      -MitTestspuren  legt eine Test-Protokolldatei und eine harmlose Session-Datei
                      mit den bekannten Merkmalen an (nur Text, kein ausfuehrbarer Code)
      -Stoppen        beendet nur den Testserver
      -Aufraeumen     beendet den Testserver und loescht alle Testordner

    ACHTUNG: Die installierten PHP-Versionen sind absichtlich verwundbar.
    Nur auf einem Testrechner verwenden. Der Webserver lauscht nur lokal.

.EXAMPLE
    .\Setup-PHP2ShellTestumgebung.ps1 -MitTestspuren
    .\Setup-PHP2ShellTestumgebung.ps1 -Stoppen
    .\Setup-PHP2ShellTestumgebung.ps1 -Aufraeumen

.NOTES
    Codekunst Systems GmbH - 25.10.2026 - nur fuer Testzwecke
#>
[CmdletBinding()]
param(
    [switch]$MitTestspuren,
    [switch]$Stoppen,
    [switch]$Aufraeumen,
    [int]$Port = 8080
)

$ErrorActionPreference = 'Stop'
$ArchiveBase = 'https://downloads.php.net/~windows/releases/archives/'
$Packages = @(
    [pscustomobject]@{ Name = 'PHP 8.3.30'; Zip = 'php-8.3.30-nts-Win32-vs16-x64.zip'; Ziel = 'C:\php83'; Webserver = $true
                       Sha256 = '42637B42B38B9C0D731E59C5CB8B755693A01B110CD2F31951F67DE5CB4CD129' }
    [pscustomobject]@{ Name = 'PHP 8.1.34'; Zip = 'php-8.1.34-nts-Win32-vs16-x64.zip'; Ziel = 'C:\php81'; Webserver = $false
                       Sha256 = '9CFE246CB144076C16F5913A3EF88A474C3DD7E60F0F0C8BB95FAF68674016CC' }
)
$LogDir  = 'C:\php2test-logs'
$PidFile = Join-Path $env:TEMP 'php2shell-testserver.pid'

function Write-Step { param([string]$Text) Write-Host "==> $Text" -ForegroundColor Cyan }
function Write-Ok   { param([string]$Text) Write-Host "    OK: $Text" -ForegroundColor Green }
function Write-Warn { param([string]$Text) Write-Host "    ACHTUNG: $Text" -ForegroundColor Yellow }

function Stop-TestServer {
    if (Test-Path -LiteralPath $PidFile) {
        $procId = Get-Content -LiteralPath $PidFile -ErrorAction SilentlyContinue
        if ($procId) { Stop-Process -Id $procId -Force -ErrorAction SilentlyContinue }
        Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
    }
    # Safety net: any php.exe started from the test folders
    Get-CimInstance Win32_Process -Filter "Name='php.exe' OR Name='php-cgi.exe'" -ErrorAction SilentlyContinue |
        Where-Object { $_.ExecutablePath -and ($_.ExecutablePath -like 'C:\php83\*' -or $_.ExecutablePath -like 'C:\php81\*') } |
        ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
    Write-Ok 'Testserver beendet (falls er lief).'
}

# --- Stop / cleanup ------------------------------------------------------------
if ($Stoppen -or $Aufraeumen) {
    Write-Step 'Testserver beenden'
    Stop-TestServer
    if ($Aufraeumen) {
        Write-Step 'Testordner loeschen'
        foreach ($d in @('C:\php83', 'C:\php81', $LogDir)) {
            if (Test-Path -LiteralPath $d) { Remove-Item -LiteralPath $d -Recurse -Force; Write-Ok "$d geloescht" }
        }
        Write-Warn 'Den Ordner "Sicherungen" im Ordner des Pruefskripts bei Bedarf von Hand loeschen.'
    }
    return
}

# --- 1./2. Download and extract -------------------------------------------------
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
foreach ($p in $Packages) {
    Write-Step "$($p.Name) einrichten"
    if (Test-Path -LiteralPath (Join-Path $p.Ziel 'php.exe')) {
        Write-Ok "$($p.Ziel) existiert bereits - Download uebersprungen."
    } else {
        $zip = Join-Path $env:TEMP $p.Zip
        Write-Host "    Lade $($p.Zip) ..."
        Invoke-WebRequest -Uri ($ArchiveBase + $p.Zip) -OutFile $zip -UseBasicParsing -TimeoutSec 600
        $hash = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash
        if ($hash -ne $p.Sha256) {
            Remove-Item -LiteralPath $zip -Force
            throw "Pruefsumme von $($p.Zip) stimmt nicht (erwartet $($p.Sha256), erhalten $hash). Abbruch."
        }
        Write-Ok 'Pruefsumme stimmt.'
        Expand-Archive -LiteralPath $zip -DestinationPath $p.Ziel -Force
        Remove-Item -LiteralPath $zip -Force
        Write-Ok "Entpackt nach $($p.Ziel)"
    }
    $ini = Join-Path $p.Ziel 'php.ini'
    if (-not (Test-Path -LiteralPath $ini)) {
        Copy-Item -LiteralPath (Join-Path $p.Ziel 'php.ini-production') -Destination $ini
        Write-Ok 'php.ini aus php.ini-production angelegt.'
    }

    # --- 3. Does PHP start? ---
    $out = & (Join-Path $p.Ziel 'php.exe') -n -v 2>&1
    if ($LASTEXITCODE -ne 0 -or -not ($out -match '^PHP \d')) {
        Write-Warn "PHP startet nicht. Meist fehlt die Visual C++ Runtime 2015-2022 (x64):"
        Write-Host  '    https://aka.ms/vs/17/release/vc_redist.x64.exe  - installieren und Skript erneut starten.'
        return
    }
    Write-Ok (($out | Select-Object -First 1) -join '')
}

# --- 4. Start built-in web server (localhost only) ------------------------------
Write-Step "Eingebauten PHP-Webserver auf 127.0.0.1:$Port starten"
$www = 'C:\php83\www'
if (-not (Test-Path -LiteralPath $www)) { New-Item -ItemType Directory -Path $www | Out-Null }
Set-Content -LiteralPath (Join-Path $www 'index.php') -Value '<?php echo "PHP2Shell-Testumgebung laeuft: PHP " . PHP_VERSION;' -Encoding ASCII
Stop-TestServer
$proc = Start-Process -FilePath 'C:\php83\php.exe' -ArgumentList @('-S', "127.0.0.1:$Port", '-t', $www) `
        -WindowStyle Hidden -PassThru
Set-Content -LiteralPath $PidFile -Value $proc.Id
Start-Sleep -Seconds 2

# --- 5. Reachability test -------------------------------------------------------
Write-Step 'Erreichbarkeit pruefen'
$ok = $false
for ($i = 0; $i -lt 5 -and -not $ok; $i++) {
    try {
        $r = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/" -UseBasicParsing -TimeoutSec 5
        if ($r.StatusCode -eq 200 -and $r.Content -match 'Testumgebung laeuft') { $ok = $true; Write-Ok $r.Content }
    } catch { Start-Sleep -Seconds 1 }
}
if (-not $ok) {
    Write-Warn "Testseite nicht erreichbar. Ist Port $Port belegt? Mit -Port 8081 erneut versuchen."
    return
}
$listen = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
if ($listen -and $listen.LocalAddress -ne '127.0.0.1') {
    Write-Warn "Der Server lauscht auf $($listen.LocalAddress) statt nur lokal - bitte pruefen!"
} else {
    Write-Ok 'Server lauscht nur lokal (127.0.0.1).'
}

# --- Optional test traces -------------------------------------------------------
if ($MitTestspuren) {
    Write-Step 'Testspuren anlegen (nur Text, kein ausfuehrbarer Code)'
    if (-not (Test-Path -LiteralPath $LogDir)) { New-Item -ItemType Directory -Path $LogDir | Out-Null }
    Set-Content -LiteralPath (Join-Path $LogDir 'test.log') -Encoding ASCII -Value @(
        '2026-10-21 08:40:00 POST /index.php - 80 - 85.11.167.243 php2shell-scan/109618 200'
        '2026-10-17 10:00:00 POST /index.php - 80 - 103.216.220.92 Mozilla 200'
    )
    Write-Ok "Protokoll-Testdatei: $LogDir\test.log (1 Angriffsmerkmal, 1 unklarer Treffer)"
    $sess = 'C:\php83\sessions'
    if (-not (Test-Path -LiteralPath $sess)) { New-Item -ItemType Directory -Path $sess | Out-Null }
    $ini = 'C:\php83\php.ini'
    if (-not (Select-String -LiteralPath $ini -Pattern '^\s*session\.save_path\s*=\s*"C:\\php83\\sessions"' -Quiet)) {
        Add-Content -LiteralPath $ini -Value 'session.save_path = "C:\php83\sessions"'
    }
    Set-Content -LiteralPath (Join-Path $sess 'sess_test123') -Value 'upload_progress|PWNED:: nur ein Test' -Encoding ASCII
    Write-Ok 'Session-Testdatei: C:\php83\sessions\sess_test123'
}

# --- Summary -------------------------------------------------------------------
Write-Host ''
Write-Host 'Testumgebung bereit:' -ForegroundColor Green
Write-Host "  C:\php83  PHP 8.3.30, laeuft als Webserver auf http://127.0.0.1:$Port  -> erwartet: VERWUNDBAR, aktiv genutzt"
Write-Host '  C:\php81  PHP 8.1.34, nicht gestartet                         -> erwartet: EOL, nicht aktiv genutzt'
Write-Host ''
Write-Host 'Naechste Schritte:'
if ($MitTestspuren) {
    Write-Host '  .\Test-PHP2Shell.ps1 -AdditionalLogPath C:\php2test-logs'
} else {
    Write-Host '  .\Test-PHP2Shell.ps1'
}
Write-Host '  Monitoring: mit New-PHP2ShellMonitorScript.ps1 erzeugtes Skript mit -Mode Full starten'
Write-Host '  Danach: .\Setup-PHP2ShellTestumgebung.ps1 -Aufraeumen'

Den gesamten Inhalt mit der Kopieren-Schaltfläche übernehmen, in einem Editor einfügen und als Setup-PHP2ShellTestumgebung.ps1 speichern. Das Skript enthält nur ASCII-Zeichen. Nur auf Testrechnern verwenden.

PrüfpunktErwartung beim Einzelplatz-Skript
Installationen2 – C:\php83 und C:\php81, je mit php.exe und php-cgi.exe
C:\php838.3.30, VERWUNDBAR, laufender Prozess, Update-Angebot auf 8.3.36
C:\php818.1.34, EOL, nicht genutzt, kein Update-Angebot, Angebot für die php.ini
Protokolle (mit -AdditionalLogPath C:\php2test-logs)1 Angriffsmerkmal und 1 unklarer Treffer
Session-Ordner1 Datei mit Webshell-Merkmalen
GesamtergebnisKRITISCH

Nur für Testrechner

Die installierten PHP-Versionen sind absichtlich verwundbar. Die Testumgebung nur auf einem Testrechner einsetzen, der nicht aus dem Internet erreichbar ist, und danach wieder entfernen.

Monitoring: New-PHP2ShellMonitorScript.ps1

Für viele Rechner erzeugt der Generator ein Skript mit eingebauter Konfiguration: Meldung nur per Exit-Code, Bericht lokal oder auf einer Freigabe, optional Mail mit Schwelle und „nur bei Änderung“. Das erzeugte Skript fragt nichts, verändert nichts und läuft als SYSTEM – über RMM, Softwareverteilung oder geplante Aufgabe, wöchentlich mit -Mode Full, täglich mit -Mode Quick.

New-PHP2ShellMonitorScript.ps1

Generator für das Monitoring-Skript. Version 1.3, Stand 25.10.2026.

New-PHP2ShellMonitorScript.ps1
#Requires -Version 5.1
<#
.SYNOPSIS
    Erzeugt ein konfiguriertes PHP2Shell-Monitoring-Skript zur Verteilung
    (RMM, Gruppenrichtlinie, geplante Aufgabe).

.DESCRIPTION
    Fragt interaktiv ab:
      - Kunde (Name/Kuerzel)
      - Ausgabe: nur Exit-Code | Bericht lokal (C:\PHP2ShellCheck) | Bericht auf Freigabe
      - Mailversand ja/nein (SMTP, Empfaenger, Schwelle, nur bei Aenderung)
      - Standardmodus Full/Quick
    und erzeugt im Ausgabeordner:
      - PHP2Shell-Monitor_<Kunde>.ps1   (zu verteilendes Skript, Konfiguration eingebaut)
      - PHP2Shell-Monitor_<Kunde>_Verteilung.txt  (Aufrufe und Hinweise)

    Das erzeugte Skript ist rein lesend, fragt nichts ab und meldet sein Ergebnis
    ueber Exit-Code (0 OK, 1 WARNUNG, 2 KRITISCH, 3 FEHLER), eine Ergebniszeile auf der
    Standardausgabe, das Windows-Ereignisprotokoll und je nach Konfiguration ueber
    Bericht und Mail. Damit laesst es sich in beliebige Monitoring- und RMM-Systeme
    einbinden.

.PARAMETER Ausgabeordner
    Wohin das erzeugte Skript geschrieben wird. Standard: Ordner dieses Generators.

.NOTES
    Codekunst Systems GmbH - Generator 1.3 - 25.10.2026
    Zielgruppe: Administratoren. Ohne Gewaehr.
#>
[CmdletBinding()]
param(
    [string]$Ausgabeordner = $(if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path })
)

$ErrorActionPreference = 'Stop'
$GeneratorVersion = '1.3'

# =============================================================================
#  TEMPLATE OF THE DISTRIBUTED MONITORING SCRIPT
#  The line '#__KONFIGURATION__#' is replaced by the generated configuration.
# =============================================================================
$Template = @'
#Requires -Version 5.1
<#
.SYNOPSIS
    PHP2Shell-Monitoring (unbeaufsichtigt, rein lesend) - erzeugt mit dem
    Codekunst-Generator. Konfiguration siehe Block KONFIGURATION unten.

.DESCRIPTION
    -Mode Full   Suche nach php.exe / php-cgi.exe auf allen lokalen Festplatten (z. B. woechentlich)
    -Mode Quick  bekannte Fundorte, IIS, laufende Prozesse, uebliche Ordner (z. B. taeglich)
    Bewertung gegen 8.2.35 / 8.3.36 / 8.4.27 / 8.5.12, Nutzung (IIS, Prozess, Apache),
    php.ini (session.upload_progress.enabled), IIS-Protokolle, Session-Ordner (Webshell),
    Erkennung des Uebergangsschutzes php_uploadprogress_guard.
    Ausgabe fuer Monitoring-Systeme:
      Exit-Code   0 OK, 1 WARNUNG, 2 KRITISCH, 3 FEHLER
      Stdout      eine Zeile: PHP2SHELL <STATUS> | <Kunde> | <Rechner> | PHP: n | Treffer: n
      Eventlog    Anwendung, Quelle PHP2ShellCheck, ID 1000 / 2000 / 3000

.NOTES
    Codekunst Systems GmbH - Monitoring 1.2 - als SYSTEM ausfuehren.
    HAFTUNGSAUSSCHLUSS: Bereitgestellt ohne Gewaehr. Die Pruefung ist rein lesend.
    Vor dem Einsatz auf produktiven Systemen in einer Testumgebung pruefen.
    Einsatz in eigener Verantwortung des Betreibers. Ersetzt keine forensische Untersuchung.
#>
[CmdletBinding()]
param(
    [ValidateSet('Full', 'Quick', '')][string]$Mode = ''
)
$ErrorActionPreference = 'Stop'
$ScriptVersion = 'MON-1.2'

#__KONFIGURATION__#

if (-not $Mode) { $Mode = $Config.StandardModus }

# --- Reference data (Stand 25.10.2026) --------------------------------------------
$FixedVersions = @{ '8.2' = 35; '8.3' = 36; '8.4' = 27; '8.5' = 12 }
$Indicators = @(
    'php2shell-', 'B7kQpoc618', 'B7kQpoc619', 'p2s9rdme2st6guc9g0rafic', 'php://filter',
    'xqsuvwz3n97wu8j3emz1axuo5y',
    '85.11.167.243', '217.60.242.76', '45.198.224.169', '221.14.159.134', '91.90.123.25',
    '87.121.84.174', '181.215.186.252', '185.196.220.132', '185.161.210.29', '152.233.51.69',
    '45.134.225.16', '107.189.14.87', '193.47.62.24', '103.147.220.17',
    '2409:40d2:204e:4be:2a1f:f9c9:a055:e00b', '206.188.197.105',
    '92.118.112.100', '134.199.154.64', 'godofthunder.st'
)
$InfoIndicators = @('109.71.252.97',
    '103.216.220.92', '31.223.184.166', '37.19.200.5', '134.209.159.64', '103.114.163.188', '95.217.98.37')
$SessionShellPatterns = @('PWNED::', 'system($_GET')
$ExcludedDirNames = @('$Recycle.Bin', 'System Volume Information', 'WinSxS', 'Installer')
$CommonRoots = @('C:\php', 'C:\Program Files\PHP', 'C:\Program Files (x86)\PHP', 'C:\xampp', 'C:\laragon',
                 'C:\wamp64', 'C:\wamp', 'C:\inetpub', 'C:\Program Files\iis express')

# --- Paths -------------------------------------------------------------------------
$Start    = Get-Date
$Stamp    = $Start.ToString('yyyyMMdd-HHmmss')
$Computer = $env:COMPUTERNAME
$StateDir = Join-Path $env:ProgramData 'PHP2ShellCheck'
if (-not (Test-Path -LiteralPath $StateDir)) { New-Item -ItemType Directory -Path $StateDir -Force | Out-Null }
$StatePath = Join-Path $StateDir 'monitor-state.json'
$LogPath   = Join-Path $StateDir 'monitor.log'

function Write-Log {
    param([string]$Text, [string]$Level = 'INFO')
    $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Text
    for ($t = 0; $t -lt 3; $t++) {
        try { Add-Content -LiteralPath $LogPath -Value $line -Encoding UTF8 -ErrorAction Stop; break } catch { Start-Sleep -Milliseconds 200 }
    }
}

# --- Discovery and assessment --------------------------------------------------------
function Find-PhpInPaths {
    param([string[]]$Roots)
    $found = New-Object System.Collections.Generic.List[string]
    foreach ($root in $Roots | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) {
        $stack = New-Object System.Collections.Generic.Stack[string]
        $stack.Push($root)
        while ($stack.Count -gt 0) {
            $dir = $stack.Pop()
            if ($dir -like 'C:\PHP2ShellCheck\Sicherungen*') { continue }
            try {
                foreach ($file in [System.IO.Directory]::GetFiles($dir)) {
                    $name = [System.IO.Path]::GetFileName($file)
                    if ($name -ieq 'php.exe' -or $name -ieq 'php-cgi.exe') { $found.Add($file) }
                }
                foreach ($sub in [System.IO.Directory]::GetDirectories($dir)) {
                    if ($ExcludedDirNames -contains [System.IO.Path]::GetFileName($sub)) { continue }
                    if ([System.IO.File]::GetAttributes($sub) -band [System.IO.FileAttributes]::ReparsePoint) { continue }
                    $stack.Push($sub)
                }
            } catch { }
        }
    }
    return $found
}

function Get-PhpVersion {
    param([string]$Path)
    try {
        $vi = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($Path)
        foreach ($c in @($vi.ProductVersion, $vi.FileVersion)) {
            if ($c -and $c -match '^(\d+)\.(\d+)\.(\d+)') {
                return [pscustomobject]@{ Text = '{0}.{1}.{2}' -f $Matches[1], $Matches[2], $Matches[3]
                    Major = [int]$Matches[1]; Minor = [int]$Matches[2]; Patch = [int]$Matches[3] }
            }
        }
    } catch { }
    return $null
}

function Get-VersionStatus {
    param($Version)
    if ($null -eq $Version) { return 'UNBEKANNT' }
    $branch = '{0}.{1}' -f $Version.Major, $Version.Minor
    if ($Version.Major -lt 8 -or ($Version.Major -eq 8 -and $Version.Minor -lt 2)) { return 'EOL' }
    if ($FixedVersions.ContainsKey($branch)) { if ($Version.Patch -ge $FixedVersions[$branch]) { return 'GEPATCHT' } else { return 'VERWUNDBAR' } }
    return 'GEPATCHT'
}

function Get-UploadProgressSetting {
    param([string]$PhpDir)
    $ini = Join-Path $PhpDir 'php.ini'
    if (-not (Test-Path -LiteralPath $ini)) { return 'an' }
    $value = $null
    foreach ($line in [System.IO.File]::ReadAllLines($ini)) {
        if ($line -match '^\s*session\.upload_progress\.enabled\s*=\s*"?([^";\s]+)') { $value = $Matches[1] }
    }
    if ($value -and $value -match '^(0|off|false|no)$') { return 'aus' }
    return 'an'
}

function Test-GuardExtension {
    param([string]$PhpDir)
    $ini = Join-Path $PhpDir 'php.ini'
    if (Test-Path -LiteralPath $ini) {
        foreach ($line in [System.IO.File]::ReadAllLines($ini)) {
            if ($line -match '^\s*(zend_)?extension\s*=\s*"?[^";]*uploadprogress_guard') { return $true }
        }
    }
    return $false
}

function Get-IisContext {
    $ctx = [pscustomobject]@{ Paths = @(); LogDirectories = @() }
    $cfgPath = Join-Path $env:windir 'System32\inetsrv\config\applicationHost.config'
    if (-not (Test-Path -LiteralPath $cfgPath)) { return $ctx }
    try {
        [xml]$cfg = [System.IO.File]::ReadAllText($cfgPath)
        $p = New-Object System.Collections.Generic.List[string]
        foreach ($a in @($cfg.configuration.'system.webServer'.fastCgi.application)) { if ($a -and $a.fullPath) { $p.Add([Environment]::ExpandEnvironmentVariables($a.fullPath)) } }
        foreach ($x in @($cfg.configuration.'system.webServer'.handlers.add)) { if ($x -and $x.scriptProcessor) { $p.Add([Environment]::ExpandEnvironmentVariables(($x.scriptProcessor -split '\|')[0])) } }
        foreach ($loc in @($cfg.configuration.location)) {
            if (-not $loc) { continue }
            foreach ($x in @($loc.'system.webServer'.handlers.add)) { if ($x -and $x.scriptProcessor) { $p.Add([Environment]::ExpandEnvironmentVariables(($x.scriptProcessor -split '\|')[0])) } }
        }
        $ctx.Paths = @($p | Select-Object -Unique)
        $logDir = $cfg.configuration.'system.applicationHost'.sites.siteDefaults.logFile.directory
        if (-not $logDir) { $logDir = '%SystemDrive%\inetpub\logs\LogFiles' }
        $ctx.LogDirectories = @([Environment]::ExpandEnvironmentVariables($logDir))
    } catch { Write-Log "IIS-Konfiguration nicht lesbar: $($_.Exception.Message)" 'WARN' }
    return $ctx
}

function Search-Indicators {
    param([string[]]$Directories, [int]$Days)
    $hits = New-Object System.Collections.Generic.List[object]
    $since = (Get-Date).AddDays(-$Days)
    foreach ($dir in $Directories | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) {
        foreach ($f in Get-ChildItem -LiteralPath $dir -Recurse -File -Include *.log -ErrorAction SilentlyContinue | Where-Object { $_.LastWriteTime -ge $since }) {
            foreach ($x in (Select-String -LiteralPath $f.FullName -Pattern ($Indicators + $InfoIndicators) -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 20)) {
                $line = $x.Line
                $kind = if (@($InfoIndicators | Where-Object { $line.Contains($_) }).Count -gt 0) { 'Unklar' } else { 'Angriffsmerkmal' }
                $hits.Add([pscustomobject]@{ Datei = $f.FullName; Zeile = $x.LineNumber; Merkmal = $x.Pattern; Einstufung = $kind
                    Auszug = $line.Substring(0, [Math]::Min(300, $line.Length)) })
            }
        }
    }
    return $hits
}

function Search-SessionWebshells {
    param($Installs)
    $dirs = New-Object System.Collections.Generic.List[string]
    foreach ($i in $Installs) {
        $ini = Join-Path $i.Ordner 'php.ini'
        if (Test-Path -LiteralPath $ini) {
            foreach ($line in [System.IO.File]::ReadAllLines($ini)) {
                if ($line -match '^\s*session\.save_path\s*=\s*"?([^"]+?)"?\s*$') {
                    $v = $Matches[1]; if ($v -match ';') { $v = ($v -split ';')[-1] }
                    if ($v) { $dirs.Add([Environment]::ExpandEnvironmentVariables($v.Trim())) }
                }
            }
        }
    }
    $dirs.Add((Join-Path $env:windir 'Temp'))
    $hits = New-Object System.Collections.Generic.List[object]
    foreach ($dir in @($dirs | Where-Object { $_ -and (Test-Path -LiteralPath $_) } | Select-Object -Unique)) {
        foreach ($f in Get-ChildItem -LiteralPath $dir -Recurse -File -Filter 'sess_*' -ErrorAction SilentlyContinue) {
            $m = Select-String -LiteralPath $f.FullName -Pattern $SessionShellPatterns -SimpleMatch -ErrorAction SilentlyContinue | Select-Object -First 1
            if ($m -or $f.Name -eq 'sess_xqsuvwz3n97wu8j3emz1axuo5y') {
                $hits.Add([pscustomobject]@{ Datei = $f.FullName; Geaendert = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm'); Merkmal = if ($m) { $m.Pattern } else { 'bekannter Dateiname' } })
            }
        }
    }
    return $hits
}

function Get-Advice {
    # Short, actionable recommendation per installation.
    # Umlauts are built from character codes so this file stays pure ASCII
    # (safe for Windows PowerShell 5.1 regardless of file encoding).
    param($Install)
    $ae = [char]0xE4; $oe = [char]0xF6; $ue = [char]0xFC; $UEc = [char]0xDC; $ss = [char]0xDF
    $a = New-Object System.Collections.Generic.List[string]
    switch ($Install.Status) {
        'VERWUNDBAR' {
            $fix = ''
            if ($Install.Version -match '^(\d+\.\d+)\.') { $b = $Matches[1]; if ($FixedVersions.ContainsKey($b)) { $fix = " $b.$($FixedVersions[$b])" } }
            if ($Install.Nutzung -ne 'ungenutzt') { $a.Add("Dringend: auf die korrigierte Version$fix aktualisieren.") }
            else { $a.Add("Auf die korrigierte Version$fix aktualisieren oder entfernen, falls nicht ben${oe}tigt.") }
        }
        'EOL' {
            if ($Install.Nutzung -ne 'ungenutzt') { $a.Add("Dringend: keine offizielle Korrektur - auf PHP 8.3 oder 8.4 wechseln, bis dahin nicht aus dem Internet erreichbar betreiben.") }
            else { $a.Add("Keine offizielle Korrektur. Nicht genutzt: entfernen, falls nicht ben${oe}tigt, sonst auf PHP 8.3 oder 8.4 wechseln.") }
        }
        'UNBEKANNT' { $a.Add("Version nicht bestimmbar: im PHP-Ordner php -v ausf${ue}hren und pr${ue}fen.") }
    }
    if ($Install.Upload -ne 'aus') { $a.Add("In der php.ini session.upload_progress.enabled = 0 setzen (CVE-2026-115560), danach Webserver neu starten.") }
    if ($Install.Uebergangsschutz -and $Install.Status -ne 'GEPATCHT') { $a.Add("${UEc}bergangsschutz aktiv - bleibt eine Br${ue}cke, Update weiterhin n${oe}tig.") }
    if ($a.Count -eq 0) { $a.Add("Keine Ma${ss}nahme n${oe}tig.") }
    return ($a -join ' ')
}

function Get-StableHash {
    param([string]$Text)
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try { return ([BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Text))) -replace '-', '').Substring(0, 16) } finally { $sha.Dispose() }
}

function Send-ReportMail {
    param([string]$Subject, [string]$HtmlBody, [string[]]$Attachments)
    $m = $Config.Mail
    $msg = New-Object System.Net.Mail.MailMessage
    $tempDir = Join-Path $env:TEMP ('php2shell-mail-' + [guid]::NewGuid().ToString('N'))
    New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
    $ok = $false; $err = $null
    try {
        $msg.From = $m.Absender
        foreach ($r in $m.Empfaenger) { $msg.To.Add($r) }
        $msg.Subject = $Subject; $msg.SubjectEncoding = [Text.Encoding]::UTF8
        $msg.Body = $HtmlBody; $msg.BodyEncoding = [Text.Encoding]::UTF8; $msg.IsBodyHtml = $true
        foreach ($a in $Attachments | Where-Object { $_ -and (Test-Path -LiteralPath $_) }) {
            $copy = Join-Path $tempDir ([IO.Path]::GetFileName($a)); Copy-Item -LiteralPath $a -Destination $copy -Force
            $msg.Attachments.Add((New-Object System.Net.Mail.Attachment($copy)))
        }
        [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
        $client = New-Object System.Net.Mail.SmtpClient($m.SmtpServer, [int]$m.SmtpPort)
        $client.EnableSsl = [bool]$m.Verschluesselung
        if ($m.Benutzer) { $client.Credentials = New-Object System.Net.NetworkCredential($m.Benutzer, $m.Kennwort) }
        $client.Send($msg); $ok = $true
    } catch {
        $err = $_.Exception.Message; if ($_.Exception.InnerException) { $err += ' / ' + $_.Exception.InnerException.Message }
    } finally {
        $msg.Dispose(); Remove-Item -LiteralPath $tempDir -Recurse -Force -ErrorAction SilentlyContinue
    }
    if ($ok) { Write-Log "Mail versendet an $($m.Empfaenger -join ', ')." } else { Write-Log "Mailversand fehlgeschlagen: $err" 'ERROR' }
}

# --- Main --------------------------------------------------------------------------
$exitCode = 3
try {
    Write-Log "Pruefung $ScriptVersion ($Mode) fuer $($Config.Kunde) auf $Computer gestartet"
    $state = $null
    if (Test-Path -LiteralPath $StatePath) { try { $state = Get-Content -LiteralPath $StatePath -Raw | ConvertFrom-Json } catch { } }

    $iis       = Get-IisContext
    $processes = @(Get-CimInstance Win32_Process -Filter "Name='php-cgi.exe' OR Name='php.exe'" -ErrorAction SilentlyContinue | Where-Object { $_.ExecutablePath } | ForEach-Object { $_.ExecutablePath })
    $apacheUp  = [bool](Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | Where-Object { $_.PathName -match 'httpd\.exe' -and $_.State -eq 'Running' })

    if ($Mode -eq 'Full') {
        $binaries = Find-PhpInPaths -Roots (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' | ForEach-Object { $_.DeviceID + '\' })
    } else {
        $cand = New-Object System.Collections.Generic.List[string]
        if ($state -and $state.BekanntePfade) { foreach ($p in $state.BekanntePfade) { $cand.Add($p) } }
        foreach ($p in @($iis.Paths) + $processes) { if ($p) { $cand.Add($p) } }
        foreach ($p in Find-PhpInPaths -Roots $CommonRoots) { $cand.Add($p) }
        $binaries = @($cand | Where-Object { $_ -match '\\php(-cgi)?\.exe$' -and (Test-Path -LiteralPath $_) })
    }
    $binaries = @($binaries | Select-Object -Unique)

    $installs = @(foreach ($grp in ($binaries | Group-Object { (Split-Path -Parent $_).ToLowerInvariant() })) {
        $bins = @($grp.Group | Sort-Object); $dir = Split-Path -Parent $bins[0]
        $ref = ($bins | Where-Object { $_ -like '*\php.exe' } | Select-Object -First 1); if (-not $ref) { $ref = $bins[0] }
        $ver = Get-PhpVersion -Path $ref
        $use = New-Object System.Collections.Generic.List[string]
        foreach ($b in $bins) {
            if ($iis.Paths | Where-Object { $_ -ieq $b }) { $use.Add('IIS') }
            if ($processes | Where-Object { $_ -ieq $b }) { $use.Add('Prozess') }
        }
        if ($apacheUp -and (Get-ChildItem -LiteralPath $dir -Filter 'php*apache*.dll' -ErrorAction SilentlyContinue)) { $use.Add('Apache') }
        $status = Get-VersionStatus -Version $ver; $upload = Get-UploadProgressSetting -PhpDir $dir; $inUse = $use.Count -gt 0
        $sev = 0
        if ($status -in 'VERWUNDBAR', 'EOL') { if ($inUse) { $sev = 2 } else { $sev = 1 } }
        if ($status -eq 'UNBEKANNT' -or $upload -ne 'aus') { $sev = [Math]::Max($sev, 1) }
        [pscustomobject]@{ Ordner = $dir; Programme = (($bins | ForEach-Object { Split-Path -Leaf $_ }) -join ', ')
            Version = if ($ver) { $ver.Text } else { '?' }; Status = $status
            Nutzung = if ($inUse) { (($use | Select-Object -Unique) -join '+') } else { 'ungenutzt' }
            Upload = $upload; Uebergangsschutz = Test-GuardExtension -PhpDir $dir; Schweregrad = $sev; Empfehlung = '' }
    })
    foreach ($i in $installs) { $i.Empfehlung = Get-Advice -Install $i }

    $allHits  = @(Search-Indicators -Directories $iis.LogDirectories -Days $Config.LogTage)
    $logHits  = @($allHits | Where-Object { $_.Einstufung -eq 'Angriffsmerkmal' })
    $infoHits = @($allHits | Where-Object { $_.Einstufung -eq 'Unklar' })
    $sessHits = @(); if ($installs.Count -gt 0) { $sessHits = @(Search-SessionWebshells -Installs $installs) }

    $overall = 0
    if ($installs.Count -gt 0) { $overall = ($installs | Measure-Object -Property Schweregrad -Maximum).Maximum }
    if ($infoHits.Count -gt 0 -and $overall -lt 1) { $overall = 1 }
    if ($logHits.Count -gt 0 -or $sessHits.Count -gt 0) { $overall = 2 }
    $statusText = @('OK', 'WARNUNG', 'KRITISCH')[$overall]

    # Change detection (for mail)
    $key = (@($installs | ForEach-Object { "$($_.Ordner)|$($_.Version)|$($_.Status)|$($_.Upload)" }) +
            @($sessHits | ForEach-Object { "S|$($_.Datei)" }) + @($logHits | Select-Object -ExpandProperty Merkmal -Unique | ForEach-Object { "L|$_" }) | Sort-Object) -join ';'
    $hash = Get-StableHash ("$statusText;$key")
    $changed = (-not $state) -or ($state.LetzterHash -ne $hash)

    # Short text
    $lines = foreach ($i in $installs) {
        $x = @(); if ($i.Upload -ne 'aus') { $x += 'UploadProgress an' }; if ($i.Uebergangsschutz) { $x += "$([char]0xDC)bergangsschutz aktiv" }
        '{0} {1} {2} ({3}){4}' -f $i.Ordner, $i.Version, $i.Status, $i.Nutzung, $(if ($x) { '; ' + ($x -join ', ') } else { '' })
    }
    if ($sessHits.Count -gt 0) { $lines = @("WEBSHELL in Session-Datei: $($sessHits[0].Datei)") + @($lines) }
    if ($logHits.Count -gt 0)  { $lines = @("Angriffsmerkmale in Protokollen: $($logHits.Count)") + @($lines) }
    $funde = if ($lines) { $lines -join ' | ' } else { 'Kein PHP gefunden' }
    if ($funde.Length -gt 900) { $funde = $funde.Substring(0, 880) + ' ... (Details im Bericht)' }

    # Report files
    $reportFiles = @()
    $html = $null
    if ($Config.Ausgabe -ne 'ExitCode' -or $Config.Mail.Aktiv) {
        $rows = ($installs | ForEach-Object { "<tr><td>$([Net.WebUtility]::HtmlEncode($_.Ordner))<br><small>$($_.Programme)</small></td><td>$($_.Version)</td><td style='font-weight:bold;color:$(@('#2e7d32','#ef6c00','#c62828')[$_.Schweregrad])'>$($_.Status)$(if ($_.Uebergangsschutz) { '<br><small>&Uuml;bergangsschutz aktiv</small>' })</td><td>$($_.Nutzung)</td><td>$($_.Upload)</td><td>$([Net.WebUtility]::HtmlEncode($_.Empfehlung))</td></tr>" }) -join ''
        $hitRows = ((@($logHits) + @($infoHits)) | Select-Object -First 50 | ForEach-Object { "<tr><td>$([Net.WebUtility]::HtmlEncode($_.Datei))</td><td>$($_.Zeile)</td><td>$([Net.WebUtility]::HtmlEncode($_.Merkmal))</td><td>$($_.Einstufung)</td></tr>" }) -join ''
        $sessRows = ($sessHits | ForEach-Object { "<li>$([Net.WebUtility]::HtmlEncode($_.Datei)) (ge&auml;ndert $($_.Geaendert)) [$([Net.WebUtility]::HtmlEncode($_.Merkmal))]</li>" }) -join ''
        $color = @('#2e7d32', '#ef6c00', '#c62828')[$overall]
        $html = "<!DOCTYPE html><html lang='de'><head><meta charset='utf-8'><title>PHP2Shell $Computer</title><style>body{font-family:Segoe UI,Arial;font-size:14px;margin:24px}table{border-collapse:collapse;margin:8px 0}td,th{border:1px solid #ccc;padding:5px 8px;text-align:left;vertical-align:top}th{background:#f0f0f0}.b{color:#fff;background:$color;padding:3px 10px;border-radius:4px;font-weight:bold}</style></head><body>" +
                "<h1>PHP2Shell-Pr&uuml;fung $([Net.WebUtility]::HtmlEncode($Config.Kunde)) &ndash; $Computer</h1><p>$($Start.ToString('dd.MM.yyyy HH:mm')) &middot; Modus $Mode &middot; $ScriptVersion &middot; Ergebnis: <span class='b'>$statusText</span></p>" +
                "<h2>PHP-Installationen ($($installs.Count))</h2>" + $(if ($installs.Count) { "<table><tr><th>Ordner</th><th>Version</th><th>Status</th><th>Nutzung</th><th>Upload-Fortschritt</th><th>Empfehlung</th></tr>$rows</table>" } else { '<p>Kein PHP gefunden.</p>' }) +
                "<h2>Webserver-Protokolle</h2><p>Angriffsmerkmale: $($logHits.Count) &middot; unklar (untersuchen, nicht automatisch sperren): $($infoHits.Count)</p>" + $(if ($hitRows) { "<table><tr><th>Datei</th><th>Zeile</th><th>Merkmal</th><th>Einstufung</th></tr>$hitRows</table>" } else { '' }) +
                "<h2>Session-Ordner</h2>" + $(if ($sessRows) { "<p style='color:#c62828'><b>Webshell-Merkmale gefunden &ndash; starker Hinweis auf eine Kompromittierung.</b> Nichts neu starten oder l&ouml;schen, zuerst Beweise sichern.</p><ul>$sessRows</ul>" } else { '<p>Keine Webshell-Merkmale gefunden.</p>' }) +
                "<p><small>Rein lesende Pr&uuml;fung. Korrigierte Versionen: 8.2.35, 8.3.36, 8.4.27, 8.5.12; bis 8.1 keine offizielle Korrektur. Hintergrund: codekunst-systems.com/aktuell/php2shell-statusuebersicht</small></p></body></html>"
    }
    if ($Config.Ausgabe -in 'Lokal', 'Share') {
        $target = if ($Config.Ausgabe -eq 'Lokal') { $Config.Ablageordner } else { Join-Path $Config.Ablageordner $Computer }
        try {
            if (-not (Test-Path -LiteralPath $target)) { New-Item -ItemType Directory -Path $target -Force | Out-Null }
            $base = Join-Path $target "PHP2Shell_$Computer`_$Stamp"
            $html | Set-Content -LiteralPath "$base.html" -Encoding UTF8
            [pscustomobject]@{ Kunde = $Config.Kunde; Computer = $Computer; Zeitpunkt = $Start.ToString('s'); Modus = $Mode; Version = $ScriptVersion
                Status = $statusText; Installationen = $installs; Protokolltreffer = $logHits; UnklareTreffer = $infoHits; SessionTreffer = $sessHits } |
                ConvertTo-Json -Depth 5 | Set-Content -LiteralPath "$base.json" -Encoding UTF8
            $reportFiles = @("$base.html", "$base.json")
            Get-ChildItem -LiteralPath $target -File -Filter 'PHP2Shell_*' -ErrorAction SilentlyContinue |
                Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-$Config.AufbewahrungTage) } | Remove-Item -Force -ErrorAction SilentlyContinue
        } catch { Write-Log "Bericht konnte nicht abgelegt werden ($target): $($_.Exception.Message)" 'ERROR' }
    }

    # Mail
    if ($Config.Mail.Aktiv) {
        $threshold = switch ($Config.Mail.SendenAb) { 'Immer' { 0 } 'Warnung' { 1 } default { 2 } }
        if ($overall -ge $threshold -and ($changed -or -not $Config.Mail.NurBeiAenderung)) {
            Send-ReportMail -Subject "[PHP2Shell] $statusText - $($Config.Kunde) - $Computer" -HtmlBody $html -Attachments $reportFiles
        }
    }

    # State
    $known = @($binaries); if ($state -and $state.BekanntePfade -and $Mode -eq 'Quick') { $known = @($known + $state.BekanntePfade | Select-Object -Unique) }
    [pscustomobject]@{ LetzterStatus = $statusText; LetzterHash = $hash; BekanntePfade = $known; Zeitpunkt = $Start.ToString('s') } |
        ConvertTo-Json | Set-Content -LiteralPath $StatePath -Encoding UTF8

    # Event log
    try {
        if (-not [Diagnostics.EventLog]::SourceExists('PHP2ShellCheck')) { New-EventLog -LogName Application -Source 'PHP2ShellCheck' }
        Write-EventLog -LogName Application -Source 'PHP2ShellCheck' -EventId @(1000, 2000, 3000)[$overall] -EntryType @('Information', 'Warning', 'Error')[$overall] `
            -Message "PHP2Shell-Monitoring ($($Config.Kunde)): $statusText. $funde"
    } catch { Write-Log "Ereignisprotokoll nicht beschreibbar: $($_.Exception.Message)" 'WARN' }

    Write-Log "Ergebnis: $statusText (Installationen $($installs.Count), Protokoll $($logHits.Count), Session $($sessHits.Count), unklar $($infoHits.Count))"
    Write-Output ("PHP2SHELL {0} | {1} | {2} | PHP: {3} | Treffer: {4}" -f $statusText, $Config.Kunde, $Computer, $installs.Count, ($logHits.Count + $sessHits.Count))
    $exitCode = $overall
}
catch {
    Write-Log "Abbruch: $($_.Exception.Message)" 'ERROR'
    Write-Output ("PHP2SHELL FEHLER | {0} | {1} | {2}" -f $Config.Kunde, $env:COMPUTERNAME, $_.Exception.Message)
    $exitCode = 3
}
exit $exitCode
'@

# =============================================================================
#  GENERATOR
# =============================================================================
function Read-Choice {
    param([string]$Question, [string[]]$Options, [int]$Default = 1)
    Write-Host ''
    Write-Host $Question -ForegroundColor Cyan
    for ($n = 0; $n -lt $Options.Count; $n++) { Write-Host ("  [{0}] {1}" -f ($n + 1), $Options[$n]) }
    while ($true) {
        $a = Read-Host "Auswahl [$Default]"
        if (-not $a) { return $Default }
        if ($a -match '^\d+$' -and [int]$a -ge 1 -and [int]$a -le $Options.Count) { return [int]$a }
        Write-Host 'Bitte eine der angezeigten Zahlen eingeben.' -ForegroundColor Yellow
    }
}
function Read-Text {
    param([string]$Question, [string]$Default = '', [switch]$Required)
    while ($true) {
        $a = Read-Host ($(if ($Default) { "$Question [$Default]" } else { $Question }))
        if (-not $a) { $a = $Default }
        if ($a -or -not $Required) { return $a }
        Write-Host 'Eingabe erforderlich.' -ForegroundColor Yellow
    }
}
function Read-YesNo {
    param([string]$Question, [bool]$Default = $false)
    $d = if ($Default) { 'J/n' } else { 'j/N' }
    $a = Read-Host "$Question [$d]"
    if (-not $a) { return $Default }
    return ($a -match '^(j|ja|y|yes)$')
}
function ConvertTo-PsLiteral {
    # Single-quoted PowerShell literal
    param([string]$Value)
    return "'" + ($Value -replace "'", "''") + "'"
}

function New-ConfigBlock {
    param($C)
    $to = ($C.MailEmpfaenger | ForEach-Object { ConvertTo-PsLiteral $_ }) -join ', '
    $lines = @(
        '# ============================================================================='
        "#  KONFIGURATION - erzeugt am $(Get-Date -Format 'dd.MM.yyyy HH:mm') mit Generator $GeneratorVersion"
        '#  Aenderungen am besten ueber den Generator, nicht von Hand.'
        '# ============================================================================='
        '$Config = @{'
        "    Kunde             = $(ConvertTo-PsLiteral $C.Kunde)"
        "    Ausgabe           = $(ConvertTo-PsLiteral $C.Ausgabe)            # ExitCode | Lokal | Share"
        "    Ablageordner      = $(ConvertTo-PsLiteral $C.Ablageordner)"
        "    AufbewahrungTage  = $($C.AufbewahrungTage)"
        "    StandardModus     = $(ConvertTo-PsLiteral $C.StandardModus)"
        "    LogTage           = $($C.LogTage)"
        '    Mail = @{'
        "        Aktiv            = `$$($C.MailAktiv.ToString().ToLower())"
        "        Empfaenger       = @($to)"
        "        Absender         = $(ConvertTo-PsLiteral $C.MailAbsender)"
        "        SmtpServer       = $(ConvertTo-PsLiteral $C.SmtpServer)"
        "        SmtpPort         = $($C.SmtpPort)"
        "        Verschluesselung = `$$($C.Verschluesselung.ToString().ToLower())"
        "        Benutzer         = $(ConvertTo-PsLiteral $C.SmtpBenutzer)"
        "        Kennwort         = $(ConvertTo-PsLiteral $C.SmtpKennwort)   # Klartext! Nur Sende-Postfach, besser Relay ohne Anmeldung"
        "        SendenAb         = $(ConvertTo-PsLiteral $C.SendenAb)       # Immer | Warnung | Kritisch"
        "        NurBeiAenderung  = `$$($C.NurBeiAenderung.ToString().ToLower())"
        '    }'
        '}'
    )
    return ($lines -join "`r`n")
}

function New-MonitorScript {
    param($C, [string]$OutDir)
    $safe = ($C.Kunde -replace '[^A-Za-z0-9_-]', '_')
    $scriptPath = Join-Path $OutDir "PHP2Shell-Monitor_$safe.ps1"
    $content = $Template.Replace('#__KONFIGURATION__#', (New-ConfigBlock $C))
    # UTF-8 with BOM so that Windows PowerShell 5.1 reads it correctly
    [IO.File]::WriteAllText($scriptPath, ($content -replace "`r?`n", "`r`n"), (New-Object Text.UTF8Encoding($true)))
    return $scriptPath
}

# --- Interactive part --------------------------------------------------------------
if ($MyInvocation.InvocationName -ne '.') {
    Write-Host "PHP2Shell-Monitoring - Skriptgenerator $GeneratorVersion" -ForegroundColor Green
    Write-Host 'Erzeugt ein konfiguriertes Pruefskript zur Verteilung (RMM, GPO, geplante Aufgabe).'
    Write-Host 'Ohne Gewaehr - vor dem Rollout auf einem Testrechner pruefen.'

    $C = [ordered]@{
        Kunde = ''; Ausgabe = 'ExitCode'; Ablageordner = 'C:\PHP2ShellCheck'; AufbewahrungTage = 30
        StandardModus = 'Quick'; LogTage = 30
        MailAktiv = $false; MailEmpfaenger = @(); MailAbsender = ''; SmtpServer = ''; SmtpPort = 25
        Verschluesselung = $false; SmtpBenutzer = ''; SmtpKennwort = ''; SendenAb = 'Kritisch'; NurBeiAenderung = $true
    }
    Write-Host ''
    $C.Kunde = Read-Text 'Kunde (Name oder Kuerzel, wird Teil des Dateinamens)' -Required

    switch (Read-Choice 'Wo sollen die Berichte abgelegt werden?' @(
            'Nur Exit-Code (keine Berichtsdateien)',
            'Lokal auf dem Zielrechner (C:\PHP2ShellCheck)',
            'Auf einer Freigabe (je Rechner ein Unterordner)') 2) {
        1 { $C.Ausgabe = 'ExitCode' }
        2 { $C.Ausgabe = 'Lokal'; $C.Ablageordner = Read-Text 'Lokaler Ordner' 'C:\PHP2ShellCheck' }
        3 {
            $C.Ausgabe = 'Share'
            while ($true) {
                $C.Ablageordner = Read-Text 'Freigabe (UNC-Pfad, z. B. \\fileserver\IT$\PHP2Shell)' -Required
                if ($C.Ablageordner -notmatch '^\\\\[^\\]+\\[^\\]+') { Write-Host 'Bitte einen UNC-Pfad angeben (\\Server\Freigabe\...).' -ForegroundColor Yellow; continue }
                if (Test-Path -LiteralPath $C.Ablageordner) {
                    $probe = Join-Path $C.Ablageordner ("schreibtest_" + [guid]::NewGuid().ToString('N') + '.tmp')
                    try { Set-Content -LiteralPath $probe -Value 'test'; Remove-Item -LiteralPath $probe -Force; Write-Host '  Schreibtest mit Ihrem Konto erfolgreich.' -ForegroundColor Green }
                    catch { Write-Host "  Schreibtest mit Ihrem Konto fehlgeschlagen: $($_.Exception.Message)" -ForegroundColor Yellow }
                } else {
                    Write-Host '  Freigabe von hier aus nicht erreichbar - bitte pruefen.' -ForegroundColor Yellow
                    if (-not (Read-YesNo 'Trotzdem verwenden?')) { continue }
                }
                Write-Host '  Hinweis: Laeuft das Skript als SYSTEM, schreibt es mit dem Computerkonto.' -ForegroundColor Yellow
                Write-Host '  Freigabe- und NTFS-Rechte daher fuer "Domaenencomputer" (Aendern) setzen.' -ForegroundColor Yellow
                break
            }
        }
    }
    if ($C.Ausgabe -ne 'ExitCode') {
        $d = Read-Text 'Berichte wie viele Tage aufbewahren' '30'
        if ($d -match '^\d+$') { $C.AufbewahrungTage = [int]$d }
    }

    $C.MailAktiv = Read-YesNo 'Bericht per Mail senden?'
    if ($C.MailAktiv) {
        $C.MailEmpfaenger = @((Read-Text 'Empfaenger (mehrere mit Komma)' -Required) -split '\s*,\s*' | Where-Object { $_ })
        $C.MailAbsender   = Read-Text 'Absenderadresse' -Required
        $C.SmtpServer     = Read-Text 'SMTP-Server (interner Relay empfohlen)' -Required
        $p = Read-Text 'Port' '25'; if ($p -match '^\d+$') { $C.SmtpPort = [int]$p }
        $C.Verschluesselung = Read-YesNo 'STARTTLS/TLS verwenden?' ($C.SmtpPort -eq 587)
        $C.SmtpBenutzer = Read-Text 'SMTP-Benutzer (leer = ohne Anmeldung, empfohlen)'
        if ($C.SmtpBenutzer) {
            Write-Host '  ACHTUNG: Das Kennwort steht im verteilten Skript im Klartext.' -ForegroundColor Yellow
            Write-Host '  Nur ein eigenes Postfach verwenden, das ausschliesslich senden darf.' -ForegroundColor Yellow
            $sec = Read-Host 'Kennwort' -AsSecureString
            $C.SmtpKennwort = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
        }
        switch (Read-Choice 'Ab welchem Ergebnis senden?' @('Immer', 'Ab Warnung', 'Nur bei kritischem Ergebnis') 3) {
            1 { $C.SendenAb = 'Immer' } 2 { $C.SendenAb = 'Warnung' } 3 { $C.SendenAb = 'Kritisch' }
        }
        $C.NurBeiAenderung = Read-YesNo 'Nur senden, wenn sich das Ergebnis geaendert hat (verhindert Mailflut)?' $true
        try {
            $tcp = New-Object Net.Sockets.TcpClient
            $iar = $tcp.BeginConnect($C.SmtpServer, $C.SmtpPort, $null, $null)
            if ($iar.AsyncWaitHandle.WaitOne(3000) -and $tcp.Connected) { Write-Host "  $($C.SmtpServer):$($C.SmtpPort) von hier erreichbar." -ForegroundColor Green }
            else { Write-Host "  $($C.SmtpServer):$($C.SmtpPort) von hier nicht erreichbar - von den Zielrechnern aus pruefen." -ForegroundColor Yellow }
            $tcp.Close()
        } catch { Write-Host '  Verbindungstest nicht moeglich.' -ForegroundColor Yellow }
    }

    switch (Read-Choice 'Standardmodus, wenn beim Aufruf nichts angegeben ist?' @('Quick (taeglich, schnell)', 'Full (alle Festplatten)') 1) {
        1 { $C.StandardModus = 'Quick' } 2 { $C.StandardModus = 'Full' }
    }

    if ((Read-Choice 'Konfiguration erzeugen?' @('Ja', 'Abbrechen') 1) -eq 2) { Write-Host 'Abgebrochen.'; return }
    $path = New-MonitorScript -C $C -OutDir $Ausgabeordner

    # Optional signing
    $cert = @(Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert -ErrorAction SilentlyContinue | Where-Object { $_.NotAfter -gt (Get-Date) }) | Select-Object -First 1
    if ($cert -and (Read-YesNo "Skript mit Zertifikat '$($cert.Subject)' signieren?" $true)) {
        $sig = Set-AuthenticodeSignature -FilePath $path -Certificate $cert -TimestampServer 'http://timestamp.digicert.com'
        Write-Host "  Signatur: $($sig.Status)" -ForegroundColor Green
    }

    # Deployment notes
    $name = Split-Path -Leaf $path
    $notes = @(
        "PHP2Shell-Monitoring fuer $($C.Kunde) - erzeugt am $(Get-Date -Format 'dd.MM.yyyy HH:mm')"
        ''
        "Skript: $name"
        "Ausgabe: $($C.Ausgabe) $(if ($C.Ausgabe -ne 'ExitCode') { '-> ' + $C.Ablageordner })"
        "Mail: $(if ($C.MailAktiv) { 'ja, an ' + ($C.MailEmpfaenger -join ', ') + ', ab ' + $C.SendenAb + $(if ($C.NurBeiAenderung) { ', nur bei Aenderung' }) } else { 'nein' })"
        ''
        'Aufruf (als SYSTEM, z. B. ueber RMM oder geplante Aufgabe):'
        "  powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\$name -Mode Full     (woechentlich)"
        "  powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\$name -Mode Quick    (taeglich)"
        ''
        'Einbindung ins Monitoring:'
        '  Exit-Code    0 OK, 1 WARNUNG, 2 KRITISCH, 3 FEHLER'
        '  Ausgabe      PHP2SHELL <STATUS> | <Kunde> | <Rechner> | PHP: n | Treffer: n'
        '  Hinweis: Manche Systeme werten jeden Exit-Code ungleich 0 als fehlgeschlagene Aufgabe.'
        '  Dann den Status aus der Ausgabezeile oder dem Ereignisprotokoll auswerten.'
        'Ereignisprotokoll: Anwendung, Quelle PHP2ShellCheck, ID 1000/2000/3000'
        'Zustand und Protokoll auf dem Zielrechner: C:\ProgramData\PHP2ShellCheck'
        ''
        'Vor dem Rollout auf einem Testrechner pruefen (Setup-PHP2ShellTestumgebung.ps1).'
    )
    if ($C.SmtpKennwort) { $notes += ''; $notes += 'ACHTUNG: Das Skript enthaelt ein SMTP-Kennwort im Klartext. Ablage und Verteilweg entsprechend schuetzen.' }
    if ($C.Ausgabe -eq 'Share') { $notes += ''; $notes += 'Freigabe: Schreibrechte (Aendern) fuer Domaenencomputer erforderlich, wenn als SYSTEM ausgefuehrt.' }
    $notesPath = [IO.Path]::ChangeExtension($path, $null).TrimEnd('.') + '_Verteilung.txt'
    $notes | Set-Content -LiteralPath $notesPath -Encoding UTF8

    Write-Host ''
    Write-Host 'Fertig:' -ForegroundColor Green
    Write-Host "  $path"
    Write-Host "  $notesPath"
}

Den gesamten Inhalt mit der Kopieren-Schaltfläche übernehmen, in einem Editor einfügen und als New-PHP2ShellMonitorScript.ps1 speichern. Das Skript enthält nur ASCII-Zeichen. Es läuft auf dem Rechner des Administrators und erzeugt das zu verteilende Skript PHP2Shell-Monitor_<Kunde>.ps1 samt Hinweisdatei zur Verteilung.

KanalInhalt
Exit-Code0 = OK, 1 = WARNUNG, 2 = KRITISCH, 3 = FEHLER
ErgebniszeilePHP2SHELL <STATUS> | <Kunde> | <Rechner> | PHP: n | Treffer: n
EreignisprotokollAnwendung, Quelle PHP2ShellCheck, ID 1000 / 2000 / 3000
Bericht (optional)HTML und JSON mit Empfehlung pro Installation, lokal oder auf einer Freigabe (je Rechner ein Unterordner)

Drei Punkte für die Verteilung

Läuft das Skript als SYSTEM, schreibt es mit dem Computerkonto auf die Freigabe – Freigabe- und NTFS-Rechte deshalb für „Domänencomputer“ setzen. Manche Monitoring-Systeme werten jeden Exit-Code ungleich 0 als fehlgeschlagene Aufgabe; dann die Ergebniszeile oder das Ereignisprotokoll auswerten. Ein SMTP-Kennwort stünde im verteilten Skript im Klartext – besser einen internen Relay ohne Anmeldung nutzen.

Bitte beachten

Bereitgestellt ohne Gewähr, Nutzung auf eigene Verantwortung. Vor dem produktiven Einsatz mit der Testumgebung prüfen. Die Skripte ersetzen keine forensische Untersuchung: Bei Hinweisen auf eine Kompromittierung zuerst Beweise sichern und Fachleute hinzuziehen.

Unterstützung bei der Prüfung?

Wir prüfen Ihre Windows- und Linux-Server, bewerten die Funde und kümmern uns um Updates, Absicherung und Überwachung – mit Bericht als Nachweis.

Kontakt aufnehmen

Häufige Fragen

Was tun bei Treffern?+
Ein Treffer in den Protokollen bedeutet einen Angriffsversuch, nicht zwingend einen Erfolg. Ein Treffer in einer Session-Datei ist dagegen ein starker Hinweis auf eine Kompromittierung. In beiden Fällen nichts neu starten oder löschen, sondern zuerst Speicherabbild, Dateien und Protokolle sichern und den Vorfall prüfen lassen.
Was bedeutet ein „unklarer“ Treffer?+
Adressen von Anonymisierungsdiensten oder angeblichen Prüfscannern. Sie werden auch legitim genutzt – Treffer untersuchen, nicht automatisch sperren.
Brauche ich für den Test einen IIS?+
Nein. Die Testumgebung nutzt den eingebauten Webserver von PHP; das Prüfskript erkennt ihn als laufenden Prozess und damit als aktiv genutzte Installation.
PHP2Shell-Prüfskripte für Windows-Server