'8.1.38', '8.2' => '8.2.35', '8.3' => '8.3.36', '8.4' => '8.4.17']; $istWindows = stripos(PHP_OS_FAMILY, 'WIN') !== false; /* Windows-Konsolen stehen oft auf Codepage 850 und zerlegen UTF-8. Der Versuch kostet nichts; schlaegt er fehl, bleibt der Text lesbar. */ if ($istWindows) { @shell_exec('chcp 65001 > nul 2>&1'); } // ==================================================================== // Argumente // ==================================================================== $argumente = []; for ($i = 1; $i < $argc; $i++) { if (str_starts_with($argv[$i], '--')) { $name = substr($argv[$i], 2); $wert = ($i + 1 < $argc && !str_starts_with($argv[$i + 1], '--')) ? $argv[++$i] : true; $argumente[$name] = $wert; } } if (isset($argumente['hilfe']) || isset($argumente['help'])) { echo << Webverzeichnis, das geprueft wird. Ohne Angabe wird es gesucht. --bericht Bericht zusaetzlich in eine Datei schreiben. --json Ausgabe als JSON statt als Text. --tief Auch grosse Verzeichnisse vollstaendig lesen (langsamer, gruendlicher). --hilfe Diese Uebersicht. Das Werkzeug veraendert nichts und sendet nichts. HILFE; exit(0); } $tief = isset($argumente['tief']); $alsJson = isset($argumente['json']); // ==================================================================== // Befundsammlung // ==================================================================== /** @var array */ $befunde = []; $uebersprungen = []; /** * Stufen – bewusst nur drei, und die oberste wird sparsam vergeben. * * 'rot' – sollte auf einem gesunden System nicht existieren. * 'gelb' – kann harmlos sein, gehoert aber angesehen. * 'grau' – reine Information, kein Verdacht. * * Ein Werkzeug, das alles rot faerbt, wird weggeklickt. Deshalb ist die * Zuordnung hier eng: 'rot' nur, wenn mir keine gutartige Erklaerung * einfaellt. */ function befund(string $stufe, string $bereich, string $titel, array $belege = [], string $rat = ''): void { global $befunde; $befunde[] = ['stufe' => $stufe, 'bereich' => $bereich, 'titel' => $titel, 'belege' => $belege, 'rat' => $rat]; } function nichtGeprueft(string $was, string $grund): void { global $uebersprungen; $uebersprungen[] = ['was' => $was, 'grund' => $grund]; } function melde(string $text): void { global $alsJson; if (!$alsJson) { echo $text, PHP_EOL; } } /** Fuehrt ein Systemkommando aus, sofern erlaubt. Gibt null zurueck, wenn nicht. */ function kommando(string $befehl): ?string { static $erlaubt = null; if ($erlaubt === null) { $aus = explode(',', (string) ini_get('disable_functions')); $aus = array_map('trim', $aus); $erlaubt = function_exists('shell_exec') && !in_array('shell_exec', $aus, true); } if (!$erlaubt) { return null; } $ergebnis = @shell_exec($befehl . ' 2>&1'); return is_string($ergebnis) ? $ergebnis : null; } function lesbar(int $bytes): string { $e = ['B', 'KB', 'MB', 'GB']; $i = 0; while ($bytes >= 1024 && $i < 3) { $bytes = intdiv($bytes, 1024); $i++; } return $bytes . ' ' . $e[$i]; } // ==================================================================== // 1. Webverzeichnis bestimmen // ==================================================================== function webverzeichnisFinden(bool $istWindows): ?string { $kandidaten = $istWindows ? ['C:/inetpub/wwwroot', 'C:/xampp/htdocs', 'C:/wamp64/www', 'C:/Program Files/Apache Group/Apache2/htdocs'] : ['/var/www/html', '/var/www', '/usr/share/nginx/html', '/srv/www', '/home/www', '/var/www/vhosts']; foreach ($kandidaten as $k) { if (is_dir($k) && is_readable($k)) { return rtrim(str_replace('\\', '/', realpath($k) ?: $k), '/'); } } return null; } $wurzel = isset($argumente['pfad']) && is_string($argumente['pfad']) ? rtrim(str_replace('\\', '/', $argumente['pfad']), '/') : webverzeichnisFinden($istWindows); melde(''); melde(' DAVINCI Spurensuche ' . VERSION . ' – Pruefung nach einem Vorfall'); melde(' ' . str_repeat('=', 66)); melde(' Dieses Werkzeug liest nur. Es veraendert nichts und sendet nichts.'); melde(''); if ($wurzel === null || !is_dir($wurzel)) { melde(' Kein Webverzeichnis gefunden. Bitte angeben:'); melde(' php davinci-check.php --pfad /var/www/html'); melde(''); exit(2); } if (!is_readable($wurzel)) { melde(' Verzeichnis nicht lesbar: ' . $wurzel); melde(' Bitte mit ausreichenden Rechten ausfuehren (Linux: sudo).'); melde(''); exit(2); } melde(' System : ' . PHP_OS_FAMILY . ' – ' . php_uname('n')); melde(' PHP : ' . PHP_VERSION . ' (' . PHP_SAPI . ')'); melde(' Webverzeichnis: ' . $wurzel); melde(' Zeitpunkt : ' . date('d.m.Y H:i:s')); melde(''); // ==================================================================== // 2. PHP-Fassung gegen PHP2Shell // ==================================================================== melde(' [1/8] PHP-Fassung ...'); $zweig = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION; $laufend = PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '.' . PHP_RELEASE_VERSION; if (isset(PHP_GEPATCHT[$zweig])) { if (version_compare($laufend, PHP_GEPATCHT[$zweig], '<')) { befund( 'rot', 'PHP', 'Die PHP-Fassung dieser Kommandozeile ist aelter als die abgesicherte', ['laeuft: ' . $laufend, 'geschlossen ab: ' . PHP_GEPATCHT[$zweig]], 'Aktualisieren. Wichtig: Die Fassung des Webservers kann eine andere sein als die hier gemessene – pruefen Sie beide.' ); } else { befund('grau', 'PHP', 'PHP-Fassung der Kommandozeile ist aktuell genug', ['laeuft: ' . $laufend], ''); } } else { befund( 'gelb', 'PHP', 'PHP-Zweig ' . $zweig . ' wird nicht mehr mit Sicherheitsaktualisierungen versorgt', ['laeuft: ' . $laufend], 'Ein Wechsel auf 8.4 ist faellig. Fuer diesen Zweig erscheinen keine Korrekturen mehr.' ); } if ($zweig === '8.2') { befund('gelb', 'PHP', 'Sicherheitsunterstuetzung fuer PHP 8.2 endet am 31.12.2026', [], 'Den Wechsel auf 8.4 jetzt einplanen, nicht im Dezember.'); } // ==================================================================== // 3. Gefaehrliche PHP-Einstellungen // ==================================================================== melde(' [2/8] PHP-Konfiguration ...'); /* auto_prepend_file ist der eleganteste Dauerzugang ueberhaupt: Es laedt bei JEDEM Aufruf eine Datei, ohne dass im Webverzeichnis etwas auffaellt. Wird regulaer fast nie gebraucht. */ foreach (['auto_prepend_file', 'auto_append_file'] as $einstellung) { $wert = trim((string) ini_get($einstellung)); if ($wert !== '') { befund( 'rot', 'PHP-Konfiguration', $einstellung . ' ist gesetzt', [$einstellung . ' = ' . $wert, 'gesetzt in: ' . (php_ini_loaded_file() ?: 'unbekannt')], 'Diese Datei wird bei jedem Seitenaufruf ausgefuehrt. Das ist ein klassischer Dauerzugang. Inhalt pruefen, bevor etwas geaendert wird.' ); } } $deaktiviert = trim((string) ini_get('disable_functions')); befund( $deaktiviert === '' ? 'gelb' : 'grau', 'PHP-Konfiguration', $deaktiviert === '' ? 'disable_functions ist leer' : 'disable_functions ist gesetzt', [$deaktiviert === '' ? 'keine Funktion gesperrt' : $deaktiviert], $deaktiviert === '' ? 'Auf einem Webserver sollten exec, shell_exec, system, passthru und proc_open gesperrt sein, sofern keine Anwendung sie braucht.' : '' ); if (ini_get('allow_url_include')) { befund('rot', 'PHP-Konfiguration', 'allow_url_include ist aktiv', [], 'Damit lassen sich Dateien aus dem Internet direkt einbinden. Abschalten.'); } if (!ini_get('expose_php')) { befund('grau', 'PHP-Konfiguration', 'expose_php ist aus – die Fassung steht nicht im Antwortkopf', [], ''); } // ==================================================================== // 4. Dateien im Webverzeichnis // ==================================================================== melde(' [3/8] Dateien im Webverzeichnis ...'); /* Muster, die in Webshells praktisch immer vorkommen. Jedes einzelne ist fuer sich harmlos – entscheidend ist die KOMBINATION aus einer Ausfuehrungsfunktion und einer Benutzereingabe im selben Ausdruck. */ $ausfuehrung = '(?:eval|assert|system|passthru|shell_exec|exec|popen|proc_open|pcntl_exec)'; $eingabe = '(?:\$_(?:GET|POST|REQUEST|COOKIE|SERVER|FILES)|php:\/\/input|getenv)'; $muster = [ 'Ausfuehrung direkt aus Benutzereingabe' => [ 'regex' => '/' . $ausfuehrung . '\s*\(\s*(?:[^;)]{0,120})?' . $eingabe . '/i', 'stufe' => 'rot', ], 'preg_replace mit /e (Codeausfuehrung)' => [ 'regex' => '/preg_replace\s*\(\s*([\'"]).{1,80}\1\s*\/\s*[a-z]*e[a-z]*\1/i', 'stufe' => 'rot', ], 'Verschachtelte Entschluesselung' => [ 'regex' => '/(?:gzinflate|gzuncompress|str_rot13|base64_decode)\s*\(\s*(?:gzinflate|gzuncompress|str_rot13|base64_decode)\s*\(/i', 'stufe' => 'rot', ], 'Ausfuehrung eines entschluesselten Textes' => [ 'regex' => '/' . $ausfuehrung . '\s*\(\s*(?:@)?\s*(?:base64_decode|gzinflate|hex2bin|pack)\s*\(/i', 'stufe' => 'rot', ], 'Funktionsaufruf ueber eine Zeichenkette' => [ 'regex' => '/\$[a-z_][a-z0-9_]*\s*=\s*([\'"])(?:assert|eval|system|shell_exec|passthru)\1\s*;/i', 'stufe' => 'gelb', ], 'Dateiuebernahme aus einem Upload' => [ 'regex' => '/move_uploaded_file\s*\([^;]{0,200}\$_(?:FILES|REQUEST|POST)/i', 'stufe' => 'gelb', ], ]; /* Verzeichnisse, in denen eine PHP-Datei fast nie etwas zu suchen hat. * * KORREKTUR nach dem ersten Testlauf (22.10.2026): Eine frühere Fassung * führte hier schlicht 'files', 'cache' und 'media' als Teilzeichenketten. * Ergebnis auf einer realen WordPress-Installation: 52 rote Befunde, alle * falsch – es waren Quelldateien des Elementor-Plugins unter * plugins/elementor/core/files/. Ein Werkzeug, das so etwas meldet, * verschreckt genau die Leute, denen es helfen soll. * * Deshalb jetzt zwei Bedingungen: ein echtes Datenverzeichnis UND kein * Quellcodebaum darüber. */ $verboteneOrte = [ 'wp-content/uploads', 'wp-content/cache', 'wp-content/upgrade', 'wp-content/wflogs', 'typo3temp', 'fileadmin/user_upload', 'uploads/', 'upload/', 'assets/uploads', 'var/cache', 'var/tmp', 'var/session', 'pub/media', 'sites/default/files', 'media/uploads', 'userfiles', 'attachments', ]; /* Wo diese Pfadteile vorkommen, ist PHP regulärer Programmcode und kein Fund. */ $quellbaeume = [ '/plugins/', '/themes/', '/vendor/', '/node_modules/', '/modules/', '/extensions/', '/typo3conf/ext/', '/libraries/', '/src/', '/lib/', '/wp-includes/', '/wp-admin/', '/bin/', '/tests/', ]; $ueberspringen = ['node_modules', '.git', 'vendor/composer', '.svn', 'wp-content/languages']; $gezaehlt = 0; $gelesen = 0; $grenzeDateien = $tief ? 400000 : 60000; $grenzeBytes = 3 * 1024 * 1024; $treffer = []; $inVerboteneOrte = []; $doppelteEndung = []; $zeitstempel = []; $jetzt = time(); $iterator = null; try { $iterator = new RecursiveIteratorIterator( new RecursiveCallbackFilterIterator( new RecursiveDirectoryIterator($wurzel, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS), function ($aktuell) use ($ueberspringen) { $p = str_replace('\\', '/', $aktuell->getPathname()); foreach ($ueberspringen as $u) { if (str_contains($p, '/' . $u)) { return false; } } return true; } ), RecursiveIteratorIterator::LEAVES_ONLY ); } catch (Throwable $e) { nichtGeprueft('Dateisuche', 'Verzeichnis nicht durchlaufbar: ' . $e->getMessage()); } if ($iterator !== null) { foreach ($iterator as $datei) { if ($gezaehlt >= $grenzeDateien) { nichtGeprueft('Dateisuche', 'Obergrenze von ' . number_format($grenzeDateien, 0, ',', '.') . ' Dateien erreicht – mit --tief vollstaendig pruefen'); break; } $gezaehlt++; try { if (!$datei->isFile()) { continue; } $pfad = str_replace('\\', '/', $datei->getPathname()); $relativ = ltrim(substr($pfad, strlen($wurzel)), '/'); $name = $datei->getFilename(); $endung = strtolower($datei->getExtension()); } catch (Throwable $e) { continue; } /* Doppelte Endung: bild.jpg.php oder shell.php.jpg */ if (preg_match('/\.(jpe?g|png|gif|webp|pdf|txt|zip|doc|ico|css|js)\.(php[0-9s]?|phtml)$/i', $name) || preg_match('/\.(php[0-9s]?|phtml)\.(jpe?g|png|gif|webp|pdf|txt|zip)$/i', $name)) { $doppelteEndung[] = $relativ; } $istPhp = in_array($endung, ['php', 'php3', 'php4', 'php5', 'php7', 'php8', 'phtml', 'phar', 'inc'], true); if ($istPhp) { /* PHP an einem Ort, an dem kein PHP sein sollte – aber nur, wenn darüber kein Quellcodebaum liegt (siehe Kommentar bei $quellbaeume). */ $imQuellbaum = false; foreach ($quellbaeume as $q) { if (stripos('/' . $relativ, $q) !== false) { $imQuellbaum = true; break; } } if (!$imQuellbaum) { foreach ($verboteneOrte as $ort) { if (stripos($relativ, rtrim($ort, '/') . '/') === 0 || stripos($relativ, '/' . rtrim($ort, '/') . '/') !== false) { $inVerboteneOrte[] = ['datei' => $relativ, 'ort' => $ort, 'geaendert' => @$datei->getMTime()]; break; } } } /* Inhalt pruefen */ $groesse = @$datei->getSize(); if ($groesse !== false && $groesse > 0 && $groesse <= $grenzeBytes && is_readable($pfad)) { $inhalt = @file_get_contents($pfad, false, null, 0, $grenzeBytes); if ($inhalt !== false) { $gelesen++; foreach ($muster as $bezeichnung => $m) { if (preg_match($m['regex'], $inhalt, $gefunden)) { $treffer[] = [ 'datei' => $relativ, 'muster' => $bezeichnung, 'stufe' => $m['stufe'], 'stelle' => trim(substr(preg_replace('/\s+/', ' ', $gefunden[0]), 0, 110)), 'geaendert' => @$datei->getMTime(), ]; break; } } /* Eine sehr lange Zeile ohne Zeilenumbrueche ist das Kennzeichen verschleierten Codes. Minifiziertes JavaScript sieht genauso aus – daher nur gelb und nur bei PHP. */ $laengsteZeile = 0; foreach (explode("\n", $inhalt) as $z) { $laengsteZeile = max($laengsteZeile, strlen($z)); } if ($laengsteZeile > 2500) { $treffer[] = [ 'datei' => $relativ, 'muster' => 'Sehr lange Zeile (' . number_format($laengsteZeile, 0, ',', '.') . ' Zeichen) – Hinweis auf Verschleierung', 'stufe' => 'gelb', 'stelle' => '', 'geaendert' => @$datei->getMTime(), ]; } } } /* Zeitstempel in der Zukunft gibt es nicht. Das ist entweder eine falsch gestellte Uhr oder ein manipulierter Stempel. */ $m = @$datei->getMTime(); if ($m !== false && $m > $jetzt + 86400) { $zeitstempel[] = ['datei' => $relativ, 'grund' => 'Aenderungszeit liegt in der Zukunft: ' . date('d.m.Y H:i', $m)]; } } } } melde(' ' . number_format($gezaehlt, 0, ',', '.') . ' Dateien gesehen, ' . number_format($gelesen, 0, ',', '.') . ' PHP-Dateien gelesen.'); /* Befunde aus der Dateisuche buendeln, nicht einzeln melden – sonst erschlaegt eine befallene Installation den ganzen Bericht. */ $rot = array_values(array_filter($treffer, fn($t) => $t['stufe'] === 'rot')); $gelb = array_values(array_filter($treffer, fn($t) => $t['stufe'] === 'gelb')); if ($rot) { $belege = []; foreach (array_slice($rot, 0, 25) as $t) { $belege[] = $t['datei'] . ' [' . $t['muster'] . ']' . ($t['geaendert'] ? ' geaendert ' . date('d.m.Y H:i', $t['geaendert']) : '') . ($t['stelle'] ? "\n > " . $t['stelle'] : ''); } if (count($rot) > 25) { $belege[] = '... und ' . (count($rot) - 25) . ' weitere'; } befund( 'rot', 'Dateien', count($rot) . ' PHP-Datei(en) fuehren Code aus Benutzereingaben aus', $belege, 'NICHT loeschen. Zuerst das Aenderungsdatum der Dateien mit den Zugriffsprotokollen abgleichen – daraus ergibt sich der Zeitraum, und der entscheidet, welche Sicherung noch brauchbar ist.' ); } if ($gelb) { $belege = []; foreach (array_slice($gelb, 0, 15) as $t) { $belege[] = $t['datei'] . ' [' . $t['muster'] . ']'; } if (count($gelb) > 15) { $belege[] = '... und ' . (count($gelb) - 15) . ' weitere'; } befund('gelb', 'Dateien', count($gelb) . ' Datei(en) mit auffaelligen, aber nicht eindeutigen Mustern', $belege, 'Haeufig Fehlalarme aus Baukaesten und Erweiterungen. Durchsehen, nicht erschrecken.'); } if ($inVerboteneOrte) { $belege = []; foreach (array_slice($inVerboteneOrte, 0, 25) as $t) { $belege[] = $t['datei'] . ($t['geaendert'] ? ' geaendert ' . date('d.m.Y H:i', $t['geaendert']) : ''); } if (count($inVerboteneOrte) > 25) { $belege[] = '... und ' . (count($inVerboteneOrte) - 25) . ' weitere'; } befund( 'rot', 'Dateien', count($inVerboteneOrte) . ' PHP-Datei(en) in Verzeichnissen, die nur Daten enthalten sollten', $belege, 'In Upload- und Cache-Verzeichnissen gehoert kein ausfuehrbarer Code. Das ist einer der verlaesslichsten Hinweise ueberhaupt. Zusaetzlich: Ausfuehrung dieser Verzeichnisse serverseitig unterbinden.' ); } if ($doppelteEndung) { befund('rot', 'Dateien', count($doppelteEndung) . ' Datei(en) mit doppelter Endung', array_slice($doppelteEndung, 0, 20), 'Doppelte Endungen dienen dazu, Upload-Pruefungen zu umgehen.'); } if ($zeitstempel) { befund('gelb', 'Dateien', count($zeitstempel) . ' Datei(en) mit unglaubwuerdigem Zeitstempel', array_map(fn($z) => $z['datei'] . ' – ' . $z['grund'], array_slice($zeitstempel, 0, 15)), 'Entweder geht die Serveruhr falsch, oder Zeitstempel wurden nachtraeglich gesetzt.'); } // ==================================================================== // 5. Konfigurationsdateien im Webverzeichnis // ==================================================================== melde(' [4/8] .htaccess und .user.ini ...'); $konfigTreffer = []; foreach (['.htaccess', '.user.ini', 'php.ini'] as $konfigName) { $gefundene = []; $suche = new RecursiveIteratorIterator( new RecursiveDirectoryIterator($wurzel, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY ); try { foreach ($suche as $d) { if ($d->isFile() && $d->getFilename() === $konfigName) { $gefundene[] = str_replace('\\', '/', $d->getPathname()); if (count($gefundene) > 200) { break; } } } } catch (Throwable $e) { nichtGeprueft($konfigName, 'nicht vollstaendig durchsuchbar'); } foreach ($gefundene as $pfad) { $inhalt = @file_get_contents($pfad, false, null, 0, 200000); if ($inhalt === false) { continue; } $relativ = ltrim(substr($pfad, strlen($wurzel)), '/'); if (preg_match('/^\s*(?:php_value\s+)?auto_(?:pre|ap)pend_file\s*[=\s]\s*(\S+)/mi', $inhalt, $m)) { $konfigTreffer[] = ['stufe' => 'rot', 'text' => $relativ . ': auto_prepend/append_file = ' . $m[1]]; } if (preg_match('/AddType\s+application\/x-httpd-php\s+\.(\w+)/i', $inhalt, $m) && !in_array(strtolower($m[1]), ['php', 'phtml'], true)) { $konfigTreffer[] = ['stufe' => 'rot', 'text' => $relativ . ': Endung .' . $m[1] . ' wird als PHP ausgefuehrt']; } if (preg_match('/(?:AddHandler|SetHandler)\s+[^\n]*php[^\n]*/i', $inhalt, $m)) { $konfigTreffer[] = ['stufe' => 'gelb', 'text' => $relativ . ': ' . trim($m[0])]; } if (preg_match('/php_(?:value|flag)\s+(allow_url_include|disable_functions|open_basedir)\s+([^\n]*)/i', $inhalt, $m)) { $konfigTreffer[] = ['stufe' => 'gelb', 'text' => $relativ . ': ' . trim($m[0])]; } } } foreach (['rot', 'gelb'] as $s) { $teil = array_values(array_filter($konfigTreffer, fn($k) => $k['stufe'] === $s)); if ($teil) { befund( $s, 'Konfiguration', count($teil) . ' Eintrag/Eintraege in Konfigurationsdateien, die Code ausfuehren koennen', array_map(fn($k) => $k['text'], array_slice($teil, 0, 20)), $s === 'rot' ? 'Eine Konfigurationsdatei, die bei jedem Aufruf eine fremde Datei laedt oder harmlose Endungen ausfuehrbar macht, ist ein Dauerzugang.' : 'Pruefen, ob dieser Eintrag von Ihrer Anwendung stammt.' ); } } // ==================================================================== // 6. Dauerzugaenge im Betriebssystem // ==================================================================== melde(' [5/8] Geplante Aufgaben und Autostarts ...'); if ($istWindows) { $aufgaben = kommando('schtasks /query /fo LIST /v'); if ($aufgaben === null) { nichtGeprueft('Aufgabenplanung', 'shell_exec ist gesperrt – bitte manuell pruefen: schtasks /query /fo LIST /v'); } else { $verdaechtig = []; foreach (preg_split('/\r?\n\r?\n/', $aufgaben) as $block) { if (preg_match('/(powershell|cmd\.exe\s*\/c|bitsadmin|certutil|mshta|wscript|cscript|rundll32|curl|wget|Invoke-WebRequest|FromBase64String|-enc\b|hidden)/i', $block, $m)) { if (preg_match('/Aufgabenname:\s*(\S+)|TaskName:\s*(\S+)/i', $block, $n)) { $verdaechtig[] = trim(($n[1] ?? $n[2] ?? '?')) . ' [' . $m[1] . ']'; } } } if ($verdaechtig) { befund('gelb', 'Dauerzugaenge', count($verdaechtig) . ' geplante Aufgabe(n) mit auffaelligem Aufruf', array_slice(array_unique($verdaechtig), 0, 20), 'Windows bringt selbst Aufgaben mit PowerShell mit. Entscheidend ist, ob Sie die Aufgabe kennen und ob sie Inhalte aus dem Netz nachlaedt.'); } else { befund('grau', 'Dauerzugaenge', 'Keine auffaelligen geplanten Aufgaben gefunden', [], ''); } } foreach ([ 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run', 'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\RunOnce', ] as $schluessel) { $aus = kommando('reg query "' . $schluessel . '"'); if ($aus === null) { nichtGeprueft('Autostart-Registry', 'shell_exec gesperrt – manuell: reg query "' . $schluessel . '"'); break; } $zeilen = array_values(array_filter(array_map('trim', explode("\n", $aus)), fn($z) => $z !== '' && !str_starts_with($z, 'HKEY'))); if ($zeilen) { befund('grau', 'Dauerzugaenge', 'Autostart-Eintraege in ' . $schluessel, array_slice($zeilen, 0, 15), 'Zur Durchsicht. Alles, was Sie hier nicht zuordnen koennen, gehoert geprueft.'); } } } else { /* Linux: crontab je Benutzer und systemweite Stellen */ $cronQuellen = []; $eigene = kommando('crontab -l'); if ($eigene === null) { nichtGeprueft('crontab', 'shell_exec gesperrt – manuell: crontab -l; ls -la /etc/cron.*'); } elseif (trim($eigene) !== '' && !str_contains($eigene, 'no crontab')) { $cronQuellen['crontab des laufenden Benutzers'] = $eigene; } foreach (['/etc/crontab', '/etc/rc.local', '/etc/ld.so.preload'] as $d) { if (is_readable($d)) { $i = (string) @file_get_contents($d); if (trim($i) !== '') { $cronQuellen[$d] = $i; } } } foreach (['/etc/cron.d', '/var/spool/cron/crontabs', '/var/spool/cron'] as $ordner) { if (is_dir($ordner) && is_readable($ordner)) { foreach ((array) @scandir($ordner) as $f) { if ($f === '.' || $f === '..') { continue; } $p = $ordner . '/' . $f; if (is_file($p) && is_readable($p)) { $i = (string) @file_get_contents($p); if (trim($i) !== '') { $cronQuellen[$p] = $i; } } } } } $auffaellig = []; foreach ($cronQuellen as $quelle => $inhalt) { foreach (explode("\n", $inhalt) as $zeile) { $zeile = trim($zeile); if ($zeile === '' || str_starts_with($zeile, '#')) { continue; } if (preg_match('/(curl|wget|base64|\/dev\/tcp|nc\s|netcat|python\s*-c|perl\s*-e|php\s*-r|bash\s*-i|\|\s*sh\b|chmod\s*\+x)/i', $zeile, $m)) { $auffaellig[] = $quelle . ': ' . substr($zeile, 0, 150) . ' [' . $m[1] . ']'; } } } if ($auffaellig) { befund('rot', 'Dauerzugaenge', count($auffaellig) . ' geplante Aufgabe(n) laden oder starten Code nach', array_slice($auffaellig, 0, 20), 'Ein Cron-Eintrag, der etwas herunterlaedt und ausfuehrt, ist der haeufigste Weg, einen Zugang zu behalten. Eintrag notieren, NICHT loeschen, bevor der Zeitraum geklaert ist.'); } elseif ($cronQuellen) { befund('grau', 'Dauerzugaenge', count($cronQuellen) . ' Cron-Quelle(n) gelesen, nichts Auffaelliges', array_keys($cronQuellen), ''); } if (is_readable('/etc/ld.so.preload') && trim((string) @file_get_contents('/etc/ld.so.preload')) !== '') { befund('rot', 'Dauerzugaenge', '/etc/ld.so.preload ist nicht leer', [trim((string) @file_get_contents('/etc/ld.so.preload'))], 'Diese Datei laedt eine Bibliothek in JEDES startende Programm. Sie ist auf den allermeisten Servern leer oder nicht vorhanden.'); } /* systemd-Einheiten, die juenger sind als das System selbst */ $units = kommando('systemctl list-unit-files --type=service --no-pager --no-legend 2>/dev/null | head -400'); if ($units !== null && trim($units) !== '') { $neu = []; foreach (['/etc/systemd/system', '/run/systemd/system'] as $ordner) { if (is_dir($ordner) && is_readable($ordner)) { foreach ((array) @scandir($ordner) as $f) { if (!str_ends_with((string) $f, '.service')) { continue; } $p = $ordner . '/' . $f; $i = (string) @file_get_contents($p); if (preg_match('/ExecStart=.*(curl|wget|base64|\/tmp\/|\/dev\/shm|nc\s|bash\s*-i|php\s*-r)/i', $i, $m)) { $neu[] = $p . ' [' . $m[1] . ']'; } } } } if ($neu) { befund('rot', 'Dauerzugaenge', count($neu) . ' systemd-Dienst(e) mit auffaelligem Startbefehl', array_slice($neu, 0, 15), 'Ein Dienst, der aus /tmp startet oder etwas nachlaedt, gehoert dort nicht hin.'); } } } // ==================================================================== // 7. SSH-Zugaenge (Linux) // ==================================================================== melde(' [6/8] Zugaenge ...'); if (!$istWindows) { $schluesselDateien = []; foreach (['/root/.ssh/authorized_keys'] as $d) { if (is_readable($d)) { $schluesselDateien[$d] = (string) @file_get_contents($d); } } if (is_dir('/home') && is_readable('/home')) { foreach ((array) @scandir('/home') as $benutzer) { if ($benutzer === '.' || $benutzer === '..') { continue; } $d = '/home/' . $benutzer . '/.ssh/authorized_keys'; if (is_readable($d)) { $schluesselDateien[$d] = (string) @file_get_contents($d); } } } $alle = []; foreach ($schluesselDateien as $d => $inhalt) { foreach (explode("\n", $inhalt) as $zeile) { $zeile = trim($zeile); if ($zeile === '' || str_starts_with($zeile, '#')) { continue; } $teile = explode(' ', $zeile); $kommentar = $teile[2] ?? '(ohne Kennung)'; $alle[] = $d . ': ' . ($teile[0] ?? '?') . ' … ' . $kommentar; } } if ($alle) { befund('gelb', 'Zugaenge', count($alle) . ' hinterlegte(r) SSH-Schluessel', array_slice($alle, 0, 25), 'Jeder dieser Schluessel erlaubt eine Anmeldung ohne Kennwort. Gehen Sie die Liste durch: Jeder Eintrag, den Sie keiner Person zuordnen koennen, gehoert entfernt – nach Klaerung des Zeitraums.'); } if (is_readable('/etc/passwd')) { $mitShell = []; foreach (explode("\n", (string) @file_get_contents('/etc/passwd')) as $zeile) { $f = explode(':', $zeile); if (count($f) >= 7 && preg_match('/(bash|sh|zsh|ksh)$/', $f[6]) && (int) $f[2] >= 1000) { $mitShell[] = $f[0] . ' (UID ' . $f[2] . ', ' . $f[6] . ')'; } } if ($mitShell) { befund('grau', 'Zugaenge', count($mitShell) . ' Benutzerkonto/-konten mit Anmeldemoeglichkeit', $mitShell, 'Zur Durchsicht: Kennen Sie alle? Ein neues Konto nach einem Vorfall ist ein deutliches Zeichen.'); } } } else { $benutzer = kommando('net localgroup Administratoren'); if ($benutzer === null) { $benutzer = kommando('net localgroup Administrators'); } if ($benutzer === null) { nichtGeprueft('Administratorkonten', 'shell_exec gesperrt – manuell: net localgroup Administratoren'); } else { $zeilen = array_values(array_filter(array_map('trim', explode("\n", $benutzer)), fn($z) => $z !== '' && !preg_match('/^(Alias|Kommentar|Mitglieder|-+|Der Befehl|The command|Comment|Members|Alias name)/i', $z))); if ($zeilen) { befund('grau', 'Zugaenge', 'Mitglieder der lokalen Administratorgruppe', array_slice($zeilen, 0, 20), 'Zur Durchsicht. Ein unbekanntes Konto hier ist ein deutliches Zeichen.'); } } } // ==================================================================== // 8. Netzwerk // ==================================================================== melde(' [7/8] Offene Verbindungen ...'); $netz = $istWindows ? kommando('netstat -ano -p TCP') : (kommando('ss -tunlp') ?? kommando('netstat -tunlp')); if ($netz === null) { nichtGeprueft('Netzwerk', 'shell_exec gesperrt – manuell: ' . ($istWindows ? 'netstat -ano' : 'ss -tunlp')); } else { $erwartet = [80, 443, 22, 3306, 5432, 25, 587, 465, 143, 993, 110, 995, 53, 21, 3389, 445, 135, 139]; $ungewoehnlich = []; foreach (explode("\n", $netz) as $zeile) { if (!preg_match('/(?:LISTEN|ABHÖREN|ABHOEREN)/i', $zeile)) { continue; } if (preg_match('/[:\.](\d{1,5})\s/', $zeile, $m)) { $port = (int) $m[1]; if ($port > 1024 && !in_array($port, $erwartet, true)) { $ungewoehnlich[] = trim(preg_replace('/\s+/', ' ', $zeile)); } } } $ungewoehnlich = array_slice(array_unique($ungewoehnlich), 0, 20); if ($ungewoehnlich) { befund('gelb', 'Netzwerk', count($ungewoehnlich) . ' Dienst(e) lauschen auf ungewoehnlichen Anschluessen', $ungewoehnlich, 'Vieles davon ist regulaer – Datenbanken, Zwischenspeicher, Anwendungsserver. Pruefen Sie, ob Sie jeden Eintrag zuordnen koennen und ob er von aussen erreichbar sein muss.'); } else { befund('grau', 'Netzwerk', 'Keine ungewoehnlichen lauschenden Anschluesse gefunden', [], ''); } } // ==================================================================== // 9. Protokolle – die PHP2Shell-Signatur // ==================================================================== melde(' [8/8] Zugriffsprotokolle ...'); $protokollOrte = $istWindows ? ['C:/inetpub/logs/LogFiles', 'C:/Windows/System32/LogFiles/W3SVC1'] : ['/var/log/apache2', '/var/log/nginx', '/var/log/httpd', '/var/log/lighttpd']; $protokolle = []; foreach ($protokollOrte as $ort) { if (!is_dir($ort) || !is_readable($ort)) { continue; } try { $it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($ort, FilesystemIterator::SKIP_DOTS)); foreach ($it as $d) { if ($d->isFile() && preg_match('/\.(log|txt)$/i', $d->getFilename()) && $d->getSize() < 300 * 1024 * 1024) { $protokolle[] = ['pfad' => str_replace('\\', '/', $d->getPathname()), 'zeit' => $d->getMTime(), 'groesse' => $d->getSize()]; } } } catch (Throwable $e) { /* nicht lesbar – wird unten als uebersprungen gemeldet */ } } if (!$protokolle) { nichtGeprueft('Zugriffsprotokolle', 'keine lesbaren Protokolle gefunden – ohne sie laesst sich der Zeitraum nicht bestimmen'); befund('gelb', 'Protokolle', 'Keine lesbaren Zugriffsprotokolle gefunden', $protokollOrte, 'Das ist der wichtigste Einzelbefund dieses Berichts, wenn er zutrifft: Ohne Protokolle laesst sich nicht sagen, seit wann etwas laeuft – und damit nicht, welche Sicherung noch sauber ist.'); } else { usort($protokolle, fn($a, $b) => $b['zeit'] <=> $a['zeit']); $protokolle = array_slice($protokolle, 0, 6); $aeltester = min(array_map(fn($p) => $p['zeit'], $protokolle)); $tage = (int) floor(($jetzt - $aeltester) / 86400); /** * Statuscode aus einer Protokollzeile lesen. * * DAS IST DER ENTSCHEIDENDE PUNKT dieses ganzen Abschnitts. Jeder * oeffentlich erreichbare Server wird rund um die Uhr nach Webshells * abgesucht – das ist Dauerrauschen und voellig normal. Interessant * ist ausschliesslich, ob so ein Aufruf AUSGELIEFERT wurde. * * Der erste Testlauf meldete 16 rote Befunde, die alle Status 404 * hatten: fehlgeschlagene Scans. Hätte das Werkzeug das so an Kunden * ausgegeben, haette es Panik erzeugt, wo nichts war. * * Erkannt werden IIS (W3C, sc-status als eigenes Feld, oft mit * SERVER-STATUS=nnn in der Abfrage) und Apache/nginx (combined: * "GET /x HTTP/1.1" nnn groesse). */ $statusLesen = static function (string $zeile): ?int { /* Apache/nginx combined: nach dem Anfuehrungszeichen der Anfrage */ if (preg_match('/"\s+(\d{3})\s+(?:\d+|-)/', $zeile, $m)) { return (int) $m[1]; } /* IIS mit ARR: SERVER-STATUS=404 in der Abfragezeichenkette */ if (preg_match('/SERVER-STATUS=(\d{3})/i', $zeile, $m)) { return (int) $m[1]; } /* IIS W3C ohne ARR: ... port benutzer ip abruf status unterstatus */ if (preg_match('/\s(\d{2,3})\s+(\d{1,3})\s+(\d{1,5})\s*$/', rtrim($zeile), $m)) { return (int) $m[1]; } return null; }; $verdaechtigeZeilen = []; $postAufBilder = []; $abgewehrt = 0; $ohneStatus = 0; foreach ($protokolle as $p) { $inhalt = @file_get_contents($p['pfad'], false, null, max(0, $p['groesse'] - 8 * 1024 * 1024), 8 * 1024 * 1024); if ($inhalt === false) { continue; } foreach (explode("\n", $inhalt) as $zeile) { if (strlen($zeile) < 20 || $zeile[0] === '#') { continue; } $istPostAufDaten = (bool) preg_match('/"?POST\s+(\S+\.(?:jpe?g|png|gif|webp|txt|ico|css|js))/i', $zeile); $istShellName = (bool) preg_match('/\b(?:GET|POST)\s+\S*\/?(?:shell|c99|r57|wso|b374k|alfa|alfanew|indoxploit|mini|bypass|xmrig|hacked|backdoor)[\w-]*\.(?:php[0-9s]?|phtml)/i', $zeile); if (!$istPostAufDaten && !$istShellName) { continue; } $status = $statusLesen($zeile); /* Nur ausgelieferte Antworten zaehlen als Befund. 404, 403 und 410 bedeuten: der Versuch ging ins Leere. */ if ($status !== null && ($status >= 400 || $status === 301 || $status === 302)) { $abgewehrt++; continue; } if ($status === null) { $ohneStatus++; } $kurz = trim(substr(preg_replace('/\s+/', ' ', $zeile), 0, 180)); if ($istPostAufDaten) { $postAufBilder[] = $kurz; } else { $verdaechtigeZeilen[] = $kurz; } } } if ($abgewehrt > 0) { befund( 'grau', 'Protokolle', number_format($abgewehrt, 0, ',', '.') . ' abgewehrte(r) Zugriffsversuch(e) auf typische Hintertuer-Namen', ['Alle mit Fehlercode beantwortet – die gesuchten Dateien gibt es hier nicht.'], 'Das ist normal. Jeder oeffentlich erreichbare Server wird fortlaufend danach abgesucht. Ein abgewehrter Versuch ist kein Vorfall, sondern der Beleg, dass es funktioniert hat.' ); } if ($ohneStatus > 0) { nichtGeprueft('Protokollauswertung', $ohneStatus . ' Zeile(n) ohne erkennbaren Statuscode – im Zweifel als Befund gewertet'); } befund('grau', 'Protokolle', count($protokolle) . ' Protokolldatei(en) gelesen, Rueckreichweite etwa ' . $tage . ' Tage', array_map(fn($p) => $p['pfad'] . ' (' . lesbar($p['groesse']) . ', bis ' . date('d.m.Y', $p['zeit']) . ')', $protokolle), $tage < 30 ? 'Weniger als 30 Tage Rueckreichweite. Fuer die Aufarbeitung eines Vorfalls ist das meist zu wenig – 90 Tage sind der uebliche Richtwert.' : ''); if ($postAufBilder) { befund('rot', 'Protokolle', count($postAufBilder) . ' ERFOLGREICHE POST-Anfrage(n) an Dateien, die keine Formulare annehmen', array_slice(array_unique($postAufBilder), 0, 15), 'Ein POST auf eine .jpg oder .txt ergibt regulaer keinen Sinn, und hier wurde er beantwortet. Das ist ein verbreitetes Muster bei getarnten Hintertueren. Die angesprochene Datei pruefen – aber nicht loeschen.'); } if ($verdaechtigeZeilen) { befund('rot', 'Protokolle', count($verdaechtigeZeilen) . ' ERFOLGREICHE(R) Aufruf(e) von Dateinamen aus Webshell-Baukaesten', array_slice(array_unique($verdaechtigeZeilen), 0, 15), 'Anders als die abgewehrten Versuche wurden diese Aufrufe beantwortet. Das bedeutet, dass die Datei existiert. Pfad notieren, Datei NICHT loeschen, Zeitpunkt mit den Dateistempeln abgleichen.'); } } // ==================================================================== // Bericht // ==================================================================== $reihenfolge = ['rot' => 0, 'gelb' => 1, 'grau' => 2]; usort($befunde, fn($a, $b) => $reihenfolge[$a['stufe']] <=> $reihenfolge[$b['stufe']]); $anzahl = ['rot' => 0, 'gelb' => 0, 'grau' => 0]; foreach ($befunde as $b) { $anzahl[$b['stufe']]++; } $marke = ['rot' => '[!]', 'gelb' => '[?]', 'grau' => '[i]']; $wort = ['rot' => 'SOLLTE SO NICHT SEIN', 'gelb' => 'BITTE ANSEHEN', 'grau' => 'INFORMATION']; $zeilen = []; $zeilen[] = ''; $zeilen[] = 'DAVINCI SPURENSUCHE – BERICHT'; $zeilen[] = str_repeat('=', 72); $zeilen[] = 'Erstellt : ' . date('d.m.Y H:i:s'); $zeilen[] = 'Server : ' . php_uname('n') . ' (' . PHP_OS_FAMILY . ')'; $zeilen[] = 'PHP : ' . PHP_VERSION; $zeilen[] = 'Verzeichnis : ' . $wurzel; $zeilen[] = 'Geprueft : ' . number_format($gezaehlt, 0, ',', '.') . ' Dateien, davon ' . number_format($gelesen, 0, ',', '.') . ' PHP-Dateien gelesen'; $zeilen[] = 'Werkzeug : Version ' . VERSION . ' – ' . HERKUNFT; $zeilen[] = ''; $zeilen[] = 'ERGEBNIS: ' . $anzahl['rot'] . ' x "sollte so nicht sein", ' . $anzahl['gelb'] . ' x "bitte ansehen", ' . $anzahl['grau'] . ' x Information'; $zeilen[] = ''; if ($anzahl['rot'] === 0 && $anzahl['gelb'] === 0) { $zeilen[] = 'Es wurden keine der gesuchten Spuren gefunden.'; $zeilen[] = 'Das ist KEINE Unbedenklichkeitsbescheinigung – siehe "Was dieser'; $zeilen[] = 'Bericht nicht leistet" am Ende.'; $zeilen[] = ''; } $letzteStufe = ''; foreach ($befunde as $b) { if ($b['stufe'] !== $letzteStufe) { $zeilen[] = str_repeat('-', 72); $zeilen[] = $wort[$b['stufe']]; $zeilen[] = str_repeat('-', 72); $letzteStufe = $b['stufe']; } $zeilen[] = $marke[$b['stufe']] . ' ' . $b['bereich'] . ': ' . $b['titel']; foreach ($b['belege'] as $beleg) { foreach (explode("\n", (string) $beleg) as $zl) { $zeilen[] = ' ' . $zl; } } if ($b['rat'] !== '') { $zeilen[] = ' -> ' . wordwrap($b['rat'], 66, "\n ", false); } $zeilen[] = ''; } if ($uebersprungen) { $zeilen[] = str_repeat('-', 72); $zeilen[] = 'NICHT GEPRUEFT'; $zeilen[] = str_repeat('-', 72); $zeilen[] = 'Diese Punkte konnte das Werkzeug nicht pruefen. Sie sind damit'; $zeilen[] = 'weder in Ordnung noch auffaellig – sie sind offen.'; $zeilen[] = ''; foreach ($uebersprungen as $u) { $zeilen[] = ' - ' . $u['was'] . ': ' . $u['grund']; } $zeilen[] = ''; } $zeilen[] = str_repeat('=', 72); $zeilen[] = 'WAS DIESER BERICHT NICHT LEISTET'; $zeilen[] = str_repeat('=', 72); $zeilen[] = ''; $zeilen[] = '1. Ein leerer Befund ist kein Freispruch. Geprueft wurde auf bekannte'; $zeilen[] = ' Muster. Wer sich Muehe gibt, hinterlaesst keine.'; $zeilen[] = ''; $zeilen[] = '2. Dieses Werkzeug laeuft AUF dem System, das es pruefen soll. Wer dort'; $zeilen[] = ' genug Rechte hat, kann ihm falsche Antworten geben. Sicherheit gibt'; $zeilen[] = ' nur eine Pruefung von aussen oder an einem abgezogenen Abbild.'; $zeilen[] = ''; $zeilen[] = '3. Datenbank und Anwendung wurden NICHT geprueft. Neue Redakteurskonten,'; $zeilen[] = ' manipulierte Inhalte und Eintraege in Erweiterungstabellen bleiben'; $zeilen[] = ' unsichtbar.'; $zeilen[] = ''; $zeilen[] = '4. Loeschen Sie nichts, bevor der Zeitraum geklaert ist. Die Frage'; $zeilen[] = ' "seit wann?" entscheidet, welche Sicherung noch brauchbar ist. Wer'; $zeilen[] = ' zuerst aufraeumt, kann sie nicht mehr beantworten.'; $zeilen[] = ''; $zeilen[] = str_repeat('-', 72); $zeilen[] = 'Dieses Werkzeug hat nichts veraendert und nichts gesendet.'; $zeilen[] = 'Der Bericht liegt ausschliesslich auf diesem Server.'; $zeilen[] = ''; $zeilen[] = 'Wenn Sie ihn von jemandem durchsehen lassen moechten:'; $zeilen[] = HERKUNFT; $zeilen[] = str_repeat('=', 72); $text = implode(PHP_EOL, $zeilen) . PHP_EOL; if ($alsJson) { echo json_encode([ 'werkzeug' => 'davinci-check', 'version' => VERSION, 'zeitpunkt' => date('c'), 'server' => php_uname('n'), 'betriebssystem' => PHP_OS_FAMILY, 'php' => PHP_VERSION, 'verzeichnis' => $wurzel, 'dateien_gesehen' => $gezaehlt, 'dateien_gelesen' => $gelesen, 'zusammenfassung' => $anzahl, 'befunde' => $befunde, 'nicht_geprueft' => $uebersprungen, ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL; } else { echo $text; } if (isset($argumente['bericht']) && is_string($argumente['bericht'])) { if (@file_put_contents($argumente['bericht'], $text) !== false) { melde(' Bericht geschrieben: ' . $argumente['bericht']); } else { melde(' Bericht konnte nicht geschrieben werden: ' . $argumente['bericht']); } } /* Rueckgabewert fuer Skripte: 1 bei rotem Befund, 0 sonst. */ exit($anzahl['rot'] > 0 ? 1 : 0);